Repository: GoogleChrome/lighthouse
Pull request #6457 (2018-11-02 21:11:54)

i18n: add i18n for LighthouseError messages
**Summary**
Adds i18n strings for LighthouseError messages. Adds support for substituting in values into an error message through i18n UIString.

**Related Issues/PRs**
fixes: #6441 

exterkamp: Not ready to review yet 😄 should land #6579 first and then merge those changes in and update to i18n that new error!

Patch:
diff --git a/changelog.md b/changelog.md
index a08db519f..cea19e697 100644
--- a/changelog.md
+++ b/changelog.md
@@ -1,3 +1,39 @@
+ <a name="4.0.0-beta"></a>
+# 4.0.0-beta (2018-11-29)
+[Full Changelog](https://github.com/googlechrome/lighthouse/compare/v4.0.0-alpha.1...v4.0.0-beta)
+
+## Core
+
+* service-worker: check that test page is in SW scope ([#6609](https://github.com/googlechrome/lighthouse/pull/6609))
+* throw fatally on page hang ([#6497](https://github.com/googlechrome/lighthouse/pull/6497))
+* define new DNS failure LH Error ([#6579](https://github.com/googlechrome/lighthouse/pull/6579))
+* assert securityIssues before getPageLoadError ([#6578](https://github.com/googlechrome/lighthouse/pull/6578))
+* remove protocol timeout for Page.navigate ([#6413](https://github.com/googlechrome/lighthouse/pull/6413))
+* rename webapp-install-banner audit to installable-manifest ([#6630](https://github.com/googlechrome/lighthouse/pull/6630))
+* computed-artifacts: fix cache and add perf timing ([#6619](https://github.com/googlechrome/lighthouse/pull/6619))
+
+## Report
+
+* add PWA category badge gauge ([#6526](https://github.com/googlechrome/lighthouse/pull/6526), [#6670](https://github.com/googlechrome/lighthouse/pull/6670))
+* give passed-opportunities group an icon ([#6594](https://github.com/googlechrome/lighthouse/pull/6594))
+* safari: fix dropdown overlap translateZ ([#6546](https://github.com/googlechrome/lighthouse/pull/6546))
+* use details-element-polyfill from node_modules ([#6593](https://github.com/googlechrome/lighthouse/pull/6593), [#6607](https://github.com/googlechrome/lighthouse/pull/6607))
+
+## CLI
+
+* exit and kill Chrome on unhandledRejection ([#6394](https://github.com/googlechrome/lighthouse/pull/6394))
+
+## Tests
+
+* add smoketest for slow service worker ([#6648](https://github.com/googlechrome/lighthouse/pull/6648))
+
+## Misc
+
+* tsc: infer dom.createElement type from tag name ([#6637](https://github.com/googlechrome/lighthouse/pull/6637))
+* rename core/gather/computed/ to core/computed/ ([#6618](https://github.com/googlechrome/lighthouse/pull/6618))
+* rename typings/ to types/ ([#6617](https://github.com/googlechrome/lighthouse/pull/6617))
+* bump extension version ([#6569](https://github.com/googlechrome/lighthouse/pull/6569))
+
  <a name="4.0.0-alpha.1"></a>
 # 4.0.0-alpha.1 (2018-11-15)
 [Full Changelog](https://github.com/googlechrome/lighthouse/compare/v4.0.0-alpha.0...v4.0.0-alpha.1)
diff --git a/clients/extension/manifest.json b/clients/extension/manifest.json
index 8093ed4b8..3bab6209c 100644
--- a/clients/extension/manifest.json
+++ b/clients/extension/manifest.json
@@ -1,6 +1,6 @@
 {
   "name": "__MSG_appName__",
-  "version": "3.3.0.4001",
+  "version": "3.3.0.4100",
   "minimum_chrome_version": "66",
   "manifest_version": 2,
   "description": "__MSG_appDescription__",
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/webapp-install-banner.js b/lighthouse-core/audits/installable-manifest.js
similarity index 78%
rename from lighthouse-core/audits/webapp-install-banner.js
rename to lighthouse-core/audits/installable-manifest.js
index bbe114413..da50e7048 100644
--- a/lighthouse-core/audits/webapp-install-banner.js
+++ b/lighthouse-core/audits/installable-manifest.js
@@ -5,12 +5,12 @@
  */
 'use strict';
 
-const MultiCheckAudit = require('./multi-check-audit');
+const MultiCheckAudit = require('./multi-check-audit.js');
 const ManifestValues = require('../computed/manifest-values.js');
 
 /**
  * @fileoverview
- * Audits if a page is configured to prompt users with the webapp install banner.
+ * Audits if the page's web app manifest qualifies for triggering a beforeinstallprompt event.
  * https://github.com/GoogleChrome/lighthouse/issues/23#issuecomment-270453303
  *
  * Requirements:
@@ -20,27 +20,21 @@ const ManifestValues = require('../computed/manifest-values.js');
  *   * manifest has a valid shortname
  *   * manifest display property is standalone, minimal-ui, or fullscreen
  *   * manifest contains icon that's a png and size >= 192px
- *   * SW is registered, and it owns this page and the manifest's start url
- *   * Site engagement score of 2 or higher
-
- * This audit covers these requirements with the following exceptions:
- *   * it doesn't consider SW controlling the starturl
- *   * it doesn't consider the site engagement score (naturally)
  */
 
-class WebappInstallBanner extends MultiCheckAudit {
+class InstallableManifest extends MultiCheckAudit {
   /**
    * @return {LH.Audit.Meta}
    */
   static get meta() {
     return {
-      id: 'webapp-install-banner',
-      title: 'User can be prompted to Install the Web App',
-      failureTitle: 'User will not be prompted to Install the Web App',
+      id: 'installable-manifest',
+      title: 'Web app manifest meets the installability requirements',
+      failureTitle: 'Web app manifest does not meet the installability requirements',
       description: 'Browsers can proactively prompt users to add your app to their homescreen, ' +
           'which can lead to higher engagement. ' +
           '[Learn more](https://developers.google.com/web/tools/lighthouse/audits/install-prompt).',
-      requiredArtifacts: ['URL', 'ServiceWorker', 'Manifest'],
+      requiredArtifacts: ['URL', 'Manifest'],
     };
   }
 
@@ -86,7 +80,7 @@ class WebappInstallBanner extends MultiCheckAudit {
    */
   static async audit_(artifacts, context) {
     const manifestValues = await ManifestValues.request(artifacts.Manifest, context);
-    const manifestFailures = WebappInstallBanner.assessManifest(manifestValues);
+    const manifestFailures = InstallableManifest.assessManifest(manifestValues);
 
     return {
       failures: [
@@ -97,4 +91,4 @@ class WebappInstallBanner extends MultiCheckAudit {
   }
 }
 
-module.exports = WebappInstallBanner;
+module.exports = InstallableManifest;
diff --git a/lighthouse-core/audits/multi-check-audit.js b/lighthouse-core/audits/multi-check-audit.js
index 35e62a59f..a41cb33e0 100644
--- a/lighthouse-core/audits/multi-check-audit.js
+++ b/lighthouse-core/audits/multi-check-audit.js
@@ -32,7 +32,6 @@ class MultiCheckAudit extends Audit {
       ...result,
       ...result.manifestValues,
       manifestValues: undefined,
-      warnings: undefined,
       allChecks: undefined,
     };
 
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/computed/manifest-values.js b/lighthouse-core/computed/manifest-values.js
index 113cb46af..722ad5372 100644
--- a/lighthouse-core/computed/manifest-values.js
+++ b/lighthouse-core/computed/manifest-values.js
@@ -15,10 +15,6 @@ const PWA_DISPLAY_VALUES = ['minimal-ui', 'fullscreen', 'standalone'];
 const SUGGESTED_SHORTNAME_LENGTH = 12;
 
 class ManifestValues {
-  static get validityIds() {
-    return ['hasManifest', 'hasParseableManifest'];
-  }
-
   /** @typedef {(val: NonNullable<LH.Artifacts.Manifest['value']>) => boolean} Validator */
 
   /**
@@ -86,8 +82,6 @@ class ManifestValues {
    */
   static async compute_(manifest) {
     // if the manifest isn't there or is invalid json, we report that and bail
-    let parseFailureReason;
-
     if (manifest === null) {
       return {
         isParseFailure: true,
@@ -115,7 +109,6 @@ class ManifestValues {
 
     return {
       isParseFailure: false,
-      parseFailureReason,
       allChecks: remainingChecks,
     };
   }
diff --git a/lighthouse-core/config/default-config.js b/lighthouse-core/config/default-config.js
index 39ad7100f..645dfd2dd 100644
--- a/lighthouse-core/config/default-config.js
+++ b/lighthouse-core/config/default-config.js
@@ -151,7 +151,7 @@ const defaultConfig = {
     'user-timings',
     'critical-request-chains',
     'redirects',
-    'webapp-install-banner',
+    'installable-manifest',
     'splash-screen',
     'themed-omnibox',
     'content-width',
@@ -371,7 +371,7 @@ const defaultConfig = {
         // Installable
         {id: 'is-on-https', weight: 2, group: 'pwa-installable'},
         {id: 'service-worker', weight: 1, group: 'pwa-installable'},
-        {id: 'webapp-install-banner', weight: 2, group: 'pwa-installable'},
+        {id: 'installable-manifest', weight: 2, group: 'pwa-installable'},
         // PWA Optimized
         {id: 'redirects-http', weight: 2, group: 'pwa-optimized'},
         {id: 'splash-screen', weight: 1, group: 'pwa-optimized'},
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/details-renderer.js b/lighthouse-core/report/html/renderer/details-renderer.js
index 8002628ff..b440662f3 100644
--- a/lighthouse-core/report/html/renderer/details-renderer.js
+++ b/lighthouse-core/report/html/renderer/details-renderer.js
@@ -130,7 +130,7 @@ class DetailsRenderer {
       displayedPath = url;
     }
 
-    const element = /** @type {HTMLElement} */ (this._dom.createElement('div', 'lh-text__url'));
+    const element = this._dom.createElement('div', 'lh-text__url');
     element.appendChild(this._renderText({
       value: displayedPath,
     }));
@@ -161,7 +161,7 @@ class DetailsRenderer {
       });
     }
 
-    const a = /** @type {HTMLAnchorElement} */ (this._dom.createElement('a'));
+    const a = this._dom.createElement('a');
     a.rel = 'noopener';
     a.target = '_blank';
     a.textContent = details.text;
@@ -197,7 +197,7 @@ class DetailsRenderer {
    * @return {Element}
    */
   _renderThumbnail(details) {
-    const element = /** @type {HTMLImageElement}*/ (this._dom.createElement('img', 'lh-thumbnail'));
+    const element = this._dom.createElement('img', 'lh-thumbnail');
     const strValue = details.value;
     element.src = strValue;
     element.title = strValue;
@@ -346,7 +346,7 @@ class DetailsRenderer {
    * @protected
    */
   renderNode(item) {
-    const element = /** @type {HTMLSpanElement} */ (this._dom.createElement('span', 'lh-node'));
+    const element = this._dom.createElement('span', 'lh-node');
     if (item.snippet) {
       element.textContent = item.snippet;
     }
diff --git a/lighthouse-core/report/html/renderer/dom.js b/lighthouse-core/report/html/renderer/dom.js
index fa1d56472..3c10ca647 100644
--- a/lighthouse-core/report/html/renderer/dom.js
+++ b/lighthouse-core/report/html/renderer/dom.js
@@ -18,6 +18,8 @@
 
 /* globals URL self */
 
+/** @typedef {HTMLElementTagNameMap & {[id: string]: HTMLElement}} HTMLElmentByTagName */
+
 class DOM {
   /**
    * @param {Document} document
@@ -27,14 +29,14 @@ class DOM {
     this._document = document;
   }
 
-  // TODO(bckenny): can pass along `createElement`'s inferred type
   /**
-   * @param {string} name
+   * @template {string} T
+   * @param {T} name
    * @param {string=} className
    * @param {Object<string, (string|undefined)>=} attrs Attribute key/val pairs.
    *     Note: if an attribute key has an undefined value, this method does not
    *     set the attribute on the node.
-   * @return {Element}
+   * @return {HTMLElmentByTagName[T]}
    */
   createElement(name, className, attrs = {}) {
     const element = this._document.createElement(name);
@@ -58,13 +60,14 @@ class DOM {
   }
 
   /**
+   * @template {string} T
    * @param {Element} parentElem
-   * @param {string} elementName
+   * @param {T} elementName
    * @param {string=} className
    * @param {Object<string, (string|undefined)>=} attrs Attribute key/val pairs.
    *     Note: if an attribute key has an undefined value, this method does not
    *     set the attribute on the node.
-   * @return {Element}
+   * @return {HTMLElmentByTagName[T]}
    */
   createChildOf(parentElem, elementName, className, attrs) {
     const element = this.createElement(elementName, className, attrs);
@@ -122,7 +125,7 @@ class DOM {
 
       // Append link if there are any.
       if (linkText && linkHref) {
-        const a = /** @type {HTMLAnchorElement} */ (this.createElement('a'));
+        const a = this.createElement('a');
         a.rel = 'noopener';
         a.target = '_blank';
         a.textContent = linkText;
@@ -147,7 +150,7 @@ class DOM {
       const [preambleText, codeText] = parts.splice(0, 2);
       element.appendChild(this._document.createTextNode(preambleText));
       if (codeText) {
-        const pre = /** @type {HTMLPreElement} */ (this.createElement('code'));
+        const pre = this.createElement('code');
         pre.textContent = codeText;
         element.appendChild(pre);
       }
diff --git a/lighthouse-core/report/html/renderer/report-renderer.js b/lighthouse-core/report/html/renderer/report-renderer.js
index 0a3509a51..c95d7e2ba 100644
--- a/lighthouse-core/report/html/renderer/report-renderer.js
+++ b/lighthouse-core/report/html/renderer/report-renderer.js
@@ -42,6 +42,7 @@ class ReportRenderer {
   /**
    * @param {LH.Result} result
    * @param {Element} container Parent element to render the report into.
+   * @return {Element}
    */
   renderReport(result, container) {
     // Mutate the UIStrings if necessary (while saving originals)
@@ -55,7 +56,7 @@ class ReportRenderer {
     // put the UIStrings back into original state
     Util.updateAllUIStrings(originalUIStrings);
 
-    return /** @type {Element} **/ (container);
+    return container;
   }
 
   /**
diff --git a/lighthouse-core/report/html/renderer/report-ui-features.js b/lighthouse-core/report/html/renderer/report-ui-features.js
index a9d294dd0..1eef99614 100644
--- a/lighthouse-core/report/html/renderer/report-ui-features.js
+++ b/lighthouse-core/report/html/renderer/report-ui-features.js
@@ -364,8 +364,7 @@ class ReportUIFeatures {
     // load event, however it is cross-domain and won't fire. Instead, listen
     // for a message from the target app saying "I'm open".
     const json = reportJson;
-    window.addEventListener('message', function msgHandler(/** @type {Event} */ e) {
-      const messageEvent = /** @type {MessageEvent} */ (e);
+    window.addEventListener('message', function msgHandler(messageEvent) {
       if (messageEvent.origin !== VIEWER_ORIGIN) {
         return;
       }
@@ -469,7 +468,7 @@ class ReportUIFeatures {
     const ext = blob.type.match('json') ? '.json' : '.html';
     const href = URL.createObjectURL(blob);
 
-    const a = /** @type {HTMLAnchorElement} */ (this._dom.createElement('a'));
+    const a = this._dom.createElement('a');
     a.download = `${filename}${ext}`;
     a.href = href;
     this._document.body.appendChild(a); // Firefox requires anchor to be in the DOM.
diff --git a/lighthouse-core/report/html/report-styles.css b/lighthouse-core/report/html/report-styles.css
index 2af995823..4001365bc 100644
--- a/lighthouse-core/report/html/report-styles.css
+++ b/lighthouse-core/report/html/report-styles.css
@@ -95,13 +95,13 @@
   --search-icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48"><path d="M31 28h-1.59l-.55-.55C30.82 25.18 32 22.23 32 19c0-7.18-5.82-13-13-13S6 11.82 6 19s5.82 13 13 13c3.23 0 6.18-1.18 8.45-3.13l.55.55V31l10 9.98L40.98 38 31 28zm-12 0a9 9 0 1 1 .001-18.001A9 9 0 0 1 19 28z" fill="%235E6268"/><path d="M0 0h48v48H0z" fill="none" /></svg>');
   --remove-circle-icon-url: url('data:image/svg+xml;utf8,<svg height="24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="M0 0h24v24H0z" fill="none"/><path d="M7 11v2h10v-2H7zm5-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z" fill="%235E6268"/></svg>');
 
-  --pwa-fast-reliable-gray-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="nonzero"><circle fill="#DAE0E3" cx="12" cy="12" r="12"/><path d="M12.3 4l6.3 2.8V11c0 3.88-2.69 7.52-6.3 8.4C8.69 18.52 6 14.89 6 11V6.8L12.3 4zm-.56 12.88l3.3-5.79.04-.08c.05-.1.01-.29-.26-.29h-1.96l.56-3.92h-.56L9.6 12.52c0 .03.07-.12-.03.07-.11.2-.12.37.2.37h1.97l-.56 3.92h.56z" fill="#FFF"/></g></svg>');
-  --pwa-installable-gray-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="nonzero"><circle fill="#DAE0E3" cx="12" cy="12" r="12"/><path d="M12 5a7 7 0 1 0 0 14 7 7 0 0 0 0-14zm3.5 7.7h-2.8v2.8h-1.4v-2.8H8.5v-1.4h2.8V8.5h1.4v2.8h2.8v1.4z" fill="#FFF"/></g></svg>');
-  --pwa-optimized-gray-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><rect fill="#DAE0E3" width="24" height="24" rx="12"/><path fill="#FFF" d="M12 15.07l3.6 2.18-.95-4.1 3.18-2.76-4.2-.36L12 6.17l-1.64 3.86-4.2.36 3.2 2.76-.96 4.1z"/><path d="M5 5h14v14H5z"/></g></svg>');
+  --pwa-fast-reliable-gray-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="nonzero"><circle fill="%23DAE0E3" cx="12" cy="12" r="12"/><path d="M12.3 4l6.3 2.8V11c0 3.88-2.69 7.52-6.3 8.4C8.69 18.52 6 14.89 6 11V6.8L12.3 4zm-.56 12.88l3.3-5.79.04-.08c.05-.1.01-.29-.26-.29h-1.96l.56-3.92h-.56L9.6 12.52c0 .03.07-.12-.03.07-.11.2-.12.37.2.37h1.97l-.56 3.92h.56z" fill="%23FFF"/></g></svg>');
+  --pwa-installable-gray-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="nonzero"><circle fill="%23DAE0E3" cx="12" cy="12" r="12"/><path d="M12 5a7 7 0 1 0 0 14 7 7 0 0 0 0-14zm3.5 7.7h-2.8v2.8h-1.4v-2.8H8.5v-1.4h2.8V8.5h1.4v2.8h2.8v1.4z" fill="%23FFF"/></g></svg>');
+  --pwa-optimized-gray-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><rect fill="%23DAE0E3" width="24" height="24" rx="12"/><path fill="%23FFF" d="M12 15.07l3.6 2.18-.95-4.1 3.18-2.76-4.2-.36L12 6.17l-1.64 3.86-4.2.36 3.2 2.76-.96 4.1z"/><path d="M5 5h14v14H5z"/></g></svg>');
 
-  --pwa-fast-reliable-color-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg"><g fill-rule="nonzero" fill="none"><circle fill="#CCE3F6" cx="12" cy="12" r="12"/><path d="M12 4.3l6.3 2.8v4.2c0 3.88-2.69 7.52-6.3 8.4-3.61-.88-6.3-4.51-6.3-8.4V7.1L12 4.3zm-.56 12.88l3.3-5.79.04-.08c.05-.1.01-.29-.26-.29h-1.96l.56-3.92h-.56L9.3 12.82c0 .03.07-.12-.03.07-.11.2-.12.37.2.37h1.97l-.56 3.92h.56z" fill="#304FFE"/></g></svg>');
-  --pwa-installable-color-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg"><g fill-rule="nonzero" fill="none"><circle fill="#D4ECD5" cx="12" cy="12" r="12"/><path d="M12 5a7 7 0 1 0 0 14 7 7 0 0 0 0-14zm3.5 7.7h-2.8v2.8h-1.4v-2.8H8.5v-1.4h2.8V8.5h1.4v2.8h2.8v1.4z" fill="#009688"/></g></svg>');
-  --pwa-optimized-color-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><rect fill="#FCE4EC" width="24" height="24" rx="12"/><path d="M5 5h14v14H5z"/><path fill="#EC3F7A" d="M12 15.07l3.6 2.18-.95-4.1 3.18-2.76-4.2-.36L12 6.17l-1.64 3.86-4.2.36 3.2 2.76-.96 4.1z"/></g></svg>');
+  --pwa-fast-reliable-color-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg"><g fill-rule="nonzero" fill="none"><circle fill="%23CCE3F6" cx="12" cy="12" r="12"/><path d="M12 4.3l6.3 2.8v4.2c0 3.88-2.69 7.52-6.3 8.4-3.61-.88-6.3-4.51-6.3-8.4V7.1L12 4.3zm-.56 12.88l3.3-5.79.04-.08c.05-.1.01-.29-.26-.29h-1.96l.56-3.92h-.56L9.3 12.82c0 .03.07-.12-.03.07-.11.2-.12.37.2.37h1.97l-.56 3.92h.56z" fill="%23304FFE"/></g></svg>');
+  --pwa-installable-color-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg"><g fill-rule="nonzero" fill="none"><circle fill="%23D4ECD5" cx="12" cy="12" r="12"/><path d="M12 5a7 7 0 1 0 0 14 7 7 0 0 0 0-14zm3.5 7.7h-2.8v2.8h-1.4v-2.8H8.5v-1.4h2.8V8.5h1.4v2.8h2.8v1.4z" fill="%23009688"/></g></svg>');
+  --pwa-optimized-color-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><rect fill="%23FCE4EC" width="24" height="24" rx="12"/><path d="M5 5h14v14H5z"/><path fill="%23EC3F7A" d="M12 15.07l3.6 2.18-.95-4.1 3.18-2.76-4.2-.36L12 6.17l-1.64 3.86-4.2.36 3.2 2.76-.96 4.1z"/></g></svg>');
 }
 
 .lh-vars.lh-devtools {
@@ -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/report/html/templates.html b/lighthouse-core/report/html/templates.html
index 1fc8f0aef..2adbb3e48 100644
--- a/lighthouse-core/report/html/templates.html
+++ b/lighthouse-core/report/html/templates.html
@@ -601,6 +601,10 @@ limitations under the License.
     .lh-gauge--pwa__wrapper:not([class*='lh-badged--']) .lh-gauge--pwa__na-line {
       display: inline;
     }
+    /* Just optimized. Same n/a line as no passing groups. */
+    .lh-gauge--pwa__wrapper.lh-badged--pwa-optimized .lh-gauge--pwa__na-line {
+      display: inline;
+    }
 
     /* Just fast and reliable. */
     .lh-gauge--pwa__wrapper.lh-badged--pwa-fast-reliable:not(.lh-badged--pwa-installable) .lh-gauge--pwa__fast-reliable-badge {
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/package.json b/package.json
index 3450f925e..cc77c34b6 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "lighthouse",
-  "version": "4.0.0-alpha.1",
+  "version": "4.0.0-beta",
   "description": "Lighthouse",
   "main": "./lighthouse-core/index.js",
   "bin": {
diff --git a/proto/sample_v2_round_trip.json b/proto/sample_v2_round_trip.json
index 9e8c3c343..3b0b1fd72 100644
--- a/proto/sample_v2_round_trip.json
+++ b/proto/sample_v2_round_trip.json
@@ -933,6 +933,25 @@
             "scoreDisplayMode": "not_applicable", 
             "title": "`<input type=\"image\">` elements have `[alt]` text"
         }, 
+        "installable-manifest": {
+            "description": "Browsers can proactively prompt users to add your app to their homescreen, which can lead to higher engagement. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/install-prompt).", 
+            "details": {
+                "items": [
+                    {
+                        "failures": [
+                            "No manifest was fetched"
+                        ], 
+                        "isParseFailure": true, 
+                        "parseFailureReason": "No manifest was fetched"
+                    }
+                ]
+            }, 
+            "explanation": "Failures: No manifest was fetched.", 
+            "id": "installable-manifest", 
+            "score": 0.0, 
+            "scoreDisplayMode": "binary", 
+            "title": "Web app manifest does not meet the installability requirements"
+        }, 
         "interactive": {
             "description": "Interactive marks the time at which the page is fully interactive. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/consistently-interactive).", 
             "displayValue": "4.9\u00a0s", 
@@ -1876,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).", 
@@ -2542,25 +2561,6 @@
             "scoreDisplayMode": "manual", 
             "title": "Visual order on the page follows DOM order"
         }, 
-        "webapp-install-banner": {
-            "description": "Browsers can proactively prompt users to add your app to their homescreen, which can lead to higher engagement. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/install-prompt).", 
-            "details": {
-                "items": [
-                    {
-                        "failures": [
-                            "No manifest was fetched"
-                        ], 
-                        "isParseFailure": true, 
-                        "parseFailureReason": "No manifest was fetched"
-                    }
-                ]
-            }, 
-            "explanation": "Failures: No manifest was fetched.", 
-            "id": "webapp-install-banner", 
-            "score": 0.0, 
-            "scoreDisplayMode": "binary", 
-            "title": "User will not be prompted to Install the Web App"
-        }, 
         "without-javascript": {
             "description": "Your app should display some content when JavaScript is disabled, even if it's just a warning to the user that JavaScript is required to use the app. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/no-js).", 
             "id": "without-javascript", 
@@ -3065,7 +3065,7 @@
                 }, 
                 {
                     "group": "pwa-installable", 
-                    "id": "webapp-install-banner", 
+                    "id": "installable-manifest", 
                     "weight": 2.0
                 }, 
                 {
@@ -3288,7 +3288,7 @@
             "warningHeader": "Warnings: "
         }
     }, 
-    "lighthouseVersion": "4.0.0-alpha.1", 
+    "lighthouseVersion": "4.0.0-beta", 
     "requestedUrl": "http://localhost:10200/dobetterweb/dbw_tester.html", 
     "runWarnings": [], 
     "timing": {
@@ -3554,7 +3554,7 @@
             {
                 "duration": 100.0, 
                 "entryType": "measure", 
-                "name": "lh:audit:webapp-install-banner", 
+                "name": "lh:audit:installable-manifest", 
                 "startTime": 0.0
             }, 
             {
diff --git a/types/artifacts.d.ts b/types/artifacts.d.ts
index a8dff54d2..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;
@@ -296,9 +305,16 @@ declare global {
 
       export type ManifestValueCheckID = 'hasStartUrl'|'hasIconsAtLeast192px'|'hasIconsAtLeast512px'|'hasPWADisplayValue'|'hasBackgroundColor'|'hasThemeColor'|'hasShortName'|'hasName'|'shortNameLength';
 
-      export interface ManifestValues {
-        isParseFailure: boolean;
-        parseFailureReason: string | undefined;
+      export type ManifestValues = {
+        isParseFailure: false;
+        allChecks: {
+          id: ManifestValueCheckID;
+          failureText: string;
+          passing: boolean;
+        }[];
+      } | {
+        isParseFailure: true;
+        parseFailureReason: string;
         allChecks: {
           id: ManifestValueCheckID;
           failureText: string;
diff --git a/types/audit.d.ts b/types/audit.d.ts
index 3ec154b77..f2596f444 100644
--- a/types/audit.d.ts
+++ b/types/audit.d.ts
@@ -179,7 +179,6 @@ declare global {
     type MultiCheckAuditP2 = Partial<Artifacts.ManifestValues>;
     interface MultiCheckAuditP3 {
       failures: Array<string>;
-      warnings?: undefined;
       manifestValues?: undefined;
       allChecks?: undefined;
     }


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-cli/test/cli/__snapshots__/index-test.js.snap b/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
index d2f3e5cca..691971d6a 100644
--- a/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
+++ b/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
@@ -64,7 +64,7 @@ Object {
       "path": "redirects",
     },
     Object {
-      "path": "webapp-install-banner",
+      "path": "installable-manifest",
     },
     Object {
       "path": "splash-screen",
@@ -840,7 +840,7 @@ Object {
         },
         Object {
           "group": "pwa-installable",
-          "id": "webapp-install-banner",
+          "id": "installable-manifest",
           "weight": 2,
         },
         Object {
diff --git a/lighthouse-cli/test/fixtures/offline-ready-sw.js b/lighthouse-cli/test/fixtures/offline-ready-sw.js
index 43dd5ac0e..011715dc6 100644
--- a/lighthouse-cli/test/fixtures/offline-ready-sw.js
+++ b/lighthouse-cli/test/fixtures/offline-ready-sw.js
@@ -24,7 +24,11 @@ const RUNTIME = 'runtime';
 
 // The install handler takes care of precaching the resources we always need.
 self.addEventListener('install', event => {
-  self.skipWaiting();
+  if (self.location.search.includes('slow')) {
+    event.waitUntil(new Promise(resolve => setTimeout(resolve, 5000)));
+  } else {
+    self.skipWaiting();
+  }
 
   const populateCaches = caches.open(PRECACHE)
       .then(cache => cache.addAll(PRECACHE_URLS));
diff --git a/lighthouse-cli/test/fixtures/offline-ready.html b/lighthouse-cli/test/fixtures/offline-ready.html
index bac166961..7cc1d772e 100644
--- a/lighthouse-cli/test/fixtures/offline-ready.html
+++ b/lighthouse-cli/test/fixtures/offline-ready.html
@@ -24,18 +24,28 @@
 </h4>
 
 <script>
-  if ('serviceWorker' in navigator) {
-    navigator.serviceWorker.register('/offline-ready-sw.js').then(function(registration) {
+  async function registerServiceWorker(qs = '') {
+    if (!('serviceWorker' in navigator)) return
+
+    try {
+      const registration = await navigator.serviceWorker.register(`/offline-ready-sw.js${qs}`)
       console.log('service worker registration complete');
 
       registration.addEventListener('statechange',  e => {
         console.log('sw registration is now', e.target.state);
       });
-    }).catch(function(e) {
+    } catch (e) {
       console.error('service worker is not so cool.', e);
       throw e;
-    });
+    }
+  }
+
+  if (window.location.search.includes('slow')) {
+    window.addEventListener('load', () => registerServiceWorker('?delay=5000&slow'));
+  } else {
+    registerServiceWorker();
   }
+
 </script>
 
 <!--
diff --git a/lighthouse-cli/test/smokehouse/offline-config.js b/lighthouse-cli/test/smokehouse/offline-config.js
index 01a263ae9..a46f2fcfd 100644
--- a/lighthouse-cli/test/smokehouse/offline-config.js
+++ b/lighthouse-cli/test/smokehouse/offline-config.js
@@ -24,7 +24,7 @@ module.exports = {
       'user-timings',
       'critical-request-chains',
       'render-blocking-resources',
-      'webapp-install-banner',
+      'installable-manifest',
       'splash-screen',
       'themed-omnibox',
       'aria-valid-attr',
diff --git a/lighthouse-cli/test/smokehouse/offline-local/offline-expectations.js b/lighthouse-cli/test/smokehouse/offline-local/offline-expectations.js
index 12f3f23f3..f139b0185 100644
--- a/lighthouse-cli/test/smokehouse/offline-local/offline-expectations.js
+++ b/lighthouse-cli/test/smokehouse/offline-local/offline-expectations.js
@@ -63,8 +63,10 @@ module.exports = [
       'critical-request-chains': {
         scoreDisplayMode: 'not-applicable',
       },
-      'webapp-install-banner': {
+      'installable-manifest': {
         score: 0,
+        explanation: 'Failures: No manifest was fetched.',
+        details: {items: [{isParseFailure: true}]},
       },
       'splash-screen': {
         score: 0,
@@ -124,7 +126,7 @@ module.exports = [
       'critical-request-chains': {
         scoreDisplayMode: 'not-applicable',
       },
-      'webapp-install-banner': {
+      'installable-manifest': {
         score: 1,
       },
       'splash-screen': {
@@ -156,4 +158,17 @@ module.exports = [
       },
     },
   },
+
+  {
+    requestedUrl: 'http://localhost:10503/offline-ready.html?slow',
+    finalUrl: 'http://localhost:10503/offline-ready.html?slow',
+    audits: {
+      'service-worker': {
+        score: 1,
+      },
+      'works-offline': {
+        score: 1,
+      },
+    },
+  },
 ];
diff --git a/lighthouse-cli/test/smokehouse/pwa-expectations.js b/lighthouse-cli/test/smokehouse/pwa-expectations.js
index dc61ca6b0..9db56360c 100644
--- a/lighthouse-cli/test/smokehouse/pwa-expectations.js
+++ b/lighthouse-cli/test/smokehouse/pwa-expectations.js
@@ -50,7 +50,7 @@ module.exports = [
       'load-fast-enough-for-pwa': {
         // Ignore speed test; just verify that it ran.
       },
-      'webapp-install-banner': {
+      'installable-manifest': {
         score: 1,
         details: {items: [pwaDetailsExpectations]},
       },
@@ -110,7 +110,7 @@ module.exports = [
       'load-fast-enough-for-pwa': {
         // Ignore speed test; just verify that it ran.
       },
-      'webapp-install-banner': {
+      'installable-manifest': {
         score: 1,
         details: {items: [pwaDetailsExpectations]},
       },
diff --git a/lighthouse-cli/test/smokehouse/pwa2-expectations.js b/lighthouse-cli/test/smokehouse/pwa2-expectations.js
index 28857e8bd..0adc92c0e 100644
--- a/lighthouse-cli/test/smokehouse/pwa2-expectations.js
+++ b/lighthouse-cli/test/smokehouse/pwa2-expectations.js
@@ -44,9 +44,10 @@ module.exports = [
       'load-fast-enough-for-pwa': {
         // Ignore speed test; just verify that it ran.
       },
-      'webapp-install-banner': {
+      'installable-manifest': {
         score: 0,
         details: {items: [jakeExpectations]},
+        explanation: /^Failures: .*short_name/,
       },
       'splash-screen': {
         score: 1,
@@ -104,7 +105,7 @@ module.exports = [
       'load-fast-enough-for-pwa': {
         // Ignore speed test; just verify that it ran.
       },
-      'webapp-install-banner': {
+      'installable-manifest': {
         score: 1,
         details: {items: [pwaDetailsExpectations]},
       },
diff --git a/lighthouse-cli/test/smokehouse/pwa3-expectations.js b/lighthouse-cli/test/smokehouse/pwa3-expectations.js
index 6554e9fa3..205ade18c 100644
--- a/lighthouse-cli/test/smokehouse/pwa3-expectations.js
+++ b/lighthouse-cli/test/smokehouse/pwa3-expectations.js
@@ -42,7 +42,7 @@ module.exports = [
       'load-fast-enough-for-pwa': {
         // Ignore speed test; just verify that it ran .
       },
-      'webapp-install-banner': {
+      'installable-manifest': {
         score: 1,
         details: {items: [pwaRocksExpectations]},
       },
diff --git a/lighthouse-core/test/audits/webapp-install-banner-test.js b/lighthouse-core/test/audits/installable-manifest-test.js
similarity index 72%
rename from lighthouse-core/test/audits/webapp-install-banner-test.js
rename to lighthouse-core/test/audits/installable-manifest-test.js
index 2835a33a1..79b51f60a 100644
--- a/lighthouse-core/test/audits/webapp-install-banner-test.js
+++ b/lighthouse-core/test/audits/installable-manifest-test.js
@@ -5,7 +5,7 @@
  */
 'use strict';
 
-const WebappInstallBannerAudit = require('../../audits/webapp-install-banner');
+const InstallableManifestAudit = require('../../audits/installable-manifest.js');
 const assert = require('assert');
 const manifestParser = require('../../lib/manifest-parser');
 
@@ -37,7 +37,7 @@ describe('PWA: webapp install banner audit', () => {
       artifacts.Manifest = null;
       const context = generateMockAuditContext();
 
-      return WebappInstallBannerAudit.audit(artifacts, context).then(result => {
+      return InstallableManifestAudit.audit(artifacts, context).then(result => {
         assert.strictEqual(result.rawValue, false);
         assert.ok(result.explanation.includes('No manifest was fetched'), result.explanation);
       });
@@ -47,7 +47,7 @@ describe('PWA: webapp install banner audit', () => {
       const artifacts = generateMockArtifacts();
       artifacts.Manifest = manifestParser('{,:}', EXAMPLE_MANIFEST_URL, EXAMPLE_DOC_URL);
       const context = generateMockAuditContext();
-      return WebappInstallBannerAudit.audit(artifacts, context).then(result => {
+      return InstallableManifestAudit.audit(artifacts, context).then(result => {
         assert.strictEqual(result.rawValue, false);
         assert.ok(result.explanation.includes('failed to parse as valid JSON'));
       });
@@ -57,7 +57,7 @@ describe('PWA: webapp install banner audit', () => {
       const artifacts = generateMockArtifacts();
       artifacts.Manifest = manifestParser('{}', EXAMPLE_MANIFEST_URL, EXAMPLE_DOC_URL);
       const context = generateMockAuditContext();
-      return WebappInstallBannerAudit.audit(artifacts, context).then(result => {
+      return InstallableManifestAudit.audit(artifacts, context).then(result => {
         assert.strictEqual(result.rawValue, false);
         assert.ok(result.explanation);
         assert.strictEqual(result.details.items[0].failures.length, 4);
@@ -66,7 +66,7 @@ describe('PWA: webapp install banner audit', () => {
 
     it('passes with complete manifest and SW', () => {
       const context = generateMockAuditContext();
-      return WebappInstallBannerAudit.audit(generateMockArtifacts(), context).then(result => {
+      return InstallableManifestAudit.audit(generateMockArtifacts(), context).then(result => {
         assert.strictEqual(result.rawValue, true, result.explanation);
         assert.strictEqual(result.explanation, undefined, result.explanation);
       });
@@ -74,31 +74,35 @@ describe('PWA: webapp install banner audit', () => {
   });
 
   describe('one-off-failures', () => {
-    /* eslint-disable camelcase */ // because start_url
     it('fails when a manifest contains no start_url', () => {
       const artifacts = generateMockArtifacts();
       artifacts.Manifest.value.start_url.value = undefined;
       const context = generateMockAuditContext();
 
-      return WebappInstallBannerAudit.audit(artifacts, context).then(result => {
+      return InstallableManifestAudit.audit(artifacts, context).then(result => {
         assert.strictEqual(result.rawValue, false);
         assert.ok(result.explanation.includes('start_url'), result.explanation);
-        const failures = result.details.items[0].failures;
-        assert.strictEqual(failures.length, 1, failures);
+
+        const details = result.details.items[0];
+        assert.strictEqual(details.failures.length, 1, details.failures);
+        assert.strictEqual(details.hasStartUrl, false);
+        assert.strictEqual(details.hasShortName, true);
       });
     });
 
-    /* eslint-disable camelcase */ // because short_name
     it('fails when a manifest contains no short_name', () => {
       const artifacts = generateMockArtifacts();
       artifacts.Manifest.value.short_name.value = undefined;
       const context = generateMockAuditContext();
 
-      return WebappInstallBannerAudit.audit(artifacts, context).then(result => {
+      return InstallableManifestAudit.audit(artifacts, context).then(result => {
         assert.strictEqual(result.rawValue, false);
         assert.ok(result.explanation.includes('short_name'), result.explanation);
-        const failures = result.details.items[0].failures;
-        assert.strictEqual(failures.length, 1, failures);
+
+        const details = result.details.items[0];
+        assert.strictEqual(details.failures.length, 1, details.failures);
+        assert.strictEqual(details.hasStartUrl, true);
+        assert.strictEqual(details.hasShortName, false);
       });
     });
 
@@ -107,11 +111,14 @@ describe('PWA: webapp install banner audit', () => {
       artifacts.Manifest.value.name.value = undefined;
       const context = generateMockAuditContext();
 
-      return WebappInstallBannerAudit.audit(artifacts, context).then(result => {
+      return InstallableManifestAudit.audit(artifacts, context).then(result => {
         assert.strictEqual(result.rawValue, false);
         assert.ok(result.explanation.includes('name'), result.explanation);
-        const failures = result.details.items[0].failures;
-        assert.strictEqual(failures.length, 1, failures);
+
+        const details = result.details.items[0];
+        assert.strictEqual(details.failures.length, 1, details.failures);
+        assert.strictEqual(details.hasStartUrl, true);
+        assert.strictEqual(details.hasName, false);
       });
     });
 
@@ -120,11 +127,14 @@ describe('PWA: webapp install banner audit', () => {
       artifacts.Manifest.value.icons.value = [];
       const context = generateMockAuditContext();
 
-      return WebappInstallBannerAudit.audit(artifacts, context).then(result => {
+      return InstallableManifestAudit.audit(artifacts, context).then(result => {
         assert.strictEqual(result.rawValue, false);
         assert.ok(result.explanation.includes('PNG icon'), result.explanation);
-        const failures = result.details.items[0].failures;
-        assert.strictEqual(failures.length, 1, failures);
+
+        const details = result.details.items[0];
+        assert.strictEqual(details.failures.length, 1, details.failures);
+        assert.strictEqual(details.hasStartUrl, true);
+        assert.strictEqual(details.hasIconsAtLeast192px, false);
       });
     });
   });
@@ -133,11 +143,14 @@ describe('PWA: webapp install banner audit', () => {
     const artifacts = generateMockArtifacts(manifestDirtyJpgSrc);
     const context = generateMockAuditContext();
 
-    return WebappInstallBannerAudit.audit(artifacts, context).then(result => {
+    return InstallableManifestAudit.audit(artifacts, context).then(result => {
       assert.strictEqual(result.rawValue, false);
       assert.ok(result.explanation.includes('PNG icon'), result.explanation);
-      const failures = result.details.items[0].failures;
-      assert.strictEqual(failures.length, 1, failures);
+
+      const details = result.details.items[0];
+      assert.strictEqual(details.failures.length, 1, details.failures);
+      assert.strictEqual(details.hasStartUrl, true);
+      assert.strictEqual(details.hasIconsAtLeast192px, false);
     });
   });
 });
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/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json
index c527d7ac5..cb09a0e23 100644
--- a/lighthouse-core/test/results/sample_v2.json
+++ b/lighthouse-core/test/results/sample_v2.json
@@ -5,7 +5,7 @@
     "hostUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3358.0 Safari/537.36",
     "benchmarkIndex": 1000
   },
-  "lighthouseVersion": "4.0.0-alpha.1",
+  "lighthouseVersion": "4.0.0-beta",
   "fetchTime": "2018-03-13T00:55:45.840Z",
   "requestedUrl": "http://localhost:10200/dobetterweb/dbw_tester.html",
   "finalUrl": "http://localhost:10200/dobetterweb/dbw_tester.html",
@@ -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",
@@ -460,9 +460,9 @@
         "overallSavingsMs": 0
       }
     },
-    "webapp-install-banner": {
-      "id": "webapp-install-banner",
-      "title": "User will not be prompted to Install the Web App",
+    "installable-manifest": {
+      "id": "installable-manifest",
+      "title": "Web app manifest does not meet the installability requirements",
       "description": "Browsers can proactively prompt users to add your app to their homescreen, which can lead to higher engagement. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/install-prompt).",
       "score": 0,
       "scoreDisplayMode": "binary",
@@ -2951,7 +2951,7 @@
           "group": "pwa-installable"
         },
         {
-          "id": "webapp-install-banner",
+          "id": "installable-manifest",
           "weight": 2,
           "group": "pwa-installable"
         },
@@ -3697,7 +3697,7 @@
       },
       {
         "startTime": 0,
-        "name": "lh:audit:webapp-install-banner",
+        "name": "lh:audit:installable-manifest",
         "duration": 100,
         "entryType": "measure"
       },
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({