Repository: GoogleChrome/lighthouse
Pull request #9054 (2019-05-24 23:25:12)

report: show metric descriptions by default when errors exist
See https://github.com/GoogleChrome/lighthouse/issues/9019#issuecomment-494961806


Patch:
diff --git a/docs/plugins.md b/docs/plugins.md
index f778be202..7ae9db821 100644
--- a/docs/plugins.md
+++ b/docs/plugins.md
@@ -350,5 +350,6 @@ Most artifacts will try to represent as truthfully as possible what was observed
 
 ## Examples
 
+- [Field Performance](https://github.com/treosh/lighthouse-plugin-field-performance) - A plugin to gather and display Chrome UX Report field data
 - [Publisher Ads Audits](https://github.com/googleads/pub-ads-lighthouse-plugin) - a well-written, but complex, plugin
 - [Lighthouse Plugin Recipe](./recipes/lighthouse-plugin-example)
diff --git a/lighthouse-cli/run.js b/lighthouse-cli/run.js
index 357b19046..88c6a4ba8 100644
--- a/lighthouse-cli/run.js
+++ b/lighthouse-cli/run.js
@@ -65,25 +65,25 @@ function getDebuggableChrome(flags) {
 }
 
 /** @return {never} */
-function showConnectionError() {
+function printConnectionErrorAndExit() {
   console.error('Unable to connect to Chrome');
   return process.exit(_RUNTIME_ERROR_CODE);
 }
 
 /** @return {never} */
-function showProtocolTimeoutError() {
+function printProtocolTimeoutErrorAndExit() {
   console.error('Debugger protocol timed out while connecting to Chrome.');
   return process.exit(_PROTOCOL_TIMEOUT_EXIT_CODE);
 }
 
 /** @param {LighthouseError} err @return {never} */
-function showPageHungError(err) {
+function printPageHungErrorAndExit(err) {
   console.error('Page hung:', err.friendlyMessage);
   return process.exit(_PAGE_HUNG_EXIT_CODE);
 }
 
 /** @param {LighthouseError} err @return {never} */
-function showInsecureDocumentRequestError(err) {
+function printInsecureDocumentRequestErrorAndExit(err) {
   console.error('Insecure document request:', err.friendlyMessage);
   return process.exit(_INSECURE_DOCUMENT_REQUEST_EXIT_CODE);
 }
@@ -92,7 +92,7 @@ function showInsecureDocumentRequestError(err) {
  * @param {LighthouseError} err
  * @return {never}
  */
-function showRuntimeError(err) {
+function printRuntimeErrorAndExit(err) {
   console.error('Runtime error encountered:', err.friendlyMessage || err.message);
   if (err.stack) {
     console.error(err.stack);
@@ -104,17 +104,17 @@ function showRuntimeError(err) {
  * @param {LighthouseError} err
  * @return {never}
  */
-function handleError(err) {
+function printErrorAndExit(err) {
   if (err.code === 'ECONNREFUSED') {
-    return showConnectionError();
+    return printConnectionErrorAndExit();
   } else if (err.code === 'CRI_TIMEOUT') {
-    return showProtocolTimeoutError();
+    return printProtocolTimeoutErrorAndExit();
   } else if (err.code === 'PAGE_HUNG') {
-    return showPageHungError(err);
+    return printPageHungErrorAndExit(err);
   } else if (err.code === 'INSECURE_DOCUMENT_REQUEST') {
-    return showInsecureDocumentRequestError(err);
+    return printInsecureDocumentRequestErrorAndExit(err);
   } else {
-    return showRuntimeError(err);
+    return printRuntimeErrorAndExit(err);
   }
 }
 
@@ -218,10 +218,24 @@ async function runLighthouse(url, flags, config) {
     await potentiallyKillChrome(launchedChrome);
     process.removeListener('unhandledRejection', handleTheUnhandled);
 
+    // Runtime errors indicate something was *very* wrong with the page result.
+    // We don't want the user to have to parse the report to figure it out, so we'll still exit
+    // with an error code after we saved the results.
+    if (runnerResult && runnerResult.lhr.runtimeError) {
+      const {runtimeError} = runnerResult.lhr;
+      return printErrorAndExit({
+        name: 'LHError',
+        friendlyMessage: runtimeError.message,
+        lhrRuntimeError: true,
+        code: runtimeError.code,
+        message: runtimeError.message,
+      });
+    }
+
     return runnerResult;
   } catch (err) {
     await potentiallyKillChrome(launchedChrome).catch(() => {});
-    handleError(err);
+    return printErrorAndExit(err);
   }
 }
 
diff --git a/lighthouse-core/config/config-helpers.js b/lighthouse-core/config/config-helpers.js
new file mode 100644
index 000000000..fdff4420f
--- /dev/null
+++ b/lighthouse-core/config/config-helpers.js
@@ -0,0 +1,210 @@
+/**
+ * @license Copyright 2019 Google Inc. All Rights Reserved.
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
+ */
+'use strict';
+
+const path = require('path');
+const Audit = require('../audits/audit.js');
+const Runner = require('../runner.js');
+
+/**
+ * If any items with identical `path` properties are found in the input array,
+ * merge their `options` properties into the first instance and then discard any
+ * other instances.
+ * Until support of jsdoc templates with constraints, type in config.d.ts.
+ * See https://github.com/Microsoft/TypeScript/issues/24283
+ * @type {LH.Config.MergeOptionsOfItems}
+ */
+const mergeOptionsOfItems = function(items) {
+  /** @type {Array<{id: string, path?: string, options?: Object<string, any>}>} */
+  const mergedItems = [];
+
+  for (const item of items) {
+    const existingItem = item.path && mergedItems.find(candidate => candidate.path === item.path);
+    if (!existingItem) {
+      mergedItems.push(item);
+      continue;
+    }
+
+    existingItem.options = Object.assign({}, existingItem.options, item.options);
+  }
+
+  return mergedItems;
+};
+
+/**
+ * Throws an error if the provided object does not implement the required properties of an audit
+ * definition.
+ * @param {LH.Config.AuditDefn} auditDefinition
+ */
+function assertValidAudit(auditDefinition) {
+  const {implementation, path: auditPath} = auditDefinition;
+  const auditName = auditPath ||
+    (implementation && implementation.meta && implementation.meta.id) ||
+    'Unknown audit';
+
+  if (typeof implementation.audit !== 'function' || implementation.audit === Audit.audit) {
+    throw new Error(`${auditName} has no audit() method.`);
+  }
+
+  if (typeof implementation.meta.id !== 'string') {
+    throw new Error(`${auditName} has no meta.id property, or the property is not a string.`);
+  }
+
+  if (typeof implementation.meta.title !== 'string') {
+    throw new Error(`${auditName} has no meta.title property, or the property is not a string.`);
+  }
+
+  // If it'll have a ✔ or ✖ displayed alongside the result, it should have failureTitle
+  if (
+    typeof implementation.meta.failureTitle !== 'string' &&
+    implementation.meta.scoreDisplayMode === Audit.SCORING_MODES.BINARY
+  ) {
+    throw new Error(`${auditName} has no failureTitle and should.`);
+  }
+
+  if (typeof implementation.meta.description !== 'string') {
+    throw new Error(
+      `${auditName} has no meta.description property, or the property is not a string.`
+    );
+  } else if (implementation.meta.description === '') {
+    throw new Error(
+      `${auditName} has an empty meta.description string. Please add a description for the UI.`
+    );
+  }
+
+  if (!Array.isArray(implementation.meta.requiredArtifacts)) {
+    throw new Error(
+      `${auditName} has no meta.requiredArtifacts property, or the property is not an array.`
+    );
+  }
+}
+
+/**
+ * Expands the audits from user-specified JSON to an internal audit definition format.
+ * @param {LH.Config.Json['audits']} audits
+ * @return {?Array<{id?: string, path: string, options?: {}} | {id?: string, implementation: typeof Audit, path?: string, options?: {}}>}
+ */
+function expandAuditShorthand(audits) {
+  if (!audits) {
+    return null;
+  }
+
+  const newAudits = audits.map(audit => {
+    if (typeof audit === 'string') {
+      // just 'path/to/audit'
+      return {path: audit, options: {}};
+    } else if ('implementation' in audit && typeof audit.implementation.audit === 'function') {
+      // {implementation: AuditClass, ...}
+      return audit;
+    } else if ('path' in audit && typeof audit.path === 'string') {
+      // {path: 'path/to/audit', ...}
+      return audit;
+    } else if ('audit' in audit && typeof audit.audit === 'function') {
+      // just AuditClass
+      return {implementation: audit, options: {}};
+    } else {
+      throw new Error('Invalid Audit type ' + JSON.stringify(audit));
+    }
+  });
+
+  return newAudits;
+}
+
+/**
+ * Take an array of audits and audit paths and require any paths (possibly
+ * relative to the optional `configDir`) using `resolveModule`,
+ * leaving only an array of AuditDefns.
+ * @param {LH.Config.Json['audits']} audits
+ * @param {string=} configDir
+ * @return {Array<LH.Config.AuditDefn>|null}
+ */
+function requireAudits(audits, configDir) {
+  const expandedAudits = expandAuditShorthand(audits);
+  if (!expandedAudits) {
+    return null;
+  }
+
+  const coreList = Runner.getAuditList();
+  const auditDefns = expandedAudits.map(audit => {
+    let implementation;
+    if ('implementation' in audit) {
+      implementation = audit.implementation;
+    } else {
+      // See if the audit is a Lighthouse core audit.
+      const auditPathJs = `${audit.path}.js`;
+      const coreAudit = coreList.find(a => a === auditPathJs);
+      let requirePath = `../audits/${audit.path}`;
+      if (!coreAudit) {
+        // Otherwise, attempt to find it elsewhere. This throws if not found.
+        requirePath = resolveModule(audit.path, configDir, 'audit');
+      }
+      implementation = /** @type {typeof Audit} */ (require(requirePath));
+    }
+
+    return {
+      implementation,
+      path: audit.path,
+      options: audit.options || {},
+    };
+  });
+
+  const mergedAuditDefns = mergeOptionsOfItems(auditDefns);
+  mergedAuditDefns.forEach(audit => assertValidAudit(audit));
+  return mergedAuditDefns;
+}
+
+/**
+ * Resolves the location of the specified module and returns an absolute
+ * string path to the file. Used for loading custom audits and gatherers.
+ * Throws an error if no module is found.
+ * @param {string} moduleIdentifier
+ * @param {string=} configDir The absolute path to the directory of the config file, if there is one.
+ * @param {string=} category Optional plugin category (e.g. 'audit') for better error messages.
+ * @return {string}
+ * @throws {Error}
+ */
+function resolveModule(moduleIdentifier, configDir, category) {
+  // First try straight `require()`. Unlikely to be specified relative to this
+  // file, but adds support for Lighthouse modules from npm since
+  // `require()` walks up parent directories looking inside any node_modules/
+  // present. Also handles absolute paths.
+  try {
+    return require.resolve(moduleIdentifier);
+  } catch (e) {}
+
+  // See if the module resolves relative to the current working directory.
+  // Most useful to handle the case of invoking Lighthouse as a module, since
+  // then the config is an object and so has no path.
+  const cwdPath = path.resolve(process.cwd(), moduleIdentifier);
+  try {
+    return require.resolve(cwdPath);
+  } catch (e) {}
+
+  const errorString =
+    'Unable to locate ' +
+    (category ? `${category}: ` : '') +
+    `${moduleIdentifier} (tried to require() from '${__dirname}' and load from '${cwdPath}'`;
+
+  if (!configDir) {
+    throw new Error(errorString + ')');
+  }
+
+  // Finally, try looking up relative to the config file path. Just like the
+  // relative path passed to `require()` is found relative to the file it's
+  // in, this allows module paths to be specified relative to the config file.
+  const relativePath = path.resolve(configDir, moduleIdentifier);
+  try {
+    return require.resolve(relativePath);
+  } catch (requireError) {}
+
+  throw new Error(errorString + ` and '${relativePath}')`);
+}
+
+module.exports = {
+  mergeOptionsOfItems,
+  requireAudits,
+  resolveModule,
+};
diff --git a/lighthouse-core/config/config.js b/lighthouse-core/config/config.js
index 7b26f588a..2a857035e 100644
--- a/lighthouse-core/config/config.js
+++ b/lighthouse-core/config/config.js
@@ -14,10 +14,10 @@ const i18n = require('./../lib/i18n/i18n.js');
 const isDeepEqual = require('lodash.isequal');
 const log = require('lighthouse-logger');
 const path = require('path');
-const Audit = require('../audits/audit.js');
 const Runner = require('../runner.js');
 const ConfigPlugin = require('./config-plugin.js');
 const Budget = require('./budget.js');
+const {requireAudits, mergeOptionsOfItems, resolveModule} = require('./config-helpers.js');
 
 /** @typedef {typeof import('../gather/gatherers/gatherer.js')} GathererConstructor */
 /** @typedef {InstanceType<GathererConstructor>} Gatherer */
@@ -26,7 +26,7 @@ const Budget = require('./budget.js');
  * @param {Config['passes']} passes
  * @param {Config['audits']} audits
  */
-function validatePasses(passes, audits) {
+function assertValidPasses(passes, audits) {
   if (!Array.isArray(passes)) {
     return;
   }
@@ -61,7 +61,7 @@ function validatePasses(passes, audits) {
  * @param {Config['audits']} audits
  * @param {Config['groups']} groups
  */
-function validateCategories(categories, audits, groups) {
+function assertValidCategories(categories, audits, groups) {
   if (!categories) {
     return;
   }
@@ -99,51 +99,6 @@ function validateCategories(categories, audits, groups) {
   });
 }
 
-/**
- * @param {typeof Audit} auditDefinition
- * @param {string=} auditPath
- */
-function assertValidAudit(auditDefinition, auditPath) {
-  const auditName = auditPath ||
-    (auditDefinition && auditDefinition.meta && auditDefinition.meta.id);
-
-  if (typeof auditDefinition.audit !== 'function' || auditDefinition.audit === Audit.audit) {
-    throw new Error(`${auditName} has no audit() method.`);
-  }
-
-  if (typeof auditDefinition.meta.id !== 'string') {
-    throw new Error(`${auditName} has no meta.id property, or the property is not a string.`);
-  }
-
-  if (typeof auditDefinition.meta.title !== 'string') {
-    throw new Error(
-      `${auditName} has no meta.title property, or the property is not a string.`
-    );
-  }
-
-  // If it'll have a ✔ or ✖ displayed alongside the result, it should have failureTitle
-  if (typeof auditDefinition.meta.failureTitle !== 'string' &&
-    auditDefinition.meta.scoreDisplayMode === Audit.SCORING_MODES.BINARY) {
-    throw new Error(`${auditName} has no failureTitle and should.`);
-  }
-
-  if (typeof auditDefinition.meta.description !== 'string') {
-    throw new Error(
-      `${auditName} has no meta.description property, or the property is not a string.`
-    );
-  } else if (auditDefinition.meta.description === '') {
-    throw new Error(
-      `${auditName} has an empty meta.description string. Please add a description for the UI.`
-    );
-  }
-
-  if (!Array.isArray(auditDefinition.meta.requiredArtifacts)) {
-    throw new Error(
-      `${auditName} has no meta.requiredArtifacts property, or the property is not an array.`
-    );
-  }
-}
-
 /**
  * @param {Gatherer} gathererInstance
  * @param {string=} gathererName
@@ -301,31 +256,6 @@ function deepCloneConfigJson(json) {
   return cloned;
 }
 
-/**
- * If any items with identical `path` properties are found in the input array,
- * merge their `options` properties into the first instance and then discard any
- * other instances.
- * Until support of jsdoc templates with constraints, type in config.d.ts.
- * See https://github.com/Microsoft/TypeScript/issues/24283
- * @type {LH.Config.MergeOptionsOfItems}
- */
-const mergeOptionsOfItems = (function(items) {
-  /** @type {Array<{path?: string, options?: Object<string, any>}>} */
-  const mergedItems = [];
-
-  for (const item of items) {
-    const existingItem = item.path && mergedItems.find(candidate => candidate.path === item.path);
-    if (!existingItem) {
-      mergedItems.push(item);
-      continue;
-    }
-
-    existingItem.options = Object.assign({}, existingItem.options, item.options);
-  }
-
-  return mergedItems;
-});
-
 class Config {
   /**
    * @constructor
@@ -385,8 +315,8 @@ class Config {
 
     Config.filterConfigIfNeeded(this);
 
-    validatePasses(this.passes, this.audits);
-    validateCategories(this.categories, this.audits, this.groups);
+    assertValidPasses(this.passes, this.audits);
+    assertValidCategories(this.categories, this.audits, this.groups);
 
     // TODO(bckenny): until tsc adds @implements support, assert that Config is a ConfigJson.
     /** @type {LH.Config.Json} */
@@ -472,7 +402,7 @@ class Config {
     for (const pluginName of pluginNames) {
       assertValidPluginName(configJSON, pluginName);
 
-      const pluginPath = Config.resolveModule(pluginName, configDir, 'plugin');
+      const pluginPath = resolveModule(pluginName, configDir, 'plugin');
       const rawPluginJson = require(pluginPath);
       const pluginJson = ConfigPlugin.parsePlugin(rawPluginJson, pluginName);
 
@@ -522,37 +452,6 @@ class Config {
     return settingsWithFlags;
   }
 
-  /**
-   * Expands the audits from user-specified JSON to an internal audit definition format.
-   * @param {LH.Config.Json['audits']} audits
-   * @return {?Array<{path: string, options?: {}} | {implementation: typeof Audit, path?: string, options?: {}}>}
-   */
-  static expandAuditShorthand(audits) {
-    if (!audits) {
-      return null;
-    }
-
-    const newAudits = audits.map(audit => {
-      if (typeof audit === 'string') {
-        // just 'path/to/audit'
-        return {path: audit, options: {}};
-      } else if ('implementation' in audit && typeof audit.implementation.audit === 'function') {
-        // {implementation: AuditClass, ...}
-        return audit;
-      } else if ('path' in audit && typeof audit.path === 'string') {
-        // {path: 'path/to/audit', ...}
-        return audit;
-      } else if ('audit' in audit && typeof audit.audit === 'function') {
-        // just AuditClass
-        return {implementation: audit, options: {}};
-      } else {
-        throw new Error('Invalid Audit type ' + JSON.stringify(audit));
-      }
-    });
-
-    return newAudits;
-  }
-
   /**
    * Expands the gatherers from user-specified to an internal gatherer definition format.
    *
@@ -796,7 +695,7 @@ class Config {
 
   /**
    * Take an array of audits and audit paths and require any paths (possibly
-   * relative to the optional `configDir`) using `Config.resolveModule`,
+   * relative to the optional `configDir`) using `resolveModule`,
    * leaving only an array of AuditDefns.
    * @param {LH.Config.Json['audits']} audits
    * @param {string=} configDir
@@ -805,39 +704,9 @@ class Config {
   static requireAudits(audits, configDir) {
     const status = {msg: 'Requiring audits', id: 'lh:config:requireAudits'};
     log.time(status, 'verbose');
-    const expandedAudits = Config.expandAuditShorthand(audits);
-    if (!expandedAudits) {
-      return null;
-    }
-
-    const coreList = Runner.getAuditList();
-    const auditDefns = expandedAudits.map(audit => {
-      let implementation;
-      if ('implementation' in audit) {
-        implementation = audit.implementation;
-      } else {
-        // See if the audit is a Lighthouse core audit.
-        const auditPathJs = `${audit.path}.js`;
-        const coreAudit = coreList.find(a => a === auditPathJs);
-        let requirePath = `../audits/${audit.path}`;
-        if (!coreAudit) {
-          // Otherwise, attempt to find it elsewhere. This throws if not found.
-          requirePath = Config.resolveModule(audit.path, configDir, 'audit');
-        }
-        implementation = /** @type {typeof Audit} */ (require(requirePath));
-      }
-
-      return {
-        implementation,
-        path: audit.path,
-        options: audit.options || {},
-      };
-    });
-
-    const mergedAuditDefns = mergeOptionsOfItems(auditDefns);
-    mergedAuditDefns.forEach(audit => assertValidAudit(audit.implementation, audit.path));
+    const auditDefns = requireAudits(audits, configDir);
     log.timeEnd(status);
-    return mergedAuditDefns;
+    return auditDefns;
   }
 
   /**
@@ -853,7 +722,7 @@ class Config {
     let requirePath = `../gather/gatherers/${path}`;
     if (!coreGatherer) {
       // Otherwise, attempt to find it elsewhere. This throws if not found.
-      requirePath = Config.resolveModule(path, configDir, 'gatherer');
+      requirePath = resolveModule(path, configDir, 'gatherer');
     }
 
     const GathererClass = /** @type {GathererConstructor} */ (require(requirePath));
@@ -869,7 +738,7 @@ class Config {
   /**
    * Takes an array of passes with every property now initialized except the
    * gatherers and requires them, (relative to the optional `configDir` if
-   * provided) using `Config.resolveModule`, returning an array of full Passes.
+   * provided) using `resolveModule`, returning an array of full Passes.
    * @param {?Array<Required<LH.Config.PassJson>>} passes
    * @param {string=} configDir
    * @return {Config['passes']}
@@ -916,52 +785,6 @@ class Config {
     log.timeEnd(status);
     return fullPasses;
   }
-
-  /**
-   * Resolves the location of the specified module and returns an absolute
-   * string path to the file. Used for loading custom audits and gatherers.
-   * Throws an error if no module is found.
-   * @param {string} moduleIdentifier
-   * @param {string=} configDir The absolute path to the directory of the config file, if there is one.
-   * @param {string=} category Optional plugin category (e.g. 'audit') for better error messages.
-   * @return {string}
-   * @throws {Error}
-   */
-  static resolveModule(moduleIdentifier, configDir, category) {
-    // First try straight `require()`. Unlikely to be specified relative to this
-    // file, but adds support for Lighthouse modules from npm since
-    // `require()` walks up parent directories looking inside any node_modules/
-    // present. Also handles absolute paths.
-    try {
-      return require.resolve(moduleIdentifier);
-    } catch (e) {}
-
-    // See if the module resolves relative to the current working directory.
-    // Most useful to handle the case of invoking Lighthouse as a module, since
-    // then the config is an object and so has no path.
-    const cwdPath = path.resolve(process.cwd(), moduleIdentifier);
-    try {
-      return require.resolve(cwdPath);
-    } catch (e) {}
-
-    const errorString = 'Unable to locate ' +
-        (category ? `${category}: ` : '') +
-        `${moduleIdentifier} (tried to require() from '${__dirname}' and load from '${cwdPath}'`;
-
-    if (!configDir) {
-      throw new Error(errorString + ')');
-    }
-
-    // Finally, try looking up relative to the config file path. Just like the
-    // relative path passed to `require()` is found relative to the file it's
-    // in, this allows module paths to be specified relative to the config file.
-    const relativePath = path.resolve(configDir, moduleIdentifier);
-    try {
-      return require.resolve(relativePath);
-    } catch (requireError) {}
-
-    throw new Error(errorString + ` and '${relativePath}')`);
-  }
 }
 
 module.exports = Config;
diff --git a/lighthouse-core/gather/gather-runner.js b/lighthouse-core/gather/gather-runner.js
index 46388f345..24f3baafb 100644
--- a/lighthouse-core/gather/gather-runner.js
+++ b/lighthouse-core/gather/gather-runner.js
@@ -48,10 +48,11 @@ class GatherRunner {
    * Loads options.url with specified options. If the main document URL
    * redirects, options.url will be updated accordingly. As such, options.url
    * will always represent the post-redirected URL. options.requestedUrl is the
-   * pre-redirect starting URL.
+   * pre-redirect starting URL. If the navigation errors with "expected" errors such as
+   * NO_FCP, a `navigationError` is returned.
    * @param {Driver} driver
    * @param {LH.Gatherer.PassContext} passContext
-   * @return {Promise<void>}
+   * @return {Promise<{navigationError?: LH.LighthouseError}>}
    */
   static async loadPage(driver, passContext) {
     const gatherers = passContext.passConfig.gatherers;
@@ -61,15 +62,25 @@ class GatherRunner {
       args: [gatherers.map(g => g.instance.name).join(', ')],
     };
     log.time(status);
+    try {
+      const finalUrl = await driver.gotoURL(passContext.url, {
+        waitForFCP: passContext.passConfig.recordTrace,
+        waitForLoad: true,
+        passContext,
+      });
+      passContext.url = finalUrl;
+    } catch (err) {
+      // If it's one of our loading-based LHErrors, we'll treat it as a page load error.
+      if (err.code === 'NO_FCP') {
+        return {navigationError: err};
+      }
 
-    const finalUrl = await driver.gotoURL(passContext.url, {
-      waitForFCP: passContext.passConfig.recordTrace,
-      waitForLoad: true,
-      passContext,
-    });
-    passContext.url = finalUrl;
+      throw err;
+    } finally {
+      log.timeEnd(status);
+    }
 
-    log.timeEnd(status);
+    return {};
   }
 
   /**
@@ -122,7 +133,7 @@ class GatherRunner {
    * Returns an error if the original network request failed or wasn't found.
    * @param {string} url The URL of the original requested page.
    * @param {Array<LH.Artifacts.NetworkRequest>} networkRecords
-   * @return {LHError|undefined}
+   * @return {LH.LighthouseError|undefined}
    */
   static getNetworkError(url, networkRecords) {
     const mainRecord = networkRecords.find(record => {
@@ -161,14 +172,24 @@ class GatherRunner {
    * main document request failure, a security issue, etc.
    * @param {LH.Gatherer.PassContext} passContext
    * @param {LH.Gatherer.LoadData} loadData
+   * @param {LighthouseError|undefined} navigationError
+   * @return {LighthouseError|undefined}
    */
-  static getPageLoadError(passContext, loadData) {
+  static getPageLoadError(passContext, loadData, navigationError) {
     const networkError = GatherRunner.getNetworkError(passContext.url, loadData.networkRecords);
 
     //  If the driver was offline, the load will fail without offline support. Ignore this case.
     if (!passContext.driver.online) return;
 
-    return networkError;
+    // Network errors are usually the most specific and provide the best reason for why the page failed to load.
+    // Prefer networkError over navigationError.
+    // Example: `DNS_FAILURE` is better than `NO_FCP`.
+    if (networkError) return networkError;
+
+    // Navigation errors are rather generic and express some failure of the page to render properly.
+    // Use `navigationError` as the last resort.
+    // Example: `NO_FCP`, the page never painted content for some unknown reason.
+    return navigationError;
   }
 
   /**
@@ -576,7 +597,7 @@ class GatherRunner {
 
     // Navigate, start recording, and run `pass()` on gatherers.
     await GatherRunner.beginRecording(passContext);
-    await GatherRunner.loadPage(driver, passContext);
+    const {navigationError: possibleNavError} = await GatherRunner.loadPage(driver, passContext);
     await GatherRunner.pass(passContext, gathererResults);
     const loadData = await GatherRunner.endRecording(passContext);
 
@@ -589,7 +610,7 @@ class GatherRunner {
     if (loadData.trace) baseArtifacts.traces[passConfig.passName] = loadData.trace;
 
     // If there were any load errors, treat all gatherers as if they errored.
-    const pageLoadError = GatherRunner.getPageLoadError(passContext, loadData);
+    const pageLoadError = GatherRunner.getPageLoadError(passContext, loadData, possibleNavError);
     if (pageLoadError) {
       log.error('GatherRunner', pageLoadError.friendlyMessage, passContext.url);
       passContext.LighthouseRunWarnings.push(pageLoadError.friendlyMessage);
diff --git a/lighthouse-core/gather/gatherers/seo/tap-targets.js b/lighthouse-core/gather/gatherers/seo/tap-targets.js
index aed7ce086..bd763492a 100644
--- a/lighthouse-core/gather/gatherers/seo/tap-targets.js
+++ b/lighthouse-core/gather/gatherers/seo/tap-targets.js
@@ -5,11 +5,16 @@
  */
 'use strict';
 
-/* global getComputedStyle, getElementsInDocument, Node, getNodePath, getNodeSelector, getNodeLabel */
+/* global document, window, getComputedStyle, getElementsInDocument, Node, getNodePath, getNodeSelector, getNodeLabel */
 
 const Gatherer = require('../gatherer.js');
 const pageFunctions = require('../../../lib/page-functions.js');
-const {rectContainsString, rectContains} = require('../../../lib/rect-helpers.js');
+const {
+  rectContains,
+  getRectArea,
+  getRectCenterPoint,
+  getLargestRect,
+} = require('../../../lib/rect-helpers.js');
 
 const TARGET_SELECTORS = [
   'button',
@@ -66,64 +71,6 @@ function elementIsVisible(element) {
   return true;
 }
 
-/**
- * @param {LH.Artifacts.Rect[]} clientRects
- */
-/* istanbul ignore next */
-function allClientRectsEmpty(clientRects) {
-  return clientRects.every(cr => cr.width === 0 && cr.height === 0);
-}
-
-/**
- * @param {Element} element
- */
-/* istanbul ignore next */
-function getVisibleClientRects(element) {
-  if (!elementIsVisible(element)) {
-    return [];
-  }
-
-  let clientRects = getClientRects(element);
-
-  if (allClientRectsEmpty(clientRects)) {
-    return [];
-  }
-
-  // Treating overflowing content in scroll containers as invisible could mean that
-  // most of a given page is deemed invisible. But:
-  // - tap targets audit doesn't consider different containers/layers
-  // - having most content in an explicit scroll container is rare
-  // - treating them as hidden only generates false passes, which is better than false failures
-  clientRects = filterClientRectsWithinAncestorsVisibleScrollArea(element, clientRects);
-
-  return clientRects;
-}
-
-/**
- *
- * @param {Element} element
- * @param {LH.Artifacts.Rect[]} clientRects
- * @returns {LH.Artifacts.Rect[]}
- */
-/* istanbul ignore next */
-function filterClientRectsWithinAncestorsVisibleScrollArea(element, clientRects) {
-  const parent = element.parentElement;
-  if (!parent) {
-    return clientRects;
-  }
-  if (getComputedStyle(parent).overflowY !== 'visible') {
-    const parentBCR = parent.getBoundingClientRect();
-    clientRects = clientRects.filter(cr => rectContains(parentBCR, cr));
-  }
-  if (parent.parentElement && parent.parentElement.tagName !== 'BODY') {
-    return filterClientRectsWithinAncestorsVisibleScrollArea(
-      parent,
-      clientRects
-    );
-  }
-  return clientRects;
-}
-
 /**
  * @param {Element} element
  * @returns {LH.Artifacts.Rect[]}
@@ -220,22 +167,6 @@ function elementIsInTextBlock(element) {
   }
 }
 
-/**
- * @param {Element} element
- * @returns {boolean}
- */
-/* istanbul ignore next */
-function elementIsPositionFixedStickyOrAbsolute(element) {
-  const {position} = getComputedStyle(element);
-  if (position === 'fixed' || position === 'absolute' || position === 'sticky') {
-    return true;
-  }
-  if (element.parentElement) {
-    return elementIsPositionFixedStickyOrAbsolute(element.parentElement);
-  }
-  return false;
-}
-
 /**
  * @param {string} str
  * @param {number} maxLength
@@ -249,6 +180,53 @@ function truncate(str, maxLength) {
   return str.slice(0, maxLength - 1) + '…';
 }
 
+/**
+ * @param {Element} el
+ * @param {{x: number, y: number}} elCenterPoint
+ */
+/* istanbul ignore next */
+function elementCenterIsAtZAxisTop(el, elCenterPoint) {
+  const viewportHeight = window.innerHeight;
+  const targetScrollY = Math.floor(elCenterPoint.y / viewportHeight) * viewportHeight;
+  if (window.scrollY !== targetScrollY) {
+    window.scrollTo(0, targetScrollY);
+  }
+
+  const topEl = document.elementFromPoint(
+    elCenterPoint.x,
+    elCenterPoint.y - window.scrollY
+  );
+
+  return topEl === el || el.contains(topEl);
+}
+
+/**
+ * Finds all position sticky/absolute elements on the page and adds a class
+ * that disables pointer events on them.
+ * @returns {() => void} - undo function to re-enable pointer events
+ */
+/* istanbul ignore next */
+function disableFixedAndStickyElementPointerEvents() {
+  const className = 'lighthouse-disable-pointer-events';
+  const styleTag = document.createElement('style');
+  styleTag.textContent = `.${className} { pointer-events: none !important }`;
+  document.body.appendChild(styleTag);
+
+  document.querySelectorAll('*').forEach(el => {
+    const position = getComputedStyle(el).position;
+    if (position === 'fixed' || position === 'sticky') {
+      el.classList.add(className);
+    }
+  });
+
+  return function undo() {
+    Array.from(document.getElementsByClassName(className)).forEach(el => {
+      el.classList.remove(className);
+    });
+    styleTag.remove();
+  };
+}
+
 /**
  * @returns {LH.Artifacts.TapTarget[]}
  */
@@ -257,10 +235,18 @@ function gatherTapTargets() {
   /** @type {LH.Artifacts.TapTarget[]} */
   const targets = [];
 
+  // Capture element positions relative to the top of the page
+  window.scrollTo(0, 0);
+
   /** @type {Element[]} */
   // @ts-ignore - getElementsInDocument put into scope via stringification
   const tapTargetElements = getElementsInDocument(tapTargetsSelector);
 
+  /** @type {{
+    tapTargetElement: Element,
+    clientRects: ClientRect[]
+  }[]} */
+  const tapTargetsWithClientRects = [];
   tapTargetElements.forEach(tapTargetElement => {
     // Filter out tap targets that are likely to cause false failures:
     if (elementHasAncestorTapTarget(tapTargetElement)) {
@@ -273,20 +259,54 @@ function gatherTapTargets() {
       // in the Web Content Accessibility Guidelines https://www.w3.org/TR/WCAG21/#target-size
       return;
     }
-    if (elementIsPositionFixedStickyOrAbsolute(tapTargetElement)) {
-      // Absolutely positioned elements might not be visible if they have a lower z-index
-      // than other tap targets, but if we don't ignore them we can get false failures.
-      //
-      // TODO: rewrite logic to use elementFromPoint to check if an element is visible
-      // (this should also mean we'd no longer need to check ancestor visible scroll area)
+    if (!elementIsVisible(tapTargetElement)) {
       return;
     }
 
-    const visibleClientRects = getVisibleClientRects(tapTargetElement);
-    if (visibleClientRects.length === 0) {
-      return;
+    tapTargetsWithClientRects.push({
+      tapTargetElement,
+      clientRects: getClientRects(tapTargetElement),
+    });
+  });
+
+  // Disable pointer events so that tap targets below them don't get
+  // detected as non-tappable (they are tappable, just not while the viewport
+  // is at the current scroll position)
+  const reenableFixedAndStickyElementPointerEvents = disableFixedAndStickyElementPointerEvents();
+
+  /** @type {{
+    tapTargetElement: Element,
+    visibleClientRects: ClientRect[]
+  }[]} */
+  const tapTargetsWithVisibleClientRects = [];
+  // We use separate loop here to get visible client rects because that involves
+  // scrolling around the page for elementCenterIsAtZAxisTop, which would affect the
+  // client rect positions.
+  tapTargetsWithClientRects.forEach(({tapTargetElement, clientRects}) => {
+    // Filter out empty client rects
+    let visibleClientRects = clientRects.filter(cr => cr.width !== 0 && cr.height !== 0);
+
+    // Filter out client rects that are invisible, e.g because they are in a position absolute element
+    // with a lower z-index than the main content.
+    // This will also filter out all position fixed or sticky tap targets elements because we disable pointer
+    // events on them before running this. That's the correct behavior because whether a position fixed/stick
+    // element overlaps with another tap target depends on the scroll position.
+    visibleClientRects = visibleClientRects.filter(rect => {
+      // Just checking the center can cause false failures for large partially hidden tap targets,
+      // but that should be a rare edge case
+      const rectCenterPoint = getRectCenterPoint(rect);
+      return elementCenterIsAtZAxisTop(tapTargetElement, rectCenterPoint);
+    });
+
+    if (visibleClientRects.length > 0) {
+      tapTargetsWithVisibleClientRects.push({
+        tapTargetElement,
+        visibleClientRects,
+      });
     }
+  });
 
+  for (const {tapTargetElement, visibleClientRects} of tapTargetsWithVisibleClientRects) {
     targets.push({
       clientRects: visibleClientRects,
       snippet: truncate(tapTargetElement.outerHTML, 300),
@@ -298,7 +318,9 @@ function gatherTapTargets() {
       nodeLabel: getNodeLabel(tapTargetElement),
       href: /** @type {HTMLAnchorElement} */(tapTargetElement)['href'] || '',
     });
-  });
+  }
+
+  reenableFixedAndStickyElementPointerEvents();
 
   return targets;
 }
@@ -312,17 +334,18 @@ class TapTargets extends Gatherer {
     const expression = `(function() {
       const tapTargetsSelector = "${tapTargetsSelector}";
       ${pageFunctions.getElementsInDocumentString};
-      ${filterClientRectsWithinAncestorsVisibleScrollArea.toString()};
-      ${elementIsPositionFixedStickyOrAbsolute.toString()};
+      ${disableFixedAndStickyElementPointerEvents.toString()};
       ${elementIsVisible.toString()};
       ${elementHasAncestorTapTarget.toString()};
-      ${getVisibleClientRects.toString()};
+      ${elementCenterIsAtZAxisTop.toString()}
       ${truncate.toString()};
       ${getClientRects.toString()};
       ${hasTextNodeSiblingsFormingTextBlock.toString()};
       ${elementIsInTextBlock.toString()};
-      ${allClientRectsEmpty.toString()};
-      ${rectContainsString};
+      ${getRectArea.toString()};
+      ${getLargestRect.toString()};
+      ${getRectCenterPoint.toString()};
+      ${rectContains.toString()};
       ${pageFunctions.getNodePathString};
       ${pageFunctions.getNodeSelectorString};
       ${pageFunctions.getNodeLabelString};
diff --git a/lighthouse-core/lib/page-functions.js b/lighthouse-core/lib/page-functions.js
index 4a09367b8..3622f35b7 100644
--- a/lighthouse-core/lib/page-functions.js
+++ b/lighthouse-core/lib/page-functions.js
@@ -168,8 +168,7 @@ function getNodePath(node) {
     while (prevNode = node.previousSibling) {
       node = prevNode;
       // skip empty text nodes
-      if (node.nodeType === Node.TEXT_NODE && node.textContent &&
-        node.textContent.trim().length === 0) continue;
+      if (node.nodeType === Node.TEXT_NODE && node.nodeValue.trim().length === 0) continue;
       index++;
     }
     return index;
diff --git a/lighthouse-core/lib/rect-helpers.js b/lighthouse-core/lib/rect-helpers.js
index ced6ba6b1..63e99d79f 100644
--- a/lighthouse-core/lib/rect-helpers.js
+++ b/lighthouse-core/lib/rect-helpers.js
@@ -29,11 +29,6 @@ function rectContains(rect1, rect2) {
     rect2.left >= rect1.left;
 }
 
-
-const rectContainsString = `
-  ${rectContains.toString()};
-`;
-
 /**
  * @param {LH.Artifacts.Rect[]} rects
  * @returns {LH.Artifacts.Rect[]}
@@ -68,6 +63,7 @@ function filterOutRectsContainedByOthers(rects) {
 /**
  * @param {LH.Artifacts.Rect} rect
  */
+/* istanbul ignore next */
 function getRectCenterPoint(rect) {
   return {
     x: rect.left + rect.width / 2,
@@ -198,6 +194,7 @@ function getRectAtCenter(rect, centerRectSize) {
 /**
  * @param {LH.Artifacts.Rect} rect
  */
+/* istanbul ignore next */
 function getRectArea(rect) {
   return rect.width * rect.height;
 }
@@ -205,6 +202,7 @@ function getRectArea(rect) {
 /**
  * @param {LH.Artifacts.Rect[]} rects
  */
+/* istanbul ignore next */
 function getLargestRect(rects) {
   let largestRect = rects[0];
   for (const rect of rects) {
@@ -234,12 +232,12 @@ function allRectsContainedWithinEachOther(rectListA, rectListB) {
 module.exports = {
   rectContainsPoint,
   rectContains,
-  rectContainsString,
   addRectWidthAndHeight,
   addRectTopAndBottom,
   getRectOverlapArea,
   getRectAtCenter,
   getLargestRect,
+  getRectArea,
   getRectCenterPoint,
   getBoundingRect,
   getBoundingRectWithPadding,
diff --git a/lighthouse-core/report/html/renderer/report-ui-features.js b/lighthouse-core/report/html/renderer/report-ui-features.js
index bfbdc7e06..8a2db2697 100644
--- a/lighthouse-core/report/html/renderer/report-ui-features.js
+++ b/lighthouse-core/report/html/renderer/report-ui-features.js
@@ -25,8 +25,6 @@
 
 /* globals getFilenamePrefix Util */
 
-/** @typedef {import('./dom.js')} DOM */
-
 /**
  * @param {HTMLTableElement} tableEl
  * @return {Array<HTMLTableRowElement>}
@@ -98,7 +96,9 @@ class ReportUIFeatures {
 
     // Fireworks.
     const scoresAll100 = Object.values(report.categories).every(cat => cat.score === 1);
-    if (!this._dom.isDevTools() && scoresAll100) {
+    const hasAllCoreCategories =
+      Object.keys(report.categories).filter(id => !Util.isPluginCategory(id)).length >= 5;
+    if (!this._dom.isDevTools() && scoresAll100 && hasAllCoreCategories) {
       turnOffTheLights = true;
       const scoresContainer = this._dom.find('.lh-scores-container', this._document);
       scoresContainer.classList.add('score100');
@@ -117,6 +117,15 @@ class ReportUIFeatures {
       this._document.addEventListener('scroll', this._updateStickyHeaderOnScroll);
       window.addEventListener('resize', this._updateStickyHeaderOnScroll);
     }
+
+    // Show the metric descriptions by default when there is an error.
+    const hasMetricError = report.categories.performance && report.categories.performance.auditRefs
+      .some(audit => Boolean(audit.group === 'metrics' && report.audits[audit.id].errorMessage));
+    if (hasMetricError) {
+      const toggleInputEl = /** @type {HTMLInputElement} */ (
+        this._dom.find('.lh-metrics-toggle__input', this._document));
+      toggleInputEl.checked = true;
+    }
   }
 
   /**
diff --git a/lighthouse-core/report/html/report-styles.css b/lighthouse-core/report/html/report-styles.css
index 370cbd326..8be7d1206 100644
--- a/lighthouse-core/report/html/report-styles.css
+++ b/lighthouse-core/report/html/report-styles.css
@@ -1111,14 +1111,13 @@
 
 .lh-gauge__percentage {
   width: 100%;
-  height: var(--inset-size);
+  height: var(--gauge-circle-size);
   position: absolute;
-  border-radius: inherit;
   font-family: var(--monospace-font-family);
-  font-size: var(--score-number-font-size);
-  line-height: var(--score-number-font-size);
+  font-size: calc(var(--gauge-circle-size) * 0.34 + 1.3px);
+  line-height: 0;
   text-align: center;
-  top: calc(var(--score-container-padding) + var(--gauge-circle-size) / 3);
+  top: calc(var(--score-container-padding) + var(--gauge-circle-size) / 2);
 }
 
 .lh-category .lh-gauge__percentage {
diff --git a/lighthouse-core/runner.js b/lighthouse-core/runner.js
index 29bdaadf0..3838016f2 100644
--- a/lighthouse-core/runner.js
+++ b/lighthouse-core/runner.js
@@ -186,9 +186,12 @@ class Runner {
     // resolution.
     .map(entry => {
       return /** @type {PerformanceEntry} */ ({
-        ...entry,
-        duration: parseFloat(entry.duration.toFixed(2)),
+        // Don't spread entry because browser PerformanceEntries can't be spread.
+        // https://github.com/GoogleChrome/lighthouse/issues/8638
         startTime: parseFloat(entry.startTime.toFixed(2)),
+        name: entry.name,
+        duration: parseFloat(entry.duration.toFixed(2)),
+        entryType: entry.entryType,
       });
     });
     const runnerEntry = timingEntries.find(e => e.name === 'lh:runner:run');
diff --git a/lighthouse-core/scripts/roll-to-devtools.sh b/lighthouse-core/scripts/roll-to-devtools.sh
index fd1068566..963cb42c0 100755
--- a/lighthouse-core/scripts/roll-to-devtools.sh
+++ b/lighthouse-core/scripts/roll-to-devtools.sh
@@ -49,4 +49,4 @@ cp -r dist/dt-report-resources/ $fe_lh_dir
 VERSION=$(node -e "console.log(require('./package.json').version)")
 sed -i '' -e "s/Version:.*/Version: $VERSION/g" "$tests_dir"/*-expected.txt
 
-echo "Done. To rebase the test expectations, run: ~/chromium/src/third_party/blink/tools/run_web_tests.py --no-retry 'http/tests/devtools/audits2/*' --reset-results"
+echo "Done. To rebase the test expectations, run: yarn --cwd ~/chromium/src/third_party/blink/renderer/devtools test 'http/tests/devtools/audits2/*' --reset-results"
diff --git a/proto/sample_v2_round_trip.json b/proto/sample_v2_round_trip.json
index 0196d5311..36cf5bd0c 100644
--- a/proto/sample_v2_round_trip.json
+++ b/proto/sample_v2_round_trip.json
@@ -2,14 +2,9 @@
     "audits": {
         "accesskeys": {
             "description": "Access keys let users quickly focus a part of the page. For proper navigation, each access key must be unique. [Learn more](https://dequeuniversity.com/rules/axe/3.1/accesskeys?application=lighthouse).",
-            "details": {
-                "headings": [],
-                "items": [],
-                "type": "table"
-            },
             "id": "accesskeys",
-            "score": 1.0,
-            "scoreDisplayMode": "binary",
+            "score": null,
+            "scoreDisplayMode": "notApplicable",
             "title": "`[accesskey]` values are unique"
         },
         "appcache-manifest": {
@@ -152,9 +147,14 @@
         },
         "button-name": {
             "description": "When a button doesn't have an accessible name, screen readers announce it as \"button\", making it unusable for users who rely on screen readers. [Learn more](https://dequeuniversity.com/rules/axe/3.1/button-name?application=lighthouse).",
+            "details": {
+                "headings": [],
+                "items": [],
+                "type": "table"
+            },
             "id": "button-name",
-            "score": null,
-            "scoreDisplayMode": "notApplicable",
+            "score": 1.0,
+            "scoreDisplayMode": "binary",
             "title": "Buttons have an accessible name"
         },
         "bypass": {
@@ -3163,7 +3163,7 @@
                 {
                     "group": "a11y-navigation",
                     "id": "accesskeys",
-                    "weight": 3.0
+                    "weight": 0.0
                 },
                 {
                     "group": "a11y-aria",
@@ -3208,7 +3208,7 @@
                 {
                     "group": "a11y-names-labels",
                     "id": "button-name",
-                    "weight": 0.0
+                    "weight": 10.0
                 },
                 {
                     "group": "a11y-navigation",
@@ -3383,7 +3383,7 @@
             "description": "These checks highlight opportunities to [improve the accessibility of your web app](https://developers.google.com/web/fundamentals/accessibility). Only a subset of accessibility issues can be automatically detected so manual testing is also encouraged.",
             "id": "accessibility",
             "manualDescription": "These items address areas which an automated testing tool cannot cover. Learn more in our guide on [conducting an accessibility review](https://developers.google.com/web/fundamentals/accessibility/how-to-review).",
-            "score": 0.38,
+            "score": 0.46,
             "title": "Accessibility"
         },
         "best-practices": {
diff --git a/types/lhr.d.ts b/types/lhr.d.ts
index 28c60b9ed..3bee01cf1 100644
--- a/types/lhr.d.ts
+++ b/types/lhr.d.ts
@@ -8,6 +8,8 @@ import LHError = require('../lighthouse-core/lib/lh-error.js');
 
 declare global {
   module LH {
+    export type LighthouseError = LHError;
+
     export type I18NMessageEntry = string | {path: string, values: any};
 
     export interface I18NMessages {


Test patch:
diff --git a/lighthouse-cli/test/fixtures/dobetterweb/dbw_tester.html b/lighthouse-cli/test/fixtures/dobetterweb/dbw_tester.html
index 5e0042bd4..ed96a7f74 100644
--- a/lighthouse-cli/test/fixtures/dobetterweb/dbw_tester.html
+++ b/lighthouse-cli/test/fixtures/dobetterweb/dbw_tester.html
@@ -198,8 +198,8 @@
   https://github.com/GoogleChrome/lighthouse/issues/1194 -->
 <script>window.Error = function(error) { this.stack = 'stacktrace'; };</script>
 
-<!--The javascriptDialogOpening test is disabled as we hit a platform bug frequently on travis-->
-<!--<script>window.confirm('Is DBW Mega Tester the best site?')</script>-->
+<!-- Does the automatic dialog suppression `driver.dismissJavaScriptDialogs()` work? -->
+<script>window.confirm('Is DBW Mega Tester the best site?')</script>
 
 <script>
 function stampTemplate(id, location) {
diff --git a/lighthouse-cli/test/fixtures/seo/seo-tap-targets.html b/lighthouse-cli/test/fixtures/seo/seo-tap-targets.html
index 141011099..9f76e82d8 100644
--- a/lighthouse-cli/test/fixtures/seo/seo-tap-targets.html
+++ b/lighthouse-cli/test/fixtures/seo/seo-tap-targets.html
@@ -27,9 +27,19 @@
   <!-- Big tap target, but it's invisible because it's behind the main content div -->
   <a style="background: red; position: absolute; top: 0; bottom: 0; left: 0; right: 0; z-index: -1;"></a>
 
-  <a style="background: red; position: sticky; top: 0; left: 0; display: block; width: 100vw; height: 40px; z-index: 1;">sticky</a>
+  <a style="background: red; position: sticky; top: 0; left: 0; display: block; width: 100vw; height: 30px; z-index: 1;">sticky</a>
 
   <div style="background: white; padding: 20px;">
+
+    <div style="height: 300px">
+      <a
+        data-gathered-target="large-link-at-bottom-of-page"
+        style="background: #afa; position: absolute; top: 0; display: block; width: calc(100% - 40px); height: 300px;">
+        This link is intentionally placed at the top of the page, so that the sticky header is hard to tap on.
+        It should not fail though because overlap with a sticky element depends on the scroll position.
+      </a>
+    </div>
+      
     <h1>SEO Tap targets</h1>
 
     <!-- Invisible nodes don't cause failures -->
@@ -47,14 +57,14 @@
         width 0 and overflow x hidden
       </a>
       <!-- Visible target should not fail because nothing overlaps it -->
-      <a>visible target</a>
+      <a data-gathered-target="visible-target">visible target</a>
     </div>
     <br/><br/>
 
     
     <div style="overflow: hidden; position: relative">
       <!-- Should be counted as visible although part of it is outside the scroll container -->
-      <a>
+      <a data-gathered-target="target-with-client-rect-outside-scroll-container">
         <div style="position: absolute;top: -100px">invisible</div>
         visible
       </a>
@@ -64,18 +74,18 @@
 
     <!-- Link contains large inline block element - no failure because finger
       should be placed in the center of the whole link area -->
-    <a style="background: red;">
+    <a style="background: red;" data-gathered-target="link-containing-large-inline-block-element">
       <div style="display: inline-block; height: 30px; width: 30px; background: orange;"></div>
       Link
     </a>
     <br/>
-    <a style="display: block; padding: 30px; background: #ddf;">
+    <a style="display: block; padding: 30px; background: #ddf;" data-gathered-target="link-next-to-link-containing-large-inline-block-element">
       Link that the top one would overlap with, if it weren't for the inline-block child.
     </a>
 
     <br/><br/>
 
-    <div role="button">
+    <div role="button" data-gathered-target="tap-target-containing-other-tap-targets">
       Tap target with children that are also tap targets should not fail.
       (Children should not be counted as independent tap targets that appear
       in the list.)
@@ -85,29 +95,78 @@
 
     <br/><br/>
 
-    <div style="position: relative">
-      <a style="display: block; position: absolute; top:0; height: 40px; width: 40px;">inner</a>
-      <a style="display: block; height: 100px; width: 100px; background: yellowgreen">outer</a>
+    <!-- The left tap target has a large client rect that would normally overlap with the right tap target, 
+         but the left link is overflow hidden, so there should be no failure. We should however
+         detect both tap targets (and not say the left one is invisible because the center of the big client rect is invisible) -->
+    <div style="overflow: hidden">
+      <a
+        data-gathered-target="child-client-rect-hidden-by-overflow-hidden"
+        style="display: block; height: 40px; width: 40px; background: #fca; float: left; overflow: hidden; position: relative;">
+        <div style="background: #afa; height: 40px; width: 80px; position: absolute; left: 30px">
+          left    
+        </div>
+      </a>
+      <a
+        data-gathered-target="tap-target-next-to-child-client-rect-hidden-by-overflow-hidden"
+        style="display: block; height: 40px; width: 60px; background: yellowgreen; float: left">right</a>
     </div>
 
     <br/><br/>
 
-    <!-- Only target that's being overlapped should fail, the overlapping one shouldn't -->
-    <div>
-      <a style="display: block; width: 100px; height: 30px;background: #ddd;">
-        too small target
-      </a>
-      <a style="display: block; width: 100px; height: 100px;background: #aaa;">
-        big enough target
+    <!--Similar to previous test, except that the position absolute child isn't hidden by "overflow: hidden" on the parent. -->
+      <div style="overflow: hidden"></div>
+      <a
+        data-gathered-target="child-client-rect-overlapping-other-target"
+        style="display: block; height: 40px; width: 40px; background: #fca; float: left; position: relative;">
+        <div style="background: #afa; height: 40px; width: 30px; position: absolute; left: 30px">
+          left    
+        </div>
       </a>
+      <a
+        data-gathered-target="tap-target-overlapped-by-other-targets-position-absolute-child-rect"
+        style="display: block; height: 40px; width: 60px; background: yellowgreen; float: left">right</a>
+    </div>
+
+    <br/><br/>
+
+    <div style="position: relative">
+      <a
+        data-gathered-target="position-absolute-tap-target-fully-contained-in-other-target"
+        style="display: block; position: absolute; top:0; height: 40px; width: 40px;">inner</a>
+      <a
+        data-gathered-target="tap-target-fully-containing-position-absolute-target"
+        style="display: block; height: 100px; width: 100px; background: yellowgreen">outer</a>
+    </div>
+
+    <br/><br/>
+
+    <!-- position: absolute wrapper shouldn't stop not prevent the check (fixed/sticky would) -->
+    <div style="position: relative; height: 200px;">
+      <div style="position: absolute;">
+        <!-- Only target that's being overlapped should fail, the overlapping one shouldn't -->
+        <a
+          data-gathered-target="too-small-failing-tap-target"
+          style="display: block; width: 100px; height: 30px;background: #ddd;">
+          too small target
+        </a>
+        <a
+          data-gathered-target="large-enough-tap-target-next-to-too-small-tap-target"
+          style="display: block; width: 100px; height: 100px;background: #aaa;">
+          big enough target
+        </a>
+      </div>
     </div>
 
     <br/><br/>
 
     <!-- Should not fail if the two links have the same link target -->
     <div>
-      <a style="display: block; width: 10px; height: 10px;" href="../seo/"></a>
-      <a style="display: block; width: 10px; height: 10px;" href="./"></a>
+      <a
+        data-gathered-target="links-with-same-link-target-1"
+        style="display: block; width: 10px; height: 10px;" href="../seo/"></a>
+      <a
+        data-gathered-target="links-with-same-link-target-2"
+        style="display: block; width: 10px; height: 10px;" href="./"></a>
     </div>
 
     <!-- Links in text blocks are exempted from size/overlap requirements and should not fail -->
@@ -118,12 +177,6 @@
       This is <a>a link in a text block.</a>
       This is <a>a link in a text block.</a>
     </p>
-
-    <a style="background: #afa; display: block; width: 100%; height: 800px;">
-      This link is intentionally placed at the bottom of the page, so that when we've scrolled
-      to the bottom of the page it overlaps with the sticky header. (Should not fail though
-      because the overlap depends on the current scroll position.)
-    </a>
   </div>
 </body>
 </html>
diff --git a/lighthouse-cli/test/smokehouse/dobetterweb/dbw-expectations.js b/lighthouse-cli/test/smokehouse/dobetterweb/dbw-expectations.js
index c39d18480..b044ba7be 100644
--- a/lighthouse-cli/test/smokehouse/dobetterweb/dbw-expectations.js
+++ b/lighthouse-cli/test/smokehouse/dobetterweb/dbw-expectations.js
@@ -155,12 +155,12 @@ module.exports = [
         },
         'dom-size': {
           score: 1,
-          numericValue: 34,
+          numericValue: 35,
           details: {
             items: [
-              {statistic: 'Total DOM Elements', value: '34'},
+              {statistic: 'Total DOM Elements', value: '35'},
               {statistic: 'Maximum DOM Depth', value: '3'},
-              {statistic: 'Maximum Child Elements', value: '32'},
+              {statistic: 'Maximum Child Elements', value: '33'},
             ],
           },
         },
diff --git a/lighthouse-cli/test/smokehouse/seo/expectations.js b/lighthouse-cli/test/smokehouse/seo/expectations.js
index 8b8e5f71a..a913495e7 100644
--- a/lighthouse-cli/test/smokehouse/seo/expectations.js
+++ b/lighthouse-cli/test/smokehouse/seo/expectations.js
@@ -16,6 +16,60 @@ function headersParam(headers) {
   return new URLSearchParams([['extra_header', headerString]]).toString();
 }
 
+const expectedGatheredTapTargets = [
+  {
+    snippet: /large-link-at-bottom-of-page/,
+  },
+  {
+    snippet: /visible-target/,
+  },
+  {
+    snippet: /target-with-client-rect-outside-scroll-container/,
+  },
+  {
+    snippet: /link-containing-large-inline-block-element/,
+  },
+  {
+    snippet: /link-next-to-link-containing-large-inline-block-element/,
+  },
+  {
+    snippet: /tap-target-containing-other-tap-targets/,
+  },
+  {
+    snippet: /child-client-rect-hidden-by-overflow-hidden/,
+  },
+  {
+    snippet: /tap-target-next-to-child-client-rect-hidden-by-overflow-hidden/,
+  },
+  {
+    snippet: /child-client-rect-overlapping-other-target/,
+    shouldFail: true,
+  },
+  {
+    snippet: /tap-target-overlapped-by-other-targets-position-absolute-child-rect/,
+    shouldFail: true,
+  },
+  {
+    snippet: /position-absolute-tap-target-fully-contained-in-other-target/,
+  },
+  {
+    snippet: /tap-target-fully-containing-position-absolute-target/,
+  },
+  {
+    snippet: /too-small-failing-tap-target/,
+    shouldFail: true,
+  },
+  {
+    snippet: /large-enough-tap-target-next-to-too-small-tap-target/,
+  },
+  {
+    snippet: /links-with-same-link-target-1/,
+  },
+  {
+    snippet: /links-with-same-link-target-2/,
+  },
+];
+
 const failureHeaders = headersParam([[
   'x-robots-tag',
   'none',
@@ -190,38 +244,50 @@ module.exports = [
       audits: {
         'tap-targets': {
           score: (() => {
-            const PASSING_TAP_TARGETS = 11;
-            const TOTAL_TAP_TARGETS = 12;
+            const totalTapTargets = expectedGatheredTapTargets.length;
+            const passingTapTargets = expectedGatheredTapTargets.filter(t => !t.shouldFail).length;
             const SCORE_FACTOR = 0.89;
-            return Math.floor(PASSING_TAP_TARGETS / TOTAL_TAP_TARGETS * SCORE_FACTOR * 100) / 100;
+            return Math.round(passingTapTargets / totalTapTargets * SCORE_FACTOR * 100) / 100;
           })(),
           details: {
             items: [
               {
                 'tapTarget': {
                   'type': 'node',
-                  'snippet': '<a ' +
-                 'style="display: block; width: 100px; height: 30px;background: #ddd;">' +
-                 '\n        too small target\n      </a>',
-                  'path': '2,HTML,1,BODY,3,DIV,21,DIV,0,A',
+                  'path': '2,HTML,1,BODY,10,DIV,0,DIV,1,A',
                   'selector': 'body > div > div > a',
                   'nodeLabel': 'too small target',
                 },
                 'overlappingTarget': {
                   'type': 'node',
-                  'snippet': '<a ' +
-                  'style="display: block; width: 100px; height: 100px;background: #aaa;">' +
-                  '\n        big enough target\n      </a>',
-                  'path': '2,HTML,1,BODY,3,DIV,21,DIV,1,A',
+                  'path': '2,HTML,1,BODY,10,DIV,0,DIV,2,A',
                   'selector': 'body > div > div > a',
                   'nodeLabel': 'big enough target',
                 },
-                'size': '100x30',
-                'width': 100,
-                'height': 30,
                 'tapTargetScore': 1440,
                 'overlappingTargetScore': 432,
                 'overlapScoreRatio': 0.3,
+                'size': '100x30',
+                'width': 100,
+                'height': 30,
+              },
+              {
+                'tapTarget': {
+                  'type': 'node',
+                  'path': '2,HTML,1,BODY,3,DIV,24,A',
+                  'selector': 'body > div > a',
+                },
+                'overlappingTarget': {
+                  'type': 'node',
+                  'path': '2,HTML,1,BODY,3,DIV,25,A',
+                  'selector': 'body > div > a',
+                },
+                'tapTargetScore': 1920,
+                'overlappingTargetScore': 560,
+                'overlapScoreRatio': 0.2916666666666667,
+                'size': '40x40',
+                'width': 40,
+                'height': 40,
               },
             ],
           },
@@ -229,9 +295,7 @@ module.exports = [
       },
     },
     artifacts: {
-      TapTargets: {
-        length: 11,
-      },
+      TapTargets: expectedGatheredTapTargets.map(({snippet}) => ({snippet})),
     },
   },
 ];
diff --git a/lighthouse-cli/test/smokehouse/smokehouse.js b/lighthouse-cli/test/smokehouse/smokehouse.js
index 34003fb26..680de8884 100755
--- a/lighthouse-cli/test/smokehouse/smokehouse.js
+++ b/lighthouse-cli/test/smokehouse/smokehouse.js
@@ -38,6 +38,21 @@ function resolveLocalOrCwd(payloadPath) {
   return resolved;
 }
 
+/**
+ * Determines if the Lighthouse run ended in an unexpected fatal result.
+ * @param {number} exitCode
+ * @param {string} outputPath
+ */
+function isUnexpectedFatalResult(exitCode, outputPath) {
+  return exitCode !== 0
+    // These runtime errors are currently fatal but "expected" runtime errors we are asserting against.
+    && exitCode !== PAGE_HUNG_EXIT_CODE
+    && exitCode !== INSECURE_DOCUMENT_REQUEST_EXIT_CODE
+    // On runtime errors we exit with a error status code, but still output a report.
+    // If the report exists, it wasn't a fatal LH error we need to abort on, it's one we're asserting :)
+    && !fs.existsSync(outputPath);
+}
+
 /**
  * Launch Chrome and do a full Lighthouse run.
  * @param {string} url
@@ -85,22 +100,20 @@ function runLighthouse(url, configPath, isDebug) {
     runResults = spawnSync(command, args, {encoding: 'utf8', stdio: ['pipe', 'pipe', 'inherit']});
   } while (runResults.status === PROTOCOL_TIMEOUT_EXIT_CODE && runCount <= RETRIES);
 
+  if (isDebug) {
+    console.log(`STDOUT: ${runResults.stdout}`);
+    console.error(`STDERR: ${runResults.stderr}`);
+  }
+
   if (runResults.status === PROTOCOL_TIMEOUT_EXIT_CODE) {
     console.error(`Lighthouse debugger connection timed out ${RETRIES} times. Giving up.`);
     process.exit(1);
-  } else if (runResults.status !== 0
-     && runResults.status !== PAGE_HUNG_EXIT_CODE
-     && runResults.status !== INSECURE_DOCUMENT_REQUEST_EXIT_CODE) {
+  } else if (isUnexpectedFatalResult(runResults.status, outputPath)) {
     console.error(`Lighthouse run failed with exit code ${runResults.status}. stderr to follow:`);
     console.error(runResults.stderr);
     process.exit(runResults.status);
   }
 
-  if (isDebug) {
-    console.log(`STDOUT: ${runResults.stdout}`);
-    console.error(`STDERR: ${runResults.stderr}`);
-  }
-
   let errorCode;
   let lhr = {requestedUrl: url, finalUrl: url, audits: {}};
   if (runResults.status === PAGE_HUNG_EXIT_CODE) {
diff --git a/lighthouse-core/test/gather/gather-runner-test.js b/lighthouse-core/test/gather/gather-runner-test.js
index 6dca2bffd..3ec3ec8e3 100644
--- a/lighthouse-core/test/gather/gather-runner-test.js
+++ b/lighthouse-core/test/gather/gather-runner-test.js
@@ -13,6 +13,8 @@ const assert = require('assert');
 const Config = require('../../config/config.js');
 const unresolvedPerfLog = require('./../fixtures/unresolved-perflog.json');
 const NetworkRequest = require('../../lib/network-request.js');
+const LHError = require('../../lib/lh-error.js');
+const networkRecordsToDevtoolsLog = require('../network-records-to-devtools-log.js');
 
 jest.mock('../../lib/stack-collector.js', () => () => Promise.resolve([]));
 
@@ -100,7 +102,7 @@ describe('GatherRunner', function() {
     };
 
     const passContext = {
-      requestedUrl: url1,
+      url: url1,
       settings: {},
       passConfig: {
         gatherers: [],
@@ -112,6 +114,26 @@ describe('GatherRunner', function() {
     });
   });
 
+  it('loads a page and returns a pageLoadError', async () => {
+    const url = 'https://example.com';
+    const error = new LHError(LHError.errors.NO_FCP);
+    const driver = {
+      gotoURL() {
+        return Promise.reject(error);
+      },
+    };
+
+    const passContext = {
+      url,
+      settings: {},
+      passConfig: {gatherers: []},
+    };
+
+    const {navigationError} = await GatherRunner.loadPage(driver, passContext);
+    expect(navigationError).toEqual(error);
+    expect(passContext.url).toEqual(url);
+  });
+
   it('collects benchmark as an artifact', async () => {
     const url = 'https://example.com';
     const driver = fakeDriver;
@@ -396,6 +418,73 @@ describe('GatherRunner', function() {
     assert.equal(tests.calledCleanBrowserCaches, true);
   });
 
+  it('fails artifacts with network errors', async () => {
+    const requestedUrl = 'https://example.com';
+    // This page load error should be overriden by NO_DOCUMENT_REQUEST for being more specific
+    const navigationError = new LHError(LHError.errors.NO_FCP);
+    const driver = Object.assign({}, fakeDriver, {
+      online: true,
+      gotoURL: url => url.includes('blank') ? null : Promise.reject(navigationError),
+    });
+
+    const passConfig = {
+      gatherers: [
+        {instance: new TestGatherer()},
+      ],
+    };
+
+    const settings = {};
+
+    const passContext = {
+      url: requestedUrl,
+      driver,
+      passConfig,
+      settings,
+      LighthouseRunWarnings: [],
+      baseArtifacts: await GatherRunner.initializeBaseArtifacts({driver, settings, requestedUrl}),
+    };
+
+    const {artifacts} = await GatherRunner.runPass(passContext);
+    expect(passContext.LighthouseRunWarnings).toHaveLength(1);
+    expect(artifacts.TestGatherer).toBeInstanceOf(Error);
+    expect(artifacts.TestGatherer.code).toEqual('NO_DOCUMENT_REQUEST');
+  });
+
+  it('fails artifacts with navigation errors', async () => {
+    const requestedUrl = 'https://example.com';
+    // This time, NO_FCP should win because it's the only error left.
+    const navigationError = new LHError(LHError.errors.NO_FCP);
+    const driver = Object.assign({}, fakeDriver, {
+      online: true,
+      gotoURL: url => url.includes('blank') ? null : Promise.reject(navigationError),
+      endDevtoolsLog() {
+        return networkRecordsToDevtoolsLog([{url: requestedUrl}]);
+      },
+    });
+
+    const passConfig = {
+      gatherers: [
+        {instance: new TestGatherer()},
+      ],
+    };
+
+    const settings = {};
+
+    const passContext = {
+      url: requestedUrl,
+      driver,
+      passConfig,
+      settings,
+      LighthouseRunWarnings: [],
+      baseArtifacts: await GatherRunner.initializeBaseArtifacts({driver, settings, requestedUrl}),
+    };
+
+    const {artifacts} = await GatherRunner.runPass(passContext);
+    expect(passContext.LighthouseRunWarnings).toHaveLength(1);
+    expect(artifacts.TestGatherer).toBeInstanceOf(Error);
+    expect(artifacts.TestGatherer.code).toEqual('NO_FCP');
+  });
+
   it('does not clear origin storage with flag --disable-storage-reset', () => {
     const asyncFunc = () => Promise.resolve();
     const tests = {
diff --git a/lighthouse-core/test/report/html/renderer/category-renderer-test.js b/lighthouse-core/test/report/html/renderer/category-renderer-test.js
index 789eb8220..56c0fc764 100644
--- a/lighthouse-core/test/report/html/renderer/category-renderer-test.js
+++ b/lighthouse-core/test/report/html/renderer/category-renderer-test.js
@@ -242,7 +242,7 @@ describe('CategoryRenderer', () => {
       const categoryDOM = renderer.render(category, sampleResults.categoryGroups);
 
       const gauge = categoryDOM.querySelector('.lh-gauge__percentage');
-      assert.equal(gauge.textContent.trim(), '38', 'score is 0-100');
+      assert.equal(gauge.textContent.trim(), '46', 'score is 0-100');
 
       const score = categoryDOM.querySelector('.lh-category-header');
       const value = categoryDOM.querySelector('.lh-gauge__percentage');
diff --git a/lighthouse-core/test/report/html/renderer/report-ui-features-test.js b/lighthouse-core/test/report/html/renderer/report-ui-features-test.js
index ca825732d..d706fc4f8 100644
--- a/lighthouse-core/test/report/html/renderer/report-ui-features-test.js
+++ b/lighthouse-core/test/report/html/renderer/report-ui-features-test.js
@@ -238,5 +238,31 @@ describe('ReportUIFeatures', () => {
       const container = render(lhr);
       assert.ok(container.querySelector('.score100'), 'has fireworks treatment');
     });
+
+    it('should not render fireworks if all core categories are not present', () => {
+      const lhr = JSON.parse(JSON.stringify(sampleResults));
+      delete lhr.categories.performance;
+      delete lhr.categoryGroups.performace;
+      Object.values(lhr.categories).forEach(element => {
+        element.score = 1;
+      });
+      const container = render(lhr);
+      assert.ok(container.querySelector('.score100') === null, 'has no fireworks treatment');
+    });
+  });
+
+  describe('metric descriptions', () => {
+    it('with no errors, hide by default', () => {
+      const lhr = JSON.parse(JSON.stringify(sampleResults));
+      const container = render(lhr);
+      assert.ok(!container.querySelector('.lh-metrics-toggle__input').checked);
+    });
+
+    it('with error, show by default', () => {
+      const lhr = JSON.parse(JSON.stringify(sampleResults));
+      lhr.audits['first-contentful-paint'].errorMessage = 'Error.';
+      const container = render(lhr);
+      assert.ok(container.querySelector('.lh-metrics-toggle__input').checked);
+    });
   });
 });
diff --git a/lighthouse-core/test/results/artifacts/artifacts.json b/lighthouse-core/test/results/artifacts/artifacts.json
index 66b699a1f..2cbcd6481 100644
--- a/lighthouse-core/test/results/artifacts/artifacts.json
+++ b/lighthouse-core/test/results/artifacts/artifacts.json
@@ -1337,29 +1337,28 @@
     ],
     "notApplicable": [
       {
-        "id": "aria-allowed-attr",
+        "id": "accesskeys",
         "impact": null,
         "tags": [
-          "cat.aria",
-          "wcag2a",
-          "wcag412"
+          "best-practice",
+          "cat.keyboard"
         ],
-        "description": "Ensures ARIA attributes are allowed for an element's role",
-        "help": "Elements must only use allowed ARIA attributes",
-        "helpUrl": "https://dequeuniversity.com/rules/axe/3.1/aria-allowed-attr?application=axeAPI",
+        "description": "Ensures every accesskey attribute value is unique",
+        "help": "accesskey attribute value must be unique",
+        "helpUrl": "https://dequeuniversity.com/rules/axe/3.2/accesskeys?application=axeAPI",
         "nodes": []
       },
       {
-        "id": "aria-dpub-role-fallback",
+        "id": "aria-allowed-attr",
         "impact": null,
         "tags": [
           "cat.aria",
           "wcag2a",
-          "wcag131"
+          "wcag412"
         ],
-        "description": "Ensures unsupported DPUB roles are only used on elements with implicit fallback roles",
-        "help": "Unsupported DPUB ARIA roles should be used on elements with implicit fallback roles",
-        "helpUrl": "https://dequeuniversity.com/rules/axe/3.1/aria-dpub-role-fallback?application=axeAPI",
+        "description": "Ensures ARIA attributes are allowed for an element's role",
+        "help": "Elements must only use allowed ARIA attributes",
+        "helpUrl": "https://dequeuniversity.com/rules/axe/3.2/aria-allowed-attr?application=axeAPI",
         "nodes": []
       },
       {
@@ -1452,22 +1451,7 @@
         ],
         "description": "Ensures <audio> elements have captions",
         "help": "<audio> elements must have a captions track",
-        "helpUrl": "https://dequeuniversity.com/rules/axe/3.1/audio-caption?application=axeAPI",
-        "nodes": []
-      },
-      {
-        "id": "button-name",
-        "impact": null,
-        "tags": [
-          "cat.name-role-value",
-          "wcag2a",
-          "wcag412",
-          "section508",
-          "section508.22.a"
-        ],
-        "description": "Ensures buttons have discernible text",
-        "help": "Buttons must have discernible text",
-        "helpUrl": "https://dequeuniversity.com/rules/axe/3.1/button-name?application=axeAPI",
+        "helpUrl": "https://dequeuniversity.com/rules/axe/3.2/audio-caption?application=axeAPI",
         "nodes": []
       },
       {
@@ -1493,33 +1477,7 @@
         ],
         "description": "Ensures <dt> and <dd> elements are contained by a <dl>",
         "help": "<dt> and <dd> elements must be contained by a <dl>",
-        "helpUrl": "https://dequeuniversity.com/rules/axe/3.1/dlitem?application=axeAPI",
-        "nodes": []
-      },
-      {
-        "id": "duplicate-id-active",
-        "impact": null,
-        "tags": [
-          "cat.parsing",
-          "wcag2a",
-          "wcag411"
-        ],
-        "description": "Ensures every id attribute value of active elements is unique",
-        "help": "IDs of active elements must be unique",
-        "helpUrl": "https://dequeuniversity.com/rules/axe/3.1/duplicate-id-active?application=axeAPI",
-        "nodes": []
-      },
-      {
-        "id": "duplicate-id-aria",
-        "impact": null,
-        "tags": [
-          "cat.parsing",
-          "wcag2a",
-          "wcag411"
-        ],
-        "description": "Ensures every id attribute value used in ARIA and in labels is unique",
-        "help": "IDs used in ARIA and labels must be unique",
-        "helpUrl": "https://dequeuniversity.com/rules/axe/3.1/duplicate-id-aria?application=axeAPI",
+        "helpUrl": "https://dequeuniversity.com/rules/axe/3.2/dlitem?application=axeAPI",
         "nodes": []
       },
       {
@@ -1548,20 +1506,7 @@
         ],
         "description": "Ensures the lang attribute of the <html> element has a valid value",
         "help": "<html> element must have a valid value for the lang attribute",
-        "helpUrl": "https://dequeuniversity.com/rules/axe/3.1/html-lang-valid?application=axeAPI",
-        "nodes": []
-      },
-      {
-        "id": "html-xml-lang-mismatch",
-        "impact": null,
-        "tags": [
-          "cat.language",
-          "wcag2a",
-          "wcag311"
-        ],
-        "description": "Ensure that HTML elements with both valid lang and xml:lang attributes agree on the base language of the page",
-        "help": "HTML elements with lang and xml:lang must have the same base language",
-        "helpUrl": "https://dequeuniversity.com/rules/axe/3.1/html-xml-lang-mismatch?application=axeAPI",
+        "helpUrl": "https://dequeuniversity.com/rules/axe/3.2/html-lang-valid?application=axeAPI",
         "nodes": []
       },
       {
diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json
index ad1b5d0b8..c8a46b36f 100644
--- a/lighthouse-core/test/results/sample_v2.json
+++ b/lighthouse-core/test/results/sample_v2.json
@@ -1493,13 +1493,8 @@
       "id": "accesskeys",
       "title": "`[accesskey]` values are unique",
       "description": "Access keys let users quickly focus a part of the page. For proper navigation, each access key must be unique. [Learn more](https://dequeuniversity.com/rules/axe/3.1/accesskeys?application=lighthouse).",
-      "score": 1,
-      "scoreDisplayMode": "binary",
-      "details": {
-        "type": "table",
-        "headings": [],
-        "items": []
-      }
+      "score": null,
+      "scoreDisplayMode": "notApplicable"
     },
     "aria-allowed-attr": {
       "id": "aria-allowed-attr",
@@ -1561,8 +1556,13 @@
       "id": "button-name",
       "title": "Buttons have an accessible name",
       "description": "When a button doesn't have an accessible name, screen readers announce it as \"button\", making it unusable for users who rely on screen readers. [Learn more](https://dequeuniversity.com/rules/axe/3.1/button-name?application=lighthouse).",
-      "score": null,
-      "scoreDisplayMode": "notApplicable"
+      "score": 1,
+      "scoreDisplayMode": "binary",
+      "details": {
+        "type": "table",
+        "headings": [],
+        "items": []
+      }
     },
     "bypass": {
       "id": "bypass",
@@ -3523,7 +3523,7 @@
       "auditRefs": [
         {
           "id": "accesskeys",
-          "weight": 3,
+          "weight": 0,
           "group": "a11y-navigation"
         },
         {
@@ -3568,7 +3568,7 @@
         },
         {
           "id": "button-name",
-          "weight": 0,
+          "weight": 10,
           "group": "a11y-names-labels"
         },
         {
@@ -3742,7 +3742,7 @@
         }
       ],
       "id": "accessibility",
-      "score": 0.38
+      "score": 0.46
     },
     "best-practices": {
       "title": "Best Practices",