Repository: GoogleChrome/lighthouse
Pull request #6932 (2019-01-05 00:25:26)

misc(emulation): Use Nexus 5X emulation real screen size
**Summary**
I'll bite and try to adjust this.  I just changed the emulation heights to 660.  I wasn't sure if `height` should be 732 and then `screenHeight` should be 660 or the other way around?  [Docs](https://chromedevtools.github.io/devtools-protocol/tot/Emulation#method-setDeviceMetricsOverride) didn't explain which was which, so I just made them both 660 for now.

When changing the emulated device in the frontend it only sends a screenWidth and screenHeight with width & height set to 0:
![image](https://user-images.githubusercontent.com/6392995/50717544-dd655a80-103c-11e9-989c-2778a954d114.png)
Note the 660 is derived on the frontend from: 732px - 24px (top bar) - 48px (bottom bar) = 660px

**Related Issues/PRs**
fixes: #6615


exterkamp: There is a lot of `height: 732` `visibleHeight: 732` etc. in the test/fixtures/traces files, don't know if we need to change those too :man_shrugging: 

Patch:
diff --git a/.appveyor.yml b/.appveyor.yml
index 402b74415..69de01ad5 100644
--- a/.appveyor.yml
+++ b/.appveyor.yml
@@ -42,7 +42,7 @@ test_script:
   # FIXME: Exclude Appveyor from running `perf` smoketest until we fix the flake.
   #     https://github.com/GoogleChrome/lighthouse/issues/5053
   # - yarn smoke
-  - yarn smoke ally pwa pwa2 pwa3 dbw redirects seo offline byte tti
+  - yarn smoke ally pwa pwa2 pwa3 dbw redirects seo offline byte metrics
 
 cache:
   #- chrome-win32 -> appveyor.yml,package.json
diff --git a/docs/understanding-results.md b/docs/understanding-results.md
index 17b8dbe23..13b40cc79 100644
--- a/docs/understanding-results.md
+++ b/docs/understanding-results.md
@@ -58,7 +58,7 @@ An object containing the results of the audits, keyed by their name.
 | rawValue | <code>boolean&#124;number</code> | The unscored value determined by the audit. Typically this will match the score if there's no additional information to impart. For performance audits, this value is typically a number indicating the metric value. |
 | displayValue | `string` | The string to display in the report alongside audit results. If empty, nothing additional is shown. This is typically used to explain additional information such as the number and nature of failing items. |
 | score | <code>number</code> | The scored value determined by the audit as a number `0-1`, representing displayed scores of 0-100. |
-| scoreDisplayMode | <code>"binary"&#124;"numeric"&#124;"error"&#124;"manual"&#124;"not-applicable"&#124;"informative"</code> | A string identifying how the score should be interpreted for display i.e. is the audit pass/fail (score of 1 or 0), did it fail, should it be ignored, or are there shades of gray (scores between 0-1 inclusive). |
+| scoreDisplayMode | <code>"binary"&#124;"numeric"&#124;"error"&#124;"manual"&#124;"notApplicable"&#124;"informative"</code> | A string identifying how the score should be interpreted for display i.e. is the audit pass/fail (score of 1 or 0), did it fail, should it be ignored, or are there shades of gray (scores between 0-1 inclusive). |
 | details | `Object` | Extra information found by the audit necessary for display. The structure of this object varies from audit to audit. The structure of this object is somewhat stable between minor version bumps as this object is used to render the HTML report. |
 
 
diff --git a/lighthouse-cli/cli-flags.js b/lighthouse-cli/cli-flags.js
index c6ca5dd57..6aefa4edb 100644
--- a/lighthouse-cli/cli-flags.js
+++ b/lighthouse-cli/cli-flags.js
@@ -35,7 +35,7 @@ function getFlags(manualArgv) {
           'lighthouse <url> --disable-device-emulation --throttling-method=provided',
           'Disable device emulation and all throttling')
       .example(
-          'lighthouse <url> --chrome-flags="--window-size=412,732"',
+          'lighthouse <url> --chrome-flags="--window-size=412,660"',
           'Launch Chrome with a specific window size')
       .example(
           'lighthouse <url> --quiet --chrome-flags="--headless"',
diff --git a/lighthouse-core/audits/audit.js b/lighthouse-core/audits/audit.js
index dfaf6668a..9eff46f94 100644
--- a/lighthouse-core/audits/audit.js
+++ b/lighthouse-core/audits/audit.js
@@ -34,7 +34,7 @@ class Audit {
       BINARY: 'binary',
       MANUAL: 'manual',
       INFORMATIVE: 'informative',
-      NOT_APPLICABLE: 'not-applicable',
+      NOT_APPLICABLE: 'notApplicable',
       ERROR: 'error',
     };
   }
diff --git a/lighthouse-core/audits/metrics/estimated-input-latency.js b/lighthouse-core/audits/metrics/estimated-input-latency.js
index 866420c1a..5c9128814 100644
--- a/lighthouse-core/audits/metrics/estimated-input-latency.js
+++ b/lighthouse-core/audits/metrics/estimated-input-latency.js
@@ -13,8 +13,8 @@ const UIStrings = {
   /** The name of the metric that marks the estimated time between the page receiving input (a user clicking, tapping, or typing) and the page responding. Shown to users as the label for the numeric metric value. Ideally fits within a ~40 character limit. */
   title: 'Estimated Input Latency',
   /** Description of the Estimated Input Latency metric that estimates the amount of time, in milliseconds, that the app takes to respond to user input. This description is displayed within a tooltip when the user hovers on the metric name to see more. No character length limits. 'Learn More' becomes link text to additional documentation. */
-  description: 'The score above is an estimate of how long your app takes to respond to user ' +
-      'input, in milliseconds, during the busiest 5s window of page load. If your ' +
+  description: 'Estimated Input Latency is an estimate of how long your app takes to respond to ' +
+      'user input, in milliseconds, during the busiest 5s window of page load. If your ' +
       'latency is higher than 50 ms, users may perceive your app as laggy. ' +
       '[Learn more](https://developers.google.com/web/tools/lighthouse/audits/estimated-input-latency).',
 };
diff --git a/lighthouse-core/audits/metrics/interactive.js b/lighthouse-core/audits/metrics/interactive.js
index cd5ab1977..df3891628 100644
--- a/lighthouse-core/audits/metrics/interactive.js
+++ b/lighthouse-core/audits/metrics/interactive.js
@@ -13,8 +13,8 @@ const UIStrings = {
   /** The name of the metric that marks the time at which the page is fully loaded and is able to quickly respond to user input (clicks, taps, and keypresses feel responsive). Shown to users as the label for the numeric metric value. Ideally fits within a ~40 character limit. */
   title: 'Time to Interactive',
   /** Description of the Time to Interactive (TTI) metric, which evaluates when a page has completed its primary network activity and main thread work. This is displayed within a tooltip when the user hovers on the metric name to see more. No character length limits. 'Learn More' becomes link text to additional documentation. */
-  description: 'Interactive marks the time at which the page is fully interactive. ' +
-    '[Learn more](https://developers.google.com/web/tools/lighthouse/audits/consistently-interactive).',
+  description: 'Time to interactive is the amount of time it takes for the page to become fully ' +
+    'interactive. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/consistently-interactive).',
 };
 
 const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
diff --git a/lighthouse-core/audits/user-timings.js b/lighthouse-core/audits/user-timings.js
index 366a849f7..a9f1295c4 100644
--- a/lighthouse-core/audits/user-timings.js
+++ b/lighthouse-core/audits/user-timings.js
@@ -7,7 +7,7 @@
 
 const Audit = require('./audit');
 const i18n = require('../lib/i18n/i18n.js');
-const TraceOfTab = require('../computed/trace-of-tab.js');
+const ComputedUserTimings = require('../computed/user-timings.js');
 
 const UIStrings = {
   /** Descriptive title of a diagnostic audit that provides details on any timestamps generated by the page. User Timing refers to the 'User Timing API', which enables a website to record specific times as 'marks', or spans of time as 'measures'. */
@@ -50,71 +50,6 @@ class UserTimings extends Audit {
     };
   }
 
-  /**
-   * @param {LH.Artifacts.TraceOfTab} tabTrace
-   * @return {Array<MarkEvent|MeasureEvent>}
-   */
-  static filterTrace(tabTrace) {
-    /** @type {Array<MarkEvent|MeasureEvent>} */
-    const userTimings = [];
-    /** @type {Record<string, number>} */
-    const measuresStartTimes = {};
-
-    // Get all blink.user_timing events
-    // The event phases we are interested in are mark and instant events (R, i, I)
-    // and duration events which correspond to measures (B, b, E, e).
-    // @see https://docs.google.com/document/d/1CvAClvFfyA5R-PhYUmn5OOQtYMH4h6I0nSsKchNAySU/preview#
-    tabTrace.processEvents.filter(evt => {
-      if (!evt.cat.includes('blink.user_timing')) {
-        return false;
-      }
-
-      // reject these "userTiming" events that aren't really UserTiming, by nuking ones with frame data (or requestStart)
-      // https://cs.chromium.org/search/?q=trace_event.*?user_timing&sq=package:chromium&type=cs
-      return evt.name !== 'requestStart' &&
-          evt.name !== 'navigationStart' &&
-          evt.name !== 'paintNonDefaultBackgroundColor' &&
-          evt.args.frame === undefined;
-    })
-    .forEach(ut => {
-      // Mark events fall under phases R and I (or i)
-      if (ut.ph === 'R' || ut.ph.toUpperCase() === 'I') {
-        userTimings.push({
-          name: ut.name,
-          isMark: true,
-          args: ut.args,
-          startTime: ut.ts,
-        });
-
-      // Beginning of measure event, keep track of this events start time
-      } else if (ut.ph.toLowerCase() === 'b') {
-        measuresStartTimes[ut.name] = ut.ts;
-
-      // End of measure event
-      } else if (ut.ph.toLowerCase() === 'e') {
-        userTimings.push({
-          name: ut.name,
-          isMark: false,
-          args: ut.args,
-          startTime: measuresStartTimes[ut.name],
-          endTime: ut.ts,
-          duration: ut.ts - measuresStartTimes[ut.name],
-        });
-      }
-    });
-
-    // baseline the timestamps against navStart, and translate to milliseconds
-    userTimings.forEach(ut => {
-      ut.startTime = (ut.startTime - tabTrace.navigationStartEvt.ts) / 1000;
-      if (!ut.isMark) {
-        ut.endTime = (ut.endTime - tabTrace.navigationStartEvt.ts) / 1000;
-        ut.duration = ut.duration / 1000;
-      }
-    });
-
-    return userTimings;
-  }
-
   /**
    * @return {Array<string>}
    */
@@ -138,8 +73,8 @@ class UserTimings extends Audit {
    */
   static audit(artifacts, context) {
     const trace = artifacts.traces[Audit.DEFAULT_PASS];
-    return TraceOfTab.request(trace, context).then(tabTrace => {
-      const userTimings = this.filterTrace(tabTrace).filter(UserTimings.excludeBlacklisted);
+    return ComputedUserTimings.request(trace, context).then(computedUserTimings => {
+      const userTimings = computedUserTimings.filter(UserTimings.excludeBlacklisted);
       const tableRows = userTimings.map(item => {
         return {
           name: item.name,
diff --git a/lighthouse-core/computed/user-timings.js b/lighthouse-core/computed/user-timings.js
new file mode 100644
index 000000000..4cddb2d94
--- /dev/null
+++ b/lighthouse-core/computed/user-timings.js
@@ -0,0 +1,83 @@
+/**
+ * @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 makeComputedArtifact = require('./computed-artifact.js');
+const TraceOfTab = require('./trace-of-tab.js');
+
+/** @typedef {{name: string, isMark: true, args: LH.TraceEvent['args'], startTime: number}} MarkEvent */
+/** @typedef {{name: string, isMark: false, args: LH.TraceEvent['args'], startTime: number, endTime: number, duration: number}} MeasureEvent */
+
+class UserTimings {
+  /**
+   * @param {LH.Trace} trace
+   * @param {LH.Audit.Context} context
+   * @return {Promise<Array<MarkEvent|MeasureEvent>>}
+   */
+  static async compute_(trace, context) {
+    const traceOfTab = await TraceOfTab.request(trace, context);
+    /** @type {Array<MarkEvent|MeasureEvent>} */
+    const userTimings = [];
+    /** @type {Record<string, number>} */
+    const measuresStartTimes = {};
+
+    // Get all blink.user_timing events
+    // The event phases we are interested in are mark and instant events (R, i, I)
+    // and duration events which correspond to measures (B, b, E, e).
+    // @see https://docs.google.com/document/d/1CvAClvFfyA5R-PhYUmn5OOQtYMH4h6I0nSsKchNAySU/preview#
+    traceOfTab.processEvents.filter(evt => {
+      if (!evt.cat.includes('blink.user_timing')) {
+        return false;
+      }
+
+      // reject these "userTiming" events that aren't really UserTiming, by nuking ones with frame data (or requestStart)
+      // https://cs.chromium.org/search/?q=trace_event.*?user_timing&sq=package:chromium&type=cs
+      return evt.name !== 'requestStart' &&
+          evt.name !== 'navigationStart' &&
+          evt.name !== 'paintNonDefaultBackgroundColor' &&
+          evt.args.frame === undefined;
+    })
+    .forEach(ut => {
+      // Mark events fall under phases R and I (or i)
+      if (ut.ph === 'R' || ut.ph.toUpperCase() === 'I') {
+        userTimings.push({
+          name: ut.name,
+          isMark: true,
+          args: ut.args,
+          startTime: ut.ts,
+        });
+
+      // Beginning of measure event, keep track of this events start time
+      } else if (ut.ph.toLowerCase() === 'b') {
+        measuresStartTimes[ut.name] = ut.ts;
+
+      // End of measure event
+      } else if (ut.ph.toLowerCase() === 'e') {
+        userTimings.push({
+          name: ut.name,
+          isMark: false,
+          args: ut.args,
+          startTime: measuresStartTimes[ut.name],
+          endTime: ut.ts,
+          duration: ut.ts - measuresStartTimes[ut.name],
+        });
+      }
+    });
+
+    // baseline the timestamps against navStart, and translate to milliseconds
+    userTimings.forEach(ut => {
+      ut.startTime = (ut.startTime - traceOfTab.navigationStartEvt.ts) / 1000;
+      if (!ut.isMark) {
+        ut.endTime = (ut.endTime - traceOfTab.navigationStartEvt.ts) / 1000;
+        ut.duration = ut.duration / 1000;
+      }
+    });
+
+    return userTimings;
+  }
+}
+
+module.exports = makeComputedArtifact(UserTimings);
diff --git a/lighthouse-core/config/config.js b/lighthouse-core/config/config.js
index b7e4b4a41..54cf15322 100644
--- a/lighthouse-core/config/config.js
+++ b/lighthouse-core/config/config.js
@@ -749,13 +749,13 @@ class Config {
 
   /**
    * Take an array of audits and audit paths and require any paths (possibly
-   * relative to the optional `configPath`) using `Runner.resolvePlugin`,
+   * relative to the optional `configDir`) using `Config.resolveModule`,
    * leaving only an array of AuditDefns.
    * @param {LH.Config.Json['audits']} audits
-   * @param {string=} configPath
+   * @param {string=} configDir
    * @return {Config['audits']}
    */
-  static requireAudits(audits, configPath) {
+  static requireAudits(audits, configDir) {
     const status = {msg: 'Requiring audits', id: 'lh:config:requireAudits'};
     log.time(status, 'verbose');
     const expandedAudits = Config.expandAuditShorthand(audits);
@@ -775,7 +775,7 @@ class Config {
         let requirePath = `../audits/${audit.path}`;
         if (!coreAudit) {
           // Otherwise, attempt to find it elsewhere. This throws if not found.
-          requirePath = Runner.resolvePlugin(audit.path, configPath, 'audit');
+          requirePath = Config.resolveModule(audit.path, configDir, 'audit');
         }
         implementation = /** @type {typeof Audit} */ (require(requirePath));
       }
@@ -797,16 +797,16 @@ class Config {
    * @param {string} path
    * @param {{}=} options
    * @param {Array<string>} coreAuditList
-   * @param {string=} configPath
+   * @param {string=} configDir
    * @return {LH.Config.GathererDefn}
    */
-  static requireGathererFromPath(path, options, coreAuditList, configPath) {
+  static requireGathererFromPath(path, options, coreAuditList, configDir) {
     const coreGatherer = coreAuditList.find(a => a === `${path}.js`);
 
     let requirePath = `../gather/gatherers/${path}`;
     if (!coreGatherer) {
       // Otherwise, attempt to find it elsewhere. This throws if not found.
-      requirePath = Runner.resolvePlugin(path, configPath, 'gatherer');
+      requirePath = Config.resolveModule(path, configDir, 'gatherer');
     }
 
     const GathererClass = /** @type {GathererConstructor} */ (require(requirePath));
@@ -821,13 +821,13 @@ class Config {
 
   /**
    * Takes an array of passes with every property now initialized except the
-   * gatherers and requires them, (relative to the optional `configPath` if
-   * provided) using `Runner.resolvePlugin`, returning an array of full Passes.
+   * gatherers and requires them, (relative to the optional `configDir` if
+   * provided) using `Config.resolveModule`, returning an array of full Passes.
    * @param {?Array<Required<LH.Config.PassJson>>} passes
-   * @param {string=} configPath
+   * @param {string=} configDir
    * @return {Config['passes']}
    */
-  static requireGatherers(passes, configPath) {
+  static requireGatherers(passes, configDir) {
     if (!passes) {
       return null;
     }
@@ -855,7 +855,7 @@ class Config {
         } else if (gathererDefn.path) {
           const path = gathererDefn.path;
           const options = gathererDefn.options;
-          return Config.requireGathererFromPath(path, options, coreList, configPath);
+          return Config.requireGathererFromPath(path, options, coreList, configDir);
         } else {
           throw new Error('Invalid expanded Gatherer: ' + JSON.stringify(gathererDefn));
         }
@@ -869,6 +869,52 @@ class Config {
     log.timeEnd(status);
     return fullPasses;
   }
+
+  /**
+   * Resolves the location of the specified module and returns an absolute
+   * string path to the file. Used for loading custom audits and gatherers.
+   * Throws an error if no module is found.
+   * @param {string} moduleIdentifier
+   * @param {string=} configDir The absolute path to the directory of the config file, if there is one.
+   * @param {string=} category Optional plugin category (e.g. 'audit') for better error messages.
+   * @return {string}
+   * @throws {Error}
+   */
+  static resolveModule(moduleIdentifier, configDir, category) {
+    // First try straight `require()`. Unlikely to be specified relative to this
+    // file, but adds support for Lighthouse modules from npm since
+    // `require()` walks up parent directories looking inside any node_modules/
+    // present. Also handles absolute paths.
+    try {
+      return require.resolve(moduleIdentifier);
+    } catch (e) {}
+
+    // See if the module resolves relative to the current working directory.
+    // Most useful to handle the case of invoking Lighthouse as a module, since
+    // then the config is an object and so has no path.
+    const cwdPath = path.resolve(process.cwd(), moduleIdentifier);
+    try {
+      return require.resolve(cwdPath);
+    } catch (e) {}
+
+    const errorString = 'Unable to locate ' +
+        (category ? `${category}: ` : '') +
+        `${moduleIdentifier} (tried to require() from '${__dirname}' and load from '${cwdPath}'`;
+
+    if (!configDir) {
+      throw new Error(errorString + ')');
+    }
+
+    // Finally, try looking up relative to the config file path. Just like the
+    // relative path passed to `require()` is found relative to the file it's
+    // in, this allows module paths to be specified relative to the config file.
+    const relativePath = path.resolve(configDir, moduleIdentifier);
+    try {
+      return require.resolve(relativePath);
+    } catch (requireError) {}
+
+    throw new Error(errorString + ` and '${relativePath}')`);
+  }
 }
 
 module.exports = Config;
diff --git a/lighthouse-core/gather/driver.js b/lighthouse-core/gather/driver.js
index aae4a4339..27674132f 100644
--- a/lighthouse-core/gather/driver.js
+++ b/lighthouse-core/gather/driver.js
@@ -512,6 +512,16 @@ class Driver {
     });
   }
 
+  /**
+   * Returns a promise that resolves immediately.
+   * Used for placeholder conditions that we don't want to start waiting for just yet, but still want
+   * to satisfy the same interface.
+   * @return {{promise: Promise<void>, cancel: function(): void}}
+   */
+  _waitForNothing() {
+    return {promise: Promise.resolve(), cancel() {}};
+  }
+
   /**
    * Returns a promise that resolve when a frame has been navigated.
    * Used for detecting that our about:blank reset has been completed.
@@ -522,6 +532,38 @@ class Driver {
     });
   }
 
+  /**
+   * Returns a promise that resolve when a frame has a FCP.
+   * @return {{promise: Promise<void>, cancel: function(): void}}
+   */
+  _waitForFCP() {
+    /** @type {(() => void)} */
+    let cancel = () => {
+      throw new Error('_waitForFCP.cancel() called before it was defined');
+    };
+
+    const promise = new Promise(resolve => {
+      /** @param {LH.Crdp.Page.LifecycleEventEvent} e */
+      const lifecycleListener = e => {
+        if (e.name === 'firstContentfulPaint') {
+          resolve();
+          this.off('Page.lifecycleEvent', lifecycleListener);
+        }
+      };
+
+      this.on('Page.lifecycleEvent', lifecycleListener);
+
+      cancel = () => {
+        this.off('Page.lifecycleEvent', lifecycleListener);
+      };
+    });
+
+    return {
+      promise,
+      cancel,
+    };
+  }
+
   /**
    * Returns a promise that resolves when the network has been idle (after DCL) for
    * `networkQuietThresholdMs` ms and a method to cancel internal network listeners/timeout.
@@ -733,25 +775,28 @@ class Driver {
    * @param {number} networkQuietThresholdMs
    * @param {number} cpuQuietThresholdMs
    * @param {number} maxWaitForLoadedMs
+   * @param {boolean} shouldWaitForFCP
    * @return {Promise<void>}
    * @private
    */
   async _waitForFullyLoaded(pauseAfterLoadMs, networkQuietThresholdMs, cpuQuietThresholdMs,
-      maxWaitForLoadedMs) {
+      maxWaitForLoadedMs, shouldWaitForFCP) {
     /** @type {NodeJS.Timer|undefined} */
     let maxTimeoutHandle;
 
+    // Listener for onload. Resolves on first FCP event.
+    const waitForFCP = shouldWaitForFCP ? this._waitForFCP() : this._waitForNothing();
     // Listener for onload. Resolves pauseAfterLoadMs ms after load.
     const waitForLoadEvent = this._waitForLoadEvent(pauseAfterLoadMs);
     // Network listener. Resolves when the network has been idle for networkQuietThresholdMs.
     const waitForNetworkIdle = this._waitForNetworkIdle(networkQuietThresholdMs);
     // CPU listener. Resolves when the CPU has been idle for cpuQuietThresholdMs after network idle.
-    /** @type {{promise: Promise<void>, cancel: function(): void}|null} */
-    let waitForCPUIdle = null;
+    let waitForCPUIdle = this._waitForNothing();
 
     // Wait for both load promises. Resolves on cleanup function the clears load
     // timeout timer.
     const loadPromise = Promise.all([
+      waitForFCP.promise,
       waitForLoadEvent.promise,
       waitForNetworkIdle.promise,
     ]).then(() => {
@@ -771,9 +816,10 @@ class Driver {
     }).then(_ => {
       return async () => {
         log.warn('Driver', 'Timed out waiting for page load. Checking if page is hung...');
+        waitForFCP.cancel();
         waitForLoadEvent.cancel();
         waitForNetworkIdle.cancel();
-        waitForCPUIdle && waitForCPUIdle.cancel();
+        waitForCPUIdle.cancel();
 
         if (await this.isPageHung()) {
           log.warn('Driver', 'Page appears to be hung, killing JavaScript...');
@@ -874,23 +920,25 @@ class Driver {
    * possible workaround.
    * Resolves on the url of the loaded page, taking into account any redirects.
    * @param {string} url
-   * @param {{waitForLoad?: boolean, waitForNavigated?: boolean, passContext?: LH.Gatherer.PassContext}} options
+   * @param {{waitForFCP?: boolean, waitForLoad?: boolean, waitForNavigated?: boolean, passContext?: LH.Gatherer.PassContext}} options
    * @return {Promise<string>}
    */
   async gotoURL(url, options = {}) {
+    const waitForFCP = options.waitForFCP || false;
     const waitForNavigated = options.waitForNavigated || false;
     const waitForLoad = options.waitForLoad || false;
     const passContext = /** @type {Partial<LH.Gatherer.PassContext>} */ (options.passContext || {});
     const disableJS = passContext.disableJavaScript || false;
 
-    if (waitForNavigated && waitForLoad) {
-      throw new Error('Cannot use both waitForNavigated and waitForLoad, pick just one');
+    if (waitForNavigated && (waitForFCP || waitForLoad)) {
+      throw new Error('Cannot use both waitForNavigated and another event, pick just one');
     }
 
     await this._beginNetworkStatusMonitoring(url);
     await this._clearIsolatedContextId();
 
     await this.sendCommand('Page.enable');
+    await this.sendCommand('Page.setLifecycleEventsEnabled', {enabled: true});
     await this.sendCommand('Emulation.setScriptExecutionDisabled', {value: disableJS});
     // No timeout needed for Page.navigate. See #6413.
     const waitforPageNavigateCmd = this._innerSendCommand('Page.navigate', {url});
@@ -910,7 +958,7 @@ class Driver {
       /* eslint-enable max-len */
 
       await this._waitForFullyLoaded(pauseAfterLoadMs, networkQuietThresholdMs, cpuQuietThresholdMs,
-          maxWaitMs);
+          maxWaitMs, waitForFCP);
     }
 
     // Bring `Page.navigate` errors back into the promise chain. See #6739.
diff --git a/lighthouse-core/gather/gather-runner.js b/lighthouse-core/gather/gather-runner.js
index 70c2c7a48..6c02edb44 100644
--- a/lighthouse-core/gather/gather-runner.js
+++ b/lighthouse-core/gather/gather-runner.js
@@ -88,6 +88,7 @@ class GatherRunner {
    */
   static async loadPage(driver, passContext) {
     const finalUrl = await driver.gotoURL(passContext.url, {
+      waitForFCP: passContext.passConfig.recordTrace,
       waitForLoad: true,
       passContext,
     });
diff --git a/lighthouse-core/lib/emulation.js b/lighthouse-core/lib/emulation.js
index b27406c6e..73a443747 100644
--- a/lighthouse-core/lib/emulation.js
+++ b/lighthouse-core/lib/emulation.js
@@ -14,9 +14,9 @@
 const NEXUS5X_EMULATION_METRICS = {
   mobile: true,
   screenWidth: 412,
-  screenHeight: 732,
+  screenHeight: 660,
   width: 412,
-  height: 732,
+  height: 660,
   positionX: 0,
   positionY: 0,
   scale: 1,
diff --git a/lighthouse-core/lib/i18n/en-US.json b/lighthouse-core/lib/i18n/en-US.json
index 1a59b2c65..b5bf42809 100644
--- a/lighthouse-core/lib/i18n/en-US.json
+++ b/lighthouse-core/lib/i18n/en-US.json
@@ -672,7 +672,7 @@
     "description": "Title of a diagnostic audit that provides detail on the main thread work the browser did to load the page. This descriptive title is shown to users when the amount is acceptable and no user action is required."
   },
   "lighthouse-core/audits/metrics/estimated-input-latency.js | description": {
-    "message": "The score above is an estimate of how long your app takes to respond to user input, in milliseconds, during the busiest 5s window of page load. If your latency is higher than 50 ms, users may perceive your app as laggy. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/estimated-input-latency).",
+    "message": "Estimated Input Latency is an estimate of how long your app takes to respond to user input, in milliseconds, during the busiest 5s window of page load. If your latency is higher than 50 ms, users may perceive your app as laggy. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/estimated-input-latency).",
     "description": "Description of the Estimated Input Latency metric that estimates the amount of time, in milliseconds, that the app takes to respond to user input. This description is displayed within a tooltip when the user hovers on the metric name to see more. No character length limits. 'Learn More' becomes link text to additional documentation."
   },
   "lighthouse-core/audits/metrics/estimated-input-latency.js | title": {
@@ -704,7 +704,7 @@
     "description": "The name of the metric that marks the time at which a majority of the content has been painted by the browser. Shown to users as the label for the numeric metric value. Ideally fits within a ~40 character limit."
   },
   "lighthouse-core/audits/metrics/interactive.js | description": {
-    "message": "Interactive marks the time at which the page is fully interactive. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/consistently-interactive).",
+    "message": "Time to interactive is the amount of time it takes for the page to become fully interactive. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/consistently-interactive).",
     "description": "Description of the Time to Interactive (TTI) metric, which evaluates when a page has completed its primary network activity and main thread work. This is displayed within a tooltip when the user hovers on the metric name to see more. No character length limits. 'Learn More' becomes link text to additional documentation."
   },
   "lighthouse-core/audits/metrics/interactive.js | title": {
diff --git a/lighthouse-core/lib/proto-preprocessor.js b/lighthouse-core/lib/proto-preprocessor.js
index 454037f73..fa73cec2f 100644
--- a/lighthouse-core/lib/proto-preprocessor.js
+++ b/lighthouse-core/lib/proto-preprocessor.js
@@ -43,11 +43,14 @@ function processForProto(result) {
     Object.keys(reportJson.audits).forEach(auditName => {
       const audit = reportJson.audits[auditName];
 
-      // Rewrite the 'not-applicable' scoreDisplayMode to 'not_applicable'. #6201
+      // Rewrite 'not-applicable' and 'not_applicable' scoreDisplayMode to 'notApplicable'. #6201, #6783.
       if (audit.scoreDisplayMode) {
-        if (audit.scoreDisplayMode === 'not-applicable') {
-          // @ts-ignore Breaking the LH.Result type
-          audit.scoreDisplayMode = 'not_applicable';
+        // @ts-ignore ts properly flags this as invalid as it should not happen,
+        // but remains in preprocessor to protect from proto translation errors from
+        // old LHRs.
+        // eslint-disable-next-line max-len
+        if (audit.scoreDisplayMode === 'not-applicable' || audit.scoreDisplayMode === 'not_applicable') {
+          audit.scoreDisplayMode = 'notApplicable';
         }
       }
       // Drop raw values. #6199
diff --git a/lighthouse-core/report/html/renderer/category-renderer.js b/lighthouse-core/report/html/renderer/category-renderer.js
index f455b9b2d..25976cce4 100644
--- a/lighthouse-core/report/html/renderer/category-renderer.js
+++ b/lighthouse-core/report/html/renderer/category-renderer.js
@@ -22,7 +22,7 @@
 /** @typedef {import('./report-renderer.js')} ReportRenderer */
 /** @typedef {import('./details-renderer.js')} DetailsRenderer */
 /** @typedef {import('./util.js')} Util */
-/** @typedef {'failed'|'manual'|'passed'|'not-applicable'} TopLevelClumpId */
+/** @typedef {'failed'|'manual'|'passed'|'notApplicable'} TopLevelClumpId */
 
 class CategoryRenderer {
   /**
@@ -45,20 +45,20 @@ class CategoryRenderer {
    */
   get _clumpDisplayInfo() {
     return {
-      'failed': {
+      failed: {
         className: 'lh-clump--failed',
       },
-      'manual': {
+      manual: {
         title: Util.UIStrings.manualAuditsGroupTitle,
         className: 'lh-clump--manual',
       },
-      'passed': {
+      passed: {
         title: Util.UIStrings.passedAuditsGroupTitle,
         className: 'lh-clump--passed',
       },
-      'not-applicable': {
+      notApplicable: {
         title: Util.UIStrings.notApplicableAuditsGroupTitle,
-        className: 'lh-clump--not-applicable',
+        className: 'lh-clump--notapplicable',
       },
     };
   }
@@ -153,7 +153,7 @@ class CategoryRenderer {
    */
   _setRatingClass(element, score, scoreDisplayMode) {
     const rating = Util.calculateRating(score, scoreDisplayMode);
-    element.classList.add(`lh-audit--${rating}`, `lh-audit--${scoreDisplayMode}`);
+    element.classList.add(`lh-audit--${rating}`, `lh-audit--${scoreDisplayMode.toLowerCase()}`);
     return element;
   }
 
@@ -279,7 +279,7 @@ class CategoryRenderer {
 
   /**
    * Renders a clump (a grouping of groups), under a status of failed, manual,
-   * passed, or not-applicable. The result ends up something like:
+   * passed, or notApplicable. The result ends up something like:
    *
    * clump (e.g. 'failed')
    *   ├── audit 1 (w/o group)
@@ -370,7 +370,7 @@ class CategoryRenderer {
    */
   _getClumpIdForAuditRef(auditRef) {
     const scoreDisplayMode = auditRef.result.scoreDisplayMode;
-    if (scoreDisplayMode === 'manual' || scoreDisplayMode === 'not-applicable') {
+    if (scoreDisplayMode === 'manual' || scoreDisplayMode === 'notApplicable') {
       return scoreDisplayMode;
     }
 
@@ -397,7 +397,7 @@ class CategoryRenderer {
     clumps.set('failed', []);
     clumps.set('manual', []);
     clumps.set('passed', []);
-    clumps.set('not-applicable', []);
+    clumps.set('notApplicable', []);
 
     // Sort audits into clumps.
     for (const auditRef of category.auditRefs) {
diff --git a/lighthouse-core/report/html/renderer/pwa-category-renderer.js b/lighthouse-core/report/html/renderer/pwa-category-renderer.js
index 2b547b738..690e8da89 100644
--- a/lighthouse-core/report/html/renderer/pwa-category-renderer.js
+++ b/lighthouse-core/report/html/renderer/pwa-category-renderer.js
@@ -31,7 +31,7 @@ class PwaCategoryRenderer extends CategoryRenderer {
 
     const auditRefs = category.auditRefs;
 
-    // Regular audits aren't split up into pass/fail/not-applicable clumps, they're
+    // Regular audits aren't split up into pass/fail/notApplicable clumps, they're
     // all put in a top-level clump that isn't expandable/collapsable.
     const regularAuditRefs = auditRefs.filter(ref => ref.result.scoreDisplayMode !== 'manual');
     const auditsElem = this._renderAudits(regularAuditRefs, groupDefinitions);
diff --git a/lighthouse-core/report/html/renderer/util.js b/lighthouse-core/report/html/renderer/util.js
index cd2cc4535..68c4f3d6c 100644
--- a/lighthouse-core/report/html/renderer/util.js
+++ b/lighthouse-core/report/html/renderer/util.js
@@ -62,12 +62,13 @@ 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.
+    // Turn 'not-applicable' and 'not_applicable' into 'notApplicable' to support old reports.
+    // TODO: remove when underscore/hyphen proto issue is resolved. See #6371, #6201, #6783.
     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';
+      // eslint-disable-next-line max-len
+      if (audit.scoreDisplayMode === 'not_applicable' || audit.scoreDisplayMode === 'not-applicable') {
+        audit.scoreDisplayMode = 'notApplicable';
       }
     }
 
@@ -143,7 +144,7 @@ class Util {
   static showAsPassed(audit) {
     switch (audit.scoreDisplayMode) {
       case 'manual':
-      case 'not-applicable':
+      case 'notApplicable':
         return true;
       case 'error':
       case 'informative':
@@ -163,7 +164,7 @@ class Util {
    */
   static calculateRating(score, scoreDisplayMode) {
     // Handle edge cases first, manual and not applicable receive 'pass', errored audits receive 'error'
-    if (scoreDisplayMode === 'manual' || scoreDisplayMode === 'not-applicable') {
+    if (scoreDisplayMode === 'manual' || scoreDisplayMode === 'notApplicable') {
       return RATINGS.PASS.label;
     } else if (scoreDisplayMode === 'error') {
       return RATINGS.ERROR.label;
diff --git a/lighthouse-core/report/html/report-styles.css b/lighthouse-core/report/html/report-styles.css
index 1a30b10ab..07b8cb9ee 100644
--- a/lighthouse-core/report/html/report-styles.css
+++ b/lighthouse-core/report/html/report-styles.css
@@ -604,7 +604,7 @@
   content: '';
   background-image: var(--check-icon-url);
 }
-.lh-clump--not-applicable > summary .lh-audit-group__header::before {
+.lh-clump--notapplicable > summary .lh-audit-group__header::before {
   content: '';
   background-image: var(--remove-circle-icon-url);
 }
@@ -787,7 +787,7 @@
 }
 
 .lh-scores-header .lh-gauge__wrapper,
-.lh-scores-header .lh-gauge--pwa__wrapper, {
+.lh-scores-header .lh-gauge--pwa__wrapper {
   margin: 0 4px;
 }
 
diff --git a/lighthouse-core/runner.js b/lighthouse-core/runner.js
index db7c06575..cb9fb5f02 100644
--- a/lighthouse-core/runner.js
+++ b/lighthouse-core/runner.js
@@ -399,52 +399,6 @@ class Runner {
     return fileList.filter(f => /\.js$/.test(f) && f !== 'gatherer.js').sort();
   }
 
-  /**
-   * Resolves the location of the specified plugin and returns an absolute
-   * string path to the file. Used for loading custom audits and gatherers.
-   * Throws an error if no plugin is found.
-   * @param {string} plugin
-   * @param {string=} configDir The absolute path to the directory of the config file, if there is one.
-   * @param {string=} category Optional plugin category (e.g. 'audit') for better error messages.
-   * @return {string}
-   * @throws {Error}
-   */
-  static resolvePlugin(plugin, configDir, category) {
-    // First try straight `require()`. Unlikely to be specified relative to this
-    // file, but adds support for Lighthouse plugins in npm modules as
-    // `require()` walks up parent directories looking inside any node_modules/
-    // present. Also handles absolute paths.
-    try {
-      return require.resolve(plugin);
-    } catch (e) {}
-
-    // See if the plugin resolves relative to the current working directory.
-    // Most useful to handle the case of invoking Lighthouse as a module, since
-    // then the config is an object and so has no path.
-    const cwdPath = path.resolve(process.cwd(), plugin);
-    try {
-      return require.resolve(cwdPath);
-    } catch (e) {}
-
-    const errorString = 'Unable to locate ' +
-        (category ? `${category}: ` : '') +
-        `${plugin} (tried to require() from '${__dirname}' and load from '${cwdPath}'`;
-
-    if (!configDir) {
-      throw new Error(errorString + ')');
-    }
-
-    // Finally, try looking up relative to the config file path. Just like the
-    // relative path passed to `require()` is found relative to the file it's
-    // in, this allows plugin paths to be specified relative to the config file.
-    const relativePath = path.resolve(configDir, plugin);
-    try {
-      return require.resolve(relativePath);
-    } catch (requireError) {}
-
-    throw new Error(errorString + ` and '${relativePath}')`);
-  }
-
   /**
    * Get path to use for -G and -A modes. Defaults to $CWD/latest-run
    * @param {LH.Config.Settings} settings
diff --git a/package.json b/package.json
index dcc8883dd..35eb169fe 100644
--- a/package.json
+++ b/package.json
@@ -81,6 +81,7 @@
     "@types/google.analytics": "0.0.39",
     "@types/inquirer": "^0.0.35",
     "@types/intl-messageformat": "^1.3.0",
+    "@types/jest": "^23.3.10",
     "@types/jpeg-js": "^0.3.0",
     "@types/lodash.isequal": "^4.5.2",
     "@types/make-dir": "^1.0.3",
diff --git a/proto/lighthouse-result.proto b/proto/lighthouse-result.proto
index e4468277c..26d3115c4 100644
--- a/proto/lighthouse-result.proto
+++ b/proto/lighthouse-result.proto
@@ -247,6 +247,7 @@ message AuditResult {
     // The audit turned out to not apply to the page. Score is NaN and should be
     // ignored.
     not_applicable = 4;
+    notApplicable = 7;
     // The audit exists only to tell you to review something yourself. Score is
     // NaN and should be ignored
     manual = 5;
diff --git a/proto/sample_v2_round_trip.json b/proto/sample_v2_round_trip.json
index e8249ace0..83b4decae 100644
--- a/proto/sample_v2_round_trip.json
+++ b/proto/sample_v2_round_trip.json
@@ -4,7 +4,7 @@
             "description": "Access keys let users quickly focus a part of the page. For proper navigation, each access key must be unique. [Learn more](https://dequeuniversity.com/rules/axe/2.2/accesskeys?application=lighthouse).", 
             "id": "accesskeys", 
             "score": null, 
-            "scoreDisplayMode": "not_applicable", 
+            "scoreDisplayMode": "notApplicable", 
             "title": "`[accesskey]` values are unique"
         }, 
         "appcache-manifest": {
@@ -19,56 +19,56 @@
             "description": "Each ARIA `role` supports a specific subset of `aria-*` attributes. Mismatching these invalidates the `aria-*` attributes. [Learn more](https://dequeuniversity.com/rules/axe/2.2/aria-allowed-attr?application=lighthouse).", 
             "id": "aria-allowed-attr", 
             "score": null, 
-            "scoreDisplayMode": "not_applicable", 
+            "scoreDisplayMode": "notApplicable", 
             "title": "`[aria-*]` attributes match their roles"
         }, 
         "aria-required-attr": {
             "description": "Some ARIA roles have required attributes that describe the state of the element to screen readers. [Learn more](https://dequeuniversity.com/rules/axe/2.2/aria-required-attr?application=lighthouse).", 
             "id": "aria-required-attr", 
             "score": null, 
-            "scoreDisplayMode": "not_applicable", 
+            "scoreDisplayMode": "notApplicable", 
             "title": "`[role]`s have all required `[aria-*]` attributes"
         }, 
         "aria-required-children": {
             "description": "Some ARIA parent roles must contain specific child roles to perform their intended accessibility functions. [Learn more](https://dequeuniversity.com/rules/axe/2.2/aria-required-children?application=lighthouse).", 
             "id": "aria-required-children", 
             "score": null, 
-            "scoreDisplayMode": "not_applicable", 
+            "scoreDisplayMode": "notApplicable", 
             "title": "Elements with `[role]` that require specific children `[role]`s, are present"
         }, 
         "aria-required-parent": {
             "description": "Some ARIA child roles must be contained by specific parent roles to properly perform their intended accessibility functions. [Learn more](https://dequeuniversity.com/rules/axe/2.2/aria-required-parent?application=lighthouse).", 
             "id": "aria-required-parent", 
             "score": null, 
-            "scoreDisplayMode": "not_applicable", 
+            "scoreDisplayMode": "notApplicable", 
             "title": "`[role]`s are contained by their required parent element"
         }, 
         "aria-roles": {
             "description": "ARIA roles must have valid values in order to perform their intended accessibility functions. [Learn more](https://dequeuniversity.com/rules/axe/2.2/aria-roles?application=lighthouse).", 
             "id": "aria-roles", 
             "score": null, 
-            "scoreDisplayMode": "not_applicable", 
+            "scoreDisplayMode": "notApplicable", 
             "title": "`[role]` values are valid"
         }, 
         "aria-valid-attr": {
             "description": "Assistive technologies, like screen readers, can't interpret ARIA attributes with invalid names. [Learn more](https://dequeuniversity.com/rules/axe/2.2/aria-valid-attr?application=lighthouse).", 
             "id": "aria-valid-attr", 
             "score": null, 
-            "scoreDisplayMode": "not_applicable", 
+            "scoreDisplayMode": "notApplicable", 
             "title": "`[aria-*]` attributes are valid and not misspelled"
         }, 
         "aria-valid-attr-value": {
             "description": "Assistive technologies, like screen readers, can't interpret ARIA attributes with invalid values. [Learn more](https://dequeuniversity.com/rules/axe/2.2/aria-valid-attr-value?application=lighthouse).", 
             "id": "aria-valid-attr-value", 
             "score": null, 
-            "scoreDisplayMode": "not_applicable", 
+            "scoreDisplayMode": "notApplicable", 
             "title": "`[aria-*]` attributes have valid values"
         }, 
         "audio-caption": {
             "description": "Captions make audio elements usable for deaf or hearing-impaired users, providing critical information such as who is talking, what they're saying, and other non-speech information. [Learn more](https://dequeuniversity.com/rules/axe/2.2/audio-caption?application=lighthouse).", 
             "id": "audio-caption", 
             "score": null, 
-            "scoreDisplayMode": "not_applicable", 
+            "scoreDisplayMode": "notApplicable", 
             "title": "`<audio>` elements contain a `<track>` element with `[kind=\"captions\"]`"
         }, 
         "bootup-time": {
@@ -134,7 +134,7 @@
             "description": "When a button doesn't have an accessible name, screen readers announce it as \"button\", making it unusable for users who rely on screen readers. [Learn more](https://dequeuniversity.com/rules/axe/2.2/button-name?application=lighthouse).", 
             "id": "button-name", 
             "score": null, 
-            "scoreDisplayMode": "not_applicable", 
+            "scoreDisplayMode": "notApplicable", 
             "title": "Buttons have an accessible name"
         }, 
         "bypass": {
@@ -153,7 +153,7 @@
             "description": "Canonical links suggest which URL to show in search results. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/canonical).", 
             "id": "canonical", 
             "score": null, 
-            "scoreDisplayMode": "not_applicable", 
+            "scoreDisplayMode": "notApplicable", 
             "title": "Document has a valid `rel=canonical`"
         }, 
         "color-contrast": {
@@ -370,7 +370,7 @@
             "description": "When definition lists are not properly marked up, screen readers may produce confusing or inaccurate output. [Learn more](https://dequeuniversity.com/rules/axe/2.2/definition-list?application=lighthouse).", 
             "id": "definition-list", 
             "score": null, 
-            "scoreDisplayMode": "not_applicable", 
+            "scoreDisplayMode": "notApplicable", 
             "title": "`<dl>`'s contain only properly-ordered `<dt>` and `<dd>` groups, `<script>` or `<template>` elements."
         }, 
         "deprecations": {
@@ -423,7 +423,7 @@
             "description": "Definition list items (`<dt>` and `<dd>`) must be wrapped in a parent `<dl>` element to ensure that screen readers can properly announce them. [Learn more](https://dequeuniversity.com/rules/axe/2.2/dlitem?application=lighthouse).", 
             "id": "dlitem", 
             "score": null, 
-            "scoreDisplayMode": "not_applicable", 
+            "scoreDisplayMode": "notApplicable", 
             "title": "Definition list items are wrapped in `<dl>` elements"
         }, 
         "doctype": {
@@ -569,7 +569,7 @@
             "title": "Browser errors were logged to the console"
         }, 
         "estimated-input-latency": {
-            "description": "The score above is an estimate of how long your app takes to respond to user input, in milliseconds, during the busiest 5s window of page load. If your latency is higher than 50 ms, users may perceive your app as laggy. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/estimated-input-latency).", 
+            "description": "Estimated Input Latency is an estimate of how long your app takes to respond to user input, in milliseconds, during the busiest 5s window of page load. If your latency is higher than 50 ms, users may perceive your app as laggy. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/estimated-input-latency).", 
             "displayValue": "20\u00a0ms", 
             "id": "estimated-input-latency", 
             "score": 1.0, 
@@ -730,7 +730,7 @@
             "description": "Screen reader users rely on frame titles to describe the contents of frames. [Learn more](https://dequeuniversity.com/rules/axe/2.2/frame-title?application=lighthouse).", 
             "id": "frame-title", 
             "score": null, 
-            "scoreDisplayMode": "not_applicable", 
+            "scoreDisplayMode": "notApplicable", 
             "title": "`<frame>` or `<iframe>` elements have a title"
         }, 
         "geolocation-on-start": {
@@ -822,7 +822,7 @@
             "description": "Specifying a valid [BCP 47 language](https://www.w3.org/International/questions/qa-choosing-language-tags#question) helps screen readers announce text properly. [Learn more](https://dequeuniversity.com/rules/axe/2.2/valid-lang?application=lighthouse).", 
             "id": "html-lang-valid", 
             "score": null, 
-            "scoreDisplayMode": "not_applicable", 
+            "scoreDisplayMode": "notApplicable", 
             "title": "`<html>` element has a valid value for its `[lang]` attribute"
         }, 
         "http-status-code": {
@@ -930,7 +930,7 @@
             "description": "When an image is being used as an `<input>` button, providing alternative text can help screen reader users understand the purpose of the button. [Learn more](https://dequeuniversity.com/rules/axe/2.2/input-image-alt?application=lighthouse).", 
             "id": "input-image-alt", 
             "score": null, 
-            "scoreDisplayMode": "not_applicable", 
+            "scoreDisplayMode": "notApplicable", 
             "title": "`<input type=\"image\">` elements have `[alt]` text"
         }, 
         "installable-manifest": {
@@ -953,7 +953,7 @@
             "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).", 
+            "description": "Time to interactive is the amount of time it takes for the page to become fully interactive. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/consistently-interactive).", 
             "displayValue": "4.9\u00a0s", 
             "id": "interactive", 
             "score": 0.78, 
@@ -1087,7 +1087,7 @@
             "description": "A table being used for layout purposes should not include data elements, such as the th or caption elements or the summary attribute, because this can create a confusing experience for screen reader users. [Learn more](https://dequeuniversity.com/rules/axe/2.2/layout-table?application=lighthouse).", 
             "id": "layout-table", 
             "score": null, 
-            "scoreDisplayMode": "not_applicable", 
+            "scoreDisplayMode": "notApplicable", 
             "title": "Presentational `<table>` elements avoid using `<th>`, `<caption>` or the `[summary]` attribute."
         }, 
         "link-name": {
@@ -1154,14 +1154,14 @@
             "description": "Screen readers have a specific way of announcing lists. Ensuring proper list structure aids screen reader output. [Learn more](https://dequeuniversity.com/rules/axe/2.2/list?application=lighthouse).", 
             "id": "list", 
             "score": null, 
-            "scoreDisplayMode": "not_applicable", 
+            "scoreDisplayMode": "notApplicable", 
             "title": "Lists contain only `<li>` elements and script supporting elements (`<script>` and `<template>`)."
         }, 
         "listitem": {
             "description": "Screen readers require list items (`<li>`) to be contained within a parent `<ul>` or `<ol>` to be announced properly. [Learn more](https://dequeuniversity.com/rules/axe/2.2/listitem?application=lighthouse).", 
             "id": "listitem", 
             "score": null, 
-            "scoreDisplayMode": "not_applicable", 
+            "scoreDisplayMode": "notApplicable", 
             "title": "List items (`<li>`) are contained within `<ul>` or `<ol>` parent elements"
         }, 
         "load-fast-enough-for-pwa": {
@@ -1257,7 +1257,7 @@
             "description": "Users do not expect a page to refresh automatically, and doing so will move focus back to the top of the page. This may create a frustrating or confusing experience. [Learn more](https://dequeuniversity.com/rules/axe/2.2/meta-refresh?application=lighthouse).", 
             "id": "meta-refresh", 
             "score": null, 
-            "scoreDisplayMode": "not_applicable", 
+            "scoreDisplayMode": "notApplicable", 
             "title": "The document does not use `<meta http-equiv=\"refresh\">`"
         }, 
         "meta-viewport": {
@@ -1646,7 +1646,7 @@
             "description": "Screen readers cannot translate non-text content. Adding alt text to `<object>` elements helps screen readers convey meaning to users. [Learn more](https://dequeuniversity.com/rules/axe/2.2/object-alt?application=lighthouse).", 
             "id": "object-alt", 
             "score": null, 
-            "scoreDisplayMode": "not_applicable", 
+            "scoreDisplayMode": "notApplicable", 
             "title": "`<object>` elements have `[alt]` text"
         }, 
         "offline-start-url": {
@@ -1935,21 +1935,21 @@
             "description": "A value greater than 0 implies an explicit navigation ordering. Although technically valid, this often creates frustrating experiences for users who rely on assistive technologies. [Learn more](https://dequeuniversity.com/rules/axe/2.2/tabindex?application=lighthouse).", 
             "id": "tabindex", 
             "score": null, 
-            "scoreDisplayMode": "not_applicable", 
+            "scoreDisplayMode": "notApplicable", 
             "title": "No element has a `[tabindex]` value greater than 0"
         }, 
         "td-headers-attr": {
             "description": "Screen readers have features to make navigating tables easier. Ensuring `<td>` cells using the `[headers]` attribute only refer to other cells in the same table may improve the experience for screen reader users. [Learn more](https://dequeuniversity.com/rules/axe/2.2/td-headers-attr?application=lighthouse).", 
             "id": "td-headers-attr", 
             "score": null, 
-            "scoreDisplayMode": "not_applicable", 
+            "scoreDisplayMode": "notApplicable", 
             "title": "Cells in a `<table>` element that use the `[headers]` attribute only refer to other cells of that same table."
         }, 
         "th-has-data-cells": {
             "description": "Screen readers have features to make navigating tables easier. Ensuring table headers always refer to some set of cells may improve the experience for screen reader users. [Learn more](https://dequeuniversity.com/rules/axe/2.2/th-has-data-cells?application=lighthouse).", 
             "id": "th-has-data-cells", 
             "score": null, 
-            "scoreDisplayMode": "not_applicable", 
+            "scoreDisplayMode": "notApplicable", 
             "title": "`<th>` elements and elements with `[role=\"columnheader\"/\"rowheader\"]` have data cells they describe."
         }, 
         "themed-omnibox": {
@@ -2135,7 +2135,7 @@
             }, 
             "id": "user-timings", 
             "score": null, 
-            "scoreDisplayMode": "not_applicable", 
+            "scoreDisplayMode": "notApplicable", 
             "title": "User Timing marks and measures"
         }, 
         "uses-http2": {
@@ -2530,21 +2530,21 @@
             "description": "Specifying a valid [BCP 47 language](https://www.w3.org/International/questions/qa-choosing-language-tags#question) on elements helps ensure that text is pronounced correctly by a screen reader. [Learn more](https://dequeuniversity.com/rules/axe/2.2/valid-lang?application=lighthouse).", 
             "id": "valid-lang", 
             "score": null, 
-            "scoreDisplayMode": "not_applicable", 
+            "scoreDisplayMode": "notApplicable", 
             "title": "`[lang]` attributes have a valid value"
         }, 
         "video-caption": {
             "description": "When a video provides a caption it is easier for deaf and hearing impaired users to access its information. [Learn more](https://dequeuniversity.com/rules/axe/2.2/video-caption?application=lighthouse).", 
             "id": "video-caption", 
             "score": null, 
-            "scoreDisplayMode": "not_applicable", 
+            "scoreDisplayMode": "notApplicable", 
             "title": "`<video>` elements contain a `<track>` element with `[kind=\"captions\"]`"
         }, 
         "video-description": {
             "description": "Audio descriptions provide relevant information for videos that dialogue cannot, such as facial expressions and scenes. [Learn more](https://dequeuniversity.com/rules/axe/2.2/video-description?application=lighthouse).", 
             "id": "video-description", 
             "score": null, 
-            "scoreDisplayMode": "not_applicable", 
+            "scoreDisplayMode": "notApplicable", 
             "title": "`<video>` elements contain a `<track>` element with `[kind=\"description\"]`"
         }, 
         "viewport": {
@@ -3498,6 +3498,12 @@
                 "name": "lh:audit:user-timings", 
                 "startTime": 0.0
             }, 
+            {
+                "duration": 100.0, 
+                "entryType": "measure", 
+                "name": "lh:computed:UserTimings", 
+                "startTime": 0.0
+            }, 
             {
                 "duration": 100.0, 
                 "entryType": "measure", 
diff --git a/readme.md b/readme.md
index c9882b27f..f07bb1b3c 100644
--- a/readme.md
+++ b/readme.md
@@ -106,7 +106,7 @@ Examples:
   lighthouse <url> --output=json --output-path=./report.json --save-assets  Save trace, devtoolslog, and named JSON report.
   lighthouse <url> --disable-device-emulation                               Disable device emulation and all throttling.
     --throttling-method=provided
-  lighthouse <url> --chrome-flags="--window-size=412,732"                   Launch Chrome with a specific window size
+  lighthouse <url> --chrome-flags="--window-size=412,660"                   Launch Chrome with a specific window size
   lighthouse <url> --quiet --chrome-flags="--headless"                      Launch Headless Chrome, turn off logging
   lighthouse <url> --extra-headers "{\"Cookie\":\"monster=blue\"}"          Stringify\'d JSON HTTP Header key/value pairs to send in requests
   lighthouse <url> --extra-headers=./path/to/file.json                      Path to JSON file of HTTP Header key/value pairs to send in requests
diff --git a/tsconfig.json b/tsconfig.json
index 6d57b103e..33e227eab 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -8,10 +8,6 @@
     "strict": true,
     // "listFiles": true,
     // "noErrorTruncation": true,
-    "typeRoots": [
-      "@types",
-      "./types"
-    ],
 
     "resolveJsonModule": true,
     "diagnostics": true
@@ -21,11 +17,11 @@
     "lighthouse-core/**/*.js",
     "clients/**/*.js",
     "build/**/*.js",
-    "./types/*.d.ts",
+    "./types/**/*.d.ts",
   ],
   "exclude": [
-    "lighthouse-cli/test/**/*.js",
     "lighthouse-core/test/**/*.js",
     "clients/test/**/*.js",
+    "lighthouse-cli/test/fixtures/**/*.js",
   ]
 }
diff --git a/types/audit.d.ts b/types/audit.d.ts
index ec5527c58..312895312 100644
--- a/types/audit.d.ts
+++ b/types/audit.d.ts
@@ -32,7 +32,7 @@ declare global {
       BINARY: 'binary';
       MANUAL: 'manual';
       INFORMATIVE: 'informative';
-      NOT_APPLICABLE: 'not-applicable';
+      NOT_APPLICABLE: 'notApplicable';
       ERROR: 'error';
     }
 
@@ -120,7 +120,7 @@ declare global {
       warnings?: string[];
       score?: number;
       extendedInfo?: {[p: string]: any};
-      /** Overrides scoreDisplayMode with not-applicable if set to true */
+      /** Overrides scoreDisplayMode with notApplicable if set to true */
       notApplicable?: boolean;
       // TODO(bckenny): define details
       details?: object;
@@ -139,7 +139,7 @@ declare global {
        * 'binary': pass/fail audit (0 and 1 are only possible scores).
        * 'numeric': scores of 0-1 (map to displayed scores of 0-100).
        * 'informative': the audit is an FYI only, and can't be interpreted as pass/fail. Score is null and should be ignored.
-       * 'not-applicable': the audit turned out to not apply to the page. Score is null and should be ignored.
+       * 'notApplicable': the audit turned out to not apply to the page. Score is null and should be ignored.
        * 'manual': The audit exists only to tell you to review something yourself. Score is null and should be ignored.
        * 'error': There was an error while running the audit (check `errorMessage` for details). Score is null and should be ignored.
        */
diff --git a/types/lhr-lite.d.ts b/types/lhr-lite.d.ts
index dc7def56f..8395a6b9c 100644
--- a/types/lhr-lite.d.ts
+++ b/types/lhr-lite.d.ts
@@ -62,11 +62,11 @@ declare global {
          * 'binary': pass/fail audit (0 and 1 are only possible scores).
          * 'numeric': scores of 0-1 (map to displayed scores of 0-100).
          * 'informative': the audit is an FYI only, and can't be interpreted as pass/fail. Score is null and should be ignored.
-         * 'not-applicable': the audit turned out to not apply to the page. Score is null and should be ignored.
+         * 'notApplicable': the audit turned out to not apply to the page. Score is null and should be ignored.
          * 'manual': The audit exists only to tell you to review something yourself. Score is null and should be ignored.
          * 'error': There was an error while running the audit (check `errorMessage` for details). Score is null and should be ignored.
          */
-        scoreDisplayMode: 'binary' | 'numeric' | 'informative' | 'not-applicable' | 'manual' | 'error';
+        scoreDisplayMode: 'binary' | 'numeric' | 'informative' | 'notApplicable' | 'manual' | 'error';
         /** An explanation of audit-related issues encountered on the test page. */
         explanation?: string;
         /** Extra information provided by some types of audits. */
diff --git a/types/lighthouse-logger/index.d.ts b/types/lighthouse-logger/index.d.ts
index f3f290a6c..3fcbb6ab8 100644
--- a/types/lighthouse-logger/index.d.ts
+++ b/types/lighthouse-logger/index.d.ts
@@ -19,8 +19,15 @@ declare module 'lighthouse-logger' {
   export function time(status: Status, level?: string): void;
   export function timeEnd(status: Status, level?: string): void;
   export function reset(): string;
+  export const cross: string;
+  export const dim: string;
+  export const tick: string;
+  export const bold: string;
+  export const purple: string;
+  export function redify(message: any): string;
+  export function greenify(message: any): string;
   /** Retrieves and clears all stored time entries */
   export function takeTimeEntries(): PerformanceEntry[];
   export function getTimeEntries(): PerformanceEntry[];
-  export var events: import('events').EventEmitter;
+  export const events: import('events').EventEmitter;
 }
diff --git a/yarn.lock b/yarn.lock
index ea0c30895..5127b9068 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -211,6 +211,11 @@
   resolved "https://registry.yarnpkg.com/@types/intl-messageformat/-/intl-messageformat-1.3.0.tgz#6af7144802b13d62ade9ad68f8420cdb33b75916"
   integrity sha1-avcUSAKxPWKt6a1o+EIM2zO3WRY=
 
+"@types/jest@^23.3.10":
+  version "23.3.10"
+  resolved "https://registry.yarnpkg.com/@types/jest/-/jest-23.3.10.tgz#4897974cc317bf99d4fe6af1efa15957fa9c94de"
+  integrity sha512-DC8xTuW/6TYgvEg3HEXS7cu9OijFqprVDXXiOcdOKZCU/5PJNLZU37VVvmZHdtMiGOa8wAA/We+JzbdxFzQTRQ==
+
 "@types/jpeg-js@^0.3.0":
   version "0.3.0"
   resolved "https://registry.yarnpkg.com/@types/jpeg-js/-/jpeg-js-0.3.0.tgz#5971f0aa50900194e9565711c265c10027385e89"


Test patch:
diff --git a/lighthouse-cli/test/cli/cli-flags-test.js b/lighthouse-cli/test/cli/cli-flags-test.js
index 1589a8983..99e676e37 100644
--- a/lighthouse-cli/test/cli/cli-flags-test.js
+++ b/lighthouse-cli/test/cli/cli-flags-test.js
@@ -14,11 +14,14 @@ describe('CLI bin', function() {
     getFlags('chrome://version');
     const yargs = require('yargs');
 
+    // @ts-ignore - getGroups is private
     const optionGroups = yargs.getGroups();
+    /** @type {string[]} */
     const allOptions = [];
     Object.keys(optionGroups).forEach(key => {
       allOptions.push(...optionGroups[key]);
     });
+    // @ts-ignore - getUsageInstance is private
     const optionsWithDescriptions = Object.keys(yargs.getUsageInstance().getDescriptions());
 
     allOptions.forEach(opt => {
diff --git a/lighthouse-cli/test/cli/printer-test.js b/lighthouse-cli/test/cli/printer-test.js
index ee1601338..5b402e481 100644
--- a/lighthouse-cli/test/cli/printer-test.js
+++ b/lighthouse-cli/test/cli/printer-test.js
@@ -18,7 +18,7 @@ describe('Printer', () => {
   });
 
   it('rejects invalid output paths', () => {
-    const path = undefined;
+    const path = /** @type {any} */ (undefined);
     assert.notEqual(Printer.checkOutputPath(path), path);
   });
 
diff --git a/lighthouse-cli/test/cli/run-test.js b/lighthouse-cli/test/cli/run-test.js
index 923bc42d9..f1681bca6 100644
--- a/lighthouse-cli/test/cli/run-test.js
+++ b/lighthouse-cli/test/cli/run-test.js
@@ -28,14 +28,21 @@ describe('CLI run', function() {
     const timeoutFlag = `--max-wait-for-load=${9000}`;
     const flags = getFlags(`--output=json --output-path=${filename} ${timeoutFlag} ${url}`);
     return run.runLighthouse(url, flags, fastConfig).then(passedResults => {
+      if (!passedResults) {
+        assert.fail('no results');
+        return;
+      }
+
       const {lhr} = passedResults;
       assert.ok(fs.existsSync(filename));
+      /** @type {LH.Result} */
       const results = JSON.parse(fs.readFileSync(filename, 'utf-8'));
       assert.equal(results.audits.viewport.rawValue, false);
 
       // passed results match saved results
       assert.strictEqual(results.fetchTime, lhr.fetchTime);
-      assert.strictEqual(results.url, lhr.url);
+      assert.strictEqual(results.requestedUrl, lhr.requestedUrl);
+      assert.strictEqual(results.finalUrl, lhr.finalUrl);
       assert.strictEqual(results.audits.viewport.rawValue, lhr.audits.viewport.rawValue);
       assert.strictEqual(
           Object.keys(results.audits).length,
@@ -57,7 +64,9 @@ describe('flag coercing', () => {
 
 describe('saveResults', () => {
   it('will quit early if we\'re in gather mode', async () => {
-    const result = await run.saveResults(undefined, {gatherMode: true});
+    const result = await run.saveResults(
+      /** @type {LH.RunnerResult} */ ({}),
+      /** @type {LH.CliFlags} */ ({gatherMode: true}));
     assert.equal(result, undefined);
   });
 });
diff --git a/lighthouse-cli/test/fixtures/delayed-fcp.html b/lighthouse-cli/test/fixtures/delayed-fcp.html
new file mode 100644
index 000000000..b369a42e8
--- /dev/null
+++ b/lighthouse-cli/test/fixtures/delayed-fcp.html
@@ -0,0 +1,17 @@
+<html>
+<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
+<body>
+  <!--
+    This page intentionally contains no content for the first 6 seconds.
+    After 6 seconds, text is finally added with a setTimeout below.
+    We are testing Lighthouse's ability to wait for First Contentful Paint, not just page load.
+  -->
+  <script>
+    setTimeout(() => {
+      const textEl = document.createElement('span');
+      textEl.textContent = 'Hello, World!';
+      document.body.appendChild(textEl);
+    }, 6000);
+  </script>
+</body>
+</html>
diff --git a/lighthouse-cli/test/global-mocha-hooks.js b/lighthouse-cli/test/global-mocha-hooks.js
deleted file mode 100644
index c9724a8ac..000000000
--- a/lighthouse-cli/test/global-mocha-hooks.js
+++ /dev/null
@@ -1,39 +0,0 @@
-'use strict';
-
-const assert = require('assert');
-
-/**
- * THIS FILE IS A DUPLICATE OF lighthouse-core/test/global-mocha-hooks.js
- * Please keep them in sync.
- */
-
-/* eslint-env jest */
-let currTest;
-
-// monkey-patch all assert.* methods
-Object.keys(assert)
-  .filter(key => typeof assert[key] === 'function')
-  .forEach(key => {
-    const _origFn = assert[key];
-    assert[key] = function(...args) {
-      if (currTest) {
-        currTest._assertions++;
-      }
-      return _origFn.apply(this, args);
-    };
-  });
-
-// store the count of assertions on each test's state object
-beforeEach(function() {
-  // eslint-disable-next-line no-invalid-this
-  currTest = this.currentTest;
-  currTest._assertions = currTest._assertions || 0;
-});
-
-afterEach(function() {
-  if (currTest._assertions === 0) {
-    throw new Error(`ZERO assertions in test: "${currTest.title}"\n${currTest.file}`);
-  }
-  currTest = null;
-});
-
diff --git a/lighthouse-cli/test/smokehouse/offline-local/offline-expectations.js b/lighthouse-cli/test/smokehouse/offline-local/offline-expectations.js
index f139b0185..4f7d46ca4 100644
--- a/lighthouse-cli/test/smokehouse/offline-local/offline-expectations.js
+++ b/lighthouse-cli/test/smokehouse/offline-local/offline-expectations.js
@@ -58,10 +58,10 @@ module.exports = [
         score: 1,
       },
       'user-timings': {
-        scoreDisplayMode: 'not-applicable',
+        scoreDisplayMode: 'notApplicable',
       },
       'critical-request-chains': {
-        scoreDisplayMode: 'not-applicable',
+        scoreDisplayMode: 'notApplicable',
       },
       'installable-manifest': {
         score: 0,
@@ -75,22 +75,22 @@ module.exports = [
         score: 0,
       },
       'aria-valid-attr': {
-        scoreDisplayMode: 'not-applicable',
+        scoreDisplayMode: 'notApplicable',
       },
       'aria-allowed-attr': {
-        scoreDisplayMode: 'not-applicable',
+        scoreDisplayMode: 'notApplicable',
       },
       'color-contrast': {
         score: 1,
       },
       'image-alt': {
-        scoreDisplayMode: 'not-applicable',
+        scoreDisplayMode: 'notApplicable',
       },
       'label': {
-        scoreDisplayMode: 'not-applicable',
+        scoreDisplayMode: 'notApplicable',
       },
       'tabindex': {
-        scoreDisplayMode: 'not-applicable',
+        scoreDisplayMode: 'notApplicable',
       },
       'content-width': {
         score: 1,
@@ -121,10 +121,10 @@ module.exports = [
         score: 1,
       },
       'user-timings': {
-        scoreDisplayMode: 'not-applicable',
+        scoreDisplayMode: 'notApplicable',
       },
       'critical-request-chains': {
-        scoreDisplayMode: 'not-applicable',
+        scoreDisplayMode: 'notApplicable',
       },
       'installable-manifest': {
         score: 1,
@@ -136,10 +136,10 @@ module.exports = [
         score: 0,
       },
       'aria-valid-attr': {
-        scoreDisplayMode: 'not-applicable',
+        scoreDisplayMode: 'notApplicable',
       },
       'aria-allowed-attr': {
-        scoreDisplayMode: 'not-applicable',
+        scoreDisplayMode: 'notApplicable',
       },
       'color-contrast': {
         score: 1,
@@ -148,10 +148,10 @@ module.exports = [
         score: 0,
       },
       'label': {
-        scoreDisplayMode: 'not-applicable',
+        scoreDisplayMode: 'notApplicable',
       },
       'tabindex': {
-        scoreDisplayMode: 'not-applicable',
+        scoreDisplayMode: 'notApplicable',
       },
       'content-width': {
         score: 1,
diff --git a/lighthouse-cli/test/smokehouse/run-smoke.js b/lighthouse-cli/test/smokehouse/run-smoke.js
index 12f2cb86d..a456d8410 100644
--- a/lighthouse-cli/test/smokehouse/run-smoke.js
+++ b/lighthouse-cli/test/smokehouse/run-smoke.js
@@ -12,6 +12,7 @@ const execAsync = promisify(require('child_process').exec);
 const {server, serverForOffline} = require('../fixtures/static-server');
 const log = require('lighthouse-logger');
 
+/** @param {string} str */
 const purpleify = str => `${log.purple}${str}${log.reset}`;
 const smokehouseDir = 'lighthouse-cli/test/smokehouse/';
 
@@ -80,26 +81,23 @@ const SMOKETESTS = [{
   config: 'lighthouse-core/config/perf-config.js',
   batch: 'perf-metric',
 }, {
-  id: 'tti',
-  expectations: 'tricky-tti/expectations.js',
+  id: 'metrics',
+  expectations: 'tricky-metrics/expectations.js',
   config: 'lighthouse-core/config/perf-config.js',
   batch: 'parallel-second',
 }];
 
 /**
  * Display smokehouse output from child process
- * @param {{id: string, process: NodeJS.Process} || {id: string, error: Error & {stdout : NodeJS.WriteStream, stderr: NodeJS.WriteStream}}} result
+ * @param {{id: string, stdout: string, stderr: string, error?: Error}} result
  */
 function displaySmokehouseOutput(result) {
   console.log(`\n${purpleify(result.id)} smoketest results:`);
   if (result.error) {
     console.log(result.error.message);
-    process.stdout.write(result.error.stdout);
-    process.stderr.write(result.error.stderr);
-  } else {
-    process.stdout.write(result.process.stdout);
-    process.stderr.write(result.process.stderr);
   }
+  process.stdout.write(result.stdout);
+  process.stderr.write(result.stderr);
   console.timeEnd(`smoketest-${result.id}`);
   console.log(`${purpleify(result.id)} smoketest complete. \n`);
   return result;
@@ -124,8 +122,8 @@ async function runSmokehouse(smokes) {
 
     // The promise ensures we output immediately, even if the process errors
     const p = execAsync(cmd, {timeout: 6 * 60 * 1000, encoding: 'utf8'})
-      .then(cp => ({id: id, process: cp}))
-      .catch(err => ({id: id, error: err}))
+      .then(cp => ({id, ...cp}))
+      .catch(err => ({id, stdout: err.stdout, stderr: err.stderr, error: err}))
       .then(result => displaySmokehouseOutput(result));
 
     // If the machine is terribly slow, we'll run all smoketests in succession, not parallel
@@ -200,6 +198,7 @@ async function cli() {
   if (failingTests.length && (process.env.RETRY_SMOKES || process.env.CI)) {
     console.log('Retrying failed tests...');
     for (const failedResult of failingTests) {
+      /** @type {number} */
       const resultIndex = smokeResults.indexOf(failedResult);
       const smokeDefn = smokeDefns.get(failedResult.id);
       smokeResults[resultIndex] = (await runSmokehouse([smokeDefn]))[0];
diff --git a/lighthouse-cli/test/smokehouse/seo/expectations.js b/lighthouse-cli/test/smokehouse/seo/expectations.js
index 1cdabbce5..a1f6269dc 100644
--- a/lighthouse-cli/test/smokehouse/seo/expectations.js
+++ b/lighthouse-cli/test/smokehouse/seo/expectations.js
@@ -7,6 +7,10 @@
 const BASE_URL = 'http://localhost:10200/seo/';
 const URLSearchParams = require('url').URLSearchParams;
 
+/**
+ * @param {[string, string][]} headers
+ * @return {string}
+ */
 function headersParam(headers) {
   const headerString = new URLSearchParams(headers).toString();
   return new URLSearchParams([['extra_header', headerString]]).toString();
@@ -73,7 +77,7 @@ module.exports = [
       },
       'robots-txt': {
         rawValue: true,
-        scoreDisplayMode: 'not-applicable',
+        scoreDisplayMode: 'notApplicable',
       },
     },
   },
diff --git a/lighthouse-cli/test/smokehouse/smokehouse.js b/lighthouse-cli/test/smokehouse/smokehouse.js
index 673e1f6ad..603d71aa0 100755
--- a/lighthouse-cli/test/smokehouse/smokehouse.js
+++ b/lighthouse-cli/test/smokehouse/smokehouse.js
@@ -6,6 +6,22 @@
  */
 'use strict';
 
+/**
+ * @typedef {{path: string, actual: *, expected: *}} Difference
+ */
+
+/**
+ * @typedef {{category: string, actual: *, expected: *, equal: boolean, diff?: Difference | null}} Comparison
+ */
+
+/**
+ * @typedef {Pick<LH.Result, 'audits' | 'finalUrl' | 'requestedUrl'> & {errorCode?: string}} ExpectedLHR
+ */
+
+/**
+ * @typedef {{audits: Comparison[], errorCode: Comparison, finalUrl: Comparison}} LHRComparison
+ */
+
 /* eslint-disable no-console */
 
 const fs = require('fs');
@@ -19,7 +35,6 @@ const PAGE_HUNG_EXIT_CODE = 68;
 const RETRIES = 3;
 const NUMERICAL_EXPECTATION_REGEXP = /^(<=?|>=?)((\d|\.)+)$/;
 
-
 /**
  * Attempt to resolve a path locally. If this fails, attempts to locate the path
  * relative to the current working directory.
@@ -43,10 +58,10 @@ function resolveLocalOrCwd(payloadPath) {
  * @param {string} url
  * @param {string} configPath
  * @param {boolean=} isDebug
- * @return {!LighthouseResults}
+ * @return {ExpectedLHR}
  */
 function runLighthouse(url, configPath, isDebug) {
-  isDebug = isDebug || process.env.SMOKEHOUSE_DEBUG;
+  isDebug = isDebug || Boolean(process.env.SMOKEHOUSE_DEBUG);
 
   const command = 'node';
   const outputPath = `smokehouse-${Math.round(Math.random() * 100000)}.report.json`;
@@ -137,6 +152,8 @@ function matchesExpectation(actual, expected) {
         return actual < number;
       case '<=':
         return actual <= number;
+      default:
+        throw new Error(`unexpected operator ${operator}`);
     }
   } else if (typeof actual === 'string' && expected instanceof RegExp && expected.test(actual)) {
     return true;
@@ -157,7 +174,7 @@ function matchesExpectation(actual, expected) {
  * @param {string} path
  * @param {*} actual
  * @param {*} expected
- * @return {({path: string, actual: *, expected: *}|null)}
+ * @return {(Difference|null)}
  */
 function findDifference(path, actual, expected) {
   if (matchesExpectation(actual, expected)) {
@@ -199,9 +216,9 @@ function findDifference(path, actual, expected) {
 
 /**
  * Collate results into comparisons of actual and expected scores on each audit.
- * @param {{finalUrl: string, audits: !Array, errorCode: string}} actual
- * @param {{finalUrl: string, audits: !Array, errorCode: string}} expected
- * @return {{finalUrl: !Object, audits: !Array<!Object>}}
+ * @param {ExpectedLHR} actual
+ * @param {ExpectedLHR} expected
+ * @return {LHRComparison}
  */
 function collateResults(actual, expected) {
   const auditNames = Object.keys(expected.audits);
@@ -224,12 +241,6 @@ function collateResults(actual, expected) {
   });
 
   return {
-    finalUrl: {
-      category: 'final url',
-      actual: actual.finalUrl,
-      expected: expected.finalUrl,
-      equal: actual.finalUrl === expected.finalUrl,
-    },
     audits: collatedAudits,
     errorCode: {
       category: 'error code',
@@ -237,15 +248,23 @@ function collateResults(actual, expected) {
       expected: expected.errorCode,
       equal: actual.errorCode === expected.errorCode,
     },
+    finalUrl: {
+      category: 'final url',
+      actual: actual.finalUrl,
+      expected: expected.finalUrl,
+      equal: actual.finalUrl === expected.finalUrl,
+    },
   };
 }
 
 /**
  * Log the result of an assertion of actual and expected results.
- * @param {{category: string, equal: boolean, diff: ?Object, actual: boolean, expected: boolean}} assertion
+ * @param {Comparison} assertion
  */
 function reportAssertion(assertion) {
+  // @ts-ignore - this doesn't exist now but could one day, so try not to break the future
   const _toJSON = RegExp.prototype.toJSON;
+  // @ts-ignore
   // eslint-disable-next-line no-extend-native
   RegExp.prototype.toJSON = RegExp.prototype.toString;
 
@@ -273,6 +292,7 @@ function reportAssertion(assertion) {
     }
   }
 
+  // @ts-ignore
   // eslint-disable-next-line no-extend-native
   RegExp.prototype.toJSON = _toJSON;
 }
@@ -280,7 +300,7 @@ function reportAssertion(assertion) {
 /**
  * Log all the comparisons between actual and expected test results, then print
  * summary. Returns count of passed and failed tests.
- * @param {{finalUrl: !Object, audits: !Array<!Object>, errorCode: !Object}} results
+ * @param {LHRComparison} results
  * @return {{passed: number, failed: number}}
  */
 function report(results) {
@@ -316,11 +336,12 @@ const cli = yargs
     'expectations-path': 'The path to the expected audit results file',
     'debug': 'Save the artifacts along with the output',
   })
-  .require('config-path')
-  .require('expectations-path')
+  .require('config-path', true)
+  .require('expectations-path', true)
   .argv;
 
 const configPath = resolveLocalOrCwd(cli['config-path']);
+/** @type {ExpectedLHR[]} */
 const expectations = require(resolveLocalOrCwd(cli['expectations-path']));
 
 // Loop sequentially over expectations, comparing against Lighthouse run, and
diff --git a/lighthouse-cli/test/smokehouse/tricky-tti/expectations.js b/lighthouse-cli/test/smokehouse/tricky-metrics/expectations.js
similarity index 79%
rename from lighthouse-cli/test/smokehouse/tricky-tti/expectations.js
rename to lighthouse-cli/test/smokehouse/tricky-metrics/expectations.js
index f63c66e6c..07d165fcc 100644
--- a/lighthouse-cli/test/smokehouse/tricky-tti/expectations.js
+++ b/lighthouse-cli/test/smokehouse/tricky-metrics/expectations.js
@@ -23,4 +23,13 @@ module.exports = [
       },
     },
   },
+  {
+    requestedUrl: 'http://localhost:10200/delayed-fcp.html',
+    finalUrl: 'http://localhost:10200/delayed-fcp.html',
+    audits: {
+      'first-contentful-paint': {
+        rawValue: '>1', // We just want to check that it doesn't error
+      },
+    },
+  },
 ];
diff --git a/lighthouse-core/test/audits/audit-test.js b/lighthouse-core/test/audits/audit-test.js
index 804f9441f..0e4251a2c 100644
--- a/lighthouse-core/test/audits/audit-test.js
+++ b/lighthouse-core/test/audits/audit-test.js
@@ -90,7 +90,7 @@ describe('Audit', () => {
     const providedResult = {rawValue: true, notApplicable: true};
     const result = Audit.generateAuditResult(B, providedResult);
     assert.equal(result.score, null);
-    assert.equal(result.scoreDisplayMode, 'not-applicable');
+    assert.equal(result.scoreDisplayMode, 'notApplicable');
   });
 
   it('sets state of failed audits', () => {
diff --git a/lighthouse-core/test/config/config-test.js b/lighthouse-core/test/config/config-test.js
index 48c08cd1e..f3a9d1476 100644
--- a/lighthouse-core/test/config/config-test.js
+++ b/lighthouse-core/test/config/config-test.js
@@ -975,7 +975,7 @@ describe('Config', () => {
     });
   });
 
-  describe('#getDisplayString', () => {
+  describe('#getPrintString', () => {
     it('doesn\'t include empty gatherer/audit options in output', () => {
       const gOpt = 'gathererOption';
       const aOpt = 'auditOption';
diff --git a/lighthouse-core/test/fixtures/artifacts/perflog/artifacts.json b/lighthouse-core/test/fixtures/artifacts/perflog/artifacts.json
index 777cdf1b6..8af6b351a 100644
--- a/lighthouse-core/test/fixtures/artifacts/perflog/artifacts.json
+++ b/lighthouse-core/test/fixtures/artifacts/perflog/artifacts.json
@@ -12,9 +12,9 @@
   "Viewport": null,
   "ViewportDimensions": {
     "innerWidth": 412,
-    "innerHeight": 732,
+    "innerHeight": 660,
     "outerWidth": 412,
-    "outerHeight": 732,
+    "outerHeight": 660,
     "devicePixelRatio": 2.625
   }
 }
diff --git a/lighthouse-core/test/global-mocha-hooks.js b/lighthouse-core/test/global-mocha-hooks.js
deleted file mode 100644
index ef76e1a6d..000000000
--- a/lighthouse-core/test/global-mocha-hooks.js
+++ /dev/null
@@ -1,40 +0,0 @@
-'use strict';
-
-const assert = global.assert = require('assert');
-
-/**
- * We don't want any mocha tests to run without evaluating any assertions
- * These hooks will throw if an it() test completes without any assert.foo()
- * having run.
- */
-
-/* eslint-env jest */
-let currTest;
-
-// monkey-patch all assert.* methods
-Object.keys(assert)
-  .filter(key => typeof assert[key] === 'function')
-  .forEach(key => {
-    const _origFn = assert[key];
-    assert[key] = function(...args) {
-      if (currTest) {
-        currTest._assertions++;
-      }
-      return _origFn.apply(this, args);
-    };
-  });
-
-// store the count of assertions on each test's state object
-beforeEach(function() {
-  // eslint-disable-next-line no-invalid-this
-  currTest = this.currentTest;
-  currTest._assertions = currTest._assertions || 0;
-});
-
-afterEach(function() {
-  if (currTest._assertions === 0) {
-    throw new Error(`ZERO assertions in test: "${currTest.title}"\n${currTest.file}`);
-  }
-  currTest = null;
-});
-
diff --git a/lighthouse-core/test/lib/proto-preprocessor-test.js b/lighthouse-core/test/lib/proto-preprocessor-test.js
index 3a76964d6..8dcb2283b 100644
--- a/lighthouse-core/test/lib/proto-preprocessor-test.js
+++ b/lighthouse-core/test/lib/proto-preprocessor-test.js
@@ -87,7 +87,7 @@ describe('processing for proto', () => {
     const expectation = {
       'audits': {
         'critical-request-chains': {
-          'scoreDisplayMode': 'not_applicable',
+          'scoreDisplayMode': 'notApplicable',
           'displayValue': 'hello %d | 123',
         },
       },
diff --git a/lighthouse-core/test/report/html/renderer/category-renderer-test.js b/lighthouse-core/test/report/html/renderer/category-renderer-test.js
index ad69de5b0..8be1099a4 100644
--- a/lighthouse-core/test/report/html/renderer/category-renderer-test.js
+++ b/lighthouse-core/test/report/html/renderer/category-renderer-test.js
@@ -59,27 +59,42 @@ describe('CategoryRenderer', () => {
     assert.equal(title.textContent, auditRef.result.title);
     assert.ok(description.querySelector('a'), 'audit help text contains coverted markdown links');
     assert.ok(auditDOM.classList.contains('lh-audit--fail'));
-    assert.ok(auditDOM.classList.contains(`lh-audit--${auditRef.result.scoreDisplayMode}`));
+    assert.ok(
+      auditDOM.classList.contains(`lh-audit--${auditRef.result.scoreDisplayMode.toLowerCase()}`));
   });
 
   it('renders an audit explanation when appropriate', () => {
     const audit1 = renderer.renderAudit({
-      scoreDisplayMode: 'binary', score: 0,
-      result: {description: 'help text', explanation: 'A reason', title: 'Audit title'},
+      result: {
+        title: 'Audit title',
+        explanation: 'A reason',
+        description: 'help text',
+        scoreDisplayMode: 'binary',
+        score: 0,
+      },
     });
     assert.ok(audit1.querySelector('.lh-audit-explanation'));
 
     const audit2 = renderer.renderAudit({
-      scoreDisplayMode: 'binary', score: 0,
-      result: {description: 'help text', title: 'Audit title'},
+      result: {
+        title: 'Audit title',
+        description: 'help text',
+        scoreDisplayMode: 'binary',
+        score: 0,
+      },
     });
     assert.ok(!audit2.querySelector('.lh-audit-explanation'));
   });
 
   it('renders an informative audit', () => {
     const auditDOM = renderer.renderAudit({
-      id: 'informative', score: 0,
-      result: {title: 'It informs', description: 'help text', scoreDisplayMode: 'informative'},
+      id: 'informative',
+      result: {
+        title: 'It informs',
+        description: 'help text',
+        scoreDisplayMode: 'informative',
+        score: 0,
+      },
     });
 
     assert.ok(auditDOM.matches('.lh-audit--informative'));
@@ -89,10 +104,11 @@ describe('CategoryRenderer', () => {
     const auditResult = {
       title: 'Audit',
       description: 'Learn more',
+      scoreDisplayMode: 'informative',
       warnings: ['It may not have worked!'],
       score: 1,
     };
-    const auditDOM = renderer.renderAudit({id: 'foo', score: 1, result: auditResult});
+    const auditDOM = renderer.renderAudit({id: 'foo', result: auditResult});
     const warningEl = auditDOM.querySelector('.lh-warnings');
     assert.ok(warningEl, 'did not render warning message');
     assert.ok(warningEl.textContent.includes(auditResult.warnings[0]), 'warning message provided');
@@ -102,10 +118,11 @@ describe('CategoryRenderer', () => {
     const auditResult = {
       title: 'Audit',
       description: 'Learn more',
+      scoreDisplayMode: 'informative',
       warnings: ['It may not have worked!', 'You should read this, though'],
       score: 1,
     };
-    const auditDOM = renderer.renderAudit({id: 'foo', score: 1, result: auditResult});
+    const auditDOM = renderer.renderAudit({id: 'foo', result: auditResult});
     const warningEl = auditDOM.querySelector('.lh-warnings');
     assert.ok(warningEl, 'did not render warning message');
     assert.ok(warningEl.textContent.includes(auditResult.warnings[0]), '1st warning provided');
@@ -158,19 +175,19 @@ describe('CategoryRenderer', () => {
     const a11yCategory = sampleResults.reportCategories.find(cat => cat.id === 'accessibility');
     const categoryDOM = renderer.render(a11yCategory, sampleResults.categoryGroups);
     assert.ok(categoryDOM.querySelector(
-        '.lh-clump--not-applicable .lh-audit-group__summary'));
+        '.lh-clump--notapplicable .lh-audit-group__summary'));
 
     const notApplicableCount = a11yCategory.auditRefs.reduce((sum, audit) =>
-        sum += audit.result.scoreDisplayMode === 'not-applicable' ? 1 : 0, 0);
+        sum += audit.result.scoreDisplayMode === 'notApplicable' ? 1 : 0, 0);
     assert.equal(
-      categoryDOM.querySelectorAll('.lh-clump--not-applicable .lh-audit').length,
+      categoryDOM.querySelectorAll('.lh-clump--notapplicable .lh-audit').length,
       notApplicableCount,
       'score shows informative and dash icon'
     );
 
     const bestPracticeCat = sampleResults.reportCategories.find(cat => cat.id === 'best-practices');
     const categoryDOM2 = renderer.render(bestPracticeCat, sampleResults.categoryGroups);
-    assert.ok(!categoryDOM2.querySelector('.lh-clump--not-applicable'));
+    assert.ok(!categoryDOM2.querySelector('.lh-clump--notapplicable'));
   });
 
   describe('category with groups', () => {
@@ -202,7 +219,7 @@ describe('CategoryRenderer', () => {
     it.skip('renders the failed audits grouped by group', () => {
       const categoryDOM = renderer.render(category, sampleResults.categoryGroups);
       const failedAudits = category.auditRefs.filter(audit => {
-        return audit.result.score !== 1 && !audit.result.scoreDisplayMode === 'not-applicable';
+        return audit.result.score !== 1 && !audit.result.scoreDisplayMode === 'notApplicable';
       });
       const failedAuditTags = new Set(failedAudits.map(audit => audit.group));
 
@@ -213,7 +230,7 @@ describe('CategoryRenderer', () => {
     it('renders the passed audits grouped by group', () => {
       const categoryDOM = renderer.render(category, sampleResults.categoryGroups);
       const passedAudits = category.auditRefs.filter(audit =>
-          audit.result.scoreDisplayMode !== 'not-applicable' && audit.result.score === 1);
+          audit.result.scoreDisplayMode !== 'notApplicable' && audit.result.score === 1);
       const passedAuditTags = new Set(passedAudits.map(audit => audit.group));
 
       const passedAuditGroups = categoryDOM.querySelectorAll('.lh-clump--passed .lh-audit-group');
@@ -233,7 +250,7 @@ describe('CategoryRenderer', () => {
       const failedAudits = elem.querySelectorAll('.lh-clump--failed .lh-audit__index');
       const manualAudits = elem.querySelectorAll('.lh-clump--manual .lh-audit__index');
       const notApplicableAudits =
-        elem.querySelectorAll('.lh-clump--not-applicable .lh-audit__index');
+        elem.querySelectorAll('.lh-clump--notapplicable .lh-audit__index');
 
       const assertAllTheIndices = (nodeList) => {
         // Must be at least one for a decent test.
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 db6269cb6..2609dbc44 100644
--- a/lighthouse-core/test/report/html/renderer/report-renderer-test.js
+++ b/lighthouse-core/test/report/html/renderer/report-renderer-test.js
@@ -180,12 +180,12 @@ describe('ReportRenderer', () => {
     assert.equal(renderer._templateContext, otherDocument);
   });
 
-  it('renders `not_applicable` audits as `not-applicable`', () => {
+  it('renders `not_applicable` audits as `notApplicable`', () => {
     const clonedSampleResult = JSON.parse(JSON.stringify(sampleResultsOrig));
 
     let notApplicableCount = 0;
     Object.values(clonedSampleResult.audits).forEach(audit => {
-      if (audit.scoreDisplayMode === 'not-applicable') {
+      if (audit.scoreDisplayMode === 'notApplicable') {
         notApplicableCount++;
         audit.scoreDisplayMode = 'not_applicable';
       }
@@ -196,8 +196,7 @@ describe('ReportRenderer', () => {
     const container = renderer._dom._document.body;
     const reportElement = renderer.renderReport(sampleResults, container);
     const notApplicableElementCount = reportElement
-      .querySelectorAll('.lh-audit--not-applicable').length;
-
+      .querySelectorAll('.lh-audit--notapplicable').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 bb33a218d..6fcd9b5a3 100644
--- a/lighthouse-core/test/report/html/renderer/util-test.js
+++ b/lighthouse-core/test/report/html/renderer/util-test.js
@@ -171,12 +171,12 @@ describe('util helpers', () => {
   });
 
   describe('#prepareReportResult', () => {
-    it('corrects underscored `not-applicable` scoreDisplayMode', () => {
+    it('corrects underscored `notApplicable` scoreDisplayMode', () => {
       const clonedSampleResult = JSON.parse(JSON.stringify(sampleResult));
 
       let notApplicableCount = 0;
       Object.values(clonedSampleResult.audits).forEach(audit => {
-        if (audit.scoreDisplayMode === 'not-applicable') {
+        if (audit.scoreDisplayMode === 'notApplicable') {
           notApplicableCount++;
           audit.scoreDisplayMode = 'not_applicable';
         }
diff --git a/lighthouse-core/test/results/artifacts/artifacts.json b/lighthouse-core/test/results/artifacts/artifacts.json
index 3e7fc8677..09f5ae673 100644
--- a/lighthouse-core/test/results/artifacts/artifacts.json
+++ b/lighthouse-core/test/results/artifacts/artifacts.json
@@ -124,9 +124,9 @@
   "Viewport": "width=device-width, initial-scale=1, minimum-scale=1",
   "ViewportDimensions": {
     "innerWidth": 412,
-    "innerHeight": 732,
+    "innerHeight": 660,
     "outerWidth": 412,
-    "outerHeight": 732,
+    "outerHeight": 660,
     "devicePixelRatio": 2.625
   },
   "ThemeColor": null,
diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json
index 54bcb7121..cf95dde58 100644
--- a/lighthouse-core/test/results/sample_v2.json
+++ b/lighthouse-core/test/results/sample_v2.json
@@ -196,7 +196,7 @@
     "estimated-input-latency": {
       "id": "estimated-input-latency",
       "title": "Estimated Input Latency",
-      "description": "The score above is an estimate of how long your app takes to respond to user input, in milliseconds, during the busiest 5s window of page load. If your latency is higher than 50 ms, users may perceive your app as laggy. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/estimated-input-latency).",
+      "description": "Estimated Input Latency is an estimate of how long your app takes to respond to user input, in milliseconds, during the busiest 5s window of page load. If your latency is higher than 50 ms, users may perceive your app as laggy. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/estimated-input-latency).",
       "score": 1,
       "scoreDisplayMode": "numeric",
       "rawValue": 16,
@@ -279,7 +279,7 @@
     "interactive": {
       "id": "interactive",
       "title": "Time to 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).",
+      "description": "Time to interactive is the amount of time it takes for the page to become fully interactive. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/consistently-interactive).",
       "score": 0.78,
       "scoreDisplayMode": "numeric",
       "rawValue": 4927.278,
@@ -290,7 +290,7 @@
       "title": "User Timing marks and measures",
       "description": "Consider instrumenting your app with the User Timing API to measure your app's real-world performance during key user experiences. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/user-timing).",
       "score": null,
-      "scoreDisplayMode": "not-applicable",
+      "scoreDisplayMode": "notApplicable",
       "rawValue": true,
       "details": {
         "type": "table",
@@ -1086,7 +1086,7 @@
       "title": "`[accesskey]` values are unique",
       "description": "Access keys let users quickly focus a part of the page. For proper navigation, each access key must be unique. [Learn more](https://dequeuniversity.com/rules/axe/2.2/accesskeys?application=lighthouse).",
       "score": null,
-      "scoreDisplayMode": "not-applicable",
+      "scoreDisplayMode": "notApplicable",
       "rawValue": true
     },
     "aria-allowed-attr": {
@@ -1094,7 +1094,7 @@
       "title": "`[aria-*]` attributes match their roles",
       "description": "Each ARIA `role` supports a specific subset of `aria-*` attributes. Mismatching these invalidates the `aria-*` attributes. [Learn more](https://dequeuniversity.com/rules/axe/2.2/aria-allowed-attr?application=lighthouse).",
       "score": null,
-      "scoreDisplayMode": "not-applicable",
+      "scoreDisplayMode": "notApplicable",
       "rawValue": true
     },
     "aria-required-attr": {
@@ -1102,7 +1102,7 @@
       "title": "`[role]`s have all required `[aria-*]` attributes",
       "description": "Some ARIA roles have required attributes that describe the state of the element to screen readers. [Learn more](https://dequeuniversity.com/rules/axe/2.2/aria-required-attr?application=lighthouse).",
       "score": null,
-      "scoreDisplayMode": "not-applicable",
+      "scoreDisplayMode": "notApplicable",
       "rawValue": true
     },
     "aria-required-children": {
@@ -1110,7 +1110,7 @@
       "title": "Elements with `[role]` that require specific children `[role]`s, are present",
       "description": "Some ARIA parent roles must contain specific child roles to perform their intended accessibility functions. [Learn more](https://dequeuniversity.com/rules/axe/2.2/aria-required-children?application=lighthouse).",
       "score": null,
-      "scoreDisplayMode": "not-applicable",
+      "scoreDisplayMode": "notApplicable",
       "rawValue": true
     },
     "aria-required-parent": {
@@ -1118,7 +1118,7 @@
       "title": "`[role]`s are contained by their required parent element",
       "description": "Some ARIA child roles must be contained by specific parent roles to properly perform their intended accessibility functions. [Learn more](https://dequeuniversity.com/rules/axe/2.2/aria-required-parent?application=lighthouse).",
       "score": null,
-      "scoreDisplayMode": "not-applicable",
+      "scoreDisplayMode": "notApplicable",
       "rawValue": true
     },
     "aria-roles": {
@@ -1126,7 +1126,7 @@
       "title": "`[role]` values are valid",
       "description": "ARIA roles must have valid values in order to perform their intended accessibility functions. [Learn more](https://dequeuniversity.com/rules/axe/2.2/aria-roles?application=lighthouse).",
       "score": null,
-      "scoreDisplayMode": "not-applicable",
+      "scoreDisplayMode": "notApplicable",
       "rawValue": true
     },
     "aria-valid-attr-value": {
@@ -1134,7 +1134,7 @@
       "title": "`[aria-*]` attributes have valid values",
       "description": "Assistive technologies, like screen readers, can't interpret ARIA attributes with invalid values. [Learn more](https://dequeuniversity.com/rules/axe/2.2/aria-valid-attr-value?application=lighthouse).",
       "score": null,
-      "scoreDisplayMode": "not-applicable",
+      "scoreDisplayMode": "notApplicable",
       "rawValue": true
     },
     "aria-valid-attr": {
@@ -1142,7 +1142,7 @@
       "title": "`[aria-*]` attributes are valid and not misspelled",
       "description": "Assistive technologies, like screen readers, can't interpret ARIA attributes with invalid names. [Learn more](https://dequeuniversity.com/rules/axe/2.2/aria-valid-attr?application=lighthouse).",
       "score": null,
-      "scoreDisplayMode": "not-applicable",
+      "scoreDisplayMode": "notApplicable",
       "rawValue": true
     },
     "audio-caption": {
@@ -1150,7 +1150,7 @@
       "title": "`<audio>` elements contain a `<track>` element with `[kind=\"captions\"]`",
       "description": "Captions make audio elements usable for deaf or hearing-impaired users, providing critical information such as who is talking, what they're saying, and other non-speech information. [Learn more](https://dequeuniversity.com/rules/axe/2.2/audio-caption?application=lighthouse).",
       "score": null,
-      "scoreDisplayMode": "not-applicable",
+      "scoreDisplayMode": "notApplicable",
       "rawValue": true
     },
     "button-name": {
@@ -1158,7 +1158,7 @@
       "title": "Buttons have an accessible name",
       "description": "When a button doesn't have an accessible name, screen readers announce it as \"button\", making it unusable for users who rely on screen readers. [Learn more](https://dequeuniversity.com/rules/axe/2.2/button-name?application=lighthouse).",
       "score": null,
-      "scoreDisplayMode": "not-applicable",
+      "scoreDisplayMode": "notApplicable",
       "rawValue": true
     },
     "bypass": {
@@ -1223,7 +1223,7 @@
       "title": "`<dl>`'s contain only properly-ordered `<dt>` and `<dd>` groups, `<script>` or `<template>` elements.",
       "description": "When definition lists are not properly marked up, screen readers may produce confusing or inaccurate output. [Learn more](https://dequeuniversity.com/rules/axe/2.2/definition-list?application=lighthouse).",
       "score": null,
-      "scoreDisplayMode": "not-applicable",
+      "scoreDisplayMode": "notApplicable",
       "rawValue": true
     },
     "dlitem": {
@@ -1231,7 +1231,7 @@
       "title": "Definition list items are wrapped in `<dl>` elements",
       "description": "Definition list items (`<dt>` and `<dd>`) must be wrapped in a parent `<dl>` element to ensure that screen readers can properly announce them. [Learn more](https://dequeuniversity.com/rules/axe/2.2/dlitem?application=lighthouse).",
       "score": null,
-      "scoreDisplayMode": "not-applicable",
+      "scoreDisplayMode": "notApplicable",
       "rawValue": true
     },
     "document-title": {
@@ -1265,7 +1265,7 @@
       "title": "`<frame>` or `<iframe>` elements have a title",
       "description": "Screen reader users rely on frame titles to describe the contents of frames. [Learn more](https://dequeuniversity.com/rules/axe/2.2/frame-title?application=lighthouse).",
       "score": null,
-      "scoreDisplayMode": "not-applicable",
+      "scoreDisplayMode": "notApplicable",
       "rawValue": true
     },
     "html-has-lang": {
@@ -1308,7 +1308,7 @@
       "title": "`<html>` element has a valid value for its `[lang]` attribute",
       "description": "Specifying a valid [BCP 47 language](https://www.w3.org/International/questions/qa-choosing-language-tags#question) helps screen readers announce text properly. [Learn more](https://dequeuniversity.com/rules/axe/2.2/valid-lang?application=lighthouse).",
       "score": null,
-      "scoreDisplayMode": "not-applicable",
+      "scoreDisplayMode": "notApplicable",
       "rawValue": true
     },
     "image-alt": {
@@ -1371,7 +1371,7 @@
       "title": "`<input type=\"image\">` elements have `[alt]` text",
       "description": "When an image is being used as an `<input>` button, providing alternative text can help screen reader users understand the purpose of the button. [Learn more](https://dequeuniversity.com/rules/axe/2.2/input-image-alt?application=lighthouse).",
       "score": null,
-      "scoreDisplayMode": "not-applicable",
+      "scoreDisplayMode": "notApplicable",
       "rawValue": true
     },
     "label": {
@@ -1435,7 +1435,7 @@
       "title": "Presentational `<table>` elements avoid using `<th>`, `<caption>` or the `[summary]` attribute.",
       "description": "A table being used for layout purposes should not include data elements, such as the th or caption elements or the summary attribute, because this can create a confusing experience for screen reader users. [Learn more](https://dequeuniversity.com/rules/axe/2.2/layout-table?application=lighthouse).",
       "score": null,
-      "scoreDisplayMode": "not-applicable",
+      "scoreDisplayMode": "notApplicable",
       "rawValue": true
     },
     "link-name": {
@@ -1491,7 +1491,7 @@
       "title": "Lists contain only `<li>` elements and script supporting elements (`<script>` and `<template>`).",
       "description": "Screen readers have a specific way of announcing lists. Ensuring proper list structure aids screen reader output. [Learn more](https://dequeuniversity.com/rules/axe/2.2/list?application=lighthouse).",
       "score": null,
-      "scoreDisplayMode": "not-applicable",
+      "scoreDisplayMode": "notApplicable",
       "rawValue": true
     },
     "listitem": {
@@ -1499,7 +1499,7 @@
       "title": "List items (`<li>`) are contained within `<ul>` or `<ol>` parent elements",
       "description": "Screen readers require list items (`<li>`) to be contained within a parent `<ul>` or `<ol>` to be announced properly. [Learn more](https://dequeuniversity.com/rules/axe/2.2/listitem?application=lighthouse).",
       "score": null,
-      "scoreDisplayMode": "not-applicable",
+      "scoreDisplayMode": "notApplicable",
       "rawValue": true
     },
     "meta-refresh": {
@@ -1507,7 +1507,7 @@
       "title": "The document does not use `<meta http-equiv=\"refresh\">`",
       "description": "Users do not expect a page to refresh automatically, and doing so will move focus back to the top of the page. This may create a frustrating or confusing experience. [Learn more](https://dequeuniversity.com/rules/axe/2.2/meta-refresh?application=lighthouse).",
       "score": null,
-      "scoreDisplayMode": "not-applicable",
+      "scoreDisplayMode": "notApplicable",
       "rawValue": true
     },
     "meta-viewport": {
@@ -1528,7 +1528,7 @@
       "title": "`<object>` elements have `[alt]` text",
       "description": "Screen readers cannot translate non-text content. Adding alt text to `<object>` elements helps screen readers convey meaning to users. [Learn more](https://dequeuniversity.com/rules/axe/2.2/object-alt?application=lighthouse).",
       "score": null,
-      "scoreDisplayMode": "not-applicable",
+      "scoreDisplayMode": "notApplicable",
       "rawValue": true
     },
     "tabindex": {
@@ -1536,7 +1536,7 @@
       "title": "No element has a `[tabindex]` value greater than 0",
       "description": "A value greater than 0 implies an explicit navigation ordering. Although technically valid, this often creates frustrating experiences for users who rely on assistive technologies. [Learn more](https://dequeuniversity.com/rules/axe/2.2/tabindex?application=lighthouse).",
       "score": null,
-      "scoreDisplayMode": "not-applicable",
+      "scoreDisplayMode": "notApplicable",
       "rawValue": true
     },
     "td-headers-attr": {
@@ -1544,7 +1544,7 @@
       "title": "Cells in a `<table>` element that use the `[headers]` attribute only refer to other cells of that same table.",
       "description": "Screen readers have features to make navigating tables easier. Ensuring `<td>` cells using the `[headers]` attribute only refer to other cells in the same table may improve the experience for screen reader users. [Learn more](https://dequeuniversity.com/rules/axe/2.2/td-headers-attr?application=lighthouse).",
       "score": null,
-      "scoreDisplayMode": "not-applicable",
+      "scoreDisplayMode": "notApplicable",
       "rawValue": true
     },
     "th-has-data-cells": {
@@ -1552,7 +1552,7 @@
       "title": "`<th>` elements and elements with `[role=\"columnheader\"/\"rowheader\"]` have data cells they describe.",
       "description": "Screen readers have features to make navigating tables easier. Ensuring table headers always refer to some set of cells may improve the experience for screen reader users. [Learn more](https://dequeuniversity.com/rules/axe/2.2/th-has-data-cells?application=lighthouse).",
       "score": null,
-      "scoreDisplayMode": "not-applicable",
+      "scoreDisplayMode": "notApplicable",
       "rawValue": true
     },
     "valid-lang": {
@@ -1560,7 +1560,7 @@
       "title": "`[lang]` attributes have a valid value",
       "description": "Specifying a valid [BCP 47 language](https://www.w3.org/International/questions/qa-choosing-language-tags#question) on elements helps ensure that text is pronounced correctly by a screen reader. [Learn more](https://dequeuniversity.com/rules/axe/2.2/valid-lang?application=lighthouse).",
       "score": null,
-      "scoreDisplayMode": "not-applicable",
+      "scoreDisplayMode": "notApplicable",
       "rawValue": true
     },
     "video-caption": {
@@ -1568,7 +1568,7 @@
       "title": "`<video>` elements contain a `<track>` element with `[kind=\"captions\"]`",
       "description": "When a video provides a caption it is easier for deaf and hearing impaired users to access its information. [Learn more](https://dequeuniversity.com/rules/axe/2.2/video-caption?application=lighthouse).",
       "score": null,
-      "scoreDisplayMode": "not-applicable",
+      "scoreDisplayMode": "notApplicable",
       "rawValue": true
     },
     "video-description": {
@@ -1576,7 +1576,7 @@
       "title": "`<video>` elements contain a `<track>` element with `[kind=\"description\"]`",
       "description": "Audio descriptions provide relevant information for videos that dialogue cannot, such as facial expressions and scenes. [Learn more](https://dequeuniversity.com/rules/axe/2.2/video-description?application=lighthouse).",
       "score": null,
-      "scoreDisplayMode": "not-applicable",
+      "scoreDisplayMode": "notApplicable",
       "rawValue": true
     },
     "custom-controls-labels": {
@@ -2708,7 +2708,7 @@
       "title": "Document has a valid `rel=canonical`",
       "description": "Canonical links suggest which URL to show in search results. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/canonical).",
       "score": null,
-      "scoreDisplayMode": "not-applicable",
+      "scoreDisplayMode": "notApplicable",
       "rawValue": true
     },
     "mobile-friendly": {
@@ -3639,6 +3639,12 @@
         "duration": 100,
         "entryType": "measure"
       },
+      {
+        "startTime": 0,
+        "name": "lh:computed:UserTimings",
+        "duration": 100,
+        "entryType": "measure"
+      },
       {
         "startTime": 0,
         "name": "lh:audit:critical-request-chains",
diff --git a/lighthouse-core/test/runner-test.js b/lighthouse-core/test/runner-test.js
index 8774274e2..c0f11d7da 100644
--- a/lighthouse-core/test/runner-test.js
+++ b/lighthouse-core/test/runner-test.js
@@ -478,7 +478,7 @@ describe('Runner', () => {
 
     return Runner.run({}, {config}).then(results => {
       assert.strictEqual(results.artifacts.ViewportDimensions.innerWidth, 412);
-      assert.strictEqual(results.artifacts.ViewportDimensions.innerHeight, 732);
+      assert.strictEqual(results.artifacts.ViewportDimensions.innerHeight, 660);
     });
   });
 
diff --git a/lighthouse-core/test/scoring-test.js b/lighthouse-core/test/scoring-test.js
index b98565dfc..723efdf6b 100644
--- a/lighthouse-core/test/scoring-test.js
+++ b/lighthouse-core/test/scoring-test.js
@@ -64,9 +64,9 @@ describe('ReportScoring', () => {
 
     it('should weight notApplicable audits as 0', () => {
       const resultsByAuditId = {
-        'my-boolean-audit': {score: 1, extendedInfo: {}, scoreDisplayMode: 'not-applicable'},
+        'my-boolean-audit': {score: 1, extendedInfo: {}, scoreDisplayMode: 'notApplicable'},
         'my-scored-audit': {score: 1},
-        'my-failed-audit': {score: 0.2, scoreDisplayMode: 'not-applicable'},
+        'my-failed-audit': {score: 0.2, scoreDisplayMode: 'notApplicable'},
         'my-boolean-failed-audit': {score: 0},
       };