Repository: GoogleChrome/lighthouse
Pull request #9129 (2019-06-05 19:14:45)

core(tracehouse): merge trace-of-tab and tracing-processor
<!-- Thank you for submitting a pull request! -->

**Summary**
- Merges trace-of-tab and tracing-processor into `trace-processor`, blame for `trace-of-tab` should be preserved by squashing down into 2 separate commits at the end.
- Removes all the dependencies from trace-processor into the rest of LH. Really this is just LHError. Behavior has been preserved by an overrideable method that constructs the errors it should throw.

Individual commits hopefully aid in reviewing as the final diff is a overinflated, final PR will be squashed down to just 2, one for file renames and one with all the changes.

**Related Issues/PRs**
Step 2 from the plan outlined in https://github.com/GoogleChrome/lighthouse/pull/8956#issue-278881171



Patch:
diff --git a/docs/architecture.md b/docs/architecture.md
index a3ed283df..cf67e925d 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -39,7 +39,7 @@ driver.sendCommand('Security.enable');
 
 ## Understanding a Trace
 
-`lighthouse-core/lib/tracehouse/trace-of-tab.js` and `lighthouse-core/lib/tracehouse/tracing-processor.js` provide the core transformation of a trace into more meaningful objects. Each raw trace event has a monotonically increasing timestamp in microseconds, a thread ID, a process ID, a duration in microseconds (potentially), and other applicable metadata properties such as the event type, the task name, the frame, etc. [Learn more about trace events](https://docs.google.com/document/d/1CvAClvFfyA5R-PhYUmn5OOQtYMH4h6I0nSsKchNAySU/preview).
+`lighthouse-core/lib/tracehouse/trace-processor.js` provides the core transformation of a trace into more meaningful objects. Each raw trace event has a monotonically increasing timestamp in microseconds, a thread ID, a process ID, a duration in microseconds (potentially), and other applicable metadata properties such as the event type, the task name, the frame, etc. [Learn more about trace events](https://docs.google.com/document/d/1CvAClvFfyA5R-PhYUmn5OOQtYMH4h6I0nSsKchNAySU/preview).
 
 ### Example Trace Event
 ```js
diff --git a/lighthouse-core/computed/metrics/cumulative-long-queuing-delay.js b/lighthouse-core/computed/metrics/cumulative-long-queuing-delay.js
index da1689072..18aa99d15 100644
--- a/lighthouse-core/computed/metrics/cumulative-long-queuing-delay.js
+++ b/lighthouse-core/computed/metrics/cumulative-long-queuing-delay.js
@@ -8,7 +8,7 @@
 const makeComputedArtifact = require('../computed-artifact.js');
 const ComputedMetric = require('./metric.js');
 const LHError = require('../../lib/lh-error.js');
-const TracingProcessor = require('../../lib/tracehouse/tracing-processor.js');
+const TracingProcessor = require('../../lib/tracehouse/trace-processor.js');
 const LanternCumulativeLongQueuingDelay = require('./lantern-cumulative-long-queuing-delay.js');
 const TimetoInteractive = require('./interactive.js');
 
diff --git a/lighthouse-core/computed/metrics/estimated-input-latency.js b/lighthouse-core/computed/metrics/estimated-input-latency.js
index a027667d9..ef4bab2d6 100644
--- a/lighthouse-core/computed/metrics/estimated-input-latency.js
+++ b/lighthouse-core/computed/metrics/estimated-input-latency.js
@@ -8,7 +8,7 @@
 const makeComputedArtifact = require('../computed-artifact.js');
 const ComputedMetric = require('./metric.js');
 const LHError = require('../../lib/lh-error.js');
-const TracingProcessor = require('../../lib/tracehouse/tracing-processor.js');
+const TracingProcessor = require('../../lib/tracehouse/trace-processor.js');
 const LanternEstimatedInputLatency = require('./lantern-estimated-input-latency.js');
 
 const ROLLING_WINDOW_SIZE = 5000;
diff --git a/lighthouse-core/computed/metrics/first-cpu-idle.js b/lighthouse-core/computed/metrics/first-cpu-idle.js
index 202821e16..ff85b0866 100644
--- a/lighthouse-core/computed/metrics/first-cpu-idle.js
+++ b/lighthouse-core/computed/metrics/first-cpu-idle.js
@@ -7,7 +7,7 @@
 
 const makeComputedArtifact = require('../computed-artifact.js');
 const ComputedMetric = require('./metric.js');
-const TracingProcessor = require('../../lib/tracehouse/tracing-processor.js');
+const TracingProcessor = require('../../lib/tracehouse/trace-processor.js');
 const LHError = require('../../lib/lh-error.js');
 const LanternFirstCPUIdle = require('./lantern-first-cpu-idle.js');
 
diff --git a/lighthouse-core/computed/metrics/interactive.js b/lighthouse-core/computed/metrics/interactive.js
index cc7fbd05f..c61ec5101 100644
--- a/lighthouse-core/computed/metrics/interactive.js
+++ b/lighthouse-core/computed/metrics/interactive.js
@@ -10,7 +10,7 @@ const ComputedMetric = require('./metric.js');
 const LanternInteractive = require('./lantern-interactive.js');
 
 const NetworkRecorder = require('../../lib/network-recorder.js');
-const TracingProcessor = require('../../lib/tracehouse/tracing-processor.js');
+const TracingProcessor = require('../../lib/tracehouse/trace-processor.js');
 const LHError = require('../../lib/lh-error.js');
 
 const REQUIRED_QUIET_WINDOW = 5000;
diff --git a/lighthouse-core/computed/metrics/max-potential-fid.js b/lighthouse-core/computed/metrics/max-potential-fid.js
index 6fed0414e..76db19c9e 100644
--- a/lighthouse-core/computed/metrics/max-potential-fid.js
+++ b/lighthouse-core/computed/metrics/max-potential-fid.js
@@ -9,7 +9,7 @@ const makeComputedArtifact = require('../computed-artifact.js');
 const MetricArtifact = require('./metric.js');
 const LanternMaxPotentialFID = require('./lantern-max-potential-fid.js');
 const LHError = require('../../lib/lh-error.js');
-const TracingProcessor = require('../../lib/tracehouse/tracing-processor.js');
+const TracingProcessor = require('../../lib/tracehouse/trace-processor.js');
 
 class MaxPotentialFID extends MetricArtifact {
   /**
diff --git a/lighthouse-core/computed/metrics/metric.js b/lighthouse-core/computed/metrics/metric.js
index 1b2457ff0..64a4a8938 100644
--- a/lighthouse-core/computed/metrics/metric.js
+++ b/lighthouse-core/computed/metrics/metric.js
@@ -5,7 +5,7 @@
  */
 'use strict';
 
-const TracingProcessor = require('../../lib/tracehouse/tracing-processor.js');
+const TracingProcessor = require('../../lib/tracehouse/trace-processor.js');
 const TraceOfTab = require('../trace-of-tab.js');
 const NetworkRecords = require('../network-records.js');
 
diff --git a/lighthouse-core/computed/page-dependency-graph.js b/lighthouse-core/computed/page-dependency-graph.js
index 2f728eaf2..df711b783 100644
--- a/lighthouse-core/computed/page-dependency-graph.js
+++ b/lighthouse-core/computed/page-dependency-graph.js
@@ -9,7 +9,7 @@ const makeComputedArtifact = require('./computed-artifact.js');
 const NetworkNode = require('../lib/dependency-graph/network-node.js');
 const CPUNode = require('../lib/dependency-graph/cpu-node.js');
 const NetworkAnalyzer = require('../lib/dependency-graph/simulator/network-analyzer.js');
-const TracingProcessor = require('../lib/tracehouse/tracing-processor.js');
+const TracingProcessor = require('../lib/tracehouse/trace-processor.js');
 const NetworkRequest = require('../lib/network-request.js');
 const TraceOfTab = require('./trace-of-tab.js');
 const NetworkRecords = require('./network-records.js');
diff --git a/lighthouse-core/computed/trace-of-tab.js b/lighthouse-core/computed/trace-of-tab.js
index 7aa3ee252..b8f572c89 100644
--- a/lighthouse-core/computed/trace-of-tab.js
+++ b/lighthouse-core/computed/trace-of-tab.js
@@ -6,7 +6,34 @@
 'use strict';
 
 const makeComputedArtifact = require('./computed-artifact.js');
-const TraceOfTab_ = require('../lib/tracehouse/trace-of-tab.js');
+const LHError = require('../lib/lh-error.js');
+const TraceProcessor = require('../lib/tracehouse/trace-processor.js');
+
+// TraceProcessor throws generic errors, but we'd like our special localized and code-specific LHError
+// objects to be thrown instead.
+class LHTraceProcessor extends TraceProcessor {
+  /**
+   * @return {Error}
+   */
+  static createNoNavstartError() {
+    return new LHError(LHError.errors.NO_NAVSTART);
+  }
+
+  /**
+   * @return {Error}
+   */
+  static createNoFirstContentfulPaintError() {
+    return new LHError(LHError.errors.NO_FCP);
+  }
+
+  /**
+   * @return {Error}
+   */
+  static createNoTracingStartedError() {
+    return new LHError(LHError.errors.NO_TRACING_STARTED);
+  }
+}
+
 
 class TraceOfTab {
   /**
@@ -16,7 +43,7 @@ class TraceOfTab {
    * @return {Promise<LH.Artifacts.TraceOfTab>}
   */
   static async compute_(trace) {
-    return TraceOfTab_.compute(trace);
+    return LHTraceProcessor.computeTraceOfTab(trace);
   }
 }
 
diff --git a/lighthouse-core/lib/asset-saver.js b/lighthouse-core/lib/asset-saver.js
index ce4f7a785..b5325c877 100644
--- a/lighthouse-core/lib/asset-saver.js
+++ b/lighthouse-core/lib/asset-saver.js
@@ -33,9 +33,9 @@ const devtoolsLogSuffix = '.devtoolslog.json';
  * Load artifacts object from files located within basePath
  * Also save the traces to their own files
  * @param {string} basePath
- * @return {Promise<LH.Artifacts>}
+ * @return {LH.Artifacts}
  */
-async function loadArtifacts(basePath) {
+function loadArtifacts(basePath) {
   log.log('Reading artifacts from disk:', basePath);
 
   if (!fs.existsSync(basePath)) {
diff --git a/lighthouse-core/lib/minify-trace.js b/lighthouse-core/lib/minify-trace.js
index 0ff5bd8f3..afda69099 100644
--- a/lighthouse-core/lib/minify-trace.js
+++ b/lighthouse-core/lib/minify-trace.js
@@ -12,11 +12,10 @@
  *  See the following files for necessary events:
  *    - lighthouse-core/computed/page-dependency-graph.js
  *    - lighthouse-core/lib/dependency-graph/cpu-node.js
- *    - lighthouse-core/lib/tracehouse/trace-of-tab.js
- *    - lighthouse-core/lib/tracehouse/tracing-processor.js
+ *    - lighthouse-core/lib/tracehouse/trace-processor.js
  */
 
-const TracingProcessor = require('./tracehouse/tracing-processor.js');
+const TracingProcessor = require('./tracehouse/trace-processor.js');
 
 const toplevelTaskNames = new Set([
   'RunTask', // m71+
diff --git a/lighthouse-core/lib/tracehouse/trace-of-tab.js b/lighthouse-core/lib/tracehouse/trace-of-tab.js
deleted file mode 100644
index 65cbc08de..000000000
--- a/lighthouse-core/lib/tracehouse/trace-of-tab.js
+++ /dev/null
@@ -1,191 +0,0 @@
-/**
- * @license Copyright 2017 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';
-
-/**
- * @fileoverview Singluar helper to parse a raw trace and extract the most useful data for
- * various tools. This artifact will take a trace and then:
- *
- * 1. Find the TracingStartedInPage and navigationStart events of our intended tab & frame.
- * 2. Find the firstContentfulPaint and marked firstMeaningfulPaint events
- * 3. Isolate only the trace events from the tab's process (including all threads like compositor)
- *      * Sort those trace events in chronological order (as order isn't guaranteed)
- * 4. Return all those items in one handy bundle.
- */
-
-const log = require('lighthouse-logger');
-const TracingProcessor = require('./tracing-processor.js');
-const LHError = require('../lh-error.js');
-const Sentry = require('../sentry.js');
-
-const ACCEPTABLE_NAVIGATION_URL_REGEX = /^(chrome|https?):/;
-
-class TraceOfTab {
-  /**
-   * Returns true if the event is a navigation start event of a document whose URL seems valid.
-   *
-   * @param {LH.TraceEvent} event
-   */
-  static isNavigationStartOfInterest(event) {
-    return event.name === 'navigationStart' &&
-      (!event.args.data || !event.args.data.documentLoaderURL ||
-        ACCEPTABLE_NAVIGATION_URL_REGEX.test(event.args.data.documentLoaderURL));
-  }
-
-  /**
-   * @param {LH.TraceEvent[]} traceEvents
-   * @param {(e: LH.TraceEvent) => boolean} filter
-   */
-  static filteredStableSort(traceEvents, filter) {
-    // create an array of the indices that we want to keep
-    const indices = [];
-    for (let srcIndex = 0; srcIndex < traceEvents.length; srcIndex++) {
-      if (filter(traceEvents[srcIndex])) {
-        indices.push(srcIndex);
-      }
-    }
-
-    // sort by ts, if there's no ts difference sort by index
-    indices.sort((indexA, indexB) => {
-      const result = traceEvents[indexA].ts - traceEvents[indexB].ts;
-      return result ? result : indexA - indexB;
-    });
-
-    // create a new array using the target indices from previous sort step
-    const sorted = [];
-    for (let i = 0; i < indices.length; i++) {
-      sorted.push(traceEvents[indices[i]]);
-    }
-
-    return sorted;
-  }
-
-
-  /**
-   * Finds key trace events, identifies main process/thread, and returns timings of trace events
-   * in milliseconds since navigation start in addition to the standard microsecond monotonic timestamps.
-   * @param {LH.Trace} trace
-   * @return {LH.Artifacts.TraceOfTab}
-  */
-  static compute(trace) {
-    // Parse the trace for our key events and sort them by timestamp. Note: sort
-    // *must* be stable to keep events correctly nested.
-    const keyEvents = TraceOfTab.filteredStableSort(trace.traceEvents, e => {
-      return e.cat.includes('blink.user_timing') ||
-          e.cat.includes('loading') ||
-          e.cat.includes('devtools.timeline') ||
-          e.cat === '__metadata';
-    });
-
-    // Find the inspected frame
-    const mainFrameIds = TracingProcessor.findMainFrameIds(keyEvents);
-
-    // Filter to just events matching the frame ID for sanity
-    const frameEvents = keyEvents.filter(e => e.args.frame === mainFrameIds.frameId);
-
-    // Our navStart will be the last frame navigation in the trace
-    const navigationStart = frameEvents.filter(TraceOfTab.isNavigationStartOfInterest).pop();
-    if (!navigationStart) throw new LHError(LHError.errors.NO_NAVSTART);
-
-    // Find our first paint of this frame
-    const firstPaint = frameEvents.find(e => e.name === 'firstPaint' && e.ts > navigationStart.ts);
-
-    // FCP will follow at/after the FP. Used in so many places we require it.
-    const firstContentfulPaint = frameEvents.find(
-      e => e.name === 'firstContentfulPaint' && e.ts > navigationStart.ts
-    );
-    if (!firstContentfulPaint) throw new LHError(LHError.errors.NO_FCP);
-
-    // fMP will follow at/after the FP
-    let firstMeaningfulPaint = frameEvents.find(
-      e => e.name === 'firstMeaningfulPaint' && e.ts > navigationStart.ts
-    );
-    let fmpFellBack = false;
-
-    // If there was no firstMeaningfulPaint event found in the trace, the network idle detection
-    // may have not been triggered before Lighthouse finished tracing.
-    // In this case, we'll use the last firstMeaningfulPaintCandidate we can find.
-    // However, if no candidates were found (a bogus trace, likely), we fail.
-    if (!firstMeaningfulPaint) {
-      // Track this with Sentry since it's likely a bug we should investigate.
-      Sentry.captureMessage('No firstMeaningfulPaint found, using fallback', {level: 'warning'});
-
-      const fmpCand = 'firstMeaningfulPaintCandidate';
-      fmpFellBack = true;
-      log.verbose('trace-of-tab', `No firstMeaningfulPaint found, falling back to last ${fmpCand}`);
-      const lastCandidate = frameEvents.filter(e => e.name === fmpCand).pop();
-      if (!lastCandidate) {
-        log.verbose('trace-of-tab', 'No `firstMeaningfulPaintCandidate` events found in trace');
-      }
-      firstMeaningfulPaint = lastCandidate;
-    }
-
-    const load = frameEvents.find(e => e.name === 'loadEventEnd' && e.ts > navigationStart.ts);
-    const domContentLoaded = frameEvents.find(
-      e => e.name === 'domContentLoadedEventEnd' && e.ts > navigationStart.ts
-    );
-
-    // subset all trace events to just our tab's process (incl threads other than main)
-    // stable-sort events to keep them correctly nested.
-    const processEvents = TraceOfTab
-      .filteredStableSort(trace.traceEvents, e => e.pid === mainFrameIds.pid);
-
-    const mainThreadEvents = processEvents
-      .filter(e => e.tid === mainFrameIds.tid);
-
-    // traceEnd must exist since at least navigationStart event was verified as existing.
-    const traceEnd = trace.traceEvents.reduce((max, evt) => {
-      return max.ts > evt.ts ? max : evt;
-    });
-    const fakeEndOfTraceEvt = {ts: traceEnd.ts + (traceEnd.dur || 0)};
-
-    /** @param {{ts: number}=} event */
-    const getTimestamp = (event) => event && event.ts;
-    /** @type {LH.Artifacts.TraceTimes} */
-    const timestamps = {
-      navigationStart: navigationStart.ts,
-      firstPaint: getTimestamp(firstPaint),
-      firstContentfulPaint: firstContentfulPaint.ts,
-      firstMeaningfulPaint: getTimestamp(firstMeaningfulPaint),
-      traceEnd: fakeEndOfTraceEvt.ts,
-      load: getTimestamp(load),
-      domContentLoaded: getTimestamp(domContentLoaded),
-    };
-
-
-    /** @param {number} ts */
-    const getTiming = (ts) => (ts - navigationStart.ts) / 1000;
-    /** @param {number=} ts */
-    const maybeGetTiming = (ts) => ts === undefined ? undefined : getTiming(ts);
-    /** @type {LH.Artifacts.TraceTimes} */
-    const timings = {
-      navigationStart: 0,
-      firstPaint: maybeGetTiming(timestamps.firstPaint),
-      firstContentfulPaint: getTiming(timestamps.firstContentfulPaint),
-      firstMeaningfulPaint: maybeGetTiming(timestamps.firstMeaningfulPaint),
-      traceEnd: getTiming(timestamps.traceEnd),
-      load: maybeGetTiming(timestamps.load),
-      domContentLoaded: maybeGetTiming(timestamps.domContentLoaded),
-    };
-
-    return {
-      timings,
-      timestamps,
-      processEvents,
-      mainThreadEvents,
-      mainFrameIds,
-      navigationStartEvt: navigationStart,
-      firstPaintEvt: firstPaint,
-      firstContentfulPaintEvt: firstContentfulPaint,
-      firstMeaningfulPaintEvt: firstMeaningfulPaint,
-      loadEvt: load,
-      domContentLoadedEvt: domContentLoaded,
-      fmpFellBack,
-    };
-  }
-}
-
-module.exports = TraceOfTab;
diff --git a/lighthouse-core/lib/tracehouse/tracing-processor.js b/lighthouse-core/lib/tracehouse/trace-processor.js
similarity index 58%
rename from lighthouse-core/lib/tracehouse/tracing-processor.js
rename to lighthouse-core/lib/tracehouse/trace-processor.js
index e3a11991c..2bc4c6fee 100644
--- a/lighthouse-core/lib/tracehouse/tracing-processor.js
+++ b/lighthouse-core/lib/tracehouse/trace-processor.js
@@ -1,10 +1,25 @@
 /**
- * @license Copyright 2016 Google Inc. All Rights Reserved.
+ * @license Copyright 2017 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';
 
+/**
+ * @fileoverview Singluar helper to parse a raw trace and extract the most useful data for
+ * various tools. This artifact will take a trace and then:
+ *
+ * 1. Find the TracingStartedInPage and navigationStart events of our intended tab & frame.
+ * 2. Find the firstContentfulPaint and marked firstMeaningfulPaint events
+ * 3. Isolate only the trace events from the tab's process (including all threads like compositor)
+ *      * Sort those trace events in chronological order (as order isn't guaranteed)
+ * 4. Return all those items in one handy bundle.
+ */
+
+const log = require('lighthouse-logger');
+
+const ACCEPTABLE_NAVIGATION_URL_REGEX = /^(chrome|https?):/;
+
 // The ideal input response latency, the time between the input task and the
 // first frame of the response.
 const BASE_RESPONSE_LATENCY = 16;
@@ -17,10 +32,67 @@ const SCHEDULABLE_TASK_TITLE_ALT2 = 'ThreadControllerImpl::DoWork';
 // m65 and earlier
 const SCHEDULABLE_TASK_TITLE_ALT3 = 'TaskQueueManager::ProcessTaskFromWorkQueue';
 
+class TraceProcessor {
+  /**
+   * @return {Error}
+   */
+  static createNoNavstartError() {
+    return new Error('No navigationStart event found');
+  }
 
-const LHError = require('../lh-error.js');
+  /**
+   * @return {Error}
+   */
+  static createNoFirstContentfulPaintError() {
+    return new Error('No firstContentfulPaint event found');
+  }
+
+  /**
+   * @return {Error}
+   */
+  static createNoTracingStartedError() {
+    return new Error('No tracingStartedInBrowser event found');
+  }
+
+  /**
+   * Returns true if the event is a navigation start event of a document whose URL seems valid.
+   *
+   * @param {LH.TraceEvent} event
+   */
+  static _isNavigationStartOfInterest(event) {
+    return event.name === 'navigationStart' &&
+      (!event.args.data || !event.args.data.documentLoaderURL ||
+        ACCEPTABLE_NAVIGATION_URL_REGEX.test(event.args.data.documentLoaderURL));
+  }
+
+  /**
+   * @param {LH.TraceEvent[]} traceEvents
+   * @param {(e: LH.TraceEvent) => boolean} filter
+   */
+  static _filteredStableSort(traceEvents, filter) {
+    // create an array of the indices that we want to keep
+    const indices = [];
+    for (let srcIndex = 0; srcIndex < traceEvents.length; srcIndex++) {
+      if (filter(traceEvents[srcIndex])) {
+        indices.push(srcIndex);
+      }
+    }
+
+    // sort by ts, if there's no ts difference sort by index
+    indices.sort((indexA, indexB) => {
+      const result = traceEvents[indexA].ts - traceEvents[indexB].ts;
+      return result ? result : indexA - indexB;
+    });
+
+    // create a new array using the target indices from previous sort step
+    const sorted = [];
+    for (let i = 0; i < indices.length; i++) {
+      sorted.push(traceEvents[indices[i]]);
+    }
+
+    return sorted;
+  }
 
-class TraceProcessor {
   /**
    * There should *always* be at least one top level event, having 0 typically means something is
    * drastically wrong with the trace and we should just give up early and loudly.
@@ -28,7 +100,7 @@ class TraceProcessor {
    * @param {LH.TraceEvent[]} events
    */
   static assertHasToplevelEvents(events) {
-    const hasToplevelTask = events.some(TraceProcessor.isScheduleableTask);
+    const hasToplevelTask = events.some(this.isScheduleableTask);
     if (!hasToplevelTask) {
       throw new Error('Could not find any top level events');
     }
@@ -122,8 +194,8 @@ class TraceProcessor {
     const totalTime = endTime - startTime;
     percentiles.sort((a, b) => a - b);
 
-    const ret = TraceProcessor.getMainThreadTopLevelEventDurations(events, startTime, endTime);
-    return TraceProcessor._riskPercentiles(ret.durations, totalTime, percentiles,
+    const ret = this.getMainThreadTopLevelEventDurations(events, startTime, endTime);
+    return this._riskPercentiles(ret.durations, totalTime, percentiles,
         ret.clippedLength);
   }
 
@@ -180,7 +252,7 @@ class TraceProcessor {
     const topLevelEvents = [];
     // note: mainThreadEvents is already sorted by event start
     for (const event of tabTrace.mainThreadEvents) {
-      if (!TraceProcessor.isScheduleableTask(event) || !event.dur) continue;
+      if (!this.isScheduleableTask(event) || !event.dur) continue;
 
       const start = (event.ts - tabTrace.navigationStartEvt.ts) / 1000;
       const end = (event.ts + event.dur - tabTrace.navigationStartEvt.ts) / 1000;
@@ -260,7 +332,7 @@ class TraceProcessor {
       }
     }
 
-    throw new LHError(LHError.errors.NO_TRACING_STARTED);
+    throw this.createNoTracingStartedError();
   }
 
   /**
@@ -273,13 +345,135 @@ class TraceProcessor {
     evt.name === SCHEDULABLE_TASK_TITLE_ALT2 ||
     evt.name === SCHEDULABLE_TASK_TITLE_ALT3;
   }
+
+
+  /**
+   * Finds key trace events, identifies main process/thread, and returns timings of trace events
+   * in milliseconds since navigation start in addition to the standard microsecond monotonic timestamps.
+   * @param {LH.Trace} trace
+   * @return {LH.Artifacts.TraceOfTab}
+  */
+  static computeTraceOfTab(trace) {
+    // Parse the trace for our key events and sort them by timestamp. Note: sort
+    // *must* be stable to keep events correctly nested.
+    const keyEvents = this._filteredStableSort(trace.traceEvents, e => {
+      return e.cat.includes('blink.user_timing') ||
+          e.cat.includes('loading') ||
+          e.cat.includes('devtools.timeline') ||
+          e.cat === '__metadata';
+    });
+
+    // Find the inspected frame
+    const mainFrameIds = this.findMainFrameIds(keyEvents);
+
+    // Filter to just events matching the frame ID for sanity
+    const frameEvents = keyEvents.filter(e => e.args.frame === mainFrameIds.frameId);
+
+    // Our navStart will be the last frame navigation in the trace
+    const navigationStart = frameEvents.filter(this._isNavigationStartOfInterest).pop();
+    if (!navigationStart) throw this.createNoNavstartError();
+
+    // Find our first paint of this frame
+    const firstPaint = frameEvents.find(e => e.name === 'firstPaint' && e.ts > navigationStart.ts);
+
+    // FCP will follow at/after the FP. Used in so many places we require it.
+    const firstContentfulPaint = frameEvents.find(
+      e => e.name === 'firstContentfulPaint' && e.ts > navigationStart.ts
+    );
+    if (!firstContentfulPaint) throw this.createNoFirstContentfulPaintError();
+
+    // fMP will follow at/after the FP
+    let firstMeaningfulPaint = frameEvents.find(
+      e => e.name === 'firstMeaningfulPaint' && e.ts > navigationStart.ts
+    );
+    let fmpFellBack = false;
+
+    // If there was no firstMeaningfulPaint event found in the trace, the network idle detection
+    // may have not been triggered before Lighthouse finished tracing.
+    // In this case, we'll use the last firstMeaningfulPaintCandidate we can find.
+    // However, if no candidates were found (a bogus trace, likely), we fail.
+    if (!firstMeaningfulPaint) {
+      const fmpCand = 'firstMeaningfulPaintCandidate';
+      fmpFellBack = true;
+      log.verbose('trace-of-tab', `No firstMeaningfulPaint found, falling back to last ${fmpCand}`);
+      const lastCandidate = frameEvents.filter(e => e.name === fmpCand).pop();
+      if (!lastCandidate) {
+        log.verbose('trace-of-tab', 'No `firstMeaningfulPaintCandidate` events found in trace');
+      }
+      firstMeaningfulPaint = lastCandidate;
+    }
+
+    const load = frameEvents.find(e => e.name === 'loadEventEnd' && e.ts > navigationStart.ts);
+    const domContentLoaded = frameEvents.find(
+      e => e.name === 'domContentLoadedEventEnd' && e.ts > navigationStart.ts
+    );
+
+    // subset all trace events to just our tab's process (incl threads other than main)
+    // stable-sort events to keep them correctly nested.
+    const processEvents = TraceProcessor
+      ._filteredStableSort(trace.traceEvents, e => e.pid === mainFrameIds.pid);
+
+    const mainThreadEvents = processEvents
+      .filter(e => e.tid === mainFrameIds.tid);
+
+    // traceEnd must exist since at least navigationStart event was verified as existing.
+    const traceEnd = trace.traceEvents.reduce((max, evt) => {
+      return max.ts > evt.ts ? max : evt;
+    });
+    const fakeEndOfTraceEvt = {ts: traceEnd.ts + (traceEnd.dur || 0)};
+
+    /** @param {{ts: number}=} event */
+    const getTimestamp = (event) => event && event.ts;
+    /** @type {LH.Artifacts.TraceTimes} */
+    const timestamps = {
+      navigationStart: navigationStart.ts,
+      firstPaint: getTimestamp(firstPaint),
+      firstContentfulPaint: firstContentfulPaint.ts,
+      firstMeaningfulPaint: getTimestamp(firstMeaningfulPaint),
+      traceEnd: fakeEndOfTraceEvt.ts,
+      load: getTimestamp(load),
+      domContentLoaded: getTimestamp(domContentLoaded),
+    };
+
+
+    /** @param {number} ts */
+    const getTiming = (ts) => (ts - navigationStart.ts) / 1000;
+    /** @param {number=} ts */
+    const maybeGetTiming = (ts) => ts === undefined ? undefined : getTiming(ts);
+    /** @type {LH.Artifacts.TraceTimes} */
+    const timings = {
+      navigationStart: 0,
+      firstPaint: maybeGetTiming(timestamps.firstPaint),
+      firstContentfulPaint: getTiming(timestamps.firstContentfulPaint),
+      firstMeaningfulPaint: maybeGetTiming(timestamps.firstMeaningfulPaint),
+      traceEnd: getTiming(timestamps.traceEnd),
+      load: maybeGetTiming(timestamps.load),
+      domContentLoaded: maybeGetTiming(timestamps.domContentLoaded),
+    };
+
+    return {
+      timings,
+      timestamps,
+      processEvents,
+      mainThreadEvents,
+      mainFrameIds,
+      navigationStartEvt: navigationStart,
+      firstPaintEvt: firstPaint,
+      firstContentfulPaintEvt: firstContentfulPaint,
+      firstMeaningfulPaintEvt: firstMeaningfulPaint,
+      loadEvt: load,
+      domContentLoadedEvt: domContentLoaded,
+      fmpFellBack,
+    };
+  }
 }
 
+module.exports = TraceProcessor;
+
+
 /**
  * @typedef ToplevelEvent
  * @prop {number} start
  * @prop {number} end
  * @prop {number} duration
  */
-
-module.exports = TraceProcessor;
diff --git a/lighthouse-core/runner.js b/lighthouse-core/runner.js
index 3838016f2..153b26075 100644
--- a/lighthouse-core/runner.js
+++ b/lighthouse-core/runner.js
@@ -60,7 +60,7 @@ class Runner {
       if (settings.auditMode && !settings.gatherMode) {
         // No browser required, just load the artifacts from disk.
         const path = Runner._getArtifactsPath(settings);
-        artifacts = await assetSaver.loadArtifacts(path);
+        artifacts = assetSaver.loadArtifacts(path);
         requestedUrl = artifacts.URL.requestedUrl;
 
         if (!requestedUrl) {
diff --git a/lighthouse-core/scripts/update-report-fixtures.js b/lighthouse-core/scripts/update-report-fixtures.js
index 10d64051c..5f1578783 100644
--- a/lighthouse-core/scripts/update-report-fixtures.js
+++ b/lighthouse-core/scripts/update-report-fixtures.js
@@ -28,7 +28,7 @@ async function update(artifactName) {
     res(address.port);
   }));
 
-  const oldArtifacts = await assetSaver.loadArtifacts(artifactPath);
+  const oldArtifacts = assetSaver.loadArtifacts(artifactPath);
 
   const url = `http://localhost:${port}/dobetterweb/dbw_tester.html`;
   const rawFlags = [
@@ -41,7 +41,7 @@ async function update(artifactName) {
 
   if (artifactName) {
     // Revert everything except the one artifact
-    const newArtifacts = await assetSaver.loadArtifacts(artifactPath);
+    const newArtifacts = assetSaver.loadArtifacts(artifactPath);
     if (!(artifactName in newArtifacts) && !(artifactName in oldArtifacts)) {
       throw Error('Unknown artifact name: ' + artifactName);
     }
diff --git a/types/smokehouse.d.ts b/types/smokehouse.d.ts
index 246e9cbc2..7cc91133e 100644
--- a/types/smokehouse.d.ts
+++ b/types/smokehouse.d.ts
@@ -19,7 +19,7 @@
     diff?: Difference | null;
   }
 
-  export type ExpectedLHR = Pick<LH.Result, 'audits' | 'finalUrl' | 'requestedUrl'>
+  export type ExpectedLHR = Pick<LH.Result, 'audits' | 'finalUrl' | 'requestedUrl' | 'runtimeError'>
 
   export type ExpectedRunnerResult = {
     lhr: ExpectedLHR,


Test patch:
diff --git a/lighthouse-cli/test/smokehouse/error-config.js b/lighthouse-cli/test/smokehouse/error-config.js
index 771bdf604..6be23c616 100644
--- a/lighthouse-cli/test/smokehouse/error-config.js
+++ b/lighthouse-cli/test/smokehouse/error-config.js
@@ -14,6 +14,8 @@ module.exports = {
     maxWaitForLoad: 5000,
     onlyAudits: [
       'first-contentful-paint',
+      // TODO: this audit collects a non-base artifact, allowing the runtimeError to be collected.
+      'content-width',
     ],
   },
 };
diff --git a/lighthouse-cli/test/smokehouse/error-expectations.js b/lighthouse-cli/test/smokehouse/error-expectations.js
index c7d436833..0d2e40ac4 100644
--- a/lighthouse-cli/test/smokehouse/error-expectations.js
+++ b/lighthouse-cli/test/smokehouse/error-expectations.js
@@ -14,15 +14,23 @@ module.exports = [
       requestedUrl: 'http://localhost:10200/infinite-loop.html',
       finalUrl: 'http://localhost:10200/infinite-loop.html',
       audits: {},
+      // TODO: runtimeError only exists because of selection of audits.
       runtimeError: {code: 'PAGE_HUNG'},
     },
+    artifacts: {
+      ViewportDimensions: {code: 'PAGE_HUNG'},
+    },
   },
   {
     lhr: {
       requestedUrl: 'https://expired.badssl.com',
       finalUrl: 'https://expired.badssl.com/',
       audits: {},
+      // TODO: runtimeError only exists because of selection of audits.
       runtimeError: {code: 'FAILED_DOCUMENT_REQUEST'},
     },
+    artifacts: {
+      ViewportDimensions: {code: 'FAILED_DOCUMENT_REQUEST'},
+    },
   },
 ];
diff --git a/lighthouse-cli/test/smokehouse/seo/expectations.js b/lighthouse-cli/test/smokehouse/seo/expectations.js
index f5b9a7b18..098d0f035 100644
--- a/lighthouse-cli/test/smokehouse/seo/expectations.js
+++ b/lighthouse-cli/test/smokehouse/seo/expectations.js
@@ -210,6 +210,10 @@ module.exports = [
       // Note: most scores are null (audit error) because the page 403ed.
       requestedUrl: BASE_URL + 'seo-failure-cases.html?status_code=403',
       finalUrl: BASE_URL + 'seo-failure-cases.html?status_code=403',
+      runtimeError: {
+        code: 'ERRORED_DOCUMENT_REQUEST',
+        message: /Status code: 403/,
+      },
       audits: {
         'http-status-code': {
           score: 0,
diff --git a/lighthouse-cli/test/smokehouse/smokehouse-report.js b/lighthouse-cli/test/smokehouse/smokehouse-report.js
index f3f4f04b0..5f670a38a 100644
--- a/lighthouse-cli/test/smokehouse/smokehouse-report.js
+++ b/lighthouse-cli/test/smokehouse/smokehouse-report.js
@@ -126,6 +126,11 @@ function makeComparison(name, actualResult, expectedResult) {
  * @return {Smokehouse.Comparison[]}
  */
 function collateResults(actual, expected) {
+  // If actual run had a runtimeError, expected *must* have a runtimeError.
+  // Relies on the fact that an `undefined` argument to makeComparison() can only match `undefined`.
+  const runtimeErrorAssertion = makeComparison('runtimeError', actual.lhr.runtimeError,
+      expected.lhr.runtimeError);
+
   /** @type {Smokehouse.Comparison[]} */
   let artifactAssertions = [];
   if (expected.artifacts) {
@@ -161,6 +166,7 @@ function collateResults(actual, expected) {
       expected: expected.lhr.finalUrl,
       equal: actual.lhr.finalUrl === expected.lhr.finalUrl,
     },
+    runtimeErrorAssertion,
     ...artifactAssertions,
     ...auditAssertions,
   ];
@@ -194,7 +200,8 @@ function reportAssertion(assertion) {
   } else {
     if (assertion.diff) {
       const diff = assertion.diff;
-      const fullActual = JSON.stringify(assertion.actual, null, 2).replace(/\n/g, '\n      ');
+      const fullActual = String(JSON.stringify(assertion.actual, null, 2))
+          .replace(/\n/g, '\n      ');
       const msg = `
   ${log.redify(log.cross)} difference at ${log.bold}${diff.path}${log.reset}
               expected: ${JSON.stringify(diff.expected)}
diff --git a/lighthouse-cli/test/smokehouse/smokehouse.js b/lighthouse-cli/test/smokehouse/smokehouse.js
index 0d3f5ff3f..c39b4f481 100755
--- a/lighthouse-cli/test/smokehouse/smokehouse.js
+++ b/lighthouse-cli/test/smokehouse/smokehouse.js
@@ -13,7 +13,10 @@ const path = require('path');
 const spawnSync = require('child_process').spawnSync;
 const yargs = require('yargs');
 const log = require('lighthouse-logger');
+const rimraf = require('rimraf');
+
 const {collateResults, report} = require('./smokehouse-report.js');
+const assetSaver = require('../../../lighthouse-core/lib/asset-saver.js');
 
 const PROTOCOL_TIMEOUT_EXIT_CODE = 67;
 const RETRIES = 3;
@@ -88,27 +91,34 @@ function runLighthouse(url, configPath, isDebug) {
     console.error(`STDERR: ${runResults.stderr}`);
   }
 
-  if (runResults.status === PROTOCOL_TIMEOUT_EXIT_CODE) {
+  const exitCode = runResults.status;
+  if (exitCode === PROTOCOL_TIMEOUT_EXIT_CODE) {
     console.error(`Lighthouse debugger connection timed out ${RETRIES} times. Giving up.`);
     process.exit(1);
   } else if (!fs.existsSync(outputPath)) {
-    console.error(`Lighthouse run failed to produce a report and exited with ${runResults.status}. stderr to follow:`); // eslint-disable-line max-len
+    console.error(`Lighthouse run failed to produce a report and exited with ${exitCode}. stderr to follow:`); // eslint-disable-line max-len
     console.error(runResults.stderr);
-    process.exit(runResults.status);
+    process.exit(exitCode);
   }
 
+  /** @type {LH.Result} */
   const lhr = JSON.parse(fs.readFileSync(outputPath, 'utf8'));
+  const artifacts = assetSaver.loadArtifacts(artifactsDirectory);
+
   if (isDebug) {
     console.log('LHR output available at: ', outputPath);
+    console.log('Artifacts avaiable in: ', artifactsDirectory);
   } else {
     fs.unlinkSync(outputPath);
+    rimraf.sync(artifactsDirectory);
   }
 
-  // Artifacts are undefined if they weren't written to disk (e.g. if there was an error).
-  let artifacts;
-  try {
-    artifacts = JSON.parse(fs.readFileSync(`${artifactsDirectory}/artifacts.json`, 'utf8'));
-  } catch (e) {}
+  // There should either be both an error exitCode and a lhr.runtimeError or neither.
+  if (Boolean(exitCode) !== Boolean(lhr.runtimeError)) {
+    const runtimeErrorCode = lhr.runtimeError && lhr.runtimeError.code;
+    console.error(`Lighthouse did not exit with an error correctly, exiting with ${exitCode} but with runtimeError '${runtimeErrorCode}'`); // eslint-disable-line max-len
+    process.exit(1);
+  }
 
   return {
     lhr,
diff --git a/lighthouse-core/test/computed/metrics/first-cpu-idle-test.js b/lighthouse-core/test/computed/metrics/first-cpu-idle-test.js
index cfb21da04..6233f46a4 100644
--- a/lighthouse-core/test/computed/metrics/first-cpu-idle-test.js
+++ b/lighthouse-core/test/computed/metrics/first-cpu-idle-test.js
@@ -6,7 +6,7 @@
 'use strict';
 
 const FirstCPUIdle = require('../../../computed/metrics/first-cpu-idle.js');
-const TracingProcessor = require('../../../lib/tracehouse/tracing-processor.js');
+const TracingProcessor = require('../../../lib/tracehouse/trace-processor.js');
 
 const tooShortTrace = require('../../fixtures/traces/progressive-app.json');
 const acceptableTrace = require('../../fixtures/traces/progressive-app-m60.json');
diff --git a/lighthouse-core/test/computed/trace-of-tab-test.js b/lighthouse-core/test/computed/trace-of-tab-test.js
index 4c14d71ca..3b337276c 100644
--- a/lighthouse-core/test/computed/trace-of-tab-test.js
+++ b/lighthouse-core/test/computed/trace-of-tab-test.js
@@ -9,6 +9,8 @@
 
 const TraceOfTab = require('../../computed/trace-of-tab.js');
 const pwaTrace = require('../fixtures/traces/progressive-app-m60.json');
+const noFCPtrace = require('../fixtures/traces/airhorner_no_fcp.json');
+const noNavStartTrace = require('../fixtures/traces/no_navstart_event.json');
 
 describe('TraceOfTabComputed', () => {
   it('computes the artifact', async () => {
@@ -122,4 +124,34 @@ describe('TraceOfTabComputed', () => {
       },
     });
   });
+
+  it('fails with NO_NAVSTART', async () => {
+    const context = {computedCache: new Map()};
+    await expect(TraceOfTab.request(noNavStartTrace, context))
+      .rejects.toMatchObject({code: 'NO_NAVSTART'});
+  });
+
+  it('fails with NO_FCP', async () => {
+    const context = {computedCache: new Map()};
+    await expect(TraceOfTab.request(noFCPtrace, context))
+      .rejects.toMatchObject({code: 'NO_FCP'});
+  });
+
+  it('fails with NO_TRACING_STARTED', async () => {
+    const context = {computedCache: new Map()};
+    const noTracingStartedTrace = {
+      traceEvents: pwaTrace.traceEvents.filter(event => {
+        if (event.name === 'TracingStartedInBrowser' ||
+            event.name === 'TracingStartedInPage' ||
+            event.name === 'ResourceSendRequest') {
+          return false;
+        }
+
+        return true;
+      }),
+    };
+
+    await expect(TraceOfTab.request(noTracingStartedTrace, context))
+      .rejects.toMatchObject({code: 'NO_TRACING_STARTED'});
+  });
 });
diff --git a/lighthouse-core/test/lib/tracehouse/main-thread-tasks-test.js b/lighthouse-core/test/lib/tracehouse/main-thread-tasks-test.js
index fbfdc5234..d73120baa 100644
--- a/lighthouse-core/test/lib/tracehouse/main-thread-tasks-test.js
+++ b/lighthouse-core/test/lib/tracehouse/main-thread-tasks-test.js
@@ -8,10 +8,10 @@
 /* eslint-env jest */
 
 const MainThreadTasks = require('../../../lib/tracehouse/main-thread-tasks.js');
-const TraceOfTab = require('../../../lib/tracehouse/trace-of-tab.js');
+const TraceProcessor = require('../../../lib/tracehouse/trace-processor.js');
 const taskGroups = require('../../../lib/tracehouse/task-groups.js').taskGroups;
 const pwaTrace = require('../../fixtures/traces/progressive-app.json');
-const TracingProcessor = require('../../../lib/tracehouse/tracing-processor.js');
+const TracingProcessor = require('../../../lib/tracehouse/trace-processor.js');
 const assert = require('assert');
 
 describe('Main Thread Tasks', () => {
@@ -31,7 +31,7 @@ describe('Main Thread Tasks', () => {
   });
 
   function run(trace) {
-    const {mainThreadEvents, timestamps} = TraceOfTab.compute(trace);
+    const {mainThreadEvents, timestamps} = TraceProcessor.computeTraceOfTab(trace);
     return MainThreadTasks.getMainThreadTasks(mainThreadEvents, timestamps.traceEnd);
   }
 
diff --git a/lighthouse-core/test/lib/tracehouse/trace-of-tab-test.js b/lighthouse-core/test/lib/tracehouse/trace-of-tab-test.js
deleted file mode 100644
index a8fee7340..000000000
--- a/lighthouse-core/test/lib/tracehouse/trace-of-tab-test.js
+++ /dev/null
@@ -1,209 +0,0 @@
-/**
- * @license Copyright 2017 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 TraceOfTab = require('../../../lib/tracehouse/trace-of-tab.js');
-
-const assert = require('assert');
-const fs = require('fs');
-const badNavStartTrace = require('../../fixtures/traces/bad-nav-start-ts.json');
-const lateTracingStartedTrace = require('../../fixtures/traces/tracingstarted-after-navstart.json');
-const noTracingStartedTrace = require('../../fixtures/traces/no-tracingstarted-m74.json');
-const preactTrace = require('../../fixtures/traces/preactjs.com_ts_of_undefined.json');
-const noFMPtrace = require('../../fixtures/traces/no_fmp_event.json');
-const noFCPtrace = require('../../fixtures/traces/airhorner_no_fcp.json');
-const noNavStartTrace = require('../../fixtures/traces/no_navstart_event.json');
-const backgroundTabTrace = require('../../fixtures/traces/backgrounded-tab-missing-paints.json');
-
-/* eslint-env jest */
-describe('Trace of Tab computed artifact:', () => {
-  it('gathers the events from the tab\'s process', () => {
-    const trace = TraceOfTab.compute(lateTracingStartedTrace);
-
-    const firstEvt = trace.processEvents[0];
-    trace.processEvents.forEach(evt => {
-      assert.equal(evt.pid, firstEvt.pid, 'A traceEvent is found from another process');
-    });
-
-    assert.ok(firstEvt.pid === trace.mainFrameIds.pid);
-    assert.ok(firstEvt.pid === trace.navigationStartEvt.pid);
-    assert.ok(firstEvt.pid === trace.firstContentfulPaintEvt.pid);
-    assert.ok(firstEvt.pid === trace.firstMeaningfulPaintEvt.pid);
-  });
-
-  it('computes timings of each event', () => {
-    const trace = TraceOfTab.compute(lateTracingStartedTrace);
-    assert.equal(Math.round(trace.timings.navigationStart), 0);
-    assert.equal(Math.round(trace.timings.firstPaint), 80);
-    assert.equal(Math.round(trace.timings.firstContentfulPaint), 80);
-    assert.equal(Math.round(trace.timings.firstMeaningfulPaint), 530);
-    assert.equal(Math.round(trace.timings.traceEnd), 649);
-  });
-
-  it('computes timestamps of each event', () => {
-    const trace = TraceOfTab.compute(lateTracingStartedTrace);
-    assert.equal(Math.round(trace.timestamps.navigationStart), 29343540951);
-    assert.equal(Math.round(trace.timestamps.firstPaint), 29343620997);
-    assert.equal(Math.round(trace.timestamps.firstContentfulPaint), 29343621005);
-    assert.equal(Math.round(trace.timestamps.firstMeaningfulPaint), 29344070867);
-    assert.equal(Math.round(trace.timestamps.traceEnd), 29344190223);
-  });
-
-  describe('finds correct FMP', () => {
-    it('if there was a tracingStartedInPage after the frame\'s navStart', () => {
-      const trace = TraceOfTab.compute(lateTracingStartedTrace);
-      assert.equal(trace.mainFrameIds.frameId, '0x163736997740');
-      assert.equal(trace.navigationStartEvt.ts, 29343540951);
-      assert.equal(trace.firstContentfulPaintEvt.ts, 29343621005);
-      assert.equal(trace.firstMeaningfulPaintEvt.ts, 29344070867);
-      assert.ok(!trace.fmpFellBack);
-    });
-
-    it('if there was a tracingStartedInPage after the frame\'s navStart #2', () => {
-      const trace = TraceOfTab.compute(badNavStartTrace);
-      assert.equal(trace.mainFrameIds.frameId, '0x89915541e48');
-      assert.equal(trace.navigationStartEvt.ts, 8885424467);
-      assert.equal(trace.firstContentfulPaintEvt.ts, 8886056886);
-      assert.equal(trace.firstMeaningfulPaintEvt.ts, 8886056891);
-      assert.ok(!trace.fmpFellBack);
-    });
-
-    it('if it appears slightly before the fCP', () => {
-      const trace = TraceOfTab.compute(preactTrace);
-      assert.equal(trace.mainFrameIds.frameId, '0x25edaa521e58');
-      assert.equal(trace.navigationStartEvt.ts, 1805796384607);
-      assert.equal(trace.firstContentfulPaintEvt.ts, 1805797263653);
-      assert.equal(trace.firstMeaningfulPaintEvt.ts, 1805797262960);
-      assert.ok(!trace.fmpFellBack);
-    });
-
-    it('from candidates if no defined FMP exists', () => {
-      const trace = TraceOfTab.compute(noFMPtrace);
-      assert.equal(trace.mainFrameIds.frameId, '0x150343381dd0');
-      assert.equal(trace.navigationStartEvt.ts, 2146735807738);
-      assert.equal(trace.firstContentfulPaintEvt.ts, 2146737302468);
-      assert.equal(trace.firstMeaningfulPaintEvt.ts, 2146740268666);
-      assert.ok(trace.fmpFellBack);
-    });
-  });
-
-  it('handles traces missing a paints (captured in background tab)', () => {
-    const trace = TraceOfTab.compute(backgroundTabTrace);
-    assert.equal(trace.mainFrameIds.frameId, '0x53965941e30');
-    assert.notEqual(trace.navigationStartEvt.ts, 1966813346529, 'picked wrong frame');
-    assert.notEqual(trace.navigationStartEvt.ts, 1966813520313, 'picked wrong frame');
-    assert.equal(
-      trace.navigationStartEvt.ts,
-      1966813258737,
-      'didnt select navStart event with same timestamp as usertiming measure'
-    );
-    assert.equal(trace.firstMeaningfulPaintEvt, undefined, 'bad fmp');
-  });
-
-  it('handles traces with TracingStartedInBrowser events', () => {
-    const tracingStartedInBrowserTrace = {
-      'traceEvents': [{
-        'pid': 69850,
-        'tid': 69850,
-        'ts': 2193564729582,
-        'ph': 'I',
-        'cat': 'disabled-by-default-devtools.timeline',
-        'name': 'TracingStartedInBrowser',
-        'args': {'data': {
-          'frameTreeNodeId': 1,
-          'frames': [{
-            'frame': 'B192D1F3355A6F961EC8F0B01623C1FB',
-            'url': 'http://www.example.com/',
-            'name': '',
-            'processId': 69920,
-          }],
-        }},
-        'tts': 1085165,
-        's': 't',
-      }, {
-        'pid': 69920,
-        'tid': 1,
-        'ts': 2193564790059,
-        'ph': 'R',
-        'cat': 'blink.user_timing',
-        'name': 'navigationStart',
-        'args': {
-          'frame': 'B192D1F3355A6F961EC8F0B01623C1FB',
-          'data': {
-            'documentLoaderURL': 'http://www.example.com/',
-            'isLoadingMainFrame': true,
-          },
-        },
-        'tts': 141371,
-      }, {
-        'pid': 69920,
-        'tid': 1,
-        'ts': 2193564790060,
-        'ph': 'R',
-        'cat': 'loading,rail,devtools.timeline',
-        'name': 'firstContentfulPaint',
-        'args': {
-          'frame': 'B192D1F3355A6F961EC8F0B01623C1FB',
-        },
-        'tts': 141372,
-      }, {
-        'pid': 69920,
-        'tid': 1,
-        'ts': 0,
-        'ph': 'M',
-        'cat': '__metadata',
-        'name': 'thread_name',
-        'args': {'name': 'CrRendererMain'},
-      }]};
-    const trace = TraceOfTab.compute(tracingStartedInBrowserTrace);
-    assert.equal(trace.mainFrameIds.frameId, 'B192D1F3355A6F961EC8F0B01623C1FB');
-    assert.equal(trace.navigationStartEvt.ts, 2193564790059);
-  });
-
-  it('handles no TracingStarted errors in m74+', () => {
-    const trace = TraceOfTab.compute(noTracingStartedTrace);
-    expect(trace.mainFrameIds.frameId).toEqual('0E0B1AF0B1BA04676037345D18A71577');
-    expect(trace.firstContentfulPaintEvt.ts).toEqual(2610265036367);
-  });
-
-  it('stably sorts events', () => {
-    const traceJson = fs.readFileSync(__dirname +
-        '/../../fixtures/traces/tracingstarted-after-navstart.json', 'utf8');
-    const trace = TraceOfTab.compute(JSON.parse(traceJson));
-    const mainPid = trace.mainThreadEvents[0].pid;
-
-    const freshProcessEvents = JSON.parse(traceJson).traceEvents
-        .filter(e => e.pid === mainPid);
-
-    // Group all events with the same timestamp in original trace order.
-    const tsMap = new Map();
-    for (const event of freshProcessEvents) {
-      const tsGroup = tsMap.get(event.ts) || [];
-      tsGroup.push(event);
-      tsMap.set(event.ts, tsGroup);
-    }
-
-    // Assert that groups of same-timestamped events are in the same order in
-    // processed events.
-    for (const [ts, tsGroup] of tsMap) {
-      if (tsGroup.length === 1) {
-        continue;
-      }
-
-      // .filter overhead could be slow, but only a handful of tsGroups.
-      const sortedEvents = trace.processEvents.filter(e => e.ts === ts);
-      assert.deepStrictEqual(sortedEvents, tsGroup);
-    }
-  });
-
-  it('throws on traces missing a navigationStart', () => {
-    expect(() =>TraceOfTab.compute(noNavStartTrace)).toThrowError('NO_NAVSTART');
-  });
-
-  it('throws on traces missing an FCP', () => {
-    expect(() =>TraceOfTab.compute(noFCPtrace)).toThrowError('NO_FCP');
-  });
-});
diff --git a/lighthouse-core/test/lib/tracehouse/trace-processor-test.js b/lighthouse-core/test/lib/tracehouse/trace-processor-test.js
new file mode 100644
index 000000000..66e1204d0
--- /dev/null
+++ b/lighthouse-core/test/lib/tracehouse/trace-processor-test.js
@@ -0,0 +1,444 @@
+/**
+ * @license Copyright 2017 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 TraceProcessor = require('../../../lib/tracehouse/trace-processor.js');
+
+const assert = require('assert');
+const fs = require('fs');
+const pwaTrace = require('../../fixtures/traces/progressive-app.json');
+const badNavStartTrace = require('../../fixtures/traces/bad-nav-start-ts.json');
+const lateTracingStartedTrace = require('../../fixtures/traces/tracingstarted-after-navstart.json');
+const noTracingStartedTrace = require('../../fixtures/traces/no-tracingstarted-m74.json');
+const preactTrace = require('../../fixtures/traces/preactjs.com_ts_of_undefined.json');
+const noFMPtrace = require('../../fixtures/traces/no_fmp_event.json');
+const noFCPtrace = require('../../fixtures/traces/airhorner_no_fcp.json');
+const noNavStartTrace = require('../../fixtures/traces/no_navstart_event.json');
+const backgroundTabTrace = require('../../fixtures/traces/backgrounded-tab-missing-paints.json');
+
+/* eslint-env jest */
+
+describe('TraceProcessor', () => {
+  describe('_riskPercentiles', () => {
+    const defaultPercentiles = [0, 0.25, 0.5, 0.75, 0.9, 0.99, 1];
+
+    /**
+     * Create a riskPercentiles result object by matching the values in percentiles
+     * and times.
+     * @param {!Array<number>} percentiles
+     * @param {!Array<number>} times
+     * @return {!Array<{percentile: number, time: number}>}
+     */
+    function createRiskPercentiles(percentiles, times) {
+      return percentiles.map((percentile, index) => {
+        return {
+          percentile,
+          time: times[index],
+        };
+      });
+    }
+
+    it('correctly calculates percentiles of no tasks', () => {
+      const results = TraceProcessor._riskPercentiles([], 100, defaultPercentiles);
+      const expected = createRiskPercentiles(defaultPercentiles, [16, 16, 16, 16, 16, 16, 16]);
+      assert.deepEqual(results, expected);
+    });
+
+    it('correctly calculates percentiles of a single task with idle time', () => {
+      const results = TraceProcessor._riskPercentiles([50], 100, defaultPercentiles);
+      const expected = createRiskPercentiles(defaultPercentiles, [16, 16, 16, 41, 56, 65, 66]);
+      assert.deepEqual(results, expected);
+    });
+
+    it('correctly calculates percentiles of a single task with no idle time', () => {
+      const results = TraceProcessor._riskPercentiles([50], 50, defaultPercentiles);
+      const expected = createRiskPercentiles(defaultPercentiles,
+          [16, 28.5, 41, 53.5, 61, 65.5, 66]);
+      assert.deepEqual(results, expected);
+    });
+
+    it('correctly calculates percentiles of several equal-length tasks', () => {
+      const results = TraceProcessor._riskPercentiles([50, 50, 50, 50], 400, defaultPercentiles);
+      const expected = createRiskPercentiles(defaultPercentiles, [16, 16, 16, 41, 56, 65, 66]);
+      assert.deepEqual(results, expected);
+    });
+
+    it('correctly calculates percentiles of tasks including zero-length durations', () => {
+      const results = TraceProcessor._riskPercentiles([0, 0, 0, 10, 20, 20, 30, 30, 120], 320,
+          defaultPercentiles);
+      const expected = createRiskPercentiles(defaultPercentiles, [16, 16, 28, 56, 104, 132.8, 136]);
+      assert.deepEqual(results, expected);
+    });
+
+    // Three tasks of one second each, all within a five-second window.
+    // Mean Queueing Time of 300ms.
+    it('correctly calculates percentiles of three one-second tasks in a five-second window', () => {
+      const results = TraceProcessor._riskPercentiles([1000, 1000, 1000], 5000,
+          defaultPercentiles, 0);
+      // Round to hundredths to simplify floating point comparison.
+      results.forEach(result => {
+        result.time = Number(result.time.toFixed(2));
+      });
+
+      const expected = createRiskPercentiles(defaultPercentiles,
+          [16, 16, 182.67, 599.33, 849.33, 999.33, 1016]);
+      assert.deepEqual(results, expected);
+    });
+
+    it('correctly calculates percentiles of tasks with a clipped task', () => {
+      const results = TraceProcessor._riskPercentiles([10, 20, 50, 60, 90, 100], 300,
+          defaultPercentiles, 30);
+      // Round to hundredths to simplify floating point comparison.
+      results.forEach(result => {
+        result.time = Number(result.time.toFixed(2));
+      });
+
+      const expected = createRiskPercentiles(defaultPercentiles,
+          [16, 32.25, 53.5, 74.33, 96, 113, 116]);
+      assert.deepEqual(results, expected);
+    });
+
+    // One 20 second long task over three five-second windows.
+    it('correctly calculates percentiles of single task over multiple windows', () => {
+      // Starts 3 seconds into the first window. Mean Queueing Time = 7600ms.
+      const TASK_LENGTH = 20000;
+      const results1 = TraceProcessor._riskPercentiles([TASK_LENGTH], 5000,
+          defaultPercentiles, TASK_LENGTH - 2000);
+      const expected1 = createRiskPercentiles(defaultPercentiles,
+          [16, 16, 16, 18766, 19516, 19966, 20016]);
+      assert.deepEqual(results1, expected1);
+
+      // Starts 2 seconds before and ends 13 seconds after. Mean Queueing Time = 15500ms.
+      const results2 = TraceProcessor._riskPercentiles([TASK_LENGTH - 2000], 5000,
+          defaultPercentiles, TASK_LENGTH - 7000);
+      const expected2 = createRiskPercentiles(defaultPercentiles,
+          [16, 14266, 15516, 16766, 17516, 17966, 18016]);
+      assert.deepEqual(results2, expected2);
+
+      // Starts 17 seconds before and ends 3 seconds into the window. Mean Queueing Time = 900ms.
+      const results3 = TraceProcessor._riskPercentiles([TASK_LENGTH - 17000], 5000,
+          defaultPercentiles, 0);
+      const expected3 = createRiskPercentiles(defaultPercentiles,
+          [16, 16, 516, 1766, 2516, 2966, 3016]);
+      assert.deepEqual(results3, expected3);
+    });
+
+    it('correctly calculates with a task shorter than the clipped length of another', () => {
+      const results = TraceProcessor._riskPercentiles([40, 100], 100,
+          defaultPercentiles, 50);
+      const expected = createRiskPercentiles(defaultPercentiles,
+          [16, 31, 56, 91, 106, 115, 116]);
+      assert.deepEqual(results, expected);
+    });
+
+    it('correctly calculates with a task clipped completely', () => {
+      const results = TraceProcessor._riskPercentiles([40, 100], 100,
+          defaultPercentiles, 100);
+      const expected = createRiskPercentiles(defaultPercentiles,
+          [16, 16, 16, 31, 46, 55, 56]);
+      assert.deepEqual(results, expected);
+    });
+
+    it('does not divide by zero when duration sum is less than whole', () => {
+      // Durations chosen such that, due to floating point error:
+      //   const idleTime = totalTime - (duration1 + duration2);
+      //   (idleTime + duration1 + duration2) < totalTime
+      const duration1 = 67 / 107;
+      const duration2 = 67 / 53;
+      const totalTime = 10;
+      const results = TraceProcessor._riskPercentiles([duration1, duration2], totalTime, [1], 0);
+      const expected = createRiskPercentiles([1], [16 + duration2]);
+      assert.deepEqual(results, expected);
+    });
+  });
+
+  describe('getMainThreadTopLevelEvents', () => {
+    it('gets durations of top-level tasks', () => {
+      const trace = {traceEvents: pwaTrace};
+      const tabTrace = TraceProcessor.computeTraceOfTab(trace);
+      const ret = TraceProcessor.getMainThreadTopLevelEvents(tabTrace);
+
+      assert.equal(ret.length, 645);
+    });
+
+    it('filters events based on start and end times', () => {
+      const baseTime = 20000 * 1000;
+      const name = 'TaskQueueManager::ProcessTaskFromWorkQueue';
+      const tabTrace = {
+        navigationStartEvt: {ts: baseTime},
+        mainThreadEvents: [
+          // 15ms to 25ms
+          {ts: baseTime + 15 * 1000, dur: 10 * 1000, name},
+          // 40ms to 60ms
+          {ts: baseTime + 40 * 1000, dur: 20 * 1000, name},
+          // 1000ms to 2000ms
+          {ts: baseTime + 1000 * 1000, dur: 1000 * 1000, name},
+          // 4000ms to 4020ms
+          {ts: baseTime + 4000 * 1000, dur: 20 * 1000, name},
+        ],
+      };
+
+      const ret = TraceProcessor.getMainThreadTopLevelEvents(
+        tabTrace,
+        50,
+        1500
+      );
+      assert.equal(ret.length, 2);
+      assert.equal(ret[0].start, 40);
+      assert.equal(ret[0].end, 60);
+      assert.equal(ret[0].duration, 20);
+      assert.equal(ret[1].start, 1000);
+      assert.equal(ret[1].end, 2000);
+      assert.equal(ret[1].duration, 1000);
+    });
+  });
+
+  describe('getMainThreadTopLevelEventDurations', () => {
+    it('gets durations of top-level tasks', async () => {
+      const trace = {traceEvents: pwaTrace};
+      const tabTrace = TraceProcessor.computeTraceOfTab(trace);
+      const events = TraceProcessor.getMainThreadTopLevelEvents(tabTrace);
+      const ret = TraceProcessor.getMainThreadTopLevelEventDurations(events);
+      const durations = ret.durations;
+
+      function getDurationFromIndex(index) {
+        return Number(durations[index].toFixed(2));
+      }
+
+      assert.equal(durations.filter(dur => isNaN(dur)).length, 0, 'NaN found');
+      assert.equal(durations.length, 645);
+
+      const sum = durations.reduce((a, b) => a + b);
+      assert.equal(Math.round(sum), 386);
+
+      assert.equal(getDurationFromIndex(50), 0.01);
+      assert.equal(getDurationFromIndex(300), 0.04);
+      assert.equal(getDurationFromIndex(400), 0.07);
+      assert.equal(getDurationFromIndex(durations.length - 3), 26.01);
+      assert.equal(getDurationFromIndex(durations.length - 2), 36.9);
+      assert.equal(getDurationFromIndex(durations.length - 1), 38.53);
+    });
+  });
+
+  describe('getRiskToResponsiveness', () => {
+    let oldFn;
+    // monkeypatch _riskPercentiles to test just getRiskToResponsiveness
+    beforeEach(() => {
+      oldFn = TraceProcessor._riskPercentiles;
+      TraceProcessor._riskPercentiles = (durations, totalTime, percentiles, clippedLength) => {
+        return {
+          durations, totalTime, percentiles, clippedLength,
+        };
+      };
+    });
+
+    it('compute correct defaults', async () => {
+      const trace = {traceEvents: pwaTrace};
+      const tabTrace = TraceProcessor.computeTraceOfTab(trace);
+      const events = TraceProcessor.getMainThreadTopLevelEvents(tabTrace);
+      const ret = TraceProcessor.getRiskToResponsiveness(events, 0, tabTrace.timings.traceEnd);
+      assert.equal(ret.durations.length, 645);
+      assert.equal(Math.round(ret.totalTime), 2143);
+      assert.equal(ret.clippedLength, 0);
+      assert.deepEqual(ret.percentiles, [0.5, 0.75, 0.9, 0.99, 1]);
+    });
+
+    afterEach(() => {
+      TraceProcessor._riskPercentiles = oldFn;
+    });
+  });
+
+  describe('computeTraceOfTab', () => {
+    it('gathers the events from the tab\'s process', () => {
+      const trace = TraceProcessor.computeTraceOfTab(lateTracingStartedTrace);
+
+      const firstEvt = trace.processEvents[0];
+      trace.processEvents.forEach(evt => {
+        assert.equal(evt.pid, firstEvt.pid, 'A traceEvent is found from another process');
+      });
+
+      assert.ok(firstEvt.pid === trace.mainFrameIds.pid);
+      assert.ok(firstEvt.pid === trace.navigationStartEvt.pid);
+      assert.ok(firstEvt.pid === trace.firstContentfulPaintEvt.pid);
+      assert.ok(firstEvt.pid === trace.firstMeaningfulPaintEvt.pid);
+    });
+
+    it('computes timings of each event', () => {
+      const trace = TraceProcessor.computeTraceOfTab(lateTracingStartedTrace);
+      assert.equal(Math.round(trace.timings.navigationStart), 0);
+      assert.equal(Math.round(trace.timings.firstPaint), 80);
+      assert.equal(Math.round(trace.timings.firstContentfulPaint), 80);
+      assert.equal(Math.round(trace.timings.firstMeaningfulPaint), 530);
+      assert.equal(Math.round(trace.timings.traceEnd), 649);
+    });
+
+    it('computes timestamps of each event', () => {
+      const trace = TraceProcessor.computeTraceOfTab(lateTracingStartedTrace);
+      assert.equal(Math.round(trace.timestamps.navigationStart), 29343540951);
+      assert.equal(Math.round(trace.timestamps.firstPaint), 29343620997);
+      assert.equal(Math.round(trace.timestamps.firstContentfulPaint), 29343621005);
+      assert.equal(Math.round(trace.timestamps.firstMeaningfulPaint), 29344070867);
+      assert.equal(Math.round(trace.timestamps.traceEnd), 29344190223);
+    });
+
+    describe('finds correct FMP', () => {
+      it('if there was a tracingStartedInPage after the frame\'s navStart', () => {
+        const trace = TraceProcessor.computeTraceOfTab(lateTracingStartedTrace);
+        assert.equal(trace.mainFrameIds.frameId, '0x163736997740');
+        assert.equal(trace.navigationStartEvt.ts, 29343540951);
+        assert.equal(trace.firstContentfulPaintEvt.ts, 29343621005);
+        assert.equal(trace.firstMeaningfulPaintEvt.ts, 29344070867);
+        assert.ok(!trace.fmpFellBack);
+      });
+
+      it('if there was a tracingStartedInPage after the frame\'s navStart #2', () => {
+        const trace = TraceProcessor.computeTraceOfTab(badNavStartTrace);
+        assert.equal(trace.mainFrameIds.frameId, '0x89915541e48');
+        assert.equal(trace.navigationStartEvt.ts, 8885424467);
+        assert.equal(trace.firstContentfulPaintEvt.ts, 8886056886);
+        assert.equal(trace.firstMeaningfulPaintEvt.ts, 8886056891);
+        assert.ok(!trace.fmpFellBack);
+      });
+
+      it('if it appears slightly before the fCP', () => {
+        const trace = TraceProcessor.computeTraceOfTab(preactTrace);
+        assert.equal(trace.mainFrameIds.frameId, '0x25edaa521e58');
+        assert.equal(trace.navigationStartEvt.ts, 1805796384607);
+        assert.equal(trace.firstContentfulPaintEvt.ts, 1805797263653);
+        assert.equal(trace.firstMeaningfulPaintEvt.ts, 1805797262960);
+        assert.ok(!trace.fmpFellBack);
+      });
+
+      it('from candidates if no defined FMP exists', () => {
+        const trace = TraceProcessor.computeTraceOfTab(noFMPtrace);
+        assert.equal(trace.mainFrameIds.frameId, '0x150343381dd0');
+        assert.equal(trace.navigationStartEvt.ts, 2146735807738);
+        assert.equal(trace.firstContentfulPaintEvt.ts, 2146737302468);
+        assert.equal(trace.firstMeaningfulPaintEvt.ts, 2146740268666);
+        assert.ok(trace.fmpFellBack);
+      });
+    });
+
+    it('handles traces missing a paints (captured in background tab)', () => {
+      const trace = TraceProcessor.computeTraceOfTab(backgroundTabTrace);
+      assert.equal(trace.mainFrameIds.frameId, '0x53965941e30');
+      assert.notEqual(trace.navigationStartEvt.ts, 1966813346529, 'picked wrong frame');
+      assert.notEqual(trace.navigationStartEvt.ts, 1966813520313, 'picked wrong frame');
+      assert.equal(
+        trace.navigationStartEvt.ts,
+        1966813258737,
+        'didnt select navStart event with same timestamp as usertiming measure'
+      );
+      assert.equal(trace.firstMeaningfulPaintEvt, undefined, 'bad fmp');
+    });
+
+    it('handles traces with TracingStartedInBrowser events', () => {
+      const tracingStartedInBrowserTrace = {
+        'traceEvents': [{
+          'pid': 69850,
+          'tid': 69850,
+          'ts': 2193564729582,
+          'ph': 'I',
+          'cat': 'disabled-by-default-devtools.timeline',
+          'name': 'TracingStartedInBrowser',
+          'args': {'data': {
+            'frameTreeNodeId': 1,
+            'frames': [{
+              'frame': 'B192D1F3355A6F961EC8F0B01623C1FB',
+              'url': 'http://www.example.com/',
+              'name': '',
+              'processId': 69920,
+            }],
+          }},
+          'tts': 1085165,
+          's': 't',
+        }, {
+          'pid': 69920,
+          'tid': 1,
+          'ts': 2193564790059,
+          'ph': 'R',
+          'cat': 'blink.user_timing',
+          'name': 'navigationStart',
+          'args': {
+            'frame': 'B192D1F3355A6F961EC8F0B01623C1FB',
+            'data': {
+              'documentLoaderURL': 'http://www.example.com/',
+              'isLoadingMainFrame': true,
+            },
+          },
+          'tts': 141371,
+        }, {
+          'pid': 69920,
+          'tid': 1,
+          'ts': 2193564790060,
+          'ph': 'R',
+          'cat': 'loading,rail,devtools.timeline',
+          'name': 'firstContentfulPaint',
+          'args': {
+            'frame': 'B192D1F3355A6F961EC8F0B01623C1FB',
+          },
+          'tts': 141372,
+        }, {
+          'pid': 69920,
+          'tid': 1,
+          'ts': 0,
+          'ph': 'M',
+          'cat': '__metadata',
+          'name': 'thread_name',
+          'args': {'name': 'CrRendererMain'},
+        }]};
+      const trace = TraceProcessor.computeTraceOfTab(tracingStartedInBrowserTrace);
+      assert.equal(trace.mainFrameIds.frameId, 'B192D1F3355A6F961EC8F0B01623C1FB');
+      assert.equal(trace.navigationStartEvt.ts, 2193564790059);
+    });
+
+    it('handles no TracingStarted errors in m74+', () => {
+      const trace = TraceProcessor.computeTraceOfTab(noTracingStartedTrace);
+      expect(trace.mainFrameIds.frameId).toEqual('0E0B1AF0B1BA04676037345D18A71577');
+      expect(trace.firstContentfulPaintEvt.ts).toEqual(2610265036367);
+    });
+
+    it('stably sorts events', () => {
+      const traceJson = fs.readFileSync(__dirname +
+          '/../../fixtures/traces/tracingstarted-after-navstart.json', 'utf8');
+      const trace = TraceProcessor.computeTraceOfTab(JSON.parse(traceJson));
+      const mainPid = trace.mainThreadEvents[0].pid;
+
+      const freshProcessEvents = JSON.parse(traceJson).traceEvents
+          .filter(e => e.pid === mainPid);
+
+      // Group all events with the same timestamp in original trace order.
+      const tsMap = new Map();
+      for (const event of freshProcessEvents) {
+        const tsGroup = tsMap.get(event.ts) || [];
+        tsGroup.push(event);
+        tsMap.set(event.ts, tsGroup);
+      }
+
+      // Assert that groups of same-timestamped events are in the same order in
+      // processed events.
+      for (const [ts, tsGroup] of tsMap) {
+        if (tsGroup.length === 1) {
+          continue;
+        }
+
+        // .filter overhead could be slow, but only a handful of tsGroups.
+        const sortedEvents = trace.processEvents.filter(e => e.ts === ts);
+        assert.deepStrictEqual(sortedEvents, tsGroup);
+      }
+    });
+
+    it('throws on traces missing a navigationStart', () => {
+      expect(() => TraceProcessor.computeTraceOfTab(noNavStartTrace))
+        .toThrowError('navigationStart');
+    });
+
+    it('throws on traces missing an FCP', () => {
+      expect(() => TraceProcessor.computeTraceOfTab(noFCPtrace))
+        .toThrowError('firstContentfulPaint');
+    });
+  });
+});
diff --git a/lighthouse-core/test/lib/tracehouse/tracing-processor-test.js b/lighthouse-core/test/lib/tracehouse/tracing-processor-test.js
deleted file mode 100644
index 3241c8298..000000000
--- a/lighthouse-core/test/lib/tracehouse/tracing-processor-test.js
+++ /dev/null
@@ -1,244 +0,0 @@
-/**
- * @license Copyright 2016 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 assert = require('assert');
-
-/* eslint-env jest */
-const TracingProcessor = require('../../../lib/tracehouse/tracing-processor.js');
-const pwaTrace = require('../../fixtures/traces/progressive-app.json');
-const defaultPercentiles = [0, 0.25, 0.5, 0.75, 0.9, 0.99, 1];
-
-const TraceOfTab = require('../../../lib/tracehouse/trace-of-tab.js');
-
-/**
- * Create a riskPercentiles result object by matching the values in percentiles
- * and times.
- * @param {!Array<number>} percentiles
- * @param {!Array<number>} times
- * @return {!Array<{percentile: number, time: number}>}
- */
-function createRiskPercentiles(percentiles, times) {
-  return percentiles.map((percentile, index) => {
-    return {
-      percentile,
-      time: times[index],
-    };
-  });
-}
-
-describe('TracingProcessor lib', () => {
-  describe('riskPercentiles calculation', () => {
-    it('correctly calculates percentiles of no tasks', () => {
-      const results = TracingProcessor._riskPercentiles([], 100, defaultPercentiles);
-      const expected = createRiskPercentiles(defaultPercentiles, [16, 16, 16, 16, 16, 16, 16]);
-      assert.deepEqual(results, expected);
-    });
-
-    it('correctly calculates percentiles of a single task with idle time', () => {
-      const results = TracingProcessor._riskPercentiles([50], 100, defaultPercentiles);
-      const expected = createRiskPercentiles(defaultPercentiles, [16, 16, 16, 41, 56, 65, 66]);
-      assert.deepEqual(results, expected);
-    });
-
-    it('correctly calculates percentiles of a single task with no idle time', () => {
-      const results = TracingProcessor._riskPercentiles([50], 50, defaultPercentiles);
-      const expected = createRiskPercentiles(defaultPercentiles,
-          [16, 28.5, 41, 53.5, 61, 65.5, 66]);
-      assert.deepEqual(results, expected);
-    });
-
-    it('correctly calculates percentiles of several equal-length tasks', () => {
-      const results = TracingProcessor._riskPercentiles([50, 50, 50, 50], 400, defaultPercentiles);
-      const expected = createRiskPercentiles(defaultPercentiles, [16, 16, 16, 41, 56, 65, 66]);
-      assert.deepEqual(results, expected);
-    });
-
-    it('correctly calculates percentiles of tasks including zero-length durations', () => {
-      const results = TracingProcessor._riskPercentiles([0, 0, 0, 10, 20, 20, 30, 30, 120], 320,
-          defaultPercentiles);
-      const expected = createRiskPercentiles(defaultPercentiles, [16, 16, 28, 56, 104, 132.8, 136]);
-      assert.deepEqual(results, expected);
-    });
-
-    // Three tasks of one second each, all within a five-second window.
-    // Mean Queueing Time of 300ms.
-    it('correctly calculates percentiles of three one-second tasks in a five-second window', () => {
-      const results = TracingProcessor._riskPercentiles([1000, 1000, 1000], 5000,
-          defaultPercentiles, 0);
-      // Round to hundredths to simplify floating point comparison.
-      results.forEach(result => {
-        result.time = Number(result.time.toFixed(2));
-      });
-
-      const expected = createRiskPercentiles(defaultPercentiles,
-          [16, 16, 182.67, 599.33, 849.33, 999.33, 1016]);
-      assert.deepEqual(results, expected);
-    });
-
-    it('correctly calculates percentiles of tasks with a clipped task', () => {
-      const results = TracingProcessor._riskPercentiles([10, 20, 50, 60, 90, 100], 300,
-          defaultPercentiles, 30);
-      // Round to hundredths to simplify floating point comparison.
-      results.forEach(result => {
-        result.time = Number(result.time.toFixed(2));
-      });
-
-      const expected = createRiskPercentiles(defaultPercentiles,
-          [16, 32.25, 53.5, 74.33, 96, 113, 116]);
-      assert.deepEqual(results, expected);
-    });
-
-    // One 20 second long task over three five-second windows.
-    it('correctly calculates percentiles of single task over multiple windows', () => {
-      // Starts 3 seconds into the first window. Mean Queueing Time = 7600ms.
-      const TASK_LENGTH = 20000;
-      const results1 = TracingProcessor._riskPercentiles([TASK_LENGTH], 5000,
-          defaultPercentiles, TASK_LENGTH - 2000);
-      const expected1 = createRiskPercentiles(defaultPercentiles,
-          [16, 16, 16, 18766, 19516, 19966, 20016]);
-      assert.deepEqual(results1, expected1);
-
-      // Starts 2 seconds before and ends 13 seconds after. Mean Queueing Time = 15500ms.
-      const results2 = TracingProcessor._riskPercentiles([TASK_LENGTH - 2000], 5000,
-          defaultPercentiles, TASK_LENGTH - 7000);
-      const expected2 = createRiskPercentiles(defaultPercentiles,
-          [16, 14266, 15516, 16766, 17516, 17966, 18016]);
-      assert.deepEqual(results2, expected2);
-
-      // Starts 17 seconds before and ends 3 seconds into the window. Mean Queueing Time = 900ms.
-      const results3 = TracingProcessor._riskPercentiles([TASK_LENGTH - 17000], 5000,
-          defaultPercentiles, 0);
-      const expected3 = createRiskPercentiles(defaultPercentiles,
-          [16, 16, 516, 1766, 2516, 2966, 3016]);
-      assert.deepEqual(results3, expected3);
-    });
-
-    it('correctly calculates with a task shorter than the clipped length of another', () => {
-      const results = TracingProcessor._riskPercentiles([40, 100], 100,
-          defaultPercentiles, 50);
-      const expected = createRiskPercentiles(defaultPercentiles,
-          [16, 31, 56, 91, 106, 115, 116]);
-      assert.deepEqual(results, expected);
-    });
-
-    it('correctly calculates with a task clipped completely', () => {
-      const results = TracingProcessor._riskPercentiles([40, 100], 100,
-          defaultPercentiles, 100);
-      const expected = createRiskPercentiles(defaultPercentiles,
-          [16, 16, 16, 31, 46, 55, 56]);
-      assert.deepEqual(results, expected);
-    });
-
-    it('does not divide by zero when duration sum is less than whole', () => {
-      // Durations chosen such that, due to floating point error:
-      //   const idleTime = totalTime - (duration1 + duration2);
-      //   (idleTime + duration1 + duration2) < totalTime
-      const duration1 = 67 / 107;
-      const duration2 = 67 / 53;
-      const totalTime = 10;
-      const results = TracingProcessor._riskPercentiles([duration1, duration2], totalTime, [1], 0);
-      const expected = createRiskPercentiles([1], [16 + duration2]);
-      assert.deepEqual(results, expected);
-    });
-  });
-
-  describe('getMainThreadTopLevelEvents', () => {
-    it('gets durations of top-level tasks', () => {
-      const trace = {traceEvents: pwaTrace};
-      const tabTrace = TraceOfTab.compute(trace);
-      const ret = TracingProcessor.getMainThreadTopLevelEvents(tabTrace);
-
-      assert.equal(ret.length, 645);
-    });
-
-    it('filters events based on start and end times', () => {
-      const baseTime = 20000 * 1000;
-      const name = 'TaskQueueManager::ProcessTaskFromWorkQueue';
-      const tabTrace = {
-        navigationStartEvt: {ts: baseTime},
-        mainThreadEvents: [
-          // 15ms to 25ms
-          {ts: baseTime + 15 * 1000, dur: 10 * 1000, name},
-          // 40ms to 60ms
-          {ts: baseTime + 40 * 1000, dur: 20 * 1000, name},
-          // 1000ms to 2000ms
-          {ts: baseTime + 1000 * 1000, dur: 1000 * 1000, name},
-          // 4000ms to 4020ms
-          {ts: baseTime + 4000 * 1000, dur: 20 * 1000, name},
-        ],
-      };
-
-      const ret = TracingProcessor.getMainThreadTopLevelEvents(
-        tabTrace,
-        50,
-        1500
-      );
-      assert.equal(ret.length, 2);
-      assert.equal(ret[0].start, 40);
-      assert.equal(ret[0].end, 60);
-      assert.equal(ret[0].duration, 20);
-      assert.equal(ret[1].start, 1000);
-      assert.equal(ret[1].end, 2000);
-      assert.equal(ret[1].duration, 1000);
-    });
-  });
-
-  describe('getMainThreadTopLevelEventDurations', () => {
-    it('gets durations of top-level tasks', async () => {
-      const trace = {traceEvents: pwaTrace};
-      const tabTrace = TraceOfTab.compute(trace);
-      const events = TracingProcessor.getMainThreadTopLevelEvents(tabTrace);
-      const ret = TracingProcessor.getMainThreadTopLevelEventDurations(events);
-      const durations = ret.durations;
-
-      function getDurationFromIndex(index) {
-        return Number(durations[index].toFixed(2));
-      }
-
-      assert.equal(durations.filter(dur => isNaN(dur)).length, 0, 'NaN found');
-      assert.equal(durations.length, 645);
-
-      const sum = durations.reduce((a, b) => a + b);
-      assert.equal(Math.round(sum), 386);
-
-      assert.equal(getDurationFromIndex(50), 0.01);
-      assert.equal(getDurationFromIndex(300), 0.04);
-      assert.equal(getDurationFromIndex(400), 0.07);
-      assert.equal(getDurationFromIndex(durations.length - 3), 26.01);
-      assert.equal(getDurationFromIndex(durations.length - 2), 36.9);
-      assert.equal(getDurationFromIndex(durations.length - 1), 38.53);
-    });
-  });
-
-  describe('risk to responsiveness', () => {
-    let oldFn;
-    // monkeypatch _riskPercentiles to test just getRiskToResponsiveness
-    beforeEach(() => {
-      oldFn = TracingProcessor._riskPercentiles;
-      TracingProcessor._riskPercentiles = (durations, totalTime, percentiles, clippedLength) => {
-        return {
-          durations, totalTime, percentiles, clippedLength,
-        };
-      };
-    });
-
-    it('compute correct defaults', async () => {
-      const trace = {traceEvents: pwaTrace};
-      const tabTrace = TraceOfTab.compute(trace);
-      const events = TracingProcessor.getMainThreadTopLevelEvents(tabTrace);
-      const ret = TracingProcessor.getRiskToResponsiveness(events, 0, tabTrace.timings.traceEnd);
-      assert.equal(ret.durations.length, 645);
-      assert.equal(Math.round(ret.totalTime), 2143);
-      assert.equal(ret.clippedLength, 0);
-      assert.deepEqual(ret.percentiles, [0.5, 0.75, 0.9, 0.99, 1]);
-    });
-
-    afterEach(() => {
-      TracingProcessor._riskPercentiles = oldFn;
-    });
-  });
-});