Repository: GoogleChrome/lighthouse
Pull request #7339 (2019-02-27 20:15:37)

cli: write report in runLighthouse before quitting Chrome
while we wait for the fix for https://crbug.com/936272 and/or in chrome-launcher, this change at least writes the report to disk before waiting for Chrome to close.

Also removes the `unhandledRejection` listener when done (fixing the `MaxListenersExceededWarning` for @Hoten) and converts`runLighthouse` to use async/await, as the ancient promise business in there was inscrutable. I think there's still more to improve here, both bikeshedding and file organization (e.g. `handleError` should maybe be in `bin.js`, not in here); happy for more ideas or leaving all that for yet another day.


Patch:
diff --git a/docs/throttling.md b/docs/throttling.md
index a09033894..41981648d 100644
--- a/docs/throttling.md
+++ b/docs/throttling.md
@@ -12,7 +12,7 @@ These exact figures are used as [Lighthouse's throttling default](https://github
 ## Throttling basics
 
 1. Simulated throttling, which Lighthouse uses by default, is computed throttling _after a trace has been recorded_ which makes it very fast and deterministic. However, due to the imperfect nature of predicting alternate execution paths, there is inherent inaccuracy that is summarized in this doc: [Lighthouse Metric Variability and Accuracy](https://docs.google.com/document/d/1BqtL-nG53rxWOI5RO0pItSRPowZVnYJ_gBEQCJ5EeUE/edit). The TLDR: while it's roughly as accurate or better than DevTools throttling for most sites, it suffers from edge cases and a deep investigation to performance should use _Packet-level_ throttling tools.
-1. The DevTools network throttling, which Lighthouse uses for `throttlingMethod='devtools'`, is implemented within Chrome at the _request-level_. As a result, it has some downsides that are now summarized in this doc: [Network Throttling & Chrome - status](https://docs.google.com/document/d/1TwWLaLAfnBfbk5_ZzpGXegPapCIfyzT4MWuZgspKUAQ/edit). The TLDR: while it's a [decent approximation](https://docs.google.com/document/d/1uS9SH1KpVH31JAmf-iIZ61VazwAF9MrCVwETshBC4UQ/edit), it's not a sufficient model of a slow connection. The [multipliers used in Lighthouse](https://github.com/GoogleChrome/lighthouse/blob/3be483287a530fb560c843b7299ef9cfe91ce1cc/lighthouse-core/lib/emulation.js#L33-L39) attempt to correct for the differences.
+1. The DevTools network throttling, which Lighthouse uses for `throttlingMethod='devtools'`, is implemented within Chrome at the _request-level_. As a result, it has some downsides that are now summarized in this doc: [Network Throttling & Chrome - status](https://docs.google.com/document/d/1TwWLaLAfnBfbk5_ZzpGXegPapCIfyzT4MWuZgspKUAQ/edit). The TLDR: while it's a [decent approximation](https://docs.google.com/document/d/10lfVdS1iDWCRKQXPfbxEn4Or99D64mvNlugP1AQuFlE/edit), it's not a sufficient model of a slow connection. The [multipliers used in Lighthouse](https://github.com/GoogleChrome/lighthouse/blob/3be483287a530fb560c843b7299ef9cfe91ce1cc/lighthouse-core/lib/emulation.js#L33-L39) attempt to correct for the differences.
 1. _Proxy-level_ throttling tools do not affect UDP data, so they're not recommended.
 1. _Packet-level_ throttling tools are able to make the most accurate network simulation.
 
diff --git a/lighthouse-cli/run.js b/lighthouse-cli/run.js
index 3247b6e19..10cbf8f6e 100644
--- a/lighthouse-cli/run.js
+++ b/lighthouse-cli/run.js
@@ -62,53 +62,57 @@ function getDebuggableChrome(flags) {
   });
 }
 
+/** @return {never} */
 function showConnectionError() {
   console.error('Unable to connect to Chrome');
-  process.exit(_RUNTIME_ERROR_CODE);
+  return process.exit(_RUNTIME_ERROR_CODE);
 }
 
+/** @return {never} */
 function showProtocolTimeoutError() {
   console.error('Debugger protocol timed out while connecting to Chrome.');
-  process.exit(_PROTOCOL_TIMEOUT_EXIT_CODE);
+  return process.exit(_PROTOCOL_TIMEOUT_EXIT_CODE);
 }
 
-/** @param {LH.LighthouseError} err */
+/** @param {LH.LighthouseError} err @return {never} */
 function showPageHungError(err) {
   console.error('Page hung:', err.friendlyMessage);
-  process.exit(_PAGE_HUNG_EXIT_CODE);
+  return process.exit(_PAGE_HUNG_EXIT_CODE);
 }
 
-/** @param {LH.LighthouseError} err */
+/** @param {LH.LighthouseError} err @return {never} */
 function showInsecureDocumentRequestError(err) {
   console.error('Insecure document request:', err.friendlyMessage);
-  process.exit(_INSECURE_DOCUMENT_REQUEST_EXIT_CODE);
+  return process.exit(_INSECURE_DOCUMENT_REQUEST_EXIT_CODE);
 }
 
 /**
  * @param {LH.LighthouseError} err
+ * @return {never}
  */
 function showRuntimeError(err) {
   console.error('Runtime error encountered:', err.friendlyMessage || err.message);
   if (err.stack) {
     console.error(err.stack);
   }
-  process.exit(_RUNTIME_ERROR_CODE);
+  return process.exit(_RUNTIME_ERROR_CODE);
 }
 
 /**
  * @param {LH.LighthouseError} err
+ * @return {never}
  */
 function handleError(err) {
   if (err.code === 'ECONNREFUSED') {
-    showConnectionError();
+    return showConnectionError();
   } else if (err.code === 'CRI_TIMEOUT') {
-    showProtocolTimeoutError();
+    return showProtocolTimeoutError();
   } else if (err.code === 'PAGE_HUNG') {
-    showPageHungError(err);
+    return showPageHungError(err);
   } else if (err.code === 'INSECURE_DOCUMENT_REQUEST') {
-    showInsecureDocumentRequestError(err);
+    return showInsecureDocumentRequestError(err);
   } else {
-    showRuntimeError(err);
+    return showRuntimeError(err);
   }
 }
 
@@ -159,66 +163,63 @@ async function saveResults(runnerResult, flags) {
   }
 }
 
+/**
+ * Attempt to kill the launched Chrome, if defined.
+ * @param {ChromeLauncher.LaunchedChrome=} launchedChrome
+ * @return {Promise<void>}
+ */
+async function potentiallyKillChrome(launchedChrome) {
+  if (!launchedChrome) return;
+
+  return Promise.race([
+    launchedChrome.kill(),
+    new Promise((_, reject) => setTimeout(reject, 5000, 'Timed out.')),
+  ]).catch(err => {
+    throw new Error(`Couldn't quit Chrome process. ${err}`);
+  });
+}
+
 /**
  * @param {string} url
  * @param {LH.CliFlags} flags
  * @param {LH.Config.Json|undefined} config
- * @return {Promise<LH.RunnerResult|void>}
+ * @return {Promise<LH.RunnerResult|undefined>}
  */
-function runLighthouse(url, flags, config) {
-  /** @type {ChromeLauncher.LaunchedChrome|undefined} */
-  let launchedChrome;
-  const shouldGather = flags.gatherMode || flags.gatherMode === flags.auditMode;
-  let chromeP = Promise.resolve();
-
-  process.on('unhandledRejection', async (reason) => {
+async function runLighthouse(url, flags, config) {
+  /** @param {any} reason */
+  async function handleTheUnhandled(reason) {
     process.stderr.write(`Unhandled Rejection. Reason: ${reason}\n`);
-    try {
-      await potentiallyKillChrome();
-    } catch (err) {
-      process.stderr.write(`Couldn't quit Chrome process. ${err.toString()}\n`);
-    }
+    await potentiallyKillChrome(launchedChrome).catch(() => {});
     setTimeout(_ => {
       process.exit(1);
     }, 100);
-  });
-
-  if (shouldGather) {
-    chromeP = chromeP.then(_ =>
-      getDebuggableChrome(flags).then(launchedChromeInstance => {
-        launchedChrome = launchedChromeInstance;
-        flags.port = launchedChrome.port;
-      })
-    );
   }
+  process.on('unhandledRejection', handleTheUnhandled);
 
-  const resultsP = chromeP.then(_ => {
-    return lighthouse(url, flags, config).then(runnerResult => {
-      return potentiallyKillChrome().then(_ => runnerResult);
-    }).then(async runnerResult => {
-      // If in gatherMode only, there will be no runnerResult.
-      if (runnerResult) {
-        await saveResults(runnerResult, flags);
-      }
+  /** @type {ChromeLauncher.LaunchedChrome|undefined} */
+  let launchedChrome;
 
-      return runnerResult;
-    });
-  });
+  try {
+    const shouldGather = flags.gatherMode || flags.gatherMode === flags.auditMode;
+    if (shouldGather) {
+      launchedChrome = await getDebuggableChrome(flags);
+      flags.port = launchedChrome.port;
+    }
 
-  return resultsP.catch(err => {
-    return Promise.resolve()
-      .then(_ => potentiallyKillChrome())
-      .then(_ => handleError(err));
-  });
+    const runnerResult = await lighthouse(url, flags, config);
 
-  /**
-   * @return {Promise<{}>}
-   */
-  function potentiallyKillChrome() {
-    if (launchedChrome !== undefined) {
-      return launchedChrome.kill();
+    // If in gatherMode only, there will be no runnerResult.
+    if (runnerResult) {
+      await saveResults(runnerResult, flags);
     }
-    return Promise.resolve({});
+
+    await potentiallyKillChrome(launchedChrome);
+    process.removeListener('unhandledRejection', handleTheUnhandled);
+
+    return runnerResult;
+  } catch (err) {
+    await potentiallyKillChrome(launchedChrome).catch(() => {});
+    handleError(err);
   }
 }
 
diff --git a/lighthouse-core/audits/bootup-time.js b/lighthouse-core/audits/bootup-time.js
index c709e5e03..c2cc645b6 100644
--- a/lighthouse-core/audits/bootup-time.js
+++ b/lighthouse-core/audits/bootup-time.js
@@ -22,7 +22,7 @@ const UIStrings = {
     'You may find delivering smaller JS payloads helps with this. [Learn ' +
     'more](https://developers.google.com/web/tools/lighthouse/audits/bootup).',
   /** Label for the total time column in a data table; entries will be the number of milliseconds spent executing per resource loaded by the page. */
-  columnTotal: 'Total',
+  columnTotal: 'Total CPU Time',
   /** Label for a time column in a data table; entries will be the number of milliseconds spent evaluating script for every script loaded by the page. */
   columnScriptEval: 'Script Evaluation',
   /** Label for a time column in a data table; entries will be the number of milliseconds spent parsing script files for every script loaded by the page. */
@@ -89,8 +89,9 @@ class BootupTime extends Audit {
     for (const task of tasks) {
       const jsURL = task.attributableURLs.find(url => jsURLs.has(url));
       const fallbackURL = task.attributableURLs[0];
-      const attributableURL = jsURL || fallbackURL;
-      if (!attributableURL || attributableURL === 'about:blank') continue;
+      let attributableURL = jsURL || fallbackURL;
+      // If we can't find what URL was responsible for this execution, just attribute it to the root page.
+      if (!attributableURL || attributableURL === 'about:blank') attributableURL = 'Other';
 
       const timingByGroupId = result.get(attributableURL) || {};
       const originalTime = timingByGroupId[task.group.id] || 0;
@@ -122,27 +123,27 @@ class BootupTime extends Audit {
     let totalBootupTime = 0;
     const results = Array.from(executionTimings)
       .map(([url, timingByGroupId]) => {
-        // Add up the totalBootupTime for all the taskGroups
-        let bootupTimeForURL = 0;
+        // Add up the totalExecutionTime for all the taskGroups
+        let totalExecutionTimeForURL = 0;
         for (const [groupId, timespanMs] of Object.entries(timingByGroupId)) {
           timingByGroupId[groupId] = timespanMs * multiplier;
-          bootupTimeForURL += timespanMs * multiplier;
-        }
-
-        // Add up all the execution time of shown URLs
-        if (bootupTimeForURL >= context.options.thresholdInMs) {
-          totalBootupTime += bootupTimeForURL;
+          totalExecutionTimeForURL += timespanMs * multiplier;
         }
 
         const scriptingTotal = timingByGroupId[taskGroups.scriptEvaluation.id] || 0;
         const parseCompileTotal = timingByGroupId[taskGroups.scriptParseCompile.id] || 0;
 
+        // Add up all the JavaScript time of shown URLs
+        if (totalExecutionTimeForURL >= context.options.thresholdInMs) {
+          totalBootupTime += scriptingTotal + parseCompileTotal;
+        }
+
         hadExcessiveChromeExtension = hadExcessiveChromeExtension ||
           (url.startsWith('chrome-extension:') && scriptingTotal > 100);
 
         return {
           url: url,
-          total: bootupTimeForURL,
+          total: totalExecutionTimeForURL,
           // Highlight the JavaScript task costs
           scripting: scriptingTotal,
           scriptParseCompile: parseCompileTotal,
diff --git a/lighthouse-core/audits/metrics/max-potential-fid.js b/lighthouse-core/audits/metrics/max-potential-fid.js
index 795798c89..29bbbfc43 100644
--- a/lighthouse-core/audits/metrics/max-potential-fid.js
+++ b/lighthouse-core/audits/metrics/max-potential-fid.js
@@ -12,7 +12,7 @@ const i18n = require('../../lib/i18n/i18n');
 const UIStrings = {
   /** The name of the metric "Maximum Potential First Input Delay" that marks the maximum 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: 'Max Potential FID',
-  /** 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 of the Maximum Potential First Input Delay metric that marks the maximum estimated time between the page receiving input (a user clicking, tapping, or typing) and the page responding. 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 potential First Input Delay that your users could experience is the ' +
       'duration, in milliseconds, of the longest task.',
 };
diff --git a/lighthouse-core/audits/seo/font-size.js b/lighthouse-core/audits/seo/font-size.js
index d48887056..d33826f2b 100644
--- a/lighthouse-core/audits/seo/font-size.js
+++ b/lighthouse-core/audits/seo/font-size.js
@@ -10,7 +10,7 @@
 const URL = require('../../lib/url-shim');
 const i18n = require('../../lib/i18n/i18n.js');
 const Audit = require('../audit');
-const ViewportAudit = require('../viewport');
+const ComputedViewportMeta = require('../../computed/viewport-meta.js');
 const MINIMAL_PERCENTAGE_OF_LEGIBLE_TEXT = 60;
 
 const UIStrings = {
@@ -213,11 +213,12 @@ class FontSize extends Audit {
 
   /**
    * @param {LH.Artifacts} artifacts
-   * @return {LH.Audit.Product}
+   * @param {LH.Audit.Context} context
+   * @return {Promise<LH.Audit.Product>}
    */
-  static audit(artifacts) {
-    const hasViewportSet = ViewportAudit.audit(artifacts).rawValue;
-    if (!hasViewportSet) {
+  static async audit(artifacts, context) {
+    const viewportMeta = await ComputedViewportMeta.request(artifacts, context);
+    if (!viewportMeta.isMobileOptimized) {
       return {
         rawValue: false,
         explanation: str_(UIStrings.explanationViewport),
diff --git a/lighthouse-core/audits/seo/tap-targets.js b/lighthouse-core/audits/seo/tap-targets.js
index 08b0c387d..73c9fea96 100644
--- a/lighthouse-core/audits/seo/tap-targets.js
+++ b/lighthouse-core/audits/seo/tap-targets.js
@@ -10,7 +10,7 @@
  * no other tap target that's too close so that the user might accidentally tap on.
  */
 const Audit = require('../audit');
-const ViewportAudit = require('../viewport');
+const ComputedViewportMeta = require('../../computed/viewport-meta.js');
 const {
   rectsTouchOrOverlap,
   getRectOverlapArea,
@@ -270,11 +270,12 @@ class TapTargets extends Audit {
 
   /**
    * @param {LH.Artifacts} artifacts
-   * @return {LH.Audit.Product}
+   * @param {LH.Audit.Context} context
+   * @return {Promise<LH.Audit.Product>}
    */
-  static audit(artifacts) {
-    const hasViewportSet = ViewportAudit.audit(artifacts).rawValue;
-    if (!hasViewportSet) {
+  static async audit(artifacts, context) {
+    const viewportMeta = await ComputedViewportMeta.request(artifacts, context);
+    if (!viewportMeta.isMobileOptimized) {
       return {
         rawValue: false,
         explanation: str_(UIStrings.explanationViewportMetaNotOptimized),
diff --git a/lighthouse-core/audits/viewport.js b/lighthouse-core/audits/viewport.js
index 90216c10f..29a59871f 100644
--- a/lighthouse-core/audits/viewport.js
+++ b/lighthouse-core/audits/viewport.js
@@ -6,7 +6,7 @@
 'use strict';
 
 const Audit = require('./audit');
-const Parser = require('metaviewport-parser');
+const ComputedViewportMeta = require('../computed/viewport-meta.js');
 
 class Viewport extends Audit {
   /**
@@ -26,33 +26,22 @@ class Viewport extends Audit {
 
   /**
    * @param {LH.Artifacts} artifacts
-   * @return {LH.Audit.Product}
+   * @param {LH.Audit.Context} context
+   * @return {Promise<LH.Audit.Product>}
    */
-  static audit(artifacts) {
-    const viewportMeta = artifacts.MetaElements.find(meta => meta.name === 'viewport');
-    if (!viewportMeta) {
+  static async audit(artifacts, context) {
+    const viewportMeta = await ComputedViewportMeta.request(artifacts, context);
+
+    if (!viewportMeta.hasViewportTag) {
       return {
-        explanation: 'No viewport meta tag found',
         rawValue: false,
+        explanation: 'No viewport meta tag found',
       };
     }
 
-    const warnings = [];
-    const parsedProps = Parser.parseMetaViewPortContent(viewportMeta.content || '');
-
-    if (Object.keys(parsedProps.unknownProperties).length) {
-      warnings.push(`Invalid properties found: ${JSON.stringify(parsedProps.unknownProperties)}`);
-    }
-    if (Object.keys(parsedProps.invalidValues).length) {
-      warnings.push(`Invalid values found: ${JSON.stringify(parsedProps.invalidValues)}`);
-    }
-
-    const viewportProps = parsedProps.validProperties;
-    const hasMobileViewport = viewportProps.width || viewportProps['initial-scale'];
-
     return {
-      rawValue: !!hasMobileViewport,
-      warnings,
+      rawValue: viewportMeta.isMobileOptimized,
+      warnings: viewportMeta.parserWarnings,
     };
   }
 }
diff --git a/lighthouse-core/computed/main-thread-tasks.js b/lighthouse-core/computed/main-thread-tasks.js
index 5b18eb33b..e66eab62c 100644
--- a/lighthouse-core/computed/main-thread-tasks.js
+++ b/lighthouse-core/computed/main-thread-tasks.js
@@ -41,6 +41,8 @@ const TraceOfTab = require('./trace-of-tab.js');
  * @prop {TaskGroup} group
  */
 
+/** @typedef {{timers: Map<string, TaskNode>}} PriorTaskData */
+
 class MainThreadTasks {
   /**
    * @param {LH.TraceEvent} event
@@ -71,15 +73,24 @@ class MainThreadTasks {
 
   /**
    * @param {LH.TraceEvent[]} mainThreadEvents
+   * @param {PriorTaskData} priorTaskData
    * @return {TaskNode[]}
    */
-  static _createTasksFromEvents(mainThreadEvents) {
+  static _createTasksFromEvents(mainThreadEvents, priorTaskData) {
     /** @type {TaskNode[]} */
     const tasks = [];
     /** @type {TaskNode|undefined} */
     let currentTask;
 
     for (const event of mainThreadEvents) {
+      // Save the timer data, TimerInstall events are instant events `ph === 'I'` so process them first.
+      if (event.name === 'TimerInstall' && currentTask) {
+        /** @type {string} */
+        // @ts-ignore - timerId exists when name is TimerInstall
+        const timerId = event.args.data.timerId;
+        priorTaskData.timers.set(timerId, currentTask);
+      }
+
       // Only look at X (Complete), B (Begin), and E (End) events as they have most data
       if (event.ph !== 'X' && event.ph !== 'B' && event.ph !== 'E') continue;
 
@@ -141,11 +152,13 @@ class MainThreadTasks {
   /**
    * @param {TaskNode} task
    * @param {string[]} parentURLs
+   * @param {PriorTaskData} priorTaskData
    */
-  static _computeRecursiveAttributableURLs(task, parentURLs) {
+  static _computeRecursiveAttributableURLs(task, parentURLs, priorTaskData) {
     const argsData = task.event.args.data || {};
     const stackFrameURLs = (argsData.stackTrace || []).map(entry => entry.url);
 
+    /** @type {Array<string|undefined>} */
     let taskURLs = [];
     switch (task.event.name) {
       /**
@@ -161,6 +174,15 @@ class MainThreadTasks {
       case 'v8.compileModule':
         taskURLs = [task.event.args.fileName].concat(stackFrameURLs);
         break;
+      case 'TimerFire': {
+        /** @type {string} */
+        // @ts-ignore - timerId exists when name is TimerFire
+        const timerId = task.event.args.data.timerId;
+        const timerInstallerTaskNode = priorTaskData.timers.get(timerId);
+        if (!timerInstallerTaskNode) break;
+        taskURLs = timerInstallerTaskNode.attributableURLs.concat(stackFrameURLs);
+        break;
+      }
       default:
         taskURLs = stackFrameURLs;
         break;
@@ -178,7 +200,7 @@ class MainThreadTasks {
 
     task.attributableURLs = attributableURLs;
     task.children.forEach(child =>
-      MainThreadTasks._computeRecursiveAttributableURLs(child, attributableURLs));
+      MainThreadTasks._computeRecursiveAttributableURLs(child, attributableURLs, priorTaskData));
   }
 
   /**
@@ -196,14 +218,16 @@ class MainThreadTasks {
    * @return {TaskNode[]}
    */
   static getMainThreadTasks(traceEvents) {
-    const tasks = MainThreadTasks._createTasksFromEvents(traceEvents);
+    const timers = new Map();
+    const priorTaskData = {timers};
+    const tasks = MainThreadTasks._createTasksFromEvents(traceEvents, priorTaskData);
 
     // Compute the recursive properties we couldn't compute earlier, starting at the toplevel tasks
     for (const task of tasks) {
       if (task.parent) continue;
 
       MainThreadTasks._computeRecursiveSelfTime(task);
-      MainThreadTasks._computeRecursiveAttributableURLs(task, []);
+      MainThreadTasks._computeRecursiveAttributableURLs(task, [], priorTaskData);
       MainThreadTasks._computeRecursiveTaskGroup(task);
     }
 
diff --git a/lighthouse-core/computed/viewport-meta.js b/lighthouse-core/computed/viewport-meta.js
new file mode 100644
index 000000000..7bf2703aa
--- /dev/null
+++ b/lighthouse-core/computed/viewport-meta.js
@@ -0,0 +1,56 @@
+/**
+ * @license Copyright 2019 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 Parser = require('metaviewport-parser');
+
+const makeComputedArtifact = require('./computed-artifact.js');
+
+class ViewportMeta {
+  /**
+   * @param {{MetaElements: LH.GathererArtifacts['MetaElements']}} artifacts
+   * @return {Promise<ViewportMetaResult>}
+  */
+  static async compute_({MetaElements}) {
+    const viewportMeta = MetaElements.find(meta => meta.name === 'viewport');
+
+    if (!viewportMeta) {
+      return {
+        hasViewportTag: false,
+        isMobileOptimized: false,
+        parserWarnings: [],
+      };
+    }
+
+    const warnings = [];
+    const parsedProps = Parser.parseMetaViewPortContent(viewportMeta.content || '');
+
+    if (Object.keys(parsedProps.unknownProperties).length) {
+      warnings.push(`Invalid properties found: ${JSON.stringify(parsedProps.unknownProperties)}`);
+    }
+    if (Object.keys(parsedProps.invalidValues).length) {
+      warnings.push(`Invalid values found: ${JSON.stringify(parsedProps.invalidValues)}`);
+    }
+
+    const viewportProps = parsedProps.validProperties;
+    const isMobileOptimized = Boolean(viewportProps.width || viewportProps['initial-scale']);
+
+    return {
+      hasViewportTag: true,
+      isMobileOptimized,
+      parserWarnings: warnings,
+    };
+  }
+}
+
+module.exports = makeComputedArtifact(ViewportMeta);
+
+/**
+ * @typedef {object} ViewportMetaResult
+ * @property {boolean} hasViewportTag Whether the page has any viewport tag.
+ * @property {boolean} isMobileOptimized Whether the viewport tag is optimized for mobile screens.
+ * @property {Array<string>} parserWarnings Warnings if the parser encountered invalid content in the viewport tag.
+ */
diff --git a/lighthouse-core/config/constants.js b/lighthouse-core/config/constants.js
index 3c4f21fb2..85c36cd31 100644
--- a/lighthouse-core/config/constants.js
+++ b/lighthouse-core/config/constants.js
@@ -8,7 +8,8 @@
 /**
  * Adjustments needed for DevTools network throttling to simulate
  * more realistic network conditions.
- * See: crbug.com/721112
+ * @see https://crbug.com/721112
+ * @see https://docs.google.com/document/d/10lfVdS1iDWCRKQXPfbxEn4Or99D64mvNlugP1AQuFlE/edit
  */
 const DEVTOOLS_RTT_ADJUSTMENT_FACTOR = 3.75;
 const DEVTOOLS_THROUGHPUT_ADJUSTMENT_FACTOR = 0.9;
diff --git a/lighthouse-core/lib/i18n/en-US.json b/lighthouse-core/lib/i18n/en-US.json
index 86bddc9be..dd8d1eebe 100644
--- a/lighthouse-core/lib/i18n/en-US.json
+++ b/lighthouse-core/lib/i18n/en-US.json
@@ -436,7 +436,7 @@
     "description": "Label for a time column in a data table; entries will be the number of milliseconds spent parsing script files for every script loaded by the page."
   },
   "lighthouse-core/audits/bootup-time.js | columnTotal": {
-    "message": "Total",
+    "message": "Total CPU Time",
     "description": "Label for the total time column in a data table; entries will be the number of milliseconds spent executing per resource loaded by the page."
   },
   "lighthouse-core/audits/bootup-time.js | description": {
@@ -713,7 +713,7 @@
   },
   "lighthouse-core/audits/metrics/max-potential-fid.js | description": {
     "message": "The potential First Input Delay that your users could experience is the duration, in milliseconds, of the longest task.",
-    "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."
+    "description": "Description of the Maximum Potential First Input Delay metric that marks the maximum estimated time between the page receiving input (a user clicking, tapping, or typing) and the page responding. 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/max-potential-fid.js | title": {
     "message": "Max Potential FID",
diff --git a/proto/sample_v2_round_trip.json b/proto/sample_v2_round_trip.json
index 0b6944e44..4df34ee9e 100644
--- a/proto/sample_v2_round_trip.json
+++ b/proto/sample_v2_round_trip.json
@@ -89,7 +89,7 @@
                         "granularity": 1.0, 
                         "itemType": "ms", 
                         "key": "total", 
-                        "text": "Total"
+                        "text": "Total CPU Time"
                     }, 
                     {
                         "granularity": 1.0, 
@@ -111,6 +111,12 @@
                         "total": 966.9800000000001, 
                         "url": "http://localhost:10200/dobetterweb/dbw_tester.html"
                     }, 
+                    {
+                        "scriptParseCompile": 0.092, 
+                        "scripting": 17.790999999999997, 
+                        "total": 391.76699999999977, 
+                        "url": "Other"
+                    }, 
                     {
                         "scriptParseCompile": 1.674, 
                         "scripting": 89.50799999999998, 
@@ -125,7 +131,7 @@
                     }
                 ], 
                 "summary": {
-                    "wastedMs": 1150.932
+                    "wastedMs": 1150.573
                 }, 
                 "type": "table"
             }, 
@@ -3805,6 +3811,12 @@
                 "name": "lh:audit:viewport", 
                 "startTime": 0.0
             }, 
+            {
+                "duration": 100.0, 
+                "entryType": "measure", 
+                "name": "lh:computed:ViewportMeta", 
+                "startTime": 0.0
+            }, 
             {
                 "duration": 100.0, 
                 "entryType": "measure", 


Test patch:
diff --git a/lighthouse-cli/test/fixtures/tricky-main-thread-consumer.js b/lighthouse-cli/test/fixtures/tricky-main-thread-consumer.js
new file mode 100644
index 000000000..f233672fe
--- /dev/null
+++ b/lighthouse-cli/test/fixtures/tricky-main-thread-consumer.js
@@ -0,0 +1,20 @@
+/**
+ * @license Copyright 2019 Google Inc. All Rights Reserved.
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
+ */
+'use strict';
+
+/* eslint-disable */
+
+if (window.location.search.includes('setTimeout')) {
+  window.library.setTimeout(() => {
+    window.library.stall(3050);
+  }, 0);
+}
+
+if (window.location.search.includes('fetch')) {
+  window.library.fetch('http://localhost:10200/tricky-main-thread.html').then(() => {
+    window.library.stall(3050);
+  });
+}
diff --git a/lighthouse-cli/test/fixtures/tricky-main-thread-library.js b/lighthouse-cli/test/fixtures/tricky-main-thread-library.js
new file mode 100644
index 000000000..a8cc4a3dd
--- /dev/null
+++ b/lighthouse-cli/test/fixtures/tricky-main-thread-library.js
@@ -0,0 +1,37 @@
+/**
+ * @license Copyright 2019 Google Inc. All Rights Reserved.
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
+ */
+'use strict';
+
+/* eslint-disable */
+
+window.library = {};
+window.library.setTimeout = (fn, time) => {
+  setTimeout(() => {
+    window.library.stall(50);
+    console.log('Custom timeout hook');
+    fn();
+  }, time);
+};
+
+window.library.fetch = (...args) => {
+  console.log('Custom fetch hook 1');
+  return fetch(...args).then(response => {
+    console.log('Custom fetch hook 2');
+    window.library.stall(50);
+    return response;
+  });
+};
+
+/**
+ * Stalls the main thread for timeInMs
+ */
+window.library.stall = function(timeInMs) {
+  const start = performance.now();
+  while (performance.now() - start < timeInMs) {
+    for (let i = 0; i < 1000000; i++) ;
+  }
+};
+
diff --git a/lighthouse-cli/test/fixtures/tricky-main-thread.html b/lighthouse-cli/test/fixtures/tricky-main-thread.html
new file mode 100644
index 000000000..6238a0df5
--- /dev/null
+++ b/lighthouse-cli/test/fixtures/tricky-main-thread.html
@@ -0,0 +1,8 @@
+<html>
+<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
+<body>
+  Just try and figure out my scripts
+  <script src="tricky-main-thread-library.js"></script>
+  <script src="tricky-main-thread-consumer.js"></script>
+</body>
+</html>
diff --git a/lighthouse-cli/test/smokehouse/byte-efficiency/expectations.js b/lighthouse-cli/test/smokehouse/byte-efficiency/expectations.js
index 2156f2e9f..2c4cc4bdb 100644
--- a/lighthouse-cli/test/smokehouse/byte-efficiency/expectations.js
+++ b/lighthouse-cli/test/smokehouse/byte-efficiency/expectations.js
@@ -26,9 +26,13 @@ module.exports = [
         details: {
           overallSavingsBytes: '>45000',
           overallSavingsMs: '>500',
-          items: {
-            length: 1,
-          },
+          items: [
+            {
+              url: 'http://localhost:10200/byte-efficiency/script.js',
+              wastedBytes: '46481 +/- 100',
+              wastedPercent: '87 +/- 5',
+            },
+          ],
         },
       },
       'unused-css-rules': {
diff --git a/lighthouse-cli/test/smokehouse/smokehouse.js b/lighthouse-cli/test/smokehouse/smokehouse.js
index d7f5ff316..be6534d35 100755
--- a/lighthouse-cli/test/smokehouse/smokehouse.js
+++ b/lighthouse-cli/test/smokehouse/smokehouse.js
@@ -34,8 +34,12 @@ const PROTOCOL_TIMEOUT_EXIT_CODE = 67;
 const PAGE_HUNG_EXIT_CODE = 68;
 const INSECURE_DOCUMENT_REQUEST_EXIT_CODE = 69;
 const RETRIES = 3;
-const NUMERICAL_EXPECTATION_REGEXP = /^(<=?|>=?)((\d|\.)+)$/;
 const VERBOSE = Boolean(process.env.LH_SMOKE_VERBOSE);
+const NUMBER_REGEXP = /(?:\d|\.)+/.source;
+const OPS_REGEXP = /<=?|>=?|\+\/-/.source;
+// An optional number, optional whitespace, an operator, optional whitespace, a number.
+const NUMERICAL_EXPECTATION_REGEXP =
+  new RegExp(`^(${NUMBER_REGEXP})?\\s*(${OPS_REGEXP})\\s*(${NUMBER_REGEXP})$`);
 
 /**
  * Attempt to resolve a path locally. If this fails, attempts to locate the path
@@ -149,17 +153,18 @@ function runLighthouse(url, configPath, isDebug) {
 function matchesExpectation(actual, expected) {
   if (typeof actual === 'number' && NUMERICAL_EXPECTATION_REGEXP.test(expected)) {
     const parts = expected.match(NUMERICAL_EXPECTATION_REGEXP);
-    const operator = parts[1];
-    const number = parseFloat(parts[2]);
+    const [, prefixNumber, operator, postfixNumber] = parts;
     switch (operator) {
       case '>':
-        return actual > number;
+        return actual > postfixNumber;
       case '>=':
-        return actual >= number;
+        return actual >= postfixNumber;
       case '<':
-        return actual < number;
+        return actual < postfixNumber;
       case '<=':
-        return actual <= number;
+        return actual <= postfixNumber;
+      case '+/-':
+        return Math.abs(actual - prefixNumber) <= postfixNumber;
       default:
         throw new Error(`unexpected operator ${operator}`);
     }
diff --git a/lighthouse-cli/test/smokehouse/tricky-metrics/expectations.js b/lighthouse-cli/test/smokehouse/tricky-metrics/expectations.js
index 07d165fcc..8a67e4228 100644
--- a/lighthouse-cli/test/smokehouse/tricky-metrics/expectations.js
+++ b/lighthouse-cli/test/smokehouse/tricky-metrics/expectations.js
@@ -6,7 +6,7 @@
 'use strict';
 
 /**
- * Expected Lighthouse audit values for tricky TTI tests
+ * Expected Lighthouse audit values for tricky metrics tests
  */
 module.exports = [
   {
@@ -32,4 +32,37 @@ module.exports = [
       },
     },
   },
+  {
+    requestedUrl: 'http://localhost:10200/tricky-main-thread.html?setTimeout',
+    finalUrl: 'http://localhost:10200/tricky-main-thread.html?setTimeout',
+    audits: {
+      'bootup-time': {
+        details: {
+          items: {
+            0: {
+              url: /main-thread-consumer/,
+              scripting: '>1000',
+            },
+          },
+        },
+      },
+    },
+  },
+  {
+    requestedUrl: 'http://localhost:10200/tricky-main-thread.html?fetch',
+    finalUrl: 'http://localhost:10200/tricky-main-thread.html?fetch',
+    audits: {
+      'bootup-time': {
+        details: {
+          items: {
+            0: {
+              // TODO: requires async stacks, https://github.com/GoogleChrome/lighthouse/pull/5504
+              // url: /main-thread-consumer/,
+              scripting: '>1000',
+            },
+          },
+        },
+      },
+    },
+  },
 ];
diff --git a/lighthouse-core/test/audits/bootup-time-test.js b/lighthouse-core/test/audits/bootup-time-test.js
index ca240f3ab..a91cb3be5 100644
--- a/lighthouse-core/test/audits/bootup-time-test.js
+++ b/lighthouse-core/test/audits/bootup-time-test.js
@@ -32,12 +32,13 @@ describe('Performance: bootup-time audit', () => {
 
     return BootupTime.audit(artifacts, {options: auditOptions, computedCache}).then(output => {
       assert.deepEqual(roundedValueOf(output, 'https://pwa.rocks/script.js'), {scripting: 31.8, scriptParseCompile: 1.3, total: 36.8});
-      assert.deepEqual(roundedValueOf(output, 'https://www.googletagmanager.com/gtm.js?id=GTM-Q5SW'), {scripting: 96.9, scriptParseCompile: 6.5, total: 104.4});
+      assert.deepEqual(roundedValueOf(output, 'https://www.googletagmanager.com/gtm.js?id=GTM-Q5SW'), {scripting: 97.2, scriptParseCompile: 6.5, total: 104.7});
       assert.deepEqual(roundedValueOf(output, 'https://www.google-analytics.com/plugins/ua/linkid.js'), {scripting: 25.2, scriptParseCompile: 1.2, total: 26.4});
       assert.deepEqual(roundedValueOf(output, 'https://www.google-analytics.com/analytics.js'), {scripting: 40.6, scriptParseCompile: 9.6, total: 53.4});
+      assert.deepEqual(roundedValueOf(output, 'Other'), {scripting: 11.7, scriptParseCompile: 0, total: 1123.8}); // eslint-disable-line max-len
 
-      assert.equal(Math.round(output.rawValue), 221);
-      assert.equal(output.details.items.length, 4);
+      assert.equal(Math.round(output.rawValue), 225);
+      assert.equal(output.details.items.length, 5);
       assert.equal(output.score, 1);
     });
   }, 10000);
@@ -55,9 +56,9 @@ describe('Performance: bootup-time audit', () => {
 
     assert.deepEqual(roundedValueOf(output, 'https://pwa.rocks/script.js'), {scripting: 95.3, scriptParseCompile: 3.9, total: 110.5});
 
-    assert.equal(output.details.items.length, 7);
+    assert.equal(output.details.items.length, 8);
     assert.equal(output.score, 0.98);
-    assert.equal(Math.round(output.rawValue), 709);
+    assert.equal(Math.round(output.rawValue), 720);
   });
 
   it('should get no data when no events are present', () => {
diff --git a/lighthouse-core/test/audits/seo/font-size-test.js b/lighthouse-core/test/audits/seo/font-size-test.js
index ee6612d09..c075fe132 100644
--- a/lighthouse-core/test/audits/seo/font-size-test.js
+++ b/lighthouse-core/test/audits/seo/font-size-test.js
@@ -15,22 +15,23 @@ const validViewport = 'width=device-width';
 
 describe('SEO: Font size audit', () => {
   const makeMetaElements = viewport => [{name: 'viewport', content: viewport}];
+  const getFakeContext = () => ({computedCache: new Map()});
 
-  it('fails when viewport is not set', () => {
+  it('fails when viewport is not set', async () => {
     const artifacts = {
       URL,
       MetaElements: [],
       FontSize: [],
     };
 
-    const auditResult = FontSizeAudit.audit(artifacts);
+    const auditResult = await FontSizeAudit.audit(artifacts, getFakeContext());
     assert.equal(auditResult.rawValue, false);
     expect(auditResult.explanation)
       .toBeDisplayString('Text is illegible because there\'s ' +
         'no viewport meta tag optimized for mobile screens.');
   });
 
-  it('fails when less than 60% of text is legible', () => {
+  it('fails when less than 60% of text is legible', async () => {
     const artifacts = {
       URL,
       MetaElements: makeMetaElements(validViewport),
@@ -46,13 +47,13 @@ describe('SEO: Font size audit', () => {
       },
     };
 
-    const auditResult = FontSizeAudit.audit(artifacts);
+    const auditResult = await FontSizeAudit.audit(artifacts, getFakeContext());
     assert.equal(auditResult.rawValue, false);
     expect(auditResult.explanation).toBeDisplayString('41% of text is too small.');
     expect(auditResult.displayValue).toBeDisplayString('59% legible text');
   });
 
-  it('passes when there is no text', () => {
+  it('passes when there is no text', async () => {
     const artifacts = {
       URL,
       MetaElements: makeMetaElements(validViewport),
@@ -67,11 +68,11 @@ describe('SEO: Font size audit', () => {
       },
     };
 
-    const auditResult = FontSizeAudit.audit(artifacts);
+    const auditResult = await FontSizeAudit.audit(artifacts, getFakeContext());
     assert.equal(auditResult.rawValue, true);
   });
 
-  it('passes when more than 60% of text is legible', () => {
+  it('passes when more than 60% of text is legible', async () => {
     const artifacts = {
       URL,
       MetaElements: makeMetaElements(validViewport),
@@ -86,12 +87,12 @@ describe('SEO: Font size audit', () => {
         ],
       },
     };
-    const auditResult = FontSizeAudit.audit(artifacts);
+    const auditResult = await FontSizeAudit.audit(artifacts, getFakeContext());
     assert.equal(auditResult.rawValue, true);
     expect(auditResult.displayValue).toBeDisplayString('90% legible text');
   });
 
-  it('groups entries with same source, sorts them by coverage', () => {
+  it('groups entries with same source, sorts them by coverage', async () => {
     const style1 = {
       styleSheetId: 1,
       type: 'Regular',
@@ -123,7 +124,7 @@ describe('SEO: Font size audit', () => {
         ],
       },
     };
-    const auditResult = FontSizeAudit.audit(artifacts);
+    const auditResult = await FontSizeAudit.audit(artifacts, getFakeContext());
 
     assert.equal(auditResult.rawValue, false);
     assert.equal(auditResult.details.items.length, 2);
@@ -131,7 +132,7 @@ describe('SEO: Font size audit', () => {
     expect(auditResult.displayValue).toBeDisplayString('0% legible text');
   });
 
-  it('adds a category for failing text that wasn\'t analyzed', () => {
+  it('adds a category for failing text that wasn\'t analyzed', async () => {
     const artifacts = {
       URL,
       MetaElements: makeMetaElements(validViewport),
@@ -145,7 +146,7 @@ describe('SEO: Font size audit', () => {
         ],
       },
     };
-    const auditResult = FontSizeAudit.audit(artifacts);
+    const auditResult = await FontSizeAudit.audit(artifacts, getFakeContext());
     assert.equal(auditResult.rawValue, false);
     assert.equal(auditResult.details.items.length, 3);
     assert.equal(auditResult.details.items[1].source, 'Add\'l illegible text');
@@ -153,7 +154,7 @@ describe('SEO: Font size audit', () => {
     expect(auditResult.displayValue).toBeDisplayString('50% legible text');
   });
 
-  it('informs user if audit haven\'t covered all text on the page', () => {
+  it('informs user if audit haven\'t covered all text on the page', async () => {
     const artifacts = {
       URL,
       MetaElements: makeMetaElements(validViewport),
@@ -167,14 +168,14 @@ describe('SEO: Font size audit', () => {
         ],
       },
     };
-    const auditResult = FontSizeAudit.audit(artifacts);
+    const auditResult = await FontSizeAudit.audit(artifacts, getFakeContext());
     assert.equal(auditResult.rawValue, false);
     expect(auditResult.explanation)
       .toBeDisplayString('100% of text is too small (based on 50% sample).');
     expect(auditResult.displayValue).toBeDisplayString('0% legible text');
   });
 
-  it('maintains 2 trailing decimal places', () => {
+  it('maintains 2 trailing decimal places', async () => {
     const artifacts = {
       URL,
       MetaElements: makeMetaElements(validViewport),
@@ -189,11 +190,11 @@ describe('SEO: Font size audit', () => {
         ],
       },
     };
-    const auditResult = FontSizeAudit.audit(artifacts);
+    const auditResult = await FontSizeAudit.audit(artifacts, getFakeContext());
     expect(auditResult.displayValue).toBeDisplayString('89.78% legible text');
   });
 
-  it('maintains 2 trailing decimal places with only 1 leading digit', () => {
+  it('maintains 2 trailing decimal places with only 1 leading digit', async () => {
     const artifacts = {
       URL,
       MetaElements: makeMetaElements(validViewport),
@@ -208,7 +209,7 @@ describe('SEO: Font size audit', () => {
         ],
       },
     };
-    const auditResult = FontSizeAudit.audit(artifacts);
+    const auditResult = await FontSizeAudit.audit(artifacts, getFakeContext());
     expect(auditResult.displayValue).toBeDisplayString('2.48% legible text');
   });
 });
diff --git a/lighthouse-core/test/audits/seo/tap-targets-test.js b/lighthouse-core/test/audits/seo/tap-targets-test.js
index 679e702bb..deb5fe5e8 100644
--- a/lighthouse-core/test/audits/seo/tap-targets-test.js
+++ b/lighthouse-core/test/audits/seo/tap-targets-test.js
@@ -10,6 +10,8 @@
 const TapTargetsAudit = require('../../../audits/seo/tap-targets.js');
 const assert = require('assert');
 
+const getFakeContext = () => ({computedCache: new Map()});
+
 function auditTapTargets(tapTargets, metaElements = [{
   name: 'viewport',
   content: 'width=device-width',
@@ -19,7 +21,7 @@ function auditTapTargets(tapTargets, metaElements = [{
     MetaElements: metaElements,
   };
 
-  return TapTargetsAudit.audit(artifacts);
+  return TapTargetsAudit.audit(artifacts, getFakeContext());
 }
 
 const tapTargetSize = 10;
@@ -113,27 +115,27 @@ function getBorderlineTapTargets(options = {}) {
 }
 
 describe('SEO: Tap targets audit', () => {
-  it('passes when there are no tap targets', () => {
-    const auditResult = auditTapTargets([]);
+  it('passes when there are no tap targets', async () => {
+    const auditResult = await auditTapTargets([]);
     assert.equal(auditResult.rawValue, true);
     expect(auditResult.displayValue).toBeDisplayString('100% appropriately sized tap targets');
     assert.equal(auditResult.score, 1);
   });
 
-  it('passes when tap targets don\'t overlap', () => {
-    const auditResult = auditTapTargets(getBorderlineTapTargets());
+  it('passes when tap targets don\'t overlap', async () => {
+    const auditResult = await auditTapTargets(getBorderlineTapTargets());
     assert.equal(auditResult.rawValue, true);
   });
 
-  it('passes when a target is fully contained in an overlapping target', () => {
-    const auditResult = auditTapTargets(getBorderlineTapTargets({
+  it('passes when a target is fully contained in an overlapping target', async () => {
+    const auditResult = await auditTapTargets(getBorderlineTapTargets({
       addFullyContainedTapTarget: true,
     }));
     assert.equal(auditResult.rawValue, true);
   });
 
-  it('fails if two tap targets overlaps each other horizontally', () => {
-    const auditResult = auditTapTargets(
+  it('fails if two tap targets overlaps each other horizontally', async () => {
+    const auditResult = await auditTapTargets(
       getBorderlineTapTargets({
         overlapRight: true,
       })
@@ -152,8 +154,8 @@ describe('SEO: Tap targets audit', () => {
     assert.equal(failure.height, 10);
   });
 
-  it('fails if a tap target overlaps vertically', () => {
-    const auditResult = auditTapTargets(
+  it('fails if a tap target overlaps vertically', async () => {
+    const auditResult = await auditTapTargets(
       getBorderlineTapTargets({
         overlapBelow: true,
       })
@@ -161,8 +163,8 @@ describe('SEO: Tap targets audit', () => {
     assert.equal(auditResult.rawValue, false);
   });
 
-  it('fails when one of the client rects overlaps', () => {
-    const auditResult = auditTapTargets(
+  it('fails when one of the client rects overlaps', async () => {
+    const auditResult = await auditTapTargets(
       getBorderlineTapTargets({
         overlapSecondClientRect: true,
       })
@@ -170,9 +172,9 @@ describe('SEO: Tap targets audit', () => {
     assert.equal(auditResult.rawValue, false);
   });
 
-  it('reports 2 items if the main target is overlapped both vertically and horizontally', () => {
+  it('reports 2 items if a target overlapped both vertically and horizontally', async () => {
     // Main is overlapped by right + below, right and below are each overlapped by main
-    const auditResult = auditTapTargets(
+    const auditResult = await auditTapTargets(
       getBorderlineTapTargets({
         overlapRight: true,
         reduceRightWidth: true,
@@ -187,8 +189,8 @@ describe('SEO: Tap targets audit', () => {
     assert.equal(failures[0].tapTarget.snippet, '<right></right>');
   });
 
-  it('reports 1 failure if only one tap target involved in an overlap fails', () => {
-    const auditResult = auditTapTargets(
+  it('reports 1 failure if only one tap target involved in an overlap fails', async () => {
+    const auditResult = await auditTapTargets(
       getBorderlineTapTargets({
         overlapRight: true,
         increaseRightWidth: true,
@@ -200,8 +202,8 @@ describe('SEO: Tap targets audit', () => {
     assert.equal(failures[0].tapTarget.snippet, '<main></main>');
   });
 
-  it('fails if no meta viewport tag is provided', () => {
-    const auditResult = auditTapTargets([], []);
+  it('fails if no meta viewport tag is provided', async () => {
+    const auditResult = await auditTapTargets([], []);
     assert.equal(auditResult.rawValue, false);
 
     expect(auditResult.explanation).toBeDisplayString(
diff --git a/lighthouse-core/test/audits/viewport-test.js b/lighthouse-core/test/audits/viewport-test.js
index 8756eb491..ec63c472e 100644
--- a/lighthouse-core/test/audits/viewport-test.js
+++ b/lighthouse-core/test/audits/viewport-test.js
@@ -12,68 +12,28 @@ const assert = require('assert');
 
 describe('Mobile-friendly: viewport audit', () => {
   const makeMetaElements = viewport => [{name: 'viewport', content: viewport}];
+  const fakeContext = {computedCache: new Map()};
 
-  it('fails when HTML does not contain a viewport meta tag', () => {
-    return assert.equal(Audit.audit({
+  it('fails when HTML does not contain a viewport meta tag', async () => {
+    const auditResult = await Audit.audit({
       MetaElements: [],
-    }).rawValue, false);
+    }, fakeContext);
+    assert.equal(auditResult.rawValue, false);
+    assert.equal(auditResult.explanation, 'No viewport meta tag found');
   });
 
-  it('fails when HTML contains a non-mobile friendly viewport meta tag', () => {
+  it('fails when HTML contains a non-mobile friendly viewport meta tag', async () => {
     const viewport = 'maximum-scale=1';
-    assert.equal(Audit.audit({MetaElements: makeMetaElements(viewport)}).rawValue, false);
-    assert.equal(Audit.audit({
-      MetaElements: makeMetaElements(viewport),
-    }).warnings[0], undefined);
+    const auditResult = await Audit.audit({MetaElements: makeMetaElements(viewport)}, fakeContext);
+    assert.equal(auditResult.rawValue, false);
+    assert.equal(auditResult.warnings[0], undefined);
   });
 
-  it('fails when HTML contains an invalid viewport meta tag key', () => {
-    const viewport = 'nonsense=true';
-    assert.equal(Audit.audit({MetaElements: makeMetaElements(viewport)}).rawValue, false);
-    assert.equal(Audit.audit({
+  it('passes when a valid viewport is provided', async () => {
+    const viewport = 'initial-scale=1';
+    const auditResult = await Audit.audit({
       MetaElements: makeMetaElements(viewport),
-    }).warnings[0], 'Invalid properties found: {"nonsense":"true"}');
-  });
-
-  it('fails when HTML contains an invalid viewport meta tag value', () => {
-    const viewport = 'initial-scale=microscopic';
-    assert.equal(Audit.audit({MetaElements: makeMetaElements(viewport)}).rawValue, false);
-    assert.equal(Audit.audit({
-      MetaElements: makeMetaElements(viewport),
-    }).warnings[0], 'Invalid values found: {"initial-scale":"microscopic"}');
-  });
-
-  it('fails when HTML contains an invalid viewport meta tag key and value', () => {
-    const viewport = 'nonsense=true, initial-scale=microscopic';
-    const {rawValue, warnings} = Audit.audit({MetaElements: makeMetaElements(viewport)});
-    assert.equal(rawValue, false);
-    assert.equal(warnings[0], 'Invalid properties found: {"nonsense":"true"}');
-    assert.equal(warnings[1], 'Invalid values found: {"initial-scale":"microscopic"}');
-  });
-
-  it('passes when a valid viewport is provided', () => {
-    const viewports = [
-      'width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1',
-      'width = device-width, initial-scale = 1',
-      'initial-scale=1',
-      'width=device-width     ',
-    ];
-    viewports.forEach(viewport => {
-      assert.equal(Audit.audit({
-        MetaElements: makeMetaElements(viewport),
-      }).rawValue, true);
-    });
-  });
-
-  it('doesn\'t throw when viewport contains "invalid" iOS properties', () => {
-    const viewports = [
-      'width=device-width, shrink-to-fit=no',
-      'width=device-width, viewport-fit=cover',
-    ];
-    viewports.forEach(viewport => {
-      const result = Audit.audit({MetaElements: makeMetaElements(viewport)});
-      assert.equal(result.rawValue, true);
-      assert.equal(result.warnings[0], undefined);
-    });
+    }, fakeContext);
+    assert.equal(auditResult.rawValue, true);
   });
 });
diff --git a/lighthouse-core/test/computed/viewport-meta-test.js b/lighthouse-core/test/computed/viewport-meta-test.js
new file mode 100644
index 000000000..62b89c7ec
--- /dev/null
+++ b/lighthouse-core/test/computed/viewport-meta-test.js
@@ -0,0 +1,85 @@
+/**
+ * @license Copyright 2019 Google Inc. All Rights Reserved.
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
+ */
+'use strict';
+
+/* eslint-env jest */
+
+const assert = require('assert');
+const ViewportMeta = require('../../computed/viewport-meta');
+
+describe('ViewportMeta computed artifact', () => {
+  const makeMetaElements = viewport => [{name: 'viewport', content: viewport}];
+
+  it('is not mobile optimized when page does not contain a viewport meta tag', async () => {
+    const {hasViewportTag, isMobileOptimized} = await ViewportMeta.compute_({MetaElements: []});
+    assert.equal(hasViewportTag, false);
+    assert.equal(isMobileOptimized, false);
+  });
+
+  /* eslint-disable-next-line max-len */
+  it('is not mobile optimized when HTML contains a non-mobile friendly viewport meta tag', async () => {
+    const viewport = 'maximum-scale=1';
+    const {hasViewportTag, isMobileOptimized} =
+      await ViewportMeta.compute_({MetaElements: makeMetaElements(viewport)});
+    assert.equal(hasViewportTag, true);
+    assert.equal(isMobileOptimized, false);
+  });
+
+  it('is not mobile optimized when HTML contains an invalid viewport meta tag key', async () => {
+    const viewport = 'nonsense=true';
+    const {hasViewportTag, isMobileOptimized} =
+      await ViewportMeta.compute_({MetaElements: makeMetaElements(viewport)});
+    assert.equal(hasViewportTag, true);
+    assert.equal(isMobileOptimized, false);
+  });
+
+  it('is not mobile optimized when HTML contains an invalid viewport meta tag value', async () => {
+    const viewport = 'initial-scale=microscopic';
+    const {isMobileOptimized, parserWarnings} =
+      await ViewportMeta.compute_({MetaElements: makeMetaElements(viewport)});
+    assert.equal(isMobileOptimized, false);
+    assert.equal(parserWarnings[0], 'Invalid values found: {"initial-scale":"microscopic"}');
+  });
+
+  /* eslint-disable-next-line max-len */
+  it('is not mobile optimized when HTML contains an invalid viewport meta tag key and value', async () => {
+    const viewport = 'nonsense=true, initial-scale=microscopic';
+    const {isMobileOptimized, parserWarnings} =
+      await ViewportMeta.compute_({MetaElements: makeMetaElements(viewport)});
+    assert.equal(isMobileOptimized, false);
+    assert.equal(parserWarnings[0], 'Invalid properties found: {"nonsense":"true"}');
+    assert.equal(parserWarnings[1], 'Invalid values found: {"initial-scale":"microscopic"}');
+  });
+
+  it('is mobile optimized when a valid viewport is provided', async () => {
+    const viewports = [
+      'width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1',
+      'width = device-width, initial-scale = 1',
+      'initial-scale=1',
+      'width=device-width     ',
+    ];
+
+    await Promise.all(viewports.map(async viewport => {
+      const {isMobileOptimized} =
+        await ViewportMeta.compute_({MetaElements: makeMetaElements(viewport)});
+      assert.equal(isMobileOptimized, true);
+    }));
+  });
+
+  it('doesn\'t throw when viewport contains "invalid" iOS properties', async () => {
+    const viewports = [
+      'width=device-width, shrink-to-fit=no',
+      'width=device-width, viewport-fit=cover',
+    ];
+    await Promise.all(viewports.map(async viewport => {
+      const {isMobileOptimized, parserWarnings} =
+        await ViewportMeta.compute_({MetaElements: makeMetaElements(viewport)});
+      assert.equal(isMobileOptimized, true);
+      assert.equal(parserWarnings[0], undefined);
+    }));
+  });
+});
+
diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json
index 166f4359a..fb0b83114 100644
--- a/lighthouse-core/test/results/sample_v2.json
+++ b/lighthouse-core/test/results/sample_v2.json
@@ -701,7 +701,7 @@
       "description": "Consider reducing the time spent parsing, compiling, and executing JS. You may find delivering smaller JS payloads helps with this. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/bootup).",
       "score": 0.92,
       "scoreDisplayMode": "numeric",
-      "rawValue": 1150.932,
+      "rawValue": 1150.573,
       "displayValue": "1.2 s",
       "details": {
         "type": "table",
@@ -715,7 +715,7 @@
             "key": "total",
             "granularity": 1,
             "itemType": "ms",
-            "text": "Total"
+            "text": "Total CPU Time"
           },
           {
             "key": "scripting",
@@ -737,6 +737,12 @@
             "scripting": 957.494,
             "scriptParseCompile": 2.777
           },
+          {
+            "url": "Other",
+            "total": 391.76699999999977,
+            "scripting": 17.790999999999997,
+            "scriptParseCompile": 0.092
+          },
           {
             "url": "http://localhost:10200/zone.js",
             "total": 101.21099999999998,
@@ -751,7 +757,7 @@
           }
         ],
         "summary": {
-          "wastedMs": 1150.932
+          "wastedMs": 1150.573
         }
       }
     },
@@ -3949,6 +3955,12 @@
         "duration": 100,
         "entryType": "measure"
       },
+      {
+        "startTime": 0,
+        "name": "lh:computed:ViewportMeta",
+        "duration": 100,
+        "entryType": "measure"
+      },
       {
         "startTime": 0,
         "name": "lh:audit:without-javascript",
@@ -4878,7 +4890,7 @@
         },
         {
           "values": {
-            "timeInMs": 1150.932
+            "timeInMs": 1150.573
           },
           "path": "audits[bootup-time].displayValue"
         }