Repository: GoogleChrome/lighthouse
Pull request #6689 (2018-11-30 02:58:52)

report(pwa): add tooltip to badge gauge
last item in #6395!

Adds a tooltip as discussed in https://github.com/GoogleChrome/lighthouse/issues/6395#issuecomment-439562286 and comments below that.

<img width="389" alt="screen shot 2018-11-29 at 18 57 01" src="https://user-images.githubusercontent.com/316891/49265823-91edda00-f408-11e8-9bbc-51a43e38aa55.png">

Was a little more work than expected because we needed `categoryGroups` piped through to get their titles, but not too bad since they were mostly already nearby.



Patch:
diff --git a/lighthouse-cli/cli-flags.js b/lighthouse-cli/cli-flags.js
index ebfc1f00b..c6ca5dd57 100644
--- a/lighthouse-cli/cli-flags.js
+++ b/lighthouse-cli/cli-flags.js
@@ -90,7 +90,7 @@ function getFlags(manualArgv) {
         'config-path': 'The path to the config JSON.',
         'preset': 'Use a built-in configuration.',
         'chrome-flags':
-            `Custom flags to pass to Chrome (space-delimited). For a full list of flags, see http://bit.ly/chrome-flags
+            `Custom flags to pass to Chrome (space-delimited). For a full list of flags, see https://bit.ly/chrome-flags
             Additionally, use the CHROME_PATH environment variable to use a specific Chrome binary. Requires Chromium version 66.0 or later. If omitted, any detected Chrome Canary or Chrome stable will be used.`,
         'hostname': 'The hostname to use for the debugging protocol.',
         'port': 'The port to use for the debugging protocol. Use 0 for a random port',
diff --git a/lighthouse-core/audits/service-worker.js b/lighthouse-core/audits/service-worker.js
index a89c186f0..d06e651f8 100644
--- a/lighthouse-core/audits/service-worker.js
+++ b/lighthouse-core/audits/service-worker.js
@@ -15,44 +15,109 @@ class ServiceWorker extends Audit {
   static get meta() {
     return {
       id: 'service-worker',
-      title: 'Registers a service worker',
-      failureTitle: 'Does not register a service worker',
+      title: 'Registers a service worker that controls page and start_url',
+      failureTitle: 'Does not register a service worker that controls page and start_url',
       description: 'The service worker is the technology that enables your app to use many ' +
          'Progressive Web App features, such as offline, add to homescreen, and push ' +
          'notifications. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/registered-service-worker).',
-      requiredArtifacts: ['URL', 'ServiceWorker'],
+      requiredArtifacts: ['URL', 'ServiceWorker', 'Manifest'],
     };
   }
 
   /**
-   * @param {LH.Artifacts} artifacts
-   * @return {LH.Audit.Product}
+   * Find active service workers for this origin.
+   * @param {Array<LH.Crdp.ServiceWorker.ServiceWorkerVersion>} versions
+   * @param {URL} pageUrl
+   * @return {Array<LH.Crdp.ServiceWorker.ServiceWorkerVersion>}
    */
-  static audit(artifacts) {
-    const {versions, registrations} = artifacts.ServiceWorker;
-    const pageUrl = new URL(artifacts.URL.finalUrl);
-
-    // Find active service workers for this origin. Match against
-    // artifacts.URL.finalUrl so audit accounts for any redirects.
-    const matchingSWVersions = versions.filter(v => v.status === 'activated')
+  static getVersionsForOrigin(versions, pageUrl) {
+    return versions
+      .filter(v => v.status === 'activated')
       .filter(v => new URL(v.scriptURL).origin === pageUrl.origin);
+  }
 
-    if (matchingSWVersions.length === 0) {
-      return {rawValue: false};
-    }
-
+  /**
+   * From the set of active service workers for this origin, find the controlling SW (if any)
+   * and return its scope URL.
+   * @param {Array<LH.Crdp.ServiceWorker.ServiceWorkerVersion>} matchingSWVersions
+   * @param {Array<LH.Crdp.ServiceWorker.ServiceWorkerRegistration>} registrations
+   * @param {URL} pageUrl
+   * @return {string|undefined}
+   */
+  static getControllingScopeUrl(matchingSWVersions, registrations, pageUrl) {
     // Find the normalized scope URLs of possibly-controlling SWs.
     const matchingScopeUrls = matchingSWVersions
       .map(v => registrations.find(r => r.registrationId === v.registrationId))
       .filter(/** @return {r is LH.Crdp.ServiceWorker.ServiceWorkerRegistration} */ r => !!r)
       .map(r => new URL(r.scopeURL).href);
 
-    // Ensure page is included in a SW's scope.
+    // Find most-specific applicable scope, the one controlling the page.
     // See https://w3c.github.io/ServiceWorker/v1/#scope-match-algorithm
-    const inScope = matchingScopeUrls.some(scopeUrl => pageUrl.href.startsWith(scopeUrl));
+    const pageControllingScope = matchingScopeUrls
+      .filter(scopeUrl => pageUrl.href.startsWith(scopeUrl))
+      .sort((scopeA, scopeB) => scopeA.length - scopeB.length)
+      .pop();
+
+    return pageControllingScope;
+  }
+
+  /**
+   * Returns a failure message if there is no start_url or if the start_url isn't
+   * contolled by the scopeUrl.
+   * @param {LH.Artifacts['Manifest']} manifest
+   * @param {string} scopeUrl
+   * @return {string|undefined}
+   */
+  static checkStartUrl(manifest, scopeUrl) {
+    if (!manifest) {
+      return 'no start_url was found because no manifest was fetched';
+    }
+    if (!manifest.value) {
+      return 'no start_url was found because manifest failed to parse as valid JSON';
+    }
+
+    const startUrl = manifest.value.start_url.value;
+    if (!startUrl.startsWith(scopeUrl)) {
+      return `the start_url ("${startUrl}") is not in the service worker's scope ("${scopeUrl}")`;
+    }
+  }
+
+  /**
+   * @param {LH.Artifacts} artifacts
+   * @return {LH.Audit.Product}
+   */
+  static audit(artifacts) {
+    // Match against artifacts.URL.finalUrl so audit accounts for any redirects.
+    const pageUrl = new URL(artifacts.URL.finalUrl);
+    const {versions, registrations} = artifacts.ServiceWorker;
+
+    const versionsForOrigin = ServiceWorker.getVersionsForOrigin(versions, pageUrl);
+    if (versionsForOrigin.length === 0) {
+      return {
+        rawValue: false,
+      };
+    }
+
+    const controllingScopeUrl = ServiceWorker.getControllingScopeUrl(versionsForOrigin,
+        registrations, pageUrl);
+    if (!controllingScopeUrl) {
+      return {
+        rawValue: false,
+        explanation: `This origin has one or more service workers, however the page ("${pageUrl.href}") is not in scope.`, // eslint-disable-line max-len
+      };
+    }
+
+    const startUrlFailure = ServiceWorker.checkStartUrl(artifacts.Manifest, controllingScopeUrl);
+    if (startUrlFailure) {
+      return {
+        rawValue: false,
+        explanation: `This page is controlled by a service worker, however ${startUrlFailure}.`,
+      };
+    }
 
+    // SW controls both finalUrl and start_url.
     return {
-      rawValue: inScope,
+      rawValue: true,
     };
   }
 }
diff --git a/lighthouse-core/gather/driver.js b/lighthouse-core/gather/driver.js
index c49e51221..6b92db6db 100644
--- a/lighthouse-core/gather/driver.js
+++ b/lighthouse-core/gather/driver.js
@@ -281,8 +281,10 @@ class Driver {
     this._nextProtocolTimeout = DEFAULT_PROTOCOL_TIMEOUT;
     return new Promise(async (resolve, reject) => {
       const asyncTimeout = setTimeout((() => {
-        const err = new LHError(LHError.errors.PROTOCOL_TIMEOUT);
-        err.message += ` Method: ${method}`;
+        const err = new LHError(
+          LHError.errors.PROTOCOL_TIMEOUT,
+          {protocolMethod: method}
+        );
         reject(err);
       }), timeout);
       try {
diff --git a/lighthouse-core/gather/gather-runner.js b/lighthouse-core/gather/gather-runner.js
index 50726d043..70c2c7a48 100644
--- a/lighthouse-core/gather/gather-runner.js
+++ b/lighthouse-core/gather/gather-runner.js
@@ -146,9 +146,8 @@ class GatherRunner {
       return URL.equalWithExcludedFragments(record.url, url);
     });
 
-    let errorDef;
     if (!mainRecord) {
-      errorDef = LHError.errors.NO_DOCUMENT_REQUEST;
+      return new LHError(LHError.errors.NO_DOCUMENT_REQUEST);
     } else if (mainRecord.failed) {
       const netErr = mainRecord.localizedFailDescription;
       // Match all resolution and DNS failures
@@ -158,18 +157,18 @@ class GatherRunner {
         netErr === 'net::ERR_NAME_RESOLUTION_FAILED' ||
         netErr.startsWith('net::ERR_DNS_')
       ) {
-        errorDef = LHError.errors.DNS_FAILURE;
+        return new LHError(LHError.errors.DNS_FAILURE);
       } else {
-        errorDef = {...LHError.errors.FAILED_DOCUMENT_REQUEST};
-        errorDef.message += ` ${netErr}.`;
+        return new LHError(
+          LHError.errors.FAILED_DOCUMENT_REQUEST,
+          {errorDetails: netErr}
+        );
       }
     } else if (mainRecord.hasErrorStatusCode()) {
-      errorDef = {...LHError.errors.ERRORED_DOCUMENT_REQUEST};
-      errorDef.message += ` Status code: ${mainRecord.statusCode}.`;
-    }
-
-    if (errorDef) {
-      return new LHError(errorDef);
+      return new LHError(
+        LHError.errors.ERRORED_DOCUMENT_REQUEST,
+        {statusCode: `${mainRecord.statusCode}`}
+      );
     }
   }
 
@@ -180,12 +179,13 @@ class GatherRunner {
    */
   static assertNoSecurityIssues({securityState, explanations}) {
     if (securityState === 'insecure') {
-      const errorDef = {...LHError.errors.INSECURE_DOCUMENT_REQUEST};
       const insecureDescriptions = explanations
         .filter(exp => exp.securityState === 'insecure')
         .map(exp => exp.description);
-      errorDef.message += ` ${insecureDescriptions.join(' ')}`;
-      throw new LHError(errorDef);
+      throw new LHError(
+        LHError.errors.INSECURE_DOCUMENT_REQUEST,
+        {securityMessages: insecureDescriptions.join(' ')}
+      );
     }
   }
 
diff --git a/lighthouse-core/gather/gatherers/accessibility.js b/lighthouse-core/gather/gatherers/accessibility.js
index a0226a7da..0af785460 100644
--- a/lighthouse-core/gather/gatherers/accessibility.js
+++ b/lighthouse-core/gather/gatherers/accessibility.js
@@ -5,7 +5,7 @@
  */
 'use strict';
 
-/* global window, document, Node, getOuterHTMLSnippet */
+/* global window, document, getOuterHTMLSnippet, getNodePath */
 
 const Gatherer = require('./gatherer');
 const fs = require('fs');
@@ -45,6 +45,7 @@ function runA11yChecks() {
     // Augment the node objects with outerHTML snippet & custom path string
     // @ts-ignore
     axeResult.violations.forEach(v => v.nodes.forEach(node => {
+      // @ts-ignore - getNodePath put into scope via stringification
       node.path = getNodePath(node.element);
       // @ts-ignore - getOuterHTMLSnippet put into scope via stringification
       node.snippet = getOuterHTMLSnippet(node.element);
@@ -56,37 +57,6 @@ function runA11yChecks() {
     axeResult = {violations: axeResult.violations, notApplicable: axeResult.inapplicable};
     return axeResult;
   });
-
-  /**
-   * Adapted from DevTools' SDK.DOMNode.prototype.path
-   *   https://github.com/ChromeDevTools/devtools-frontend/blob/7a2e162ddefd/front_end/sdk/DOMModel.js#L530-L552
-   * TODO: Doesn't handle frames or shadow roots...
-   * @param {Node} node
-   */
-  function getNodePath(node) {
-    /** @param {Node} node */
-    function getNodeIndex(node) {
-      let index = 0;
-      let prevNode;
-      while (prevNode = node.previousSibling) {
-        node = prevNode;
-        // skip empty text nodes
-        if (node.nodeType === Node.TEXT_NODE && node.textContent &&
-          node.textContent.trim().length === 0) continue;
-        index++;
-      }
-      return index;
-    }
-
-    const path = [];
-    while (node && node.parentNode) {
-      const index = getNodeIndex(node);
-      path.push([index, node.nodeName]);
-      node = node.parentNode;
-    }
-    path.reverse();
-    return path.join(',');
-  }
 }
 
 class Accessibility extends Gatherer {
@@ -98,6 +68,7 @@ class Accessibility extends Gatherer {
     const driver = passContext.driver;
     const expression = `(function () {
       ${pageFunctions.getOuterHTMLSnippetString};
+      ${pageFunctions.getNodePathString};
       ${axeLibSource};
       return (${runA11yChecks.toString()}());
     })()`;
diff --git a/lighthouse-core/gather/gatherers/image-usage.js b/lighthouse-core/gather/gatherers/image-usage.js
index 0f9753713..6071b86af 100644
--- a/lighthouse-core/gather/gatherers/image-usage.js
+++ b/lighthouse-core/gather/gatherers/image-usage.js
@@ -53,8 +53,9 @@ function collectImageElementInfo() {
       naturalHeight: element.naturalHeight,
       isCss: false,
       isPicture: !!element.parentElement && element.parentElement.tagName === 'PICTURE',
-      usesObjectFit: computedStyle.getPropertyValue('object-fit') === 'cover'
-      || computedStyle.getPropertyValue('object-fit') === 'contain',
+      usesObjectFit: ['cover', 'contain', 'scale-down', 'none'].includes(
+        computedStyle.getPropertyValue('object-fit')
+      ),
     };
   });
 
diff --git a/lighthouse-core/lib/i18n/en-US.json b/lighthouse-core/lib/i18n/en-US.json
index 0bbdab614..e35e009b1 100644
--- a/lighthouse-core/lib/i18n/en-US.json
+++ b/lighthouse-core/lib/i18n/en-US.json
@@ -923,6 +923,58 @@
     "message": "{timeInMs, number, seconds} s",
     "description": "Used to show the duration in seconds that something lasted. The {timeInMs} placeholder will be replaced with the time duration, shown in seconds (e.g. 5.2 s)"
   },
+  "lighthouse-core/lib/lh-error.js | badTraceRecording": {
+    "message": "Something went wrong with recording the trace over your page load. Please run Lighthouse again.",
+    "description": "Error message explaining that the network trace was not able to be recorded for the Lighthouse run."
+  },
+  "lighthouse-core/lib/lh-error.js | didntCollectScreenshots": {
+    "message": "Chrome didn't collect any screenshots during the page load. Please make sure there is content visible on the page, and then try re-running Lighthouse.",
+    "description": "Error message explaining that the Lighthouse run was not able to collect screenshots through Chrome."
+  },
+  "lighthouse-core/lib/lh-error.js | dnsFailure": {
+    "message": "DNS servers could not resolve the provided domain.",
+    "description": "Error message explaining that the requested page could not be resolved by the DNS server."
+  },
+  "lighthouse-core/lib/lh-error.js | internalChromeError": {
+    "message": "An internal Chrome error occurred. Please restart Chrome and try re-running Lighthouse.",
+    "description": "Error message explaining that Chrome has encountered an error during the Lighthouse run, and that Chrome should be restarted."
+  },
+  "lighthouse-core/lib/lh-error.js | pageLoadFailed": {
+    "message": "Lighthouse was unable to reliably load the page you requested. Make sure you are testing the correct URL and that the server is properly responding to all requests.",
+    "description": "Error message explaining that Lighthouse could not load the requested URL and the steps that might be taken to fix the unreliability."
+  },
+  "lighthouse-core/lib/lh-error.js | pageLoadFailedHung": {
+    "message": "Lighthouse was unable to reliably load the URL you requested because the page stopped responding.",
+    "description": "Error message explaining that Lighthouse couldn't complete because the page has stopped responding to its instructions."
+  },
+  "lighthouse-core/lib/lh-error.js | pageLoadFailedInsecure": {
+    "message": "The URL you have provided does not have valid security credentials. ({securityMessages})",
+    "description": "Error message explaining that the credentials included in the Lighthouse run were invalid, so the URL cannot be accessed."
+  },
+  "lighthouse-core/lib/lh-error.js | pageLoadFailedWithDetails": {
+    "message": "Lighthouse was unable to reliably load the page you requested. Make sure you are testing the correct URL and that the server is properly responding to all requests. (Details: {errorDetails})",
+    "description": "Error message explaining that Lighthouse could not load the requested URL and the steps that might be taken to fix the unreliability."
+  },
+  "lighthouse-core/lib/lh-error.js | pageLoadFailedWithStatusCode": {
+    "message": "Lighthouse was unable to reliably load the page you requested. Make sure you are testing the correct URL and that the server is properly responding to all requests. (Status code: {statusCode})",
+    "description": "Error message explaining that Lighthouse could not load the requested URL and the steps that might be taken to fix the unreliability."
+  },
+  "lighthouse-core/lib/lh-error.js | pageLoadTookTooLong": {
+    "message": "Your page took too long to load. Please follow the opportunities in the report to reduce your page load time, and then try re-running Lighthouse.",
+    "description": "Error message explaining that the page loaded too slowly to perform a Lighthouse run."
+  },
+  "lighthouse-core/lib/lh-error.js | protocolTimeout": {
+    "message": "Waiting for DevTools protocol response has exceeded the allotted time. (Method: {protocolMethod})",
+    "description": "Error message explaining that the Chrome Devtools protocol has exceeded the maximum timeout allowed."
+  },
+  "lighthouse-core/lib/lh-error.js | requestContentTimeout": {
+    "message": "Fetching resource content has exceeded the allotted time",
+    "description": "Error message explaining that fetching the resources of the webpage has taken longer than the maximum time."
+  },
+  "lighthouse-core/lib/lh-error.js | urlInvalid": {
+    "message": "The URL you have provided appears to be invalid.",
+    "description": "Error message explaining that the provided URL Lighthouse points to is not valid, and cannot be loaded."
+  },
   "lighthouse-core/report/html/renderer/util.js | auditGroupExpandTooltip": {
     "message": "Show audits",
     "description": "The tooltip text on an expandable chevron icon. Clicking the icon expands a section to reveal a list of audit results that was hidden by default."
diff --git a/lighthouse-core/lib/lh-error.js b/lighthouse-core/lib/lh-error.js
index dbaae2b08..c83015e3f 100644
--- a/lighthouse-core/lib/lh-error.js
+++ b/lighthouse-core/lib/lh-error.js
@@ -5,7 +5,40 @@
  */
 'use strict';
 
-const strings = require('./strings');
+const i18n = require('./i18n/i18n.js');
+
+/* eslint-disable max-len */
+const UIStrings = {
+  /** Error message explaining that the Lighthouse run was not able to collect screenshots through Chrome.*/
+  didntCollectScreenshots: `Chrome didn't collect any screenshots during the page load. Please make sure there is content visible on the page, and then try re-running Lighthouse.`,
+  /** Error message explaining that the network trace was not able to be recorded for the Lighthouse run. */
+  badTraceRecording: 'Something went wrong with recording the trace over your page load. Please run Lighthouse again.',
+  /** Error message explaining that the page loaded too slowly to perform a Lighthouse run.  */
+  pageLoadTookTooLong: 'Your page took too long to load. Please follow the opportunities in the report to reduce your page load time, and then try re-running Lighthouse.',
+  /** Error message explaining that Lighthouse could not load the requested URL and the steps that might be taken to fix the unreliability. */
+  pageLoadFailed: 'Lighthouse was unable to reliably load the page you requested. Make sure you are testing the correct URL and that the server is properly responding to all requests.',
+  /** Error message explaining that Lighthouse could not load the requested URL and the steps that might be taken to fix the unreliability. */
+  pageLoadFailedWithStatusCode: 'Lighthouse was unable to reliably load the page you requested. Make sure you are testing the correct URL and that the server is properly responding to all requests. (Status code: {statusCode})',
+  /** Error message explaining that Lighthouse could not load the requested URL and the steps that might be taken to fix the unreliability. */
+  pageLoadFailedWithDetails: 'Lighthouse was unable to reliably load the page you requested. Make sure you are testing the correct URL and that the server is properly responding to all requests. (Details: {errorDetails})',
+  /** Error message explaining that the credentials included in the Lighthouse run were invalid, so the URL cannot be accessed. */
+  pageLoadFailedInsecure: 'The URL you have provided does not have valid security credentials. ({securityMessages})',
+  /** Error message explaining that Chrome has encountered an error during the Lighthouse run, and that Chrome should be restarted. */
+  internalChromeError: 'An internal Chrome error occurred. Please restart Chrome and try re-running Lighthouse.',
+  /** Error message explaining that fetching the resources of the webpage has taken longer than the maximum time. */
+  requestContentTimeout: 'Fetching resource content has exceeded the allotted time',
+  /** Error message explaining that the provided URL Lighthouse points to is not valid, and cannot be loaded. */
+  urlInvalid: 'The URL you have provided appears to be invalid.',
+  /** Error message explaining that the Chrome Devtools protocol has exceeded the maximum timeout allowed. */
+  protocolTimeout: 'Waiting for DevTools protocol response has exceeded the allotted time. (Method: {protocolMethod})',
+  /** Error message explaining that the requested page could not be resolved by the DNS server. */
+  dnsFailure: 'DNS servers could not resolve the provided domain.',
+  /** Error message explaining that Lighthouse couldn't complete because the page has stopped responding to its instructions. */
+  pageLoadFailedHung: 'Lighthouse was unable to reliably load the URL you requested because the page stopped responding.',
+};
+
+const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
+
 
 /**
  * @typedef LighthouseErrorDefinition
@@ -24,7 +57,7 @@ class LighthouseError extends Error {
     super(errorDefinition.code);
     this.name = 'LHError';
     this.code = errorDefinition.code;
-    this.friendlyMessage = errorDefinition.message;
+    this.friendlyMessage = str_(errorDefinition.message, properties);
     this.lhrRuntimeError = !!errorDefinition.lhrRuntimeError;
     if (properties) Object.assign(this, properties);
 
@@ -70,107 +103,113 @@ const ERRORS = {
   // Screenshot/speedline errors
   NO_SPEEDLINE_FRAMES: {
     code: 'NO_SPEEDLINE_FRAMES',
-    message: strings.didntCollectScreenshots,
+    message: UIStrings.didntCollectScreenshots,
     lhrRuntimeError: true,
   },
   SPEEDINDEX_OF_ZERO: {
     code: 'SPEEDINDEX_OF_ZERO',
-    message: strings.didntCollectScreenshots,
+    message: UIStrings.didntCollectScreenshots,
     lhrRuntimeError: true,
   },
   NO_SCREENSHOTS: {
     code: 'NO_SCREENSHOTS',
-    message: strings.didntCollectScreenshots,
+    message: UIStrings.didntCollectScreenshots,
     lhrRuntimeError: true,
   },
   INVALID_SPEEDLINE: {
     code: 'INVALID_SPEEDLINE',
-    message: strings.didntCollectScreenshots,
+    message: UIStrings.didntCollectScreenshots,
     lhrRuntimeError: true,
   },
 
   // Trace parsing errors
   NO_TRACING_STARTED: {
     code: 'NO_TRACING_STARTED',
-    message: strings.badTraceRecording,
+    message: UIStrings.badTraceRecording,
     lhrRuntimeError: true,
   },
   NO_NAVSTART: {
     code: 'NO_NAVSTART',
-    message: strings.badTraceRecording,
+    message: UIStrings.badTraceRecording,
     lhrRuntimeError: true,
   },
   NO_FCP: {
     code: 'NO_FCP',
-    message: strings.badTraceRecording,
+    message: UIStrings.badTraceRecording,
     lhrRuntimeError: true,
   },
   NO_DCL: {
     code: 'NO_DCL',
-    message: strings.badTraceRecording,
+    message: UIStrings.badTraceRecording,
     lhrRuntimeError: true,
   },
   NO_FMP: {
     code: 'NO_FMP',
-    message: strings.badTraceRecording,
+    message: UIStrings.badTraceRecording,
   },
 
   // TTI calculation failures
-  FMP_TOO_LATE_FOR_FCPUI: {code: 'FMP_TOO_LATE_FOR_FCPUI', message: strings.pageLoadTookTooLong},
-  NO_FCPUI_IDLE_PERIOD: {code: 'NO_FCPUI_IDLE_PERIOD', message: strings.pageLoadTookTooLong},
-  NO_TTI_CPU_IDLE_PERIOD: {code: 'NO_TTI_CPU_IDLE_PERIOD', message: strings.pageLoadTookTooLong},
+  FMP_TOO_LATE_FOR_FCPUI: {code: 'FMP_TOO_LATE_FOR_FCPUI', message: UIStrings.pageLoadTookTooLong},
+  NO_FCPUI_IDLE_PERIOD: {code: 'NO_FCPUI_IDLE_PERIOD', message: UIStrings.pageLoadTookTooLong},
+  NO_TTI_CPU_IDLE_PERIOD: {code: 'NO_TTI_CPU_IDLE_PERIOD', message: UIStrings.pageLoadTookTooLong},
   NO_TTI_NETWORK_IDLE_PERIOD: {
     code: 'NO_TTI_NETWORK_IDLE_PERIOD',
-    message: strings.pageLoadTookTooLong,
+    message: UIStrings.pageLoadTookTooLong,
   },
 
   // Page load failures
   NO_DOCUMENT_REQUEST: {
     code: 'NO_DOCUMENT_REQUEST',
-    message: strings.pageLoadFailed,
+    message: UIStrings.pageLoadFailed,
     lhrRuntimeError: true,
   },
-  /* Used when DevTools reports loading failed. Usually an internal (Chrome) issue. */
+  /* Used when DevTools reports loading failed. Usually an internal (Chrome) issue.
+   * Requries an additional `errorDetails` field for translation.
+   */
   FAILED_DOCUMENT_REQUEST: {
     code: 'FAILED_DOCUMENT_REQUEST',
-    message: strings.pageLoadFailed,
+    message: UIStrings.pageLoadFailedWithDetails,
     lhrRuntimeError: true,
   },
-  /* Used when status code is 4xx or 5xx. */
+  /* Used when status code is 4xx or 5xx.
+   * Requires an additional `statusCode` field for translation.
+   */
   ERRORED_DOCUMENT_REQUEST: {
     code: 'ERRORED_DOCUMENT_REQUEST',
-    message: strings.pageLoadFailed,
+    message: UIStrings.pageLoadFailedWithStatusCode,
     lhrRuntimeError: true,
   },
-  /* Used when security error prevents page load. */
+  /* Used when security error prevents page load.
+   * Requires an additional `securityMessages` field for translation.
+   */
   INSECURE_DOCUMENT_REQUEST: {
     code: 'INSECURE_DOCUMENT_REQUEST',
-    message: strings.pageLoadFailedInsecure,
+    message: UIStrings.pageLoadFailedInsecure,
     lhrRuntimeError: true,
   },
   /* Used when the page stopped responding and did not finish loading. */
   PAGE_HUNG: {
     code: 'PAGE_HUNG',
-    message: strings.pageLoadFailedHung,
+    message: UIStrings.pageLoadFailedHung,
     lhrRuntimeError: true,
   },
 
   // Protocol internal failures
   TRACING_ALREADY_STARTED: {
     code: 'TRACING_ALREADY_STARTED',
-    message: strings.internalChromeError,
+    message: UIStrings.internalChromeError,
     pattern: /Tracing.*started/,
     lhrRuntimeError: true,
   },
   PARSING_PROBLEM: {
     code: 'PARSING_PROBLEM',
-    message: strings.internalChromeError,
+    message: UIStrings.internalChromeError,
     pattern: /Parsing problem/,
     lhrRuntimeError: true,
   },
   READ_FAILED: {
     code: 'READ_FAILED',
-    message: strings.internalChromeError,
+    message: UIStrings.internalChromeError,
     pattern: /Read failed/,
     lhrRuntimeError: true,
   },
@@ -178,20 +217,22 @@ const ERRORS = {
   // URL parsing failures
   INVALID_URL: {
     code: 'INVALID_URL',
-    message: strings.urlInvalid,
+    message: UIStrings.urlInvalid,
   },
 
-  // Protocol timeout failures
+  /* Protocol timeout failures
+   * Requires an additional `icuProtocolMethod` field for translation.
+   */
   PROTOCOL_TIMEOUT: {
     code: 'PROTOCOL_TIMEOUT',
-    message: strings.protocolTimeout,
+    message: UIStrings.protocolTimeout,
     lhrRuntimeError: true,
   },
 
   // DNS failure on main document (no resolution, timed out, etc)
   DNS_FAILURE: {
     code: 'DNS_FAILURE',
-    message: strings.dnsFailure,
+    message: UIStrings.dnsFailure,
     lhrRuntimeError: true,
   },
 
@@ -203,4 +244,4 @@ LighthouseError.errors = ERRORS;
 LighthouseError.NO_ERROR = 'NO_ERROR';
 LighthouseError.UNKNOWN_ERROR = 'UNKNOWN_ERROR';
 module.exports = LighthouseError;
-
+module.exports.UIStrings = UIStrings;
diff --git a/lighthouse-core/lib/minification-estimator.js b/lighthouse-core/lib/minification-estimator.js
index 88686e1ca..256ed1565 100644
--- a/lighthouse-core/lib/minification-estimator.js
+++ b/lighthouse-core/lib/minification-estimator.js
@@ -47,6 +47,7 @@ function computeTokenLength(content, features) {
   let isInLicenseComment = false;
   let isInString = false;
   let isInRegex = false;
+  let isInRegexCharacterClass = false;
   let stringOpenChar = null;
 
   for (let i = 0; i < content.length; i++) {
@@ -94,7 +95,13 @@ function computeTokenLength(content, features) {
         // Skip over any escaped characters
         totalTokenLength++;
         i++;
-      } else if (char === '/') {
+      } else if (char === '[') {
+        // Register that we're entering a character class so we don't leave the regex prematurely
+        isInRegexCharacterClass = true;
+      } else if (char === ']' && isInRegexCharacterClass) {
+        // Register that we're exiting the character class
+        isInRegexCharacterClass = false;
+      } else if (char === '/' && !isInRegexCharacterClass) {
         // End the string when we hit the regex close character
         isInRegex = false;
         // console.log(i, 'leaving regex', char)
diff --git a/lighthouse-core/lib/page-functions.js b/lighthouse-core/lib/page-functions.js
index 960476f15..9e9b79d69 100644
--- a/lighthouse-core/lib/page-functions.js
+++ b/lighthouse-core/lib/page-functions.js
@@ -6,7 +6,7 @@
 // @ts-nocheck
 'use strict';
 
-/* global window document */
+/* global window document Node */
 
 /**
  * Helper functions that are passed by `toString()` by Driver to be evaluated in target page.
@@ -119,7 +119,7 @@ function getOuterHTMLSnippet(element, ignoreAttrs = []) {
     clone.removeAttribute(attribute);
   });
 
-  const reOpeningTag = /^.*?>/;
+  const reOpeningTag = /^[\s\S]*?>/;
   const match = clone.outerHTML.match(reOpeningTag);
 
   return (match && match[0]) || '';
@@ -153,6 +153,71 @@ function ultradumbBenchmark() {
   return Math.round(iterations / durationInSeconds);
 }
 
+/**
+ * Adapted from DevTools' SDK.DOMNode.prototype.path
+ *   https://github.com/ChromeDevTools/devtools-frontend/blob/7a2e162ddefd/front_end/sdk/DOMModel.js#L530-L552
+ * TODO: Doesn't handle frames or shadow roots...
+ * @param {Node} node
+ */
+/* istanbul ignore next */
+function getNodePath(node) {
+  /** @param {Node} node */
+  function getNodeIndex(node) {
+    let index = 0;
+    let prevNode;
+    while (prevNode = node.previousSibling) {
+      node = prevNode;
+      // skip empty text nodes
+      if (node.nodeType === Node.TEXT_NODE && node.textContent &&
+        node.textContent.trim().length === 0) continue;
+      index++;
+    }
+    return index;
+  }
+
+  const path = [];
+  while (node && node.parentNode) {
+    const index = getNodeIndex(node);
+    path.push([index, node.nodeName]);
+    node = node.parentNode;
+  }
+  path.reverse();
+  return path.join(',');
+}
+
+/**
+ * @param {Element} node
+ * @returns {string}
+ */
+/* istanbul ignore next */
+function getNodeSelector(node) {
+  /**
+   * @param {Element} node
+   */
+  function getSelectorPart(node) {
+    let part = node.tagName.toLowerCase();
+    if (node.id) {
+      part += '#' + node.id;
+    } else if (node.classList.length > 0) {
+      part += '.' + node.classList[0];
+    }
+    return part;
+  }
+
+  const parts = [];
+  while (parts.length < 4) {
+    parts.unshift(getSelectorPart(node));
+    if (!node.parentElement) {
+      break;
+    }
+    node = node.parentElement;
+    if (node.tagName === 'HTML') {
+      break;
+    }
+  }
+  return parts.join(' > ');
+}
+
 module.exports = {
   wrapRuntimeEvalErrorInBrowserString: wrapRuntimeEvalErrorInBrowser.toString(),
   registerPerformanceObserverInPageString: registerPerformanceObserverInPage.toString(),
@@ -162,4 +227,7 @@ module.exports = {
   getOuterHTMLSnippet: getOuterHTMLSnippet,
   ultradumbBenchmark: ultradumbBenchmark,
   ultradumbBenchmarkString: ultradumbBenchmark.toString(),
+  getNodePathString: getNodePath.toString(),
+  getNodeSelectorString: getNodeSelector.toString(),
+  getNodeSelector: getNodeSelector,
 };
diff --git a/lighthouse-core/lib/rect-helpers.js b/lighthouse-core/lib/rect-helpers.js
new file mode 100644
index 000000000..9deb75bbf
--- /dev/null
+++ b/lighthouse-core/lib/rect-helpers.js
@@ -0,0 +1,258 @@
+/**
+ * @license Copyright 2018 Google Inc. All Rights Reserved.
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
+ */
+'use strict';
+
+/**
+ * @param {LH.Artifacts.Rect} rect
+ * @param {{x:number, y:number}} point
+ */
+// We sometimes run this as a part of a gatherer script injected into the page, so prevent
+// renaming the function for code coverage.
+/* istanbul ignore next */
+function rectContainsPoint(rect, {x, y}) {
+  return rect.left <= x && rect.right >= x && rect.top <= y && rect.bottom >= y;
+}
+
+/**
+ * @param {LH.Artifacts.Rect} rect1
+ * @param {LH.Artifacts.Rect} rect2
+ */
+// We sometimes run this as a part of a gatherer script injected into the page, so prevent
+// renaming the function for code coverage.
+/* istanbul ignore next */
+function rectContains(rect1, rect2) {
+  return (
+    // top left corner
+    rectContainsPoint(rect1, {
+      x: rect2.left,
+      y: rect2.top,
+    }) &&
+    // top right corner
+    rectContainsPoint(rect1, {
+      x: rect2.right,
+      y: rect2.top,
+    }) &&
+    // bottom left corner
+    rectContainsPoint(rect1, {
+      x: rect2.left,
+      y: rect2.bottom,
+    }) &&
+    // bottom right corner
+    rectContainsPoint(rect1, {
+      x: rect2.right,
+      y: rect2.bottom,
+    })
+  );
+}
+
+
+const rectContainsString = `
+  ${rectContainsPoint.toString()}
+  ${rectContains.toString()};
+`;
+
+/**
+ * @param {LH.Artifacts.Rect[]} rects
+ * @returns {LH.Artifacts.Rect[]}
+ */
+function filterOutTinyRects(rects) {
+  return rects.filter(
+    rect => rect.width > 1 && rect.height > 1
+  );
+}
+
+/**
+ * @param {LH.Artifacts.Rect[]} rects
+ * @returns {LH.Artifacts.Rect[]}
+ */
+function filterOutRectsContainedByOthers(rects) {
+  const rectsToKeep = new Set(rects);
+
+  for (const rect of rects) {
+    for (const possiblyContainingRect of rects) {
+      if (rect === possiblyContainingRect) continue;
+      if (!rectsToKeep.has(possiblyContainingRect)) continue;
+      if (rectContains(possiblyContainingRect, rect)) {
+        rectsToKeep.delete(rect);
+        break;
+      }
+    }
+  }
+
+  return Array.from(rectsToKeep);
+}
+
+/**
+ * @param {LH.Artifacts.Rect} rect
+ */
+function getRectCenterPoint(rect) {
+  return {
+    x: rect.left + rect.width / 2,
+    y: rect.top + rect.height / 2,
+  };
+}
+
+/**
+ * @param {LH.Artifacts.Rect} rectA
+ * @param {LH.Artifacts.Rect} rectB
+ * @returns {boolean}
+ */
+function rectsTouchOrOverlap(rectA, rectB) {
+  // https://stackoverflow.com/questions/2752349/fast-rectangle-to-rectangle-intersection
+  return (
+    rectA.left <= rectB.right &&
+    rectB.left <= rectA.right &&
+    rectA.top <= rectB.bottom &&
+    rectB.top <= rectA.bottom
+  );
+}
+
+/**
+ * @param {LH.Artifacts.Rect} rectA
+ * @param {LH.Artifacts.Rect} rectB
+ */
+function getBoundingRect(rectA, rectB) {
+  const left = Math.min(rectA.left, rectB.left);
+  const right = Math.max(rectA.right, rectB.right);
+  const top = Math.min(rectA.top, rectB.top);
+  const bottom = Math.max(rectA.bottom, rectB.bottom);
+  return addRectWidthAndHeight({
+    left,
+    right,
+    top,
+    bottom,
+  });
+}
+
+/**
+ * @param {{left:number, top:number, right:number, bottom: number}} rect
+ * @return {LH.Artifacts.Rect}
+ */
+function addRectWidthAndHeight({left, top, right, bottom}) {
+  return {
+    left,
+    top,
+    right,
+    bottom,
+    width: right - left,
+    height: bottom - top,
+  };
+}
+
+/**
+ * @param {{x:number, y:number, width:number, height: number}} rect
+ * @return {LH.Artifacts.Rect}
+ */
+function addRectTopAndBottom({x, y, width, height}) {
+  return {
+    left: x,
+    top: y,
+    right: x + width,
+    bottom: y + height,
+    width,
+    height,
+  };
+}
+
+/**
+ * @param {LH.Artifacts.Rect} rect1
+ * @param {LH.Artifacts.Rect} rect2
+ */
+function getRectXOverlap(rect1, rect2) {
+  // https://stackoverflow.com/a/9325084/1290545
+  return Math.max(
+    0,
+    Math.min(rect1.right, rect2.right) - Math.max(rect1.left, rect2.left)
+  );
+}
+
+/**
+ * @param {LH.Artifacts.Rect} rect1
+ * @param {LH.Artifacts.Rect} rect2
+ */
+function getRectYOverlap(rect1, rect2) {
+  // https://stackoverflow.com/a/9325084/1290545
+  return Math.max(
+    0,
+    Math.min(rect1.bottom, rect2.bottom) - Math.max(rect1.top, rect2.top)
+  );
+}
+
+/**
+ * @param {LH.Artifacts.Rect} rect1
+ * @param {LH.Artifacts.Rect} rect2
+ */
+function getRectOverlapArea(rect1, rect2) {
+  return getRectXOverlap(rect1, rect2) * getRectYOverlap(rect1, rect2);
+}
+
+/**
+ * @param {LH.Artifacts.Rect} rect
+ * @param {number} centerRectSize
+ */
+function getRectAtCenter(rect, centerRectSize) {
+  return addRectWidthAndHeight({
+    left: rect.left + rect.width / 2 - centerRectSize / 2,
+    top: rect.top + rect.height / 2 - centerRectSize / 2,
+    right: rect.right - rect.width / 2 + centerRectSize / 2,
+    bottom: rect.bottom - rect.height / 2 + centerRectSize / 2,
+  });
+}
+
+/**
+ * @param {LH.Artifacts.Rect} rect
+ */
+function getRectArea(rect) {
+  return rect.width * rect.height;
+}
+
+/**
+ * @param {LH.Artifacts.Rect[]} rects
+ */
+function getLargestRect(rects) {
+  let largestRect = rects[0];
+  for (const rect of rects) {
+    if (getRectArea(rect) > getRectArea(largestRect)) {
+      largestRect = rect;
+    }
+  }
+  return largestRect;
+}
+
+/**
+ *
+ * @param {LH.Artifacts.Rect[]} rectListA
+ * @param {LH.Artifacts.Rect[]} rectListB
+ */
+function allRectsContainedWithinEachOther(rectListA, rectListB) {
+  for (const rectA of rectListA) {
+    for (const rectB of rectListB) {
+      if (!rectContains(rectA, rectB) && !rectContains(rectB, rectA)) {
+        return false;
+      }
+    }
+  }
+  return true;
+}
+
+module.exports = {
+  rectContainsPoint,
+  rectContains,
+  rectContainsString,
+  addRectWidthAndHeight,
+  addRectTopAndBottom,
+  getRectXOverlap,
+  getRectYOverlap,
+  getRectOverlapArea,
+  getRectAtCenter,
+  getLargestRect,
+  getRectCenterPoint,
+  getBoundingRect,
+  rectsTouchOrOverlap,
+  allRectsContainedWithinEachOther,
+  filterOutRectsContainedByOthers,
+  filterOutTinyRects,
+};
diff --git a/lighthouse-core/lib/strings.js b/lighthouse-core/lib/strings.js
deleted file mode 100644
index 3792cae3f..000000000
--- a/lighthouse-core/lib/strings.js
+++ /dev/null
@@ -1,21 +0,0 @@
-/**
- * @license Copyright 2018 Google Inc. All Rights Reserved.
- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
- * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
- */
-'use strict';
-
-/* eslint-disable max-len */
-module.exports = {
-  didntCollectScreenshots: `Chrome didn't collect any screenshots during the page load. Please make sure there is content visible on the page, and then try re-running Lighthouse.`,
-  badTraceRecording: `Something went wrong with recording the trace over your page load. Please run Lighthouse again.`,
-  pageLoadTookTooLong: `Your page took too long to load. Please follow the opportunities in the report to reduce your page load time, and then try re-running Lighthouse.`,
-  pageLoadFailed: `Lighthouse was unable to reliably load the page you requested. Make sure you are testing the correct URL and that the server is properly responding to all requests.`,
-  pageLoadFailedHung: `Lighthouse was unable to reliably load the URL you requested because the page stopped responding.`,
-  pageLoadFailedInsecure: `The URL you have provided does not have valid security credentials.`,
-  internalChromeError: `An internal Chrome error occurred. Please restart Chrome and try re-running Lighthouse.`,
-  requestContentTimeout: 'Fetching resource content has exceeded the allotted time',
-  urlInvalid: `The URL you have provided appears to be invalid.`,
-  protocolTimeout: `Waiting for DevTools protocol response has exceeded the allotted time.`,
-  dnsFailure: `DNS servers could not resolve the provided domain.`,
-};
diff --git a/lighthouse-core/lib/tappable-rects.js b/lighthouse-core/lib/tappable-rects.js
new file mode 100644
index 000000000..118d87916
--- /dev/null
+++ b/lighthouse-core/lib/tappable-rects.js
@@ -0,0 +1,104 @@
+/**
+ * @license Copyright 2018 Google Inc. All Rights Reserved.
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
+ */
+'use strict';
+
+const {
+  filterOutRectsContainedByOthers,
+  filterOutTinyRects,
+  rectsTouchOrOverlap,
+  rectContainsPoint,
+  getBoundingRect,
+  getRectCenterPoint,
+} = require('./rect-helpers');
+
+/**
+ * Merge client rects together and remove small ones. This may result in a larger overall
+ * size than that of the individual client rects.
+ * We use this to simulate a finger tap on those targets later on.
+ * @param {LH.Artifacts.Rect[]} clientRects
+ */
+function getTappableRectsFromClientRects(clientRects) {
+  // 1x1px rect shouldn't be reason to treat the rect as something the user should tap on.
+  // Often they're made invisble in some obscure way anyway, and only exist for e.g. accessibiliity.
+  clientRects = filterOutTinyRects(clientRects);
+  clientRects = filterOutRectsContainedByOthers(clientRects);
+  clientRects = mergeTouchingClientRects(clientRects);
+  return clientRects;
+}
+
+/**
+ * @param {number} a
+ * @param {number} b
+ */
+function almostEqual(a, b) {
+  // Sometimes a child will reach out of the parent by
+  // 1px or 2px, so be somewhat tolerant for merging
+  return Math.abs(a - b) <= 2;
+}
+
+/**
+ * Merge touching rects based on what appears as one tappable area to the user.
+ * @param {LH.Artifacts.Rect[]} clientRects
+ * @returns {LH.Artifacts.Rect[]}
+ */
+function mergeTouchingClientRects(clientRects) {
+  for (let i = 0; i < clientRects.length; i++) {
+    for (let j = i + 1; j < clientRects.length; j++) {
+      const crA = clientRects[i];
+      const crB = clientRects[j];
+
+      /**
+       * We try to determine whether the rects appear as a single tappable
+       * area to the user, so that they'd tap in the middle of the merged rect.
+       * Examples of what we want to merge:
+       *
+       * AAABBB
+       *
+       * AAA
+       * AAA
+       * BBBBB
+       */
+      const rectsLineUpHorizontally =
+        almostEqual(crA.top, crB.top) || almostEqual(crA.bottom, crB.bottom);
+      const rectsLineUpVertically =
+        almostEqual(crA.left, crB.left) || almostEqual(crA.right, crB.right);
+      const canMerge =
+        rectsTouchOrOverlap(crA, crB) &&
+        (rectsLineUpHorizontally || rectsLineUpVertically);
+
+      if (canMerge) {
+        const replacementClientRect = getBoundingRect(crA, crB);
+        const mergedRectCenter = getRectCenterPoint(replacementClientRect);
+
+        if (
+          !(
+            rectContainsPoint(crA, mergedRectCenter) ||
+            rectContainsPoint(crB, mergedRectCenter)
+          )
+        ) {
+          // Don't merge because the new shape is too different from the
+          // merged rects, and tapping in the middle wouldn't actually hit
+          // either rect
+          continue;
+        }
+
+        // Replace client rects with merged version
+        clientRects = clientRects.filter(cr => cr !== crA && cr !== crB);
+        clientRects.push(replacementClientRect);
+
+        // Start over so we don't have to handle complexity introduced by array mutation.
+        // Client rect ararys rarely contain more than 5 rects, so starting again doesn't cause perf issues.
+        return mergeTouchingClientRects(clientRects);
+      }
+    }
+  }
+
+  return clientRects;
+}
+
+module.exports = {
+  getTappableRectsFromClientRects,
+};
diff --git a/lighthouse-core/lib/url-shim.js b/lighthouse-core/lib/url-shim.js
index 260136058..0bf06d2fa 100644
--- a/lighthouse-core/lib/url-shim.js
+++ b/lighthouse-core/lib/url-shim.js
@@ -209,7 +209,7 @@ URLShim.URL = URL;
 URLShim.URLSearchParams = (typeof self !== 'undefined' && self.URLSearchParams) ||
     require('url').URLSearchParams;
 
-URLShim.NON_NETWORK_PROTOCOLS = ['blob', 'data'];
+URLShim.NON_NETWORK_PROTOCOLS = ['blob', 'data', 'intent'];
 
 URLShim.INVALID_URL_DEBUG_STRING =
     'Lighthouse was unable to determine the URL of some script executions. ' +
diff --git a/lighthouse-core/report/html/renderer/category-renderer.js b/lighthouse-core/report/html/renderer/category-renderer.js
index 5b4b7b0c2..f455b9b2d 100644
--- a/lighthouse-core/report/html/renderer/category-renderer.js
+++ b/lighthouse-core/report/html/renderer/category-renderer.js
@@ -159,13 +159,14 @@ class CategoryRenderer {
 
   /**
    * @param {LH.ReportResult.Category} category
+   * @param {Record<string, LH.Result.ReportGroup>} groupDefinitions
    * @return {Element}
    */
-  renderCategoryHeader(category) {
+  renderCategoryHeader(category, groupDefinitions) {
     const tmpl = this.dom.cloneTemplate('#tmpl-lh-category-header', this.templateContext);
 
     const gaugeContainerEl = this.dom.find('.lh-score__gauge', tmpl);
-    const gaugeEl = this.renderScoreGauge(category);
+    const gaugeEl = this.renderScoreGauge(category, groupDefinitions);
     gaugeContainerEl.appendChild(gaugeEl);
 
     this.dom.find('.lh-category-header__title', tmpl).appendChild(
@@ -329,9 +330,10 @@ class CategoryRenderer {
 
   /**
    * @param {LH.ReportResult.Category} category
+   * @param {Record<string, LH.Result.ReportGroup>} groupDefinitions
    * @return {DocumentFragment}
    */
-  renderScoreGauge(category) {
+  renderScoreGauge(category, groupDefinitions) { // eslint-disable-line no-unused-vars
     const tmpl = this.dom.cloneTemplate('#tmpl-lh-gauge', this.templateContext);
     const wrapper = /** @type {HTMLAnchorElement} */ (this.dom.find('.lh-gauge__wrapper', tmpl));
     wrapper.href = `#${category.id}`;
@@ -387,7 +389,7 @@ class CategoryRenderer {
   render(category, groupDefinitions = {}) {
     const element = this.dom.createElement('div', 'lh-category');
     this.createPermalinkSpan(element, category.id);
-    element.appendChild(this.renderCategoryHeader(category));
+    element.appendChild(this.renderCategoryHeader(category, groupDefinitions));
 
     // Top level clumps for audits, in order they will appear in the report.
     /** @type {Map<TopLevelClumpId, Array<LH.ReportResult.AuditRef>>} */
diff --git a/lighthouse-core/report/html/renderer/performance-category-renderer.js b/lighthouse-core/report/html/renderer/performance-category-renderer.js
index 327fa164b..252b66701 100644
--- a/lighthouse-core/report/html/renderer/performance-category-renderer.js
+++ b/lighthouse-core/report/html/renderer/performance-category-renderer.js
@@ -120,11 +120,11 @@ class PerformanceCategoryRenderer extends CategoryRenderer {
     const element = this.dom.createElement('div', 'lh-category');
     if (environment === 'PSI') {
       const gaugeEl = this.dom.createElement('div', 'lh-score__gauge');
-      gaugeEl.appendChild(this.renderScoreGauge(category));
+      gaugeEl.appendChild(this.renderScoreGauge(category, groups));
       element.appendChild(gaugeEl);
     } else {
       this.createPermalinkSpan(element, category.id);
-      element.appendChild(this.renderCategoryHeader(category));
+      element.appendChild(this.renderCategoryHeader(category, groups));
     }
 
     // Metrics
diff --git a/lighthouse-core/report/html/renderer/pwa-category-renderer.js b/lighthouse-core/report/html/renderer/pwa-category-renderer.js
index 7c9be675a..2b547b738 100644
--- a/lighthouse-core/report/html/renderer/pwa-category-renderer.js
+++ b/lighthouse-core/report/html/renderer/pwa-category-renderer.js
@@ -27,7 +27,7 @@ class PwaCategoryRenderer extends CategoryRenderer {
   render(category, groupDefinitions = {}) {
     const categoryElem = this.dom.createElement('div', 'lh-category');
     this.createPermalinkSpan(categoryElem, category.id);
-    categoryElem.appendChild(this.renderCategoryHeader(category));
+    categoryElem.appendChild(this.renderCategoryHeader(category, groupDefinitions));
 
     const auditRefs = category.auditRefs;
 
@@ -48,12 +48,13 @@ class PwaCategoryRenderer extends CategoryRenderer {
 
   /**
    * @param {LH.ReportResult.Category} category
+   * @param {Record<string, LH.Result.ReportGroup>} groupDefinitions
    * @return {DocumentFragment}
    */
-  renderScoreGauge(category) {
+  renderScoreGauge(category, groupDefinitions) {
     // Defer to parent-gauge style if category error.
     if (category.score === null) {
-      return super.renderScoreGauge(category);
+      return super.renderScoreGauge(category, groupDefinitions);
     }
 
     const tmpl = this.dom.cloneTemplate('#tmpl-lh-gauge--pwa', this.templateContext);
@@ -73,6 +74,7 @@ class PwaCategoryRenderer extends CategoryRenderer {
     }
 
     this.dom.find('.lh-gauge__label', tmpl).textContent = category.title;
+    wrapper.title = this._getGaugeTooltip(category.auditRefs, groupDefinitions);
     return tmpl;
   }
 
@@ -104,6 +106,28 @@ class PwaCategoryRenderer extends CategoryRenderer {
     return uniqueGroupIds;
   }
 
+  /**
+   * Returns a tooltip string summarizing group pass rates.
+   * @param {Array<LH.ReportResult.AuditRef>} auditRefs
+   * @param {Record<string, LH.Result.ReportGroup>} groupDefinitions
+   * @return {string}
+   */
+  _getGaugeTooltip(auditRefs, groupDefinitions) {
+    const groupIds = this._getGroupIds(auditRefs);
+
+    const tips = [];
+    for (const groupId of groupIds) {
+      const groupAuditRefs = auditRefs.filter(ref => ref.group === groupId);
+      const auditCount = groupAuditRefs.length;
+      const passedCount = groupAuditRefs.filter(ref => Util.showAsPassed(ref.result)).length;
+
+      const title = groupDefinitions[groupId].title;
+      tips.push(`${title}: ${passedCount}/${auditCount}`);
+    }
+
+    return tips.join(', ');
+  }
+
   /**
    * Render non-manual audits in groups, giving a badge to any group that has
    * all passing audits.
diff --git a/lighthouse-core/report/html/renderer/report-renderer.js b/lighthouse-core/report/html/renderer/report-renderer.js
index c95d7e2ba..8b9559fa4 100644
--- a/lighthouse-core/report/html/renderer/report-renderer.js
+++ b/lighthouse-core/report/html/renderer/report-renderer.js
@@ -211,7 +211,7 @@ class ReportRenderer {
       const customGauges = [];
       for (const category of report.reportCategories) {
         const renderer = specificCategoryRenderers[category.id] || categoryRenderer;
-        const categoryGauge = renderer.renderScoreGauge(category);
+        const categoryGauge = renderer.renderScoreGauge(category, report.categoryGroups || {});
 
         // Group gauges that aren't default at the end of the header
         if (renderer.renderScoreGauge === categoryRenderer.renderScoreGauge) {
diff --git a/lighthouse-core/report/html/report-styles.css b/lighthouse-core/report/html/report-styles.css
index da536ff7a..4001365bc 100644
--- a/lighthouse-core/report/html/report-styles.css
+++ b/lighthouse-core/report/html/report-styles.css
@@ -652,6 +652,7 @@
   border-bottom: none;
 }
 .lh-audit-group--metrics .lh-audit-group__summary {
+  margin-top: 0;
   margin-bottom: 0;
 }
 
@@ -659,7 +660,8 @@
   display: flex;
   justify-content: space-between;
   padding-right: var(--text-indent);
-  margin-bottom: var(--lh-audit-vpadding);
+  margin-top: calc(var(--section-padding) * 1.5);
+  margin-bottom: var(--section-padding);
 }
 
 .lh-audit-group__itemcount {
diff --git a/lighthouse-core/runner.js b/lighthouse-core/runner.js
index 83d84b8fb..a77a5a8a6 100644
--- a/lighthouse-core/runner.js
+++ b/lighthouse-core/runner.js
@@ -30,8 +30,8 @@ class Runner {
    * @return {Promise<LH.RunnerResult|undefined>}
    */
   static async run(connection, runOpts) {
+    const settings = runOpts.config.settings;
     try {
-      const settings = runOpts.config.settings;
       const runnerStatus = {msg: 'Runner setup', id: 'lh:runner:run'};
       log.time(runnerStatus, 'verbose');
 
@@ -157,6 +157,8 @@ class Runner {
 
       return {lhr, artifacts, report};
     } catch (err) {
+      // i18n error strings
+      err.friendlyMessage = i18n.getFormatted(err.friendlyMessage, settings.locale);
       await Sentry.captureException(err, {level: 'fatal'});
       throw err;
     }
@@ -193,7 +195,6 @@ class Runner {
     if (!runnerOpts.config.passes) {
       throw new Error('No browser artifacts are either provided or requested.');
     }
-
     const driver = runnerOpts.driverMock || new Driver(connection);
     const gatherOpts = {
       driver,
diff --git a/proto/sample_v2_round_trip.json b/proto/sample_v2_round_trip.json
index 47e33802f..3b0b1fd72 100644
--- a/proto/sample_v2_round_trip.json
+++ b/proto/sample_v2_round_trip.json
@@ -1895,7 +1895,7 @@
             "id": "service-worker", 
             "score": 0.0, 
             "scoreDisplayMode": "binary", 
-            "title": "Does not register a service worker"
+            "title": "Does not register a service worker that controls page and start_url"
         }, 
         "speed-index": {
             "description": "Speed Index shows how quickly the contents of a page are visibly populated. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/speed-index).", 
diff --git a/types/artifacts.d.ts b/types/artifacts.d.ts
index aebaa284d..d37048300 100644
--- a/types/artifacts.d.ts
+++ b/types/artifacts.d.ts
@@ -278,6 +278,15 @@ declare global {
         };
       }
 
+      export interface Rect {
+        width: number;
+        height: number;
+        top: number;
+        right: number;
+        bottom: number;
+        left: number;
+      }
+
       export interface ViewportDimensions {
         innerWidth: number;
         innerHeight: number;


Test patch:
diff --git a/clients/test/lightrider-entry-test.js b/clients/test/lightrider-entry-test.js
index 408c7dc94..2151241c4 100644
--- a/clients/test/lightrider-entry-test.js
+++ b/clients/test/lightrider-entry-test.js
@@ -14,7 +14,10 @@ const LHError = require('../../lighthouse-core/lib/lh-error.js');
 describe('lightrider-entry', () => {
   describe('#runLighthouseInLR', () => {
     it('returns a runtimeError LHR when lighthouse throws a runtimeError', async () => {
-      const connectionError = new LHError(LHError.errors.FAILED_DOCUMENT_REQUEST);
+      const connectionError = new LHError(
+        LHError.errors.FAILED_DOCUMENT_REQUEST,
+        {errorDetails: 'Bad connection req'}
+      );
       assert.strictEqual(connectionError.lhrRuntimeError, true);
       const mockConnection = {
         async connect() {
diff --git a/lighthouse-core/test/audits/service-worker-test.js b/lighthouse-core/test/audits/service-worker-test.js
index da0a8b243..95a1aedc6 100644
--- a/lighthouse-core/test/audits/service-worker-test.js
+++ b/lighthouse-core/test/audits/service-worker-test.js
@@ -7,6 +7,7 @@
 
 const ServiceWorker = require('../../audits/service-worker.js');
 const URL = require('../../lib/url-shim.js');
+const manifestParser = require('../../lib/manifest-parser.js');
 const assert = require('assert');
 
 /* eslint-env jest */
@@ -19,11 +20,10 @@ function getBaseDirectory(urlStr) {
 /**
  * Create a ServiceWorker artifact from an array of SW config opts.
  * @param {Array<{scriptURL: string, status: string, scopeURL?: string}>} swOpts
- * @param {string} finalUrl
+ * @return {LH.Artifact['ServiceWorker']}
  */
-function createArtifacts(swOpts, finalUrl) {
+function createSWArtifact(swOpts) {
   const artifact = {versions: [], registrations: []};
-
   swOpts.forEach((sw, index) => {
     artifact.versions.push({
       registrationId: `${index}`,
@@ -40,33 +40,56 @@ function createArtifacts(swOpts, finalUrl) {
     });
   });
 
+  return artifact;
+}
+
+/**
+ * Create a set of artifacts for the ServiceWorker audit.
+ * @param {Array<{scriptURL: string, status: string, scopeURL?: string}>} swOpts
+ * @param {string} finalUrl
+ * @param {{}}} manifestJson Manifest object or null if no manifest desired.
+ */
+function createArtifacts(swOpts, finalUrl, manifestJson) {
+  const manifestUrl = getBaseDirectory(finalUrl) + 'manifest.json';
+  let Manifest;
+  if (manifestJson === null) {
+    Manifest = null;
+  } else if (typeof manifestJson === 'object') {
+    Manifest = manifestParser(JSON.stringify(manifestJson), manifestUrl, finalUrl);
+  } else {
+    throw new Error('unsupported test manifest format');
+  }
+
   return {
-    ServiceWorker: artifact,
+    ServiceWorker: createSWArtifact(swOpts),
     URL: {finalUrl},
+    Manifest,
   };
 }
 
 describe('Offline: service worker audit', () => {
-  it('passes when given a matching service worker version', () => {
+  it('passes when given a controlling service worker', () => {
     const finalUrl = 'https://example.com';
     const swOpts = [{
       status: 'activated',
       scriptURL: 'https://example.com/sw.js',
     }];
+    const manifest = {start_url: finalUrl};
 
-    const output = ServiceWorker.audit(createArtifacts(swOpts, finalUrl));
-    assert.equal(output.rawValue, true);
+    const output = ServiceWorker.audit(createArtifacts(swOpts, finalUrl, manifest));
+    assert.deepStrictEqual(output, {rawValue: true});
   });
 
-  it('fails when matching service worker is not activated', () => {
+  it('fails when controlling service worker is not activated', () => {
     const finalUrl = 'https://example.com';
     const swOpts = [{
       status: 'redundant',
       scriptURL: 'https://example.com/sw.js',
     }];
+    const manifest = {start_url: finalUrl};
 
-    const output = ServiceWorker.audit(createArtifacts(swOpts, finalUrl));
-    assert.equal(output.rawValue, false);
+    const output = ServiceWorker.audit(createArtifacts(swOpts, finalUrl, manifest));
+    assert.deepStrictEqual(output, {rawValue: false});
   });
 
   it('discards service worker registrations for other origins', () => {
@@ -75,35 +98,80 @@ describe('Offline: service worker audit', () => {
       status: 'activated',
       scriptURL: 'https://other-example.com',
     }];
+    const manifest = {start_url: finalUrl};
 
-    const output = ServiceWorker.audit(createArtifacts(swOpts, finalUrl));
-    assert.equal(output.rawValue, false);
+    const output = ServiceWorker.audit(createArtifacts(swOpts, finalUrl, manifest));
+    assert.deepStrictEqual(output, {rawValue: false});
   });
 
-  it('fails when URL is out of scope', () => {
+  it('fails when page URL is out of scope', () => {
     const finalUrl = 'https://example.com/index.html';
     const swOpts = [{
       status: 'activated',
       scriptURL: 'https://example.com/serviceworker/sw.js',
     }];
+    const manifest = {start_url: finalUrl};
 
-    const output = ServiceWorker.audit(createArtifacts(swOpts, finalUrl));
-    assert.equal(output.rawValue, false);
+    const output = ServiceWorker.audit(createArtifacts(swOpts, finalUrl, manifest));
+    expect(output).toMatchObject({
+      rawValue: false,
+      explanation: expect.stringMatching(new RegExp(`${finalUrl}.*not in scope`)),
+    });
   });
 
-  it('fails when explicit scopeURL puts the URL out of scope', () => {
+  it('fails when start_url is out of scope', () => {
+    const finalUrl = 'https://example.com/serviceworker/index.html';
+    const swOpts = [{
+      status: 'activated',
+      scriptURL: 'https://example.com/serviceworker/sw.js',
+    }];
+    const startUrl = 'https://example.com/';
+    const manifest = {start_url: startUrl};
+
+    const scopeURL = 'https://example.com/serviceworker';
+
+    const output = ServiceWorker.audit(createArtifacts(swOpts, finalUrl, manifest));
+    expect(output).toMatchObject({
+      rawValue: false,
+      explanation: expect.stringMatching(new RegExp(`start_url.*${startUrl}.*${scopeURL}`)),
+    });
+  });
+
+  it('fails when explicit scopeURL puts the page URL out of scope', () => {
     const finalUrl = 'https://example.com/index.html';
     const swOpts = [{
       status: 'activated',
       scriptURL: 'https://example.com/sw.js',
       scopeURL: 'https://example.com/serviceworker/',
     }];
+    const manifest = {start_url: finalUrl};
 
-    const output = ServiceWorker.audit(createArtifacts(swOpts, finalUrl));
-    assert.equal(output.rawValue, false);
+    const output = ServiceWorker.audit(createArtifacts(swOpts, finalUrl, manifest));
+    expect(output).toMatchObject({
+      rawValue: false,
+      explanation: expect.stringMatching(new RegExp(`${finalUrl}.*not in scope`)),
+    });
   });
 
-  it('passes when outside default scope but explicit scopeURL puts it back in', () => {
+  it('fails when explicit scopeURL puts the start_url out of scope', () => {
+    const finalUrl = 'https://example.com/serviceworker/index.html';
+    const scopeURL = 'https://example.com/serviceworker/';
+    const swOpts = [{
+      status: 'activated',
+      scriptURL: 'https://example.com/sw.js',
+      scopeURL,
+    }];
+    const startUrl = 'https://example.com/';
+    const manifest = {start_url: startUrl};
+
+    const output = ServiceWorker.audit(createArtifacts(swOpts, finalUrl, manifest));
+    expect(output).toMatchObject({
+      rawValue: false,
+      explanation: expect.stringMatching(new RegExp(`start_url.*${startUrl}.*${scopeURL}`)),
+    });
+  });
+
+  it('passes when both outside default scope but explicit scopeURL puts it back in', () => {
     const finalUrl = 'https://example.com/index.html';
     const swOpts = [{
       status: 'activated',
@@ -111,9 +179,10 @@ describe('Offline: service worker audit', () => {
       // can happen when 'Service-Worker-Allowed' header widens max scope.
       scopeURL: 'https://example.com/',
     }];
+    const manifest = {start_url: finalUrl};
 
-    const output = ServiceWorker.audit(createArtifacts(swOpts, finalUrl));
-    assert.equal(output.rawValue, true);
+    const output = ServiceWorker.audit(createArtifacts(swOpts, finalUrl, manifest));
+    assert.deepStrictEqual(output, {rawValue: true});
   });
 
   it('passes when multiple SWs control the scope', () => {
@@ -125,9 +194,10 @@ describe('Offline: service worker audit', () => {
       status: 'activated',
       scriptURL: 'https://example.com/project/sw.js',
     }];
+    const manifest = {start_url: finalUrl};
 
-    const output = ServiceWorker.audit(createArtifacts(swOpts, finalUrl));
-    assert.equal(output.rawValue, true);
+    const output = ServiceWorker.audit(createArtifacts(swOpts, finalUrl, manifest));
+    assert.deepStrictEqual(output, {rawValue: true});
   });
 
   it('passes when multiple SWs control the origin but only one is in scope', () => {
@@ -145,9 +215,10 @@ describe('Offline: service worker audit', () => {
       scriptURL: 'https://example.com/project/subproject/sw.js',
       scopeURL: 'https://example.com/',
     }];
+    const manifest = {start_url: finalUrl};
 
-    const output = ServiceWorker.audit(createArtifacts(swOpts, finalUrl));
-    assert.equal(output.rawValue, true);
+    const output = ServiceWorker.audit(createArtifacts(swOpts, finalUrl, manifest));
+    assert.deepStrictEqual(output, {rawValue: true});
   });
 
   it('fails when multiple SWs control the origin but are all out of scope', () => {
@@ -165,8 +236,66 @@ describe('Offline: service worker audit', () => {
       scriptURL: 'https://example.com/project/subproject/sw.js',
       scopeURL: 'https://example.com/project/',
     }];
+    const manifest = {start_url: finalUrl};
+
+    const output = ServiceWorker.audit(createArtifacts(swOpts, finalUrl, manifest));
+    expect(output).toMatchObject({
+      rawValue: false,
+      explanation: expect.stringMatching(new RegExp(`${finalUrl}.*not in scope`)),
+    });
+  });
+
+  it('fails when SW that controls start_url is different than SW that controls page', () => {
+    // Tests that most specific SW found for page.
+    const finalUrl = 'https://example.com/project/index.html';
+    const swOpts = [{
+      status: 'activated',
+      scriptURL: 'https://example.com/sw.js',
+    }, {
+      status: 'activated',
+      scriptURL: 'https://example.com/project/sw.js',
+    }];
+    const startUrl = 'https://example.com/index.html';
+    const manifest = {start_url: startUrl};
+
+    const scopeURL = 'https://example.com/project';
+
+    const output = ServiceWorker.audit(createArtifacts(swOpts, finalUrl, manifest));
+    expect(output).toMatchObject({
+      rawValue: false,
+      explanation: expect.stringMatching(new RegExp(`start_url.*${startUrl}.*${scopeURL}`)),
+    });
+  });
 
-    const output = ServiceWorker.audit(createArtifacts(swOpts, finalUrl));
-    assert.equal(output.rawValue, false);
+  it('fails when a manifest was not found', () => {
+    const finalUrl = 'https://example.com';
+    const swOpts = [{
+      status: 'activated',
+      scriptURL: 'https://example.com/sw.js',
+    }];
+    const manifest = null;
+
+    const output = ServiceWorker.audit(createArtifacts(swOpts, finalUrl, manifest));
+    expect(output).toMatchObject({
+      rawValue: false,
+      explanation: expect.stringMatching(/start_url.*no manifest was fetched/),
+    });
+  });
+
+  it('fails when a manifest is invalid', () => {
+    const finalUrl = 'https://example.com';
+    const swOpts = [{
+      status: 'activated',
+      scriptURL: 'https://example.com/sw.js',
+    }];
+
+    const artifacts = createArtifacts(swOpts, finalUrl, {});
+    artifacts.Manifest = manifestParser('{,;}', finalUrl, finalUrl);
+
+    const output = ServiceWorker.audit(artifacts);
+    expect(output).toMatchObject({
+      rawValue: false,
+      explanation: expect.stringMatching(/start_url.*manifest failed to parse as valid JSON/),
+    });
   });
 });
diff --git a/lighthouse-core/test/audits/uses-rel-preload-test.js b/lighthouse-core/test/audits/uses-rel-preload-test.js
index 12176f309..6765a7370 100644
--- a/lighthouse-core/test/audits/uses-rel-preload-test.js
+++ b/lighthouse-core/test/audits/uses-rel-preload-test.js
@@ -210,6 +210,18 @@ describe('Performance: uses-rel-preload audit', () => {
     });
   });
 
+  it(`shouldn't suggest preload for protocol intent`, () => {
+    const networkRecords = getMockNetworkRecords();
+    networkRecords[2].protocol = 'intent';
+
+    const artifacts = mockArtifacts(networkRecords, defaultMainResourceUrl);
+    const context = {settings: {}, computedCache: new Map()};
+    return UsesRelPreload.audit(artifacts, context).then(output => {
+      assert.equal(output.rawValue, 0);
+      assert.equal(output.details.items.length, 0);
+    });
+  });
+
   it('does not throw on a real trace/devtools log', async () => {
     const artifacts = {
       URL: {finalUrl: 'https://pwa.rocks/'},
diff --git a/lighthouse-core/test/gather/driver-test.js b/lighthouse-core/test/gather/driver-test.js
index a68266a4b..3229755d6 100644
--- a/lighthouse-core/test/gather/driver-test.js
+++ b/lighthouse-core/test/gather/driver-test.js
@@ -166,6 +166,8 @@ describe('Browser Driver', () => {
       assert.ok(false, 'long-running getRequestContent supposed to reject');
     }, e => {
       assert.equal(e.code, 'PROTOCOL_TIMEOUT');
+      expect(e.friendlyMessage).toBeDisplayString(
+        /^Waiting for DevTools.*Method: Network.getResponseBody/);
     });
   });
 
diff --git a/lighthouse-core/test/gather/gather-runner-test.js b/lighthouse-core/test/gather/gather-runner-test.js
index c302d5db3..68c2076e1 100644
--- a/lighthouse-core/test/gather/gather-runner-test.js
+++ b/lighthouse-core/test/gather/gather-runner-test.js
@@ -646,7 +646,8 @@ describe('GatherRunner', function() {
       const error = GatherRunner.getPageLoadError(url, [mainRecord]);
       assert.equal(error.message, 'FAILED_DOCUMENT_REQUEST');
       assert.equal(error.code, 'FAILED_DOCUMENT_REQUEST');
-      assert.ok(/^Lighthouse was unable to reliably load/.test(error.friendlyMessage));
+      expect(error.friendlyMessage)
+        .toBeDisplayString(/^Lighthouse was unable to reliably load.*foobar/);
     });
 
     it('fails when page times out', () => {
@@ -655,7 +656,7 @@ describe('GatherRunner', function() {
       const error = GatherRunner.getPageLoadError(url, records);
       assert.equal(error.message, 'NO_DOCUMENT_REQUEST');
       assert.equal(error.code, 'NO_DOCUMENT_REQUEST');
-      assert.ok(/^Lighthouse was unable to reliably load/.test(error.friendlyMessage));
+      expect(error.friendlyMessage).toBeDisplayString(/^Lighthouse was unable to reliably load/);
     });
 
     it('fails when page returns with a 404', () => {
@@ -666,7 +667,8 @@ describe('GatherRunner', function() {
       const error = GatherRunner.getPageLoadError(url, [mainRecord]);
       assert.equal(error.message, 'ERRORED_DOCUMENT_REQUEST');
       assert.equal(error.code, 'ERRORED_DOCUMENT_REQUEST');
-      assert.ok(/^Lighthouse was unable to reliably load/.test(error.friendlyMessage));
+      expect(error.friendlyMessage)
+        .toBeDisplayString(/^Lighthouse was unable to reliably load.*404/);
     });
 
     it('fails when page returns with a 500', () => {
@@ -677,7 +679,8 @@ describe('GatherRunner', function() {
       const error = GatherRunner.getPageLoadError(url, [mainRecord]);
       assert.equal(error.message, 'ERRORED_DOCUMENT_REQUEST');
       assert.equal(error.code, 'ERRORED_DOCUMENT_REQUEST');
-      assert.ok(/^Lighthouse was unable to reliably load/.test(error.friendlyMessage));
+      expect(error.friendlyMessage)
+        .toBeDisplayString(/^Lighthouse was unable to reliably load.*500/);
     });
 
     it('fails when page domain doesn\'t resolve', () => {
@@ -689,7 +692,7 @@ describe('GatherRunner', function() {
       const error = GatherRunner.getPageLoadError(url, [mainRecord]);
       assert.equal(error.message, 'DNS_FAILURE');
       assert.equal(error.code, 'DNS_FAILURE');
-      assert.ok(/^DNS servers could not resolve/.test(error.friendlyMessage));
+      expect(error.friendlyMessage).toBeDisplayString(/^DNS servers could not resolve/);
     });
   });
 
@@ -705,7 +708,7 @@ describe('GatherRunner', function() {
       const insecureSecurityState = {
         explanations: [
           {
-            description: 'reason 1.',
+            description: 'reason 1',
             securityState: 'insecure',
           },
           {
@@ -713,7 +716,7 @@ describe('GatherRunner', function() {
             securityState: 'info',
           },
           {
-            description: 'reason 2.',
+            description: 'reason 2',
             securityState: 'insecure',
           },
         ],
@@ -725,8 +728,8 @@ describe('GatherRunner', function() {
       } catch (err) {
         assert.equal(err.message, 'INSECURE_DOCUMENT_REQUEST');
         assert.equal(err.code, 'INSECURE_DOCUMENT_REQUEST');
-        /* eslint-disable-next-line max-len */
-        assert.equal(err.friendlyMessage, 'The URL you have provided does not have valid security credentials. reason 1. reason 2.');
+        expect(err.friendlyMessage)
+          .toBeDisplayString(/The URL.*security credentials.*reason 1 reason 2/);
       }
     });
   });
@@ -1071,7 +1074,8 @@ describe('GatherRunner', function() {
         config: new Config({}),
       }).then(artifacts => {
         assert.equal(artifacts.LighthouseRunWarnings.length, 1);
-        assert.ok(/DNS servers could not resolve/.test(artifacts.LighthouseRunWarnings[0]));
+        expect(artifacts.LighthouseRunWarnings[0])
+          .toBeDisplayString(/DNS servers could not resolve/);
       });
     });
 
diff --git a/lighthouse-core/test/lib/minification-estimator-test.js b/lighthouse-core/test/lib/minification-estimator-test.js
index e04771723..f82c13b41 100644
--- a/lighthouse-core/test/lib/minification-estimator-test.js
+++ b/lighthouse-core/test/lib/minification-estimator-test.js
@@ -180,6 +180,31 @@ describe('minification estimator', () => {
       assert.equal(computeJSTokenLength(js), 9);
     });
 
+    it('should handle regular expression character classes', () => {
+      // test a slash inside of a character class to make sure it doesn't end the regex
+      // The below is the string-equivalent of
+      const _ = /regex [^/]\//.test('this should be in string not comment 123456789');
+
+      const js = `
+        /regex [^/]\\//.test('this should be in string not comment 123456789')
+      `;
+
+      assert.equal(computeJSTokenLength(js), 69);
+      assert.equal(computeJSTokenLength(js), js.trim().length);
+    });
+
+    it('should handle escaped regular expression characters', () => {
+      // test an escaped [ to make sure we can still close regexes
+      // This is the string-equivalent of
+      const _ = /regex \[/; // this should be in comment not string 123456789
+
+      const js = `
+        /regex \\[/ // this should be in comment not string 123456789
+      `;
+
+      assert.equal(computeJSTokenLength(js), 10);
+    });
+
     it('should distinguish regex from divide', () => {
       const js = `
         return 1 / 2 // hello
@@ -190,8 +215,8 @@ describe('minification estimator', () => {
 
     it('should handle large, real javscript files', () => {
       assert.equal(angularFullScript.length, 1364217);
-      // 1 - 405199 / 1364217 = estimated 70% smaller minified
-      assert.equal(computeJSTokenLength(angularFullScript), 405199);
+      // 1 - 334968 / 1364217 = estimated 75% smaller minified
+      assert.equal(computeJSTokenLength(angularFullScript), 334968);
     });
   });
 });
diff --git a/lighthouse-core/test/lib/page-functions-test.js b/lighthouse-core/test/lib/page-functions-test.js
index 8db4648fb..d04114a0c 100644
--- a/lighthouse-core/test/lib/page-functions-test.js
+++ b/lighthouse-core/test/lib/page-functions-test.js
@@ -12,7 +12,7 @@ const pageFunctions = require('../../lib/page-functions');
 
 /* eslint-env jest */
 
-describe('DetailsRenderer', () => {
+describe('Page Functions', () => {
   let dom;
 
   beforeAll(() => {
@@ -44,5 +44,19 @@ describe('DetailsRenderer', () => {
         ['style-missing', 'aria-label-missing']
       ), '<div id="1" style="style" aria-label="label">');
     });
+
+    it('works if attribute values contain line breaks', () => {
+      assert.equal(pageFunctions.getOuterHTMLSnippet(
+        dom.createElement('div', '', {style: 'style1\nstyle2'})), '<div style="style1\nstyle2">');
+    });
+  });
+
+  describe('getNodeSelector', () => {
+    it('Uses IDs where available and otherwise falls back to classes', () => {
+      const parentEl = dom.createElement('div', '', {id: 'wrapper', class: 'dont-use-this'});
+      const childEl = dom.createElement('div', '', {class: 'child'});
+      parentEl.appendChild(childEl);
+      assert.equal(pageFunctions.getNodeSelector(childEl), 'div#wrapper > div.child');
+    });
   });
 });
diff --git a/lighthouse-core/test/lib/rect-helpers-test.js b/lighthouse-core/test/lib/rect-helpers-test.js
new file mode 100644
index 000000000..22c0a19be
--- /dev/null
+++ b/lighthouse-core/test/lib/rect-helpers-test.js
@@ -0,0 +1,87 @@
+/**
+ * @license Copyright 2018 Google Inc. All Rights Reserved.
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
+ */
+'use strict';
+
+/* eslint-env jest */
+
+const {
+  addRectTopAndBottom,
+  getRectOverlapArea,
+  getLargestRect,
+  getRectAtCenter,
+  allRectsContainedWithinEachOther,
+} = require('../../lib/rect-helpers');
+
+describe('Rect Helpers', () => {
+  it('getRectOverlapArea', () => {
+    const overlapArea = getRectOverlapArea(
+      addRectTopAndBottom({
+        x: 0,
+        y: 0,
+        width: 10,
+        height: 10,
+      }),
+      addRectTopAndBottom({
+        x: 8,
+        y: 6,
+        width: 10,
+        height: 10,
+      })
+    );
+    expect(overlapArea).toBe(8);
+  });
+
+  it('getLargestRect', () => {
+    const rect1 = addRectTopAndBottom({
+      x: 0,
+      y: 0,
+      width: 5,
+      height: 5,
+    });
+    const rect2 = addRectTopAndBottom({
+      x: 0,
+      y: 0,
+      width: 10,
+      height: 10,
+    });
+    const largestRect = getLargestRect([rect1, rect2]);
+    expect(largestRect).toBe(rect2);
+  });
+
+  it('getRectAtCenter', () => {
+    const rect = addRectTopAndBottom({
+      x: 0,
+      y: 0,
+      width: 100,
+      height: 100,
+    });
+    const largestRect = getRectAtCenter(rect, 10);
+    expect(largestRect).toEqual(
+      addRectTopAndBottom({
+        x: 45,
+        y: 45,
+        width: 10,
+        height: 10,
+      })
+    );
+  });
+
+  describe('allRectsContainedWithinEachOther', () => {
+    it('Returns true if the rect lists are both empty', () => {
+      expect(allRectsContainedWithinEachOther([], [])).toBe(true);
+    });
+    it('Returns true if rects are contained in each other', () => {
+      const rect1 = addRectTopAndBottom({x: 0, y: 0, width: 100, height: 100});
+      const rect2 = addRectTopAndBottom({x: 40, y: 40, width: 20, height: 20});
+      expect(allRectsContainedWithinEachOther([rect1], [rect2])).toBe(true);
+    });
+    it('Returns true if rects aren\'t contained in each other', () => {
+      const rect1 = addRectTopAndBottom({x: 0, y: 0, width: 100, height: 100});
+      const rect2 = addRectTopAndBottom({x: 200, y: 200, width: 20, height: 20});
+      expect(allRectsContainedWithinEachOther([rect1], [rect2])).toBe(false);
+    });
+  });
+});
diff --git a/lighthouse-core/test/lib/tappable-rects-test.js b/lighthouse-core/test/lib/tappable-rects-test.js
new file mode 100644
index 000000000..030a4e397
--- /dev/null
+++ b/lighthouse-core/test/lib/tappable-rects-test.js
@@ -0,0 +1,246 @@
+/**
+ * @license Copyright 2018 Google Inc. All Rights Reserved.
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
+ */
+'use strict';
+
+/* eslint-env jest */
+
+const {addRectTopAndBottom} = require('../../lib/rect-helpers');
+const {getTappableRectsFromClientRects} = require('../../lib/tappable-rects');
+const assert = require('assert');
+
+describe('getTappableRectsFromClientRects', () => {
+  it('Merges rects if a smaller rect is inside a larger one', () => {
+    const containingRect = addRectTopAndBottom({
+      x: 10,
+      y: 10,
+      width: 100,
+      height: 10,
+    });
+    const containedRect = addRectTopAndBottom({
+      x: 10,
+      y: 10,
+      width: 50,
+      height: 10,
+    });
+
+    assert.deepEqual(getTappableRectsFromClientRects([
+      containingRect,
+      containedRect,
+    ]), [containingRect]);
+    assert.deepEqual(getTappableRectsFromClientRects([
+      containedRect,
+      containingRect,
+    ]), [containingRect]);
+  });
+  it('Merges two horizontally adjacent client rects', () => {
+    const res = getTappableRectsFromClientRects([
+      addRectTopAndBottom({
+        x: 10,
+        y: 10,
+        width: 100,
+        height: 10,
+      }),
+      addRectTopAndBottom({
+        x: 110,
+        y: 10,
+        width: 100,
+        height: 10,
+      }),
+    ]);
+    assert.deepEqual(res, [
+      addRectTopAndBottom({
+        x: 10,
+        y: 10,
+        width: 200,
+        height: 10,
+      }),
+    ]);
+  });
+
+  it('Merges three horizontally adjacent client rects', () => {
+    const res = getTappableRectsFromClientRects([
+      addRectTopAndBottom({
+        x: 10,
+        y: 10,
+        width: 100,
+        height: 10,
+      }),
+      addRectTopAndBottom({
+        x: 110,
+        y: 10,
+        width: 100,
+        height: 10,
+      }),
+      addRectTopAndBottom({
+        x: 210,
+        y: 10,
+        width: 100,
+        height: 10,
+      }),
+    ]);
+    assert.deepEqual(res, [
+      addRectTopAndBottom({
+        x: 10,
+        y: 10,
+        width: 300,
+        height: 10,
+      }),
+    ]);
+  });
+
+  it('Merges client rects correctly if one is duplicated', () => {
+    const res = getTappableRectsFromClientRects([
+      addRectTopAndBottom({
+        x: 10,
+        y: 10,
+        width: 90,
+        height: 10,
+      }),
+      addRectTopAndBottom({
+        x: 10,
+        y: 10,
+        width: 90,
+        height: 10,
+      }),
+      addRectTopAndBottom({
+        x: 100,
+        y: 10,
+        width: 10,
+        height: 10,
+      }),
+    ]);
+    assert.deepEqual(res, [
+      addRectTopAndBottom({
+        x: 10,
+        y: 10,
+        width: 100,
+        height: 10,
+      }),
+    ]);
+  });
+
+  it('Merges two vertically adjacent client rects even if one is wider than the other', () => {
+    // We do this because to fix issues with children (e.g. images) inside links.
+    // The link itself might be small, so if we put a finger on it directly then it's
+    // likely to overlap with something.
+    const res = getTappableRectsFromClientRects([
+      addRectTopAndBottom({
+        x: 10,
+        y: 10,
+        width: 100,
+        height: 10,
+      }),
+      addRectTopAndBottom({
+        x: 10,
+        y: 15,
+        width: 200,
+        height: 15,
+      }),
+    ]);
+    assert.deepEqual(res, [
+      addRectTopAndBottom({
+        x: 10,
+        y: 10,
+        width: 200,
+        height: 20,
+      }),
+    ]);
+  });
+
+  it('Does not merge if the center of the merged rect wouldn\'t be in the original rects', () => {
+    const res = getTappableRectsFromClientRects([
+      addRectTopAndBottom({
+        x: 10,
+        y: 10,
+        width: 10,
+        height: 100,
+      }),
+      addRectTopAndBottom({
+        x: 10,
+        y: 10,
+        width: 200,
+        height: 10,
+      }),
+    ]);
+    assert.equal(res.length, 2);
+  });
+
+  it('Merges two horizontally adjacent client rects that don\'t line up exactly', () => {
+    // 2px difference is ok, often there are cases where an image is a 1px or 2px out of the main link client rect
+    const res = getTappableRectsFromClientRects([
+      addRectTopAndBottom({
+        x: 10,
+        y: 10,
+        width: 100,
+        height: 10,
+      }),
+      addRectTopAndBottom({
+        x: 110,
+        y: 12,
+        width: 100,
+        height: 10,
+      }),
+    ]);
+    assert.deepEqual(res, [
+      addRectTopAndBottom({
+        x: 10,
+        y: 10,
+        width: 200,
+        height: 12,
+      }),
+    ]);
+  });
+
+  it('Merges two identical client rects into one', () => {
+    const res = getTappableRectsFromClientRects([
+      addRectTopAndBottom({
+        x: 10,
+        y: 10,
+        width: 10,
+        height: 10,
+      }),
+      addRectTopAndBottom({
+        x: 10,
+        y: 10,
+        width: 10,
+        height: 10,
+      }),
+    ]);
+    assert.deepEqual(res, [
+      addRectTopAndBottom({
+        x: 10,
+        y: 10,
+        width: 10,
+        height: 10,
+      }),
+    ]);
+  });
+
+  it('Removes tiny 1x1px client rects', () => {
+    const res = getTappableRectsFromClientRects([
+      addRectTopAndBottom({
+        x: 10,
+        y: 10,
+        width: 100,
+        height: 100,
+      }),
+      addRectTopAndBottom({
+        x: 5,
+        y: 5,
+        width: 1,
+        height: 1,
+      }),
+    ]);
+    assert.deepEqual(res, [
+      addRectTopAndBottom({
+        x: 10,
+        y: 10,
+        width: 100,
+        height: 100,
+      }),
+    ]);
+  });
+});
diff --git a/lighthouse-core/test/report/html/renderer/pwa-category-renderer-test.js b/lighthouse-core/test/report/html/renderer/pwa-category-renderer-test.js
index 03043112d..5698032e9 100644
--- a/lighthouse-core/test/report/html/renderer/pwa-category-renderer-test.js
+++ b/lighthouse-core/test/report/html/renderer/pwa-category-renderer-test.js
@@ -119,6 +119,7 @@ describe('PwaCategoryRenderer', () => {
 
       const targetGroupId = groupIds[2];
       assert.ok(targetGroupId);
+      const targetGroupTitle = sampleResults.categoryGroups[targetGroupId].title;
       const targetAuditRefs = auditRefs.filter(ref => ref.group === targetGroupId);
 
       // Try every permutation of audit scoring.
@@ -134,13 +135,25 @@ describe('PwaCategoryRenderer', () => {
         const badgedScoreGauge =
           categoryElem.querySelector('.lh-gauge--pwa__wrapper[class*="lh-badged--"]');
 
+        const tooltip = categoryElem.querySelector('.lh-gauge--pwa__wrapper').title;
+        const targetGroupTip = tooltip.split(', ').find(tip => tip.startsWith(targetGroupTitle));
+        assert.ok(targetGroupTip);
+
         // Only expect a badge (and badged gauge) on last permutation (all bits are set).
         if (i !== totalPermutations - 1) {
           assert.strictEqual(badgedElems.length, 0);
           assert.strictEqual(badgedScoreGauge, null);
+
+          // Tooltip ends with passing/total.
+          const passingCount = categoryElem.querySelectorAll(
+              `.lh-audit-group--${targetGroupId} .lh-audit--pass`).length;
+          assert.ok(targetGroupTip.endsWith(`${passingCount}/${targetAuditRefs.length}`));
         } else {
           assert.strictEqual(badgedElems.length, 1);
           assert.ok(badgedScoreGauge.classList.contains(`lh-badged--${targetGroupId}`));
+
+          // Tooltip ends with total/total.
+          assert.ok(targetGroupTip.endsWith(`${targetAuditRefs.length}/${targetAuditRefs.length}`));
         }
       }
     });
@@ -152,7 +165,17 @@ describe('PwaCategoryRenderer', () => {
 
       const categoryElem = pwaRenderer.render(category, sampleResults.categoryGroups);
       assert.strictEqual(categoryElem.querySelectorAll('.lh-badged').length, groupIds.length);
-      assert.ok(categoryElem.querySelector('.lh-gauge--pwa__wrapper.lh-badged--all'));
+
+      // Score gauge.
+      const gaugeElem = categoryElem.querySelector('.lh-gauge--pwa__wrapper');
+      assert.ok(gaugeElem.classList.contains('lh-badged--all'));
+
+      // All tooltips should have x/x audits passed.
+      const tips = gaugeElem.title.split(', ');
+      assert.strictEqual(tips.length, groupIds.length);
+      for (const tip of tips) {
+        assert.ok(/(\d+)\/\1$/.test(tip));
+      }
     });
 
     it('renders no badges when no audit groups are passing', () => {
@@ -162,8 +185,17 @@ describe('PwaCategoryRenderer', () => {
 
       const categoryElem = pwaRenderer.render(category, sampleResults.categoryGroups);
       assert.strictEqual(categoryElem.querySelectorAll('.lh-badged').length, 0);
-      assert.strictEqual(categoryElem.querySelector('.lh-gauge--pwa__wrapper[class*="lh-badged-"]'),
-        null);
+
+      // Score gauge.
+      const gaugeElem = categoryElem.querySelector('.lh-gauge--pwa__wrapper');
+      assert.ok(!gaugeElem.matches('.lh-gauge--pwa__wrapper[class*="lh-badged-"]'));
+
+      // All tooltips should have 0/x audits passed.
+      const tips = gaugeElem.title.split(', ');
+      assert.strictEqual(tips.length, groupIds.length);
+      for (const tip of tips) {
+        assert.ok(/0\/\d+$/.test(tip));
+      }
     });
 
     it('renders all but one badge when all groups but one are passing', () => {
@@ -176,13 +208,33 @@ describe('PwaCategoryRenderer', () => {
       const categoryElem = pwaRenderer.render(category, sampleResults.categoryGroups);
       const gaugeElem = categoryElem.querySelector('.lh-gauge--pwa__wrapper');
 
+      const tips = gaugeElem.title.split(', ');
+      assert.strictEqual(tips.length, groupIds.length);
+
       for (const groupId of groupIds) {
         const expectedCount = groupId === failingGroupId ? 0 : 1;
 
+        // Individual group badges.
         const groupElems = categoryElem.querySelectorAll(`.lh-audit-group--${groupId}.lh-badged`);
         assert.strictEqual(groupElems.length, expectedCount);
 
-        gaugeElem.classList.contains(`lh-badged--${groupId}`);
+        // Score gauge.
+        if (groupId !== failingGroupId) {
+          assert.ok(gaugeElem.classList.contains(`lh-badged--${groupId}`));
+        }
+
+        // Map back from groupId to groupTitle (used in tooltip).
+        const groupTitle = sampleResults.categoryGroups[groupId].title;
+        const groupTip = tips.find(tip => tip.startsWith(groupTitle));
+        assert.ok(groupTip);
+
+        // All tooltips should be x/x except for failingGroup, which should be (x-1)/x.
+        if (groupId !== failingGroupId) {
+          assert.ok(/(\d+)\/\1$/.test(groupTip));
+        } else {
+          const [, passingCount, totalCount] = /(\d+)\/(\d+)$/.exec(groupTip);
+          assert.strictEqual(Number(passingCount) + 1, Number(totalCount));
+        }
       }
     });
   });
diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json
index 8860d86a5..cb09a0e23 100644
--- a/lighthouse-core/test/results/sample_v2.json
+++ b/lighthouse-core/test/results/sample_v2.json
@@ -49,7 +49,7 @@
     },
     "service-worker": {
       "id": "service-worker",
-      "title": "Does not register a service worker",
+      "title": "Does not register a service worker that controls page and start_url",
       "description": "The service worker is the technology that enables your app to use many Progressive Web App features, such as offline, add to homescreen, and push notifications. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/registered-service-worker).",
       "score": 0,
       "scoreDisplayMode": "binary",
diff --git a/lighthouse-core/test/runner-test.js b/lighthouse-core/test/runner-test.js
index 45a83721e..d3683f316 100644
--- a/lighthouse-core/test/runner-test.js
+++ b/lighthouse-core/test/runner-test.js
@@ -609,6 +609,27 @@ describe('Runner', () => {
     assert.ok(lhr.runtimeError.message.includes(NO_FCP.message));
   });
 
+  it('localized errors thrown from driver', async () => {
+    const erroringDriver = {...driverMock,
+      async connect() {
+        const err = new LHError(
+          LHError.errors.PROTOCOL_TIMEOUT,
+          {protocolMethod: 'Method.Failure'}
+        );
+        throw err;
+      },
+    };
+
+    try {
+      await Runner.run(null, {url: 'https://example.com/', driverMock: erroringDriver, config: new Config()});
+      assert.fail('should have thrown');
+    } catch (err) {
+      assert.equal(err.code, LHError.errors.PROTOCOL_TIMEOUT.code);
+      assert.ok(/^Waiting for DevTools protocol.*Method: Method.Failure/.test(err.friendlyMessage),
+        'did not localize error message');
+    }
+  });
+
   it('can handle array of outputs', async () => {
     const url = 'https://example.com';
     const config = new Config({