Repository: GoogleChrome/lighthouse
Pull request #6526 (2018-11-12 04:40:26)

report: add PWA category badge gauge
Part of #6395

~A bit hacked together, but it works. Work in earlier PRs keeps the hackiness fairly well contained, though, and it should be easy to revise what's in this initial draft.~

Currently
- [x] needs tests
- [x] the svg needs optimizing (a naive optimization eliminated the gradients, so skipped for now)
- [x] there should be a cleaner way to reveal score badges or portions of the score badges
- [ ] (maybe eventually) needs a rethink of css classes (e.g. should the "gauge" be anything in that spot, or just the gauge-like gauge. If so, what should that spot be called)

<img width="867" alt="screen shot 2018-11-11 at 19 24 20" src="https://user-images.githubusercontent.com/316891/48327102-bcaaf680-e5f1-11e8-8047-b183d20a43c7.png">

<img width="924" alt="screen shot 2018-11-11 at 20 29 50" src="https://user-images.githubusercontent.com/316891/48327106-c03e7d80-e5f1-11e8-8508-1cda6962e6d0.png">




Patch:
diff --git a/changelog.md b/changelog.md
index 97acb31c8..a08db519f 100644
--- a/changelog.md
+++ b/changelog.md
@@ -1,3 +1,38 @@
+ <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)
+
+## Core
+
+* skip h2 audit in LR ([#6564](https://github.com/googlechrome/lighthouse/pull/6564))
+* config: faster category validation ([#6445](https://github.com/googlechrome/lighthouse/pull/6445))
+* driver: wait for Page.frameNavigated for about:blank ([#6446](https://github.com/googlechrome/lighthouse/pull/6446))
+* lib: add chrome-extension to URL allowedProtocols and add test ([#6447](https://github.com/googlechrome/lighthouse/pull/6447))
+* pwa: audit and report group renames ([#6494](https://github.com/googlechrome/lighthouse/pull/6494))
+* seo: properly handle anchors in SVG ([#6483](https://github.com/googlechrome/lighthouse/pull/6483))
+* increase protocol timeout to 30s ([#6505](https://github.com/googlechrome/lighthouse/pull/6505), [#6531](https://github.com/googlechrome/lighthouse/pull/6531))
+
+## Report
+
+* pwa: give badges to groups with all passing audits ([#6504](https://github.com/googlechrome/lighthouse/pull/6504))
+* pwa: add pwa-category-renderer ([#6486](https://github.com/googlechrome/lighthouse/pull/6486))
+* compat: add details-element polyfill for Edge ([#6465](https://github.com/googlechrome/lighthouse/pull/6465))
+* compat: fix support for edge ([#6459](https://github.com/googlechrome/lighthouse/pull/6459))
+* compat: ignore Edge's Invalid Argument error ([#6456](https://github.com/googlechrome/lighthouse/pull/6456))
+* compat: fix summary rendering in FF ([#6448](https://github.com/googlechrome/lighthouse/pull/6448))
+* handle underscored not_applicable scoreDisplayMode ([#6549](https://github.com/googlechrome/lighthouse/pull/6549))
+* css: make performance category more compact ([#6437](https://github.com/googlechrome/lighthouse/pull/6437))
+* rename clump classes; give classes to all audit groups ([#6482](https://github.com/googlechrome/lighthouse/pull/6482))
+* refactor rendering of top-level failed/passing/etc clumps ([#6460](https://github.com/googlechrome/lighthouse/pull/6460))
+
+## Misc
+
+* build: fix viewer deploy ([#6449](https://github.com/googlechrome/lighthouse/pull/6449))
+* timing: move timing-trace to async events ([#6440](https://github.com/googlechrome/lighthouse/pull/6440))
+* update puppeteer to version 1.10.0 ([#6493](https://github.com/googlechrome/lighthouse/pull/6493))
+* readme: use official appveyor badge ([#6443](https://github.com/googlechrome/lighthouse/pull/6443))
+* i18n: convert accessibility audits ([#6229](https://github.com/googlechrome/lighthouse/pull/6229))
+
  <a name="4.0.0-alpha.0"></a>
 # 4.0.0-alpha.0 (2018-10-31)
 [Full Changelog](https://github.com/googlechrome/lighthouse/compare/v3.2.0...v4.0.0-alpha.0)
diff --git a/clients/extension/manifest.json b/clients/extension/manifest.json
index 21bc64040..8093ed4b8 100644
--- a/clients/extension/manifest.json
+++ b/clients/extension/manifest.json
@@ -1,6 +1,6 @@
 {
   "name": "__MSG_appName__",
-  "version": "3.3.0.4000",
+  "version": "3.3.0.4001",
   "minimum_chrome_version": "66",
   "manifest_version": 2,
   "description": "__MSG_appDescription__",
diff --git a/lighthouse-cli/run.js b/lighthouse-cli/run.js
index 6a55fe5f5..52661c3dd 100644
--- a/lighthouse-cli/run.js
+++ b/lighthouse-cli/run.js
@@ -22,6 +22,7 @@ const opn = require('opn');
 
 const _RUNTIME_ERROR_CODE = 1;
 const _PROTOCOL_TIMEOUT_EXIT_CODE = 67;
+const _PAGE_HUNG_EXIT_CODE = 68;
 
 /**
  * exported for testing
@@ -70,6 +71,12 @@ function showProtocolTimeoutError() {
   process.exit(_PROTOCOL_TIMEOUT_EXIT_CODE);
 }
 
+/** @param {LH.LighthouseError} err */
+function showPageHungError(err) {
+  console.error('Page hung:', err.friendlyMessage);
+  process.exit(_PAGE_HUNG_EXIT_CODE);
+}
+
 /**
  * @param {LH.LighthouseError} err
  */
@@ -89,6 +96,8 @@ function handleError(err) {
     showConnectionError();
   } else if (err.code === 'CRI_TIMEOUT') {
     showProtocolTimeoutError();
+  } else if (err.code === 'PAGE_HUNG') {
+    showPageHungError(err);
   } else {
     showRuntimeError(err);
   }
diff --git a/lighthouse-core/config/lr-desktop-config.js b/lighthouse-core/config/lr-desktop-config.js
index 76bdafb63..36ba73592 100644
--- a/lighthouse-core/config/lr-desktop-config.js
+++ b/lighthouse-core/config/lr-desktop-config.js
@@ -18,6 +18,8 @@ const config = {
       throughputKbps: 10 * 1024,
       cpuSlowdownMultiplier: 1,
     },
+    // Skip the h2 audit so it doesn't lie to us. See #6539
+    skipAudits: ['uses-http2'],
   },
 };
 
diff --git a/lighthouse-core/config/lr-mobile-config.js b/lighthouse-core/config/lr-mobile-config.js
index 778acb403..264663542 100644
--- a/lighthouse-core/config/lr-mobile-config.js
+++ b/lighthouse-core/config/lr-mobile-config.js
@@ -10,6 +10,8 @@ const config = {
   extends: 'lighthouse:default',
   settings: {
     maxWaitForLoad: 35 * 1000,
+    // Skip the h2 audit so it doesn't lie to us. See #6539
+    skipAudits: ['uses-http2'],
   },
 };
 
diff --git a/lighthouse-core/gather/driver.js b/lighthouse-core/gather/driver.js
index d181c4901..7444607e6 100644
--- a/lighthouse-core/gather/driver.js
+++ b/lighthouse-core/gather/driver.js
@@ -26,11 +26,11 @@ const Connection = require('./connections/connection.js');
 // Controls how long to wait after onLoad before continuing
 const DEFAULT_PAUSE_AFTER_LOAD = 0;
 // Controls how long to wait between network requests before determining the network is quiet
-const DEFAULT_NETWORK_QUIET_THRESHOLD = 10000;
+const DEFAULT_NETWORK_QUIET_THRESHOLD = 5000;
 // Controls how long to wait between longtasks before determining the CPU is idle, off by default
 const DEFAULT_CPU_QUIET_THRESHOLD = 0;
 // Controls how long to wait for a response after sending a DevTools protocol command.
-const DEFAULT_PROTOCOL_TIMEOUT = 5000;
+const DEFAULT_PROTOCOL_TIMEOUT = 30000;
 
 /**
  * @typedef {LH.Protocol.StrictEventEmitter<LH.CrdpEvents>} CrdpEventEmitter
@@ -369,6 +369,7 @@ class Driver {
       includeCommandLineAPI: true,
       awaitPromise: true,
       returnByValue: true,
+      timeout: 60000,
       contextId,
     };
 
@@ -687,6 +688,25 @@ class Driver {
     };
   }
 
+  /**
+   * Returns whether the page appears to be hung.
+   * @return {Promise<boolean>}
+   */
+  async isPageHung() {
+    try {
+      this.setNextProtocolTimeout(1000);
+      await this.sendCommand('Runtime.evaluate', {
+        expression: '"ping"',
+        returnByValue: true,
+        timeout: 1000,
+      });
+
+      return false;
+    } catch (err) {
+      return true;
+    }
+  }
+
   /**
    * Returns a promise that resolves when:
    * - All of the following conditions have been met:
@@ -736,11 +756,18 @@ class Driver {
     const maxTimeoutPromise = new Promise((resolve, reject) => {
       maxTimeoutHandle = setTimeout(resolve, maxWaitForLoadedMs);
     }).then(_ => {
-      return function() {
-        log.warn('Driver', 'Timed out waiting for page load. Moving on...');
+      return async () => {
+        log.warn('Driver', 'Timed out waiting for page load. Checking if page is hung...');
         waitForLoadEvent.cancel();
         waitForNetworkIdle.cancel();
         waitForCPUIdle && waitForCPUIdle.cancel();
+
+        if (await this.isPageHung()) {
+          log.warn('Driver', 'Page appears to be hung, killing JavaScript...');
+          await this.sendCommand('Emulation.setScriptExecutionDisabled', {value: true});
+          await this.sendCommand('Runtime.terminateExecution');
+          throw new LHError(LHError.errors.PAGE_HUNG);
+        }
       };
     });
 
@@ -749,7 +776,7 @@ class Driver {
       loadPromise,
       maxTimeoutPromise,
     ]);
-    cleanupFn();
+    await cleanupFn();
   }
 
   /**
diff --git a/lighthouse-core/gather/gather-runner.js b/lighthouse-core/gather/gather-runner.js
index 6eaeb4b07..50726d043 100644
--- a/lighthouse-core/gather/gather-runner.js
+++ b/lighthouse-core/gather/gather-runner.js
@@ -150,8 +150,19 @@ class GatherRunner {
     if (!mainRecord) {
       errorDef = LHError.errors.NO_DOCUMENT_REQUEST;
     } else if (mainRecord.failed) {
-      errorDef = {...LHError.errors.FAILED_DOCUMENT_REQUEST};
-      errorDef.message += ` ${mainRecord.localizedFailDescription}.`;
+      const netErr = mainRecord.localizedFailDescription;
+      // Match all resolution and DNS failures
+      // https://cs.chromium.org/chromium/src/net/base/net_error_list.h?rcl=cd62979b
+      if (
+        netErr === 'net::ERR_NAME_NOT_RESOLVED' ||
+        netErr === 'net::ERR_NAME_RESOLUTION_FAILED' ||
+        netErr.startsWith('net::ERR_DNS_')
+      ) {
+        errorDef = LHError.errors.DNS_FAILURE;
+      } else {
+        errorDef = {...LHError.errors.FAILED_DOCUMENT_REQUEST};
+        errorDef.message += ` ${netErr}.`;
+      }
     } else if (mainRecord.hasErrorStatusCode()) {
       errorDef = {...LHError.errors.ERRORED_DOCUMENT_REQUEST};
       errorDef.message += ` Status code: ${mainRecord.statusCode}.`;
@@ -300,6 +311,8 @@ class GatherRunner {
     const networkRecords = NetworkRecorder.recordsFromLogs(devtoolsLog);
     log.timeEnd(status);
 
+    this.assertNoSecurityIssues(driver.getSecurityState());
+
     let pageLoadError = GatherRunner.getPageLoadError(passContext.url, networkRecords);
     // If the driver was offline, a page load error is expected, so do not save it.
     if (!driver.online) pageLoadError = undefined;
@@ -309,8 +322,6 @@ class GatherRunner {
       passContext.LighthouseRunWarnings.push(pageLoadError.friendlyMessage);
     }
 
-    this.assertNoSecurityIssues(driver.getSecurityState());
-
     // Expose devtoolsLog, networkRecords, and trace (if present) to gatherers
     /** @type {LH.Gatherer.LoadData} */
     const passData = {
diff --git a/lighthouse-core/lib/lh-error.js b/lighthouse-core/lib/lh-error.js
index 2a09ba497..dbaae2b08 100644
--- a/lighthouse-core/lib/lh-error.js
+++ b/lighthouse-core/lib/lh-error.js
@@ -148,6 +148,12 @@ const ERRORS = {
     message: strings.pageLoadFailedInsecure,
     lhrRuntimeError: true,
   },
+  /* Used when the page stopped responding and did not finish loading. */
+  PAGE_HUNG: {
+    code: 'PAGE_HUNG',
+    message: strings.pageLoadFailedHung,
+    lhrRuntimeError: true,
+  },
 
   // Protocol internal failures
   TRACING_ALREADY_STARTED: {
@@ -182,6 +188,13 @@ const ERRORS = {
     lhrRuntimeError: true,
   },
 
+  // DNS failure on main document (no resolution, timed out, etc)
+  DNS_FAILURE: {
+    code: 'DNS_FAILURE',
+    message: strings.dnsFailure,
+    lhrRuntimeError: true,
+  },
+
   // Hey! When adding a new error type, update lighthouse-result.proto too.
 };
 
diff --git a/lighthouse-core/lib/strings.js b/lighthouse-core/lib/strings.js
index 377a4117e..3792cae3f 100644
--- a/lighthouse-core/lib/strings.js
+++ b/lighthouse-core/lib/strings.js
@@ -11,9 +11,11 @@ module.exports = {
   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/report/html/renderer/pwa-category-renderer.js b/lighthouse-core/report/html/renderer/pwa-category-renderer.js
index 57a5710b2..7c9be675a 100644
--- a/lighthouse-core/report/html/renderer/pwa-category-renderer.js
+++ b/lighthouse-core/report/html/renderer/pwa-category-renderer.js
@@ -46,14 +46,53 @@ class PwaCategoryRenderer extends CategoryRenderer {
     return categoryElem;
   }
 
+  /**
+   * @param {LH.ReportResult.Category} category
+   * @return {DocumentFragment}
+   */
+  renderScoreGauge(category) {
+    // Defer to parent-gauge style if category error.
+    if (category.score === null) {
+      return super.renderScoreGauge(category);
+    }
+
+    const tmpl = this.dom.cloneTemplate('#tmpl-lh-gauge--pwa', this.templateContext);
+    const wrapper = /** @type {HTMLAnchorElement} */ (this.dom.find('.lh-gauge--pwa__wrapper',
+      tmpl));
+    wrapper.href = `#${category.id}`;
+
+    const allGroups = this._getGroupIds(category.auditRefs);
+    const passingGroupIds = this._getPassingGroupIds(category.auditRefs);
+
+    if (passingGroupIds.size === allGroups.size) {
+      wrapper.classList.add('lh-badged--all');
+    } else {
+      for (const passingGroupId of passingGroupIds) {
+        wrapper.classList.add(`lh-badged--${passingGroupId}`);
+      }
+    }
+
+    this.dom.find('.lh-gauge__label', tmpl).textContent = category.title;
+    return tmpl;
+  }
+
+  /**
+   * Returns the group IDs found in auditRefs.
+   * @param {Array<LH.ReportResult.AuditRef>} auditRefs
+   * @return {Set<string>}
+   */
+  _getGroupIds(auditRefs) {
+    const groupIds = auditRefs.map(ref => ref.group).filter(/** @return {g is string} */ g => !!g);
+    return new Set(groupIds);
+  }
+
   /**
    * Returns the group IDs whose audits are all considered passing.
    * @param {Array<LH.ReportResult.AuditRef>} auditRefs
    * @return {Set<string>}
    */
   _getPassingGroupIds(auditRefs) {
-    const groupIds = auditRefs.map(ref => ref.group).filter(/** @return {g is string} */ g => !!g);
-    const uniqueGroupIds = new Set(groupIds);
+    const uniqueGroupIds = this._getGroupIds(auditRefs);
 
     // Remove any that have a failing audit.
     for (const auditRef of auditRefs) {
diff --git a/lighthouse-core/report/html/renderer/report-renderer.js b/lighthouse-core/report/html/renderer/report-renderer.js
index 31f48cec5..0a3509a51 100644
--- a/lighthouse-core/report/html/renderer/report-renderer.js
+++ b/lighthouse-core/report/html/renderer/report-renderer.js
@@ -201,15 +201,26 @@ class ReportRenderer {
     const categories = reportSection.appendChild(this._dom.createElement('div', 'lh-categories'));
 
     for (const category of report.reportCategories) {
-      if (scoreHeader) {
-        scoreHeader.appendChild(categoryRenderer.renderScoreGauge(category));
-      }
-
       const renderer = specificCategoryRenderers[category.id] || categoryRenderer;
       categories.appendChild(renderer.render(category, report.categoryGroups));
     }
 
     if (scoreHeader) {
+      const defaultGauges = [];
+      const customGauges = [];
+      for (const category of report.reportCategories) {
+        const renderer = specificCategoryRenderers[category.id] || categoryRenderer;
+        const categoryGauge = renderer.renderScoreGauge(category);
+
+        // Group gauges that aren't default at the end of the header
+        if (renderer.renderScoreGauge === categoryRenderer.renderScoreGauge) {
+          defaultGauges.push(categoryGauge);
+        } else {
+          customGauges.push(categoryGauge);
+        }
+      }
+      scoreHeader.append(...defaultGauges, ...customGauges);
+
       const scoreScale = this._dom.cloneTemplate('#tmpl-lh-scorescale', this._templateContext);
       this._dom.find('.lh-scorescale-label', scoreScale).textContent =
         Util.UIStrings.scorescaleLabel;
diff --git a/lighthouse-core/report/html/renderer/util.js b/lighthouse-core/report/html/renderer/util.js
index 875a89f66..cd2cc4535 100644
--- a/lighthouse-core/report/html/renderer/util.js
+++ b/lighthouse-core/report/html/renderer/util.js
@@ -62,6 +62,15 @@ class Util {
     if (typeof clone.categories !== 'object') throw new Error('No categories provided.');
     clone.reportCategories = Object.values(clone.categories);
 
+    // The proto process turns 'not-applicable' into 'not_applicable'. Correct this to support both.
+    // TODO: remove when underscore/hyphen proto issue is resolved. See #6371, #6201.
+    for (const audit of Object.values(clone.audits)) {
+      // @ts-ignore tsc rightly flags that this value shouldn't occur.
+      if (audit.scoreDisplayMode === 'not_applicable') {
+        audit.scoreDisplayMode = 'not-applicable';
+      }
+    }
+
     // For convenience, smoosh all AuditResults into their auditDfn (which has just weight & group)
     for (const category of clone.reportCategories) {
       category.auditRefs.forEach(auditMeta => {
diff --git a/lighthouse-core/report/html/report-styles.css b/lighthouse-core/report/html/report-styles.css
index c9723c168..e23b95a7c 100644
--- a/lighthouse-core/report/html/report-styles.css
+++ b/lighthouse-core/report/html/report-styles.css
@@ -79,6 +79,7 @@
   --lh-audit-group-vpadding: 8px;
   --lh-section-vpadding: 12px;
   --chevron-size: 12px;
+  --circle-size: calc(3 * var(--header-font-size));
 
   --pass-icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><title>check</title><path fill="%23178239" d="M24 4C12.95 4 4 12.95 4 24c0 11.04 8.95 20 20 20 11.04 0 20-8.96 20-20 0-11.05-8.96-20-20-20zm-4 30L10 24l2.83-2.83L20 28.34l15.17-15.17L38 16 20 34z"/></svg>');
   --average-icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><title>info</title><path fill="%23E67700" d="M24 4C12.95 4 4 12.95 4 24s8.95 20 20 20 20-8.95 20-20S35.05 4 24 4zm2 30h-4V22h4v12zm0-16h-4v-4h4v4z"/></svg>');
@@ -785,10 +786,15 @@ details, summary {
   border: 0;
 }
 
-.lh-scores-header .lh-gauge__wrapper {
+.lh-scores-header .lh-gauge__wrapper,
+.lh-scores-header .lh-gauge--pwa__wrapper, {
   margin: 0 4px;
 }
 
+.lh-scores-header .lh-gauge--pwa__wrapper {
+  border-left: 1px solid var(--report-secondary-border-color)
+}
+
 .lh-scorescale {
   color: var(--medium-75-gray);
   padding: 0 calc(var(--section-indent) * 1.5) 0;
@@ -835,7 +841,6 @@ details, summary {
 }
 
 .lh-category {
-  --circle-size: calc(2.5 * var(--header-font-size));
   padding: var(--section-padding);
 }
 
diff --git a/lighthouse-core/report/html/templates.html b/lighthouse-core/report/html/templates.html
index d280232b2..1fc8f0aef 100644
--- a/lighthouse-core/report/html/templates.html
+++ b/lighthouse-core/report/html/templates.html
@@ -197,7 +197,6 @@ limitations under the License.
     }
     .lh-scores-wrapper {
       margin-top: -30px;
-      transform: translateZ(1px);
     }
     .lh-scores-wrapper__shadow {
       opacity: 0;
@@ -478,7 +477,6 @@ limitations under the License.
 <template id="tmpl-lh-gauge">
   <style>
     .lh-gauge__wrapper {
-      --circle-size: calc(3 * var(--header-font-size));
       --circle-size-half: calc(var(--circle-size) / 2);
       --circle-background: hsl(216, 12%, 92%);
       --circle-border-width: 9;
@@ -574,6 +572,143 @@ limitations under the License.
   </a>
 </template>
 
+
+<!-- Lighthouse PWA badge gauge -->
+<template id="tmpl-lh-gauge--pwa">
+  <style>
+    .lh-gauge--pwa__wrapper {
+      display: inline-flex;
+      align-items: center;
+      flex-direction: column;
+      text-decoration: none;
+      flex: 1;
+      min-width: auto;
+      position: relative;
+    }
+    .lh-gauge--pwa {
+      width: var(--circle-size);
+      height: var(--circle-size);
+    }
+    .lh-gauge--pwa .lh-gauge--pwa__component {
+      display: none;
+    }
+    .lh-gauge--pwa__wrapper:not(.lh-badged--all) .lh-gauge--pwa__logo > path {
+      /* Gray logo unless everything is passing. */
+      fill: #B0B0B0;
+    }
+
+    /* No passing groups. */
+    .lh-gauge--pwa__wrapper:not([class*='lh-badged--']) .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 {
+      display: inline;
+    }
+
+    /* Just installable. */
+    .lh-gauge--pwa__wrapper.lh-badged--pwa-installable:not(.lh-badged--pwa-fast-reliable) .lh-gauge--pwa__installable-badge {
+      display: inline;
+    }
+
+    /* Fast and reliable and installable. */
+    .lh-gauge--pwa__wrapper.lh-badged--pwa-fast-reliable.lh-badged--pwa-installable .lh-gauge--pwa__fast-reliable-installable-badges {
+      display: inline;
+    }
+
+    /* All passing groups. */
+    .lh-gauge--pwa__wrapper.lh-badged--all .lh-gauge--pwa__check-circle {
+      display: inline;
+    }
+
+    .lh-gauge__label {
+      font-size: var(--body-font-size);
+      line-height: var(--body-line-height);
+      margin-top: calc(0.5 * var(--body-line-height));
+      text-align: center;
+      color: black;
+    }
+  </style>
+
+  <a href="#" class="lh-gauge--pwa__wrapper">
+    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" class="lh-gauge--pwa">
+      <defs>
+        <linearGradient id="lh-gauge--pwa__bg-disk__gradient" x1="50%" y1="2.15%" x2="50%" y2="97.645%">
+          <stop stop-color="#F1F3F4" offset="0%"></stop>
+          <stop stop-color="#DEE6EA" offset="100%"></stop>
+        </linearGradient>
+        <linearGradient id="lh-gauge--pwa__check-circle__gradient" x1="50%" y1="0%" x2="50%" y2="100%">
+          <stop stop-color="#00C852" offset="0%"></stop>
+          <stop stop-color="#009688" offset="100%"></stop>
+        </linearGradient>
+        <linearGradient id="lh-gauge--pwa__installable__shadow-gradient" x1="76.056%" x2="24.111%" y1="82.995%" y2="24.735%">
+          <stop stop-color="#A5D6A7" offset="0%"></stop>
+          <stop stop-color="#80CBC4" offset="100%"></stop>
+        </linearGradient>
+        <linearGradient id="lh-gauge--pwa__fast-reliable__shadow-gradient" x1="76.056%" y1="82.995%" x2="25.678%" y2="26.493%">
+          <stop stop-color="#64B5F6" offset="0%"></stop>
+          <stop stop-color="#2979FF" offset="100%"></stop>
+        </linearGradient>
+
+        <g id="lh-gauge--pwa__fast-reliable-badge">
+          <circle fill="#FFFFFF" cx="10" cy="10" r="10"></circle>
+          <path fill="#304FFE" d="M10 3.58l5.25 2.34v3.5c0 3.23-2.24 6.26-5.25 7-3.01-.74-5.25-3.77-5.25-7v-3.5L10 3.58zm-.47 10.74l2.76-4.83.03-.07c.04-.08 0-.24-.22-.24h-1.64l.47-3.26h-.47l-2.7 4.77c-.02.01.05-.1-.04.05-.09.16-.1.31.18.31h1.63l-.47 3.27h.47z"/>
+        </g>
+        <g id="lh-gauge--pwa__installable-badge">
+          <circle fill="#FFFFFF" cx="10" cy="10" r="10"></circle>
+          <path fill="#009688" d="M10 4.167A5.835 5.835 0 0 0 4.167 10 5.835 5.835 0 0 0 10 15.833 5.835 5.835 0 0 0 15.833 10 5.835 5.835 0 0 0 10 4.167zm2.917 6.416h-2.334v2.334H9.417v-2.334H7.083V9.417h2.334V7.083h1.166v2.334h2.334v1.166z"/>
+        </g>
+      </defs>
+
+      <g stroke="none" fill-rule="nonzero">
+        <!-- Background and PWA logo (color by default) -->
+        <circle fill="url(#lh-gauge--pwa__bg-disk__gradient)" cx="30" cy="30" r="30"></circle>
+        <g class="lh-gauge--pwa__logo">
+          <path fill="#3D3D3D" d="M35.66 19.39l.7-1.75h2L37.4 15 38.6 12l3.4 9h-2.51l-.58-1.61z"/>
+          <path fill="#304FFE" d="M33.52 21l3.65-9h-2.42l-2.5 5.82L30.5 12h-1.86l-1.9 5.82-1.35-2.65-1.21 3.72L25.4 21h2.38l1.72-5.2 1.64 5.2z"/>
+          <path fill="#3D3D3D" fill-rule="nonzero" d="M20.3 17.91h1.48c.45 0 .85-.05 1.2-.15l.39-1.18 1.07-3.3a2.64 2.64 0 0 0-.28-.37c-.55-.6-1.36-.91-2.42-.91H18v9h2.3V17.9zm1.96-3.84c.22.22.33.5.33.87 0 .36-.1.65-.29.87-.2.23-.59.35-1.15.35h-.86v-2.41h.87c.52 0 .89.1 1.1.32z"/>
+        </g>
+
+        <!-- No badges. -->
+        <rect class="lh-gauge--pwa__component lh-gauge--pwa__na-line" fill="#FFFFFF" x="20" y="32" width="20" height="4" rx="2"></rect>
+
+        <!-- Just fast and reliable. -->
+        <g class="lh-gauge--pwa__component lh-gauge--pwa__fast-reliable-badge" transform="translate(20, 29)">
+          <path fill="url(#lh-gauge--pwa__fast-reliable__shadow-gradient)" d="M33.63 19.49A30 30 0 0 1 16.2 30.36L3 17.14 17.14 3l16.49 16.49z"/>
+          <use xlink:href="#lh-gauge--pwa__fast-reliable-badge" />
+        </g>
+
+        <!-- Just installable. -->
+        <g class="lh-gauge--pwa__component lh-gauge--pwa__installable-badge" transform="translate(20, 29)">
+          <path fill="url(#lh-gauge--pwa__installable__shadow-gradient)" d="M33.629 19.487c-4.272 5.453-10.391 9.39-17.415 10.869L3 17.142 17.142 3 33.63 19.487z"/>
+          <use xlink:href="#lh-gauge--pwa__installable-badge" />
+        </g>
+
+        <!-- Fast and reliable and installable. -->
+        <g class="lh-gauge--pwa__component lh-gauge--pwa__fast-reliable-installable-badges">
+          <g transform="translate(8, 29)"> <!-- fast and reliable -->
+            <path fill="url(#lh-gauge--pwa__fast-reliable__shadow-gradient)" d="M16.321 30.463L3 17.143 17.142 3l22.365 22.365A29.864 29.864 0 0 1 22 31c-1.942 0-3.84-.184-5.679-.537z"/>
+            <use xlink:href="#lh-gauge--pwa__fast-reliable-badge" />
+          </g>
+          <g transform="translate(32, 29)"> <!-- installable -->
+            <path fill="url(#lh-gauge--pwa__installable__shadow-gradient)" d="M25.982 11.84a30.107 30.107 0 0 1-13.08 15.203L3 17.143 17.142 3l8.84 8.84z"/>
+            <use xlink:href="#lh-gauge--pwa__installable-badge" />
+          </g>
+        </g>
+
+        <!-- Full PWA. -->
+        <g class="lh-gauge--pwa__component lh-gauge--pwa__check-circle" transform="translate(18, 28)">
+          <circle fill="#FFFFFF" cx="12" cy="12" r="12"></circle>
+          <path fill="url(#lh-gauge--pwa__check-circle__gradient)" d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"></path>
+        </g>
+      </g>
+    </svg>
+
+    <div class="lh-gauge__label"></div>
+  </a>
+</template>
+
 <!-- Lighthouse crtiical request chains component -->
 <template id="tmpl-lh-crc">
   <div class="lh-crc-container">
diff --git a/package.json b/package.json
index 7558182be..25ff3214f 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "lighthouse",
-  "version": "4.0.0-alpha.0",
+  "version": "4.0.0-alpha.1",
   "description": "Lighthouse",
   "main": "./lighthouse-core/index.js",
   "bin": {
diff --git a/proto/lighthouse-result.proto b/proto/lighthouse-result.proto
index befb32303..0af762379 100644
--- a/proto/lighthouse-result.proto
+++ b/proto/lighthouse-result.proto
@@ -47,6 +47,10 @@ enum LighthouseError {
   INSECURE_DOCUMENT_REQUEST = 16;
   // Used when protocol command times out.
   PROTOCOL_TIMEOUT = 17;
+  // Used when the page is not responding after maxWaitForLoad.
+  PAGE_HUNG = 18;
+  // DNS failure on main document (no resolution, timed out, etc)
+  DNS_FAILURE = 19;
 }
 
 // The overarching Lighthouse Response object (LHR)
diff --git a/proto/sample_v2_round_trip.json b/proto/sample_v2_round_trip.json
index 07a2b4974..551bb7fb9 100644
--- a/proto/sample_v2_round_trip.json
+++ b/proto/sample_v2_round_trip.json
@@ -3289,7 +3289,7 @@
             "warningHeader": "Warnings: "
         }
     }, 
-    "lighthouseVersion": "4.0.0-alpha.0", 
+    "lighthouseVersion": "4.0.0-alpha.1", 
     "requestedUrl": "http://localhost:10200/dobetterweb/dbw_tester.html", 
     "runWarnings": [], 
     "timing": {


Test patch:
diff --git a/lighthouse-cli/test/fixtures/infinite-loop.html b/lighthouse-cli/test/fixtures/infinite-loop.html
new file mode 100644
index 000000000..2b77d7f1c
--- /dev/null
+++ b/lighthouse-cli/test/fixtures/infinite-loop.html
@@ -0,0 +1,22 @@
+<!doctype html>
+<!--
+ * 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.
+-->
+<html>
+<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
+<body>
+  This is the function that never ends
+  Yes, it just goes on and on my friends.
+  Some people started computing it, not knowing what it was.
+  And they'll continue computing it forever just because,
+  This is the function that never ends.
+
+  <script>
+    while (true) {
+      for (let i = 0; i < 1000000; i++) ;
+    }
+  </script>
+</body>
+</html>
diff --git a/lighthouse-cli/test/smokehouse/error-config.js b/lighthouse-cli/test/smokehouse/error-config.js
new file mode 100644
index 000000000..771bdf604
--- /dev/null
+++ b/lighthouse-cli/test/smokehouse/error-config.js
@@ -0,0 +1,19 @@
+/**
+ * @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';
+
+/**
+ * Config file for sites with various errors, just fail out quickly.
+ */
+module.exports = {
+  extends: 'lighthouse:default',
+  settings: {
+    maxWaitForLoad: 5000,
+    onlyAudits: [
+      'first-contentful-paint',
+    ],
+  },
+};
diff --git a/lighthouse-cli/test/smokehouse/error-expectations.js b/lighthouse-cli/test/smokehouse/error-expectations.js
new file mode 100644
index 000000000..40d057806
--- /dev/null
+++ b/lighthouse-cli/test/smokehouse/error-expectations.js
@@ -0,0 +1,18 @@
+/**
+ * @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';
+
+/**
+ * Expected Lighthouse audit values for sites with various errors.
+ */
+module.exports = [
+  {
+    requestedUrl: 'http://localhost:10200/infinite-loop.html',
+    finalUrl: 'http://localhost:10200/infinite-loop.html',
+    errorCode: 'PAGE_HUNG',
+    audits: {},
+  },
+];
diff --git a/lighthouse-cli/test/smokehouse/run-smoke.js b/lighthouse-cli/test/smokehouse/run-smoke.js
index 93e71efae..12f2cb86d 100644
--- a/lighthouse-cli/test/smokehouse/run-smoke.js
+++ b/lighthouse-cli/test/smokehouse/run-smoke.js
@@ -29,6 +29,11 @@ const SMOKETESTS = [{
   config: smokehouseDir + 'a11y/a11y-config.js',
   expectations: 'a11y/expectations.js',
   batch: 'parallel-first',
+}, {
+  id: 'errors',
+  expectations: smokehouseDir + 'error-expectations.js',
+  config: smokehouseDir + 'error-config.js',
+  batch: 'errors',
 }, {
   id: 'pwa',
   expectations: smokehouseDir + 'pwa-expectations.js',
diff --git a/lighthouse-cli/test/smokehouse/smokehouse.js b/lighthouse-cli/test/smokehouse/smokehouse.js
index 8510b4c0b..c3bf337e9 100755
--- a/lighthouse-cli/test/smokehouse/smokehouse.js
+++ b/lighthouse-cli/test/smokehouse/smokehouse.js
@@ -15,6 +15,7 @@ const yargs = require('yargs');
 const log = require('lighthouse-logger');
 
 const PROTOCOL_TIMEOUT_EXIT_CODE = 67;
+const PAGE_HUNG_EXIT_CODE = 68;
 const RETRIES = 3;
 const NUMERICAL_EXPECTATION_REGEXP = /^(<=?|>=?)((\d|\.)+)$/;
 
@@ -87,7 +88,7 @@ function runLighthouse(url, configPath, isDebug) {
   if (runResults.status === PROTOCOL_TIMEOUT_EXIT_CODE) {
     console.error(`Lighthouse debugger connection timed out ${RETRIES} times. Giving up.`);
     process.exit(1);
-  } else if (runResults.status !== 0) {
+  } else if (runResults.status !== 0 && runResults.status !== PAGE_HUNG_EXIT_CODE) {
     console.error(`Lighthouse run failed with exit code ${runResults.status}. stderr to follow:`);
     console.error(runResults.stderr);
     process.exit(runResults.status);
@@ -98,10 +99,14 @@ function runLighthouse(url, configPath, isDebug) {
     console.error(`STDERR: ${runResults.stderr}`);
   }
 
+  if (runResults.status === PAGE_HUNG_EXIT_CODE) {
+    return {requestedUrl: url, finalUrl: url, errorCode: 'PAGE_HUNG', audits: {}};
+  }
+
   const lhr = fs.readFileSync(outputPath, 'utf8');
   if (isDebug) {
     console.log('LHR output available at: ', outputPath);
-  } else {
+  } else if (fs.existsSync(outputPath)) {
     fs.unlinkSync(outputPath);
   }
 
diff --git a/lighthouse-core/test/gather/gather-runner-test.js b/lighthouse-core/test/gather/gather-runner-test.js
index 2b773a8d8..c302d5db3 100644
--- a/lighthouse-core/test/gather/gather-runner-test.js
+++ b/lighthouse-core/test/gather/gather-runner-test.js
@@ -679,6 +679,18 @@ describe('GatherRunner', function() {
       assert.equal(error.code, 'ERRORED_DOCUMENT_REQUEST');
       assert.ok(/^Lighthouse was unable to reliably load/.test(error.friendlyMessage));
     });
+
+    it('fails when page domain doesn\'t resolve', () => {
+      const url = 'http://the-page.com';
+      const mainRecord = new NetworkRequest();
+      mainRecord.url = url;
+      mainRecord.failed = true;
+      mainRecord.localizedFailDescription = 'net::ERR_NAME_NOT_RESOLVED';
+      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));
+    });
   });
 
   describe('#assertNoSecurityIssues', () => {
@@ -1059,7 +1071,7 @@ describe('GatherRunner', function() {
         config: new Config({}),
       }).then(artifacts => {
         assert.equal(artifacts.LighthouseRunWarnings.length, 1);
-        assert.ok(/unable.*load the page/.test(artifacts.LighthouseRunWarnings[0]));
+        assert.ok(/DNS servers could not resolve/.test(artifacts.LighthouseRunWarnings[0]));
       });
     });
 
diff --git a/lighthouse-core/test/report/html/renderer/pwa-category-renderer-test.js b/lighthouse-core/test/report/html/renderer/pwa-category-renderer-test.js
index 11a12a0cd..03043112d 100644
--- a/lighthouse-core/test/report/html/renderer/pwa-category-renderer-test.js
+++ b/lighthouse-core/test/report/html/renderer/pwa-category-renderer-test.js
@@ -131,10 +131,17 @@ describe('PwaCategoryRenderer', () => {
 
         const categoryElem = pwaRenderer.render(category, sampleResults.categoryGroups);
         const badgedElems = categoryElem.querySelectorAll(`.lh-badged`);
-
-        // Only expect a badge on last permutation (all bits are set).
-        const expectedBadgeCount = i === totalPermutations - 1 ? 1 : 0;
-        assert.strictEqual(badgedElems.length, expectedBadgeCount);
+        const badgedScoreGauge =
+          categoryElem.querySelector('.lh-gauge--pwa__wrapper[class*="lh-badged--"]');
+
+        // Only expect a badge (and badged gauge) on last permutation (all bits are set).
+        if (i !== totalPermutations - 1) {
+          assert.strictEqual(badgedElems.length, 0);
+          assert.strictEqual(badgedScoreGauge, null);
+        } else {
+          assert.strictEqual(badgedElems.length, 1);
+          assert.ok(badgedScoreGauge.classList.contains(`lh-badged--${targetGroupId}`));
+        }
       }
     });
 
@@ -145,6 +152,7 @@ describe('PwaCategoryRenderer', () => {
 
       const categoryElem = pwaRenderer.render(category, sampleResults.categoryGroups);
       assert.strictEqual(categoryElem.querySelectorAll('.lh-badged').length, groupIds.length);
+      assert.ok(categoryElem.querySelector('.lh-gauge--pwa__wrapper.lh-badged--all'));
     });
 
     it('renders no badges when no audit groups are passing', () => {
@@ -154,6 +162,8 @@ describe('PwaCategoryRenderer', () => {
 
       const categoryElem = pwaRenderer.render(category, sampleResults.categoryGroups);
       assert.strictEqual(categoryElem.querySelectorAll('.lh-badged').length, 0);
+      assert.strictEqual(categoryElem.querySelector('.lh-gauge--pwa__wrapper[class*="lh-badged-"]'),
+        null);
     });
 
     it('renders all but one badge when all groups but one are passing', () => {
@@ -164,13 +174,30 @@ describe('PwaCategoryRenderer', () => {
       const failingGroupId = auditRefs[0].group;
 
       const categoryElem = pwaRenderer.render(category, sampleResults.categoryGroups);
+      const gaugeElem = categoryElem.querySelector('.lh-gauge--pwa__wrapper');
 
       for (const groupId of groupIds) {
         const expectedCount = groupId === failingGroupId ? 0 : 1;
 
         const groupElems = categoryElem.querySelectorAll(`.lh-audit-group--${groupId}.lh-badged`);
         assert.strictEqual(groupElems.length, expectedCount);
+
+        gaugeElem.classList.contains(`lh-badged--${groupId}`);
       }
     });
   });
+
+  describe('#renderScoreGauge', () => {
+    it('renders an error score gauge in case of category error', () => {
+      category.score = null;
+      const badgeGauge = pwaRenderer.renderScoreGauge(category);
+
+      // Not a PWA gauge.
+      assert.strictEqual(badgeGauge.querySelector('.lh-gauge--pwa__wrapper'), null);
+
+      const percentageElem = badgeGauge.querySelector('.lh-gauge__percentage');
+      assert.strictEqual(percentageElem.textContent, '?');
+      assert.strictEqual(percentageElem.title, Util.UIStrings.errorLabel);
+    });
+  });
 });
diff --git a/lighthouse-core/test/report/html/renderer/report-renderer-test.js b/lighthouse-core/test/report/html/renderer/report-renderer-test.js
index 22c3fee12..db6269cb6 100644
--- a/lighthouse-core/test/report/html/renderer/report-renderer-test.js
+++ b/lighthouse-core/test/report/html/renderer/report-renderer-test.js
@@ -79,7 +79,7 @@ describe('ReportRenderer', () => {
       const output = renderer.renderReport(sampleResults, container);
       assert.ok(output.querySelector('.lh-header-sticky'), 'has a header');
       assert.ok(output.querySelector('.lh-report'), 'has report body');
-      assert.equal(output.querySelectorAll('.lh-gauge').length,
+      assert.equal(output.querySelectorAll('.lh-gauge__wrapper, .lh-gauge--pwa__wrapper').length,
           sampleResults.reportCategories.length * 2, 'renders category gauges');
     });
 
@@ -104,6 +104,31 @@ describe('ReportRenderer', () => {
       assert.equal(url.href, sampleResults.finalUrl);
     });
 
+    it('renders special score gauges after the mainstream ones', () => {
+      const container = renderer._dom._document.body;
+      const output = renderer.renderReport(sampleResults, container);
+
+      const allGaugeCount = output
+        .querySelectorAll('.lh-scores-header > a[class*="lh-gauge"]').length;
+      const regularGaugeCount = output
+        .querySelectorAll('.lh-scores-header > .lh-gauge__wrapper').length;
+
+      // Not all gauges are regular.
+      assert.ok(regularGaugeCount < allGaugeCount);
+
+      const scoresHeaderElem = output.querySelector('.lh-scores-header');
+      for (let i = 0; i < scoresHeaderElem.children.length; i++) {
+        const gauge = scoresHeaderElem.children[i];
+
+        if (i < regularGaugeCount) {
+          assert.ok(gauge.classList.contains('lh-gauge__wrapper'));
+        } else {
+          assert.ok(!gauge.classList.contains('lh-gauge__wrapper'));
+          assert.ok(gauge.classList.contains('lh-gauge--pwa__wrapper'));
+        }
+      }
+    });
+
     it('should not mutate a report object', () => {
       const container = renderer._dom._document.body;
       const originalResults = JSON.parse(JSON.stringify(sampleResults));
@@ -154,4 +179,25 @@ describe('ReportRenderer', () => {
     renderer.setTemplateContext(otherDocument);
     assert.equal(renderer._templateContext, otherDocument);
   });
+
+  it('renders `not_applicable` audits as `not-applicable`', () => {
+    const clonedSampleResult = JSON.parse(JSON.stringify(sampleResultsOrig));
+
+    let notApplicableCount = 0;
+    Object.values(clonedSampleResult.audits).forEach(audit => {
+      if (audit.scoreDisplayMode === 'not-applicable') {
+        notApplicableCount++;
+        audit.scoreDisplayMode = 'not_applicable';
+      }
+    });
+
+    assert.ok(notApplicableCount > 20); // Make sure something's being tested.
+
+    const container = renderer._dom._document.body;
+    const reportElement = renderer.renderReport(sampleResults, container);
+    const notApplicableElementCount = reportElement
+      .querySelectorAll('.lh-audit--not-applicable').length;
+
+    assert.strictEqual(notApplicableCount, notApplicableElementCount);
+  });
 });
diff --git a/lighthouse-core/test/report/html/renderer/util-test.js b/lighthouse-core/test/report/html/renderer/util-test.js
index e870d48fd..bb33a218d 100644
--- a/lighthouse-core/test/report/html/renderer/util-test.js
+++ b/lighthouse-core/test/report/html/renderer/util-test.js
@@ -7,6 +7,7 @@
 
 const assert = require('assert');
 const Util = require('../../../../report/html/renderer/util.js');
+const sampleResult = require('../../../results/sample_v2.json');
 
 const NBSP = '\xa0';
 
@@ -168,4 +169,24 @@ describe('util helpers', () => {
     Util.formatDisplayValue(displayValue);
     assert.deepStrictEqual(displayValue, cloned, 'displayValue was mutated');
   });
+
+  describe('#prepareReportResult', () => {
+    it('corrects underscored `not-applicable` scoreDisplayMode', () => {
+      const clonedSampleResult = JSON.parse(JSON.stringify(sampleResult));
+
+      let notApplicableCount = 0;
+      Object.values(clonedSampleResult.audits).forEach(audit => {
+        if (audit.scoreDisplayMode === 'not-applicable') {
+          notApplicableCount++;
+          audit.scoreDisplayMode = 'not_applicable';
+        }
+      });
+
+      assert.ok(notApplicableCount > 20); // Make sure something's being tested.
+
+      // Original audit results should be restored.
+      const preparedResult = Util.prepareReportResult(clonedSampleResult);
+      assert.deepStrictEqual(preparedResult.audits, sampleResult.audits);
+    });
+  });
 });
diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json
index d6bbe1740..053f381a5 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.0",
+  "lighthouseVersion": "4.0.0-alpha.1",
   "fetchTime": "2018-03-13T00:55:45.840Z",
   "requestedUrl": "http://localhost:10200/dobetterweb/dbw_tester.html",
   "finalUrl": "http://localhost:10200/dobetterweb/dbw_tester.html",