Repository: GoogleChrome/lighthouse (js)

Issue:
Add type checking to smokehouse
Smoke test harness isn't type checked. At least, this file isn't: `lighthouse-cli/test/smokehouse/smokehouse.js`

Pull request:
tests: add type checking to cli/tests
Fixes #6852.

This just adds type checking for `lighthouse/cli/tests`. I attempted to also do `lighthouse-core/test`, but 1) it's significantly more work, so better to be done in smaller pieces later and 2) simply removing that folder from the `tsconfig.json` ignore list caused an OOM error :)

Patch:
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/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/report/html/report-styles.css b/lighthouse-core/report/html/report-styles.css
index 5d88e5c96..07b8cb9ee 100644
--- a/lighthouse-core/report/html/report-styles.css
+++ b/lighthouse-core/report/html/report-styles.css
@@ -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/sample_v2_round_trip.json b/proto/sample_v2_round_trip.json
index dd98e9e4a..83b4decae 100644
--- a/proto/sample_v2_round_trip.json
+++ b/proto/sample_v2_round_trip.json
@@ -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, 
@@ -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, 
@@ -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/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/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/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/run-smoke.js b/lighthouse-cli/test/smokehouse/run-smoke.js
index 12f2cb86d..0f3df089f 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/';
 
@@ -88,18 +89,15 @@ const SMOKETESTS = [{
 
 /**
  * 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 a4aa46f66..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();
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-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/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/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json
index ea41f930c..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,
@@ -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",