Repository: GoogleChrome/lighthouse
Pull request #9718 (2019-09-23 13:47:57)

core(scriptelements): expose id attribute for ScriptElements
<!-- Thank you for submitting a pull request! -->

**Summary**
<!-- What kind of change does this PR introduce? -->
This is a small feature to include the `id` attribute in the output when `ScriptElements` are requested in an audit.

This change makes it easier to choose a specific script and adds ease of use when writing audits.
<!-- Is this a bugfix, feature, refactoring, build related change, etc? -->

<!-- Describe the need for this change -->

<!-- Link any documentation or information that would help understand this change -->

**Related Issues/PRs**
This is the issue that the PR addresses. https://github.com/GoogleChrome/lighthouse/issues/9681
<!-- Provide any additional information we might need to understand the pull request -->


uchoudh: First three changes are looking good. Having some trouble with `yarn update:sample-json`.
Getting this error regarding google protobuff
 `Install protobuf ≥ 3.7.1 to compile the proto file`

Steps I took:
1. `npm install google-protobuf`
2. Download the protobuf compiler from https://github.com/protocolbuffers/protobuf/releases/tag/v3.10.0-rc1

After these steps not sure how to use it with the lighthouse project.
patrickhulce: @uchoudh are you using a Mac and homebrew?

I just needed to `brew install protobuf` (or `brew upgrade protobuf` if you have an older version) without manually downloading the compiler from github.
uchoudh: @patrickhulce Yes I am using a mac and homebrew. I tried the what you suggested but the problem still persists. I think it's still an error with protobuf not bring installed properly but the error message is a little different.
`ImportError: No module named google.protobuf.internal`

brendankenny: @uchoudh I think some people have run into this before. Check out this comment: https://github.com/GoogleChrome/lighthouse/pull/9084#issuecomment-499975442

You can also commit what you have and one of us can run the command.

Sorry for the trouble!
uchoudh: > @uchoudh I think some people have run into this before. Check out this comment: [#9084 (comment)](https://github.com/GoogleChrome/lighthouse/pull/9084#issuecomment-499975442)
> 
> You can also commit what you have and one of us can run the command.
> 
> Sorry for the trouble!

Thank you. I've pushed my latest changes.

Patch:
diff --git a/.travis.yml b/.travis.yml
index 1c1c165e8..69ce7695c 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -39,6 +39,7 @@ script:
   - yarn test-viewer
   - yarn test-lantern
   - yarn i18n:checks
+  - yarn dogfood-lhci
 before_cache:
   # nyc, jest and other projects store files in here. They mess up the travis build cache.
   - rm -rf ./node_modules/.cache/
diff --git a/docs/throttling.md b/docs/throttling.md
index c29cf95a1..4b24c1d7c 100644
--- a/docs/throttling.md
+++ b/docs/throttling.md
@@ -20,6 +20,8 @@ Within web performance testing, there are four typical styles of throttling:
 
 Lighthouse, by default, uses simulated throttling as it provides both quick evaluation and minimized variance. However, some may want to experiment with more accurate throttling...
 
+[Learn more about these throttling types and how they behave in in different scenarios](https://www.debugbear.com/blog/network-throttling-methods).
+
 ## How do I get packet-level throttling?
 
 This Performance Calendar article, [Testing with Realistic Networking Conditions](https://calendar.perfplanet.com/2016/testing-with-realistic-networking-conditions/), has a good explanation of packet-level traffic shaping (which applies across TCP/UDP/ICMP) and recommendations.
@@ -36,7 +38,7 @@ go get github.com/tylertreat/comcast
 # Ensure your $GOPATH/bin is in your $PATH (https://github.com/golang/go/wiki/GOPATH)
 
 # To use the recommended throttling values:
-comcast --latency=150 --target-bw=1600 --dry-run
+comcast --latency=150 --target-bw=1638 --dry-run
 
 # To disable throttling
 # comcast --stop
diff --git a/docs/variability.md b/docs/variability.md
index dce77de3b..72f9a721b 100644
--- a/docs/variability.md
+++ b/docs/variability.md
@@ -83,3 +83,4 @@ The median Lighthouse score of 5 runs is twice as stable as 1 run, and tools lik
 
 - [Lighthouse Variability and Accuracy Analysis](https://docs.google.com/document/d/1BqtL-nG53rxWOI5RO0pItSRPowZVnYJ_gBEQCJ5EeUE/edit?usp=sharing)
 - [Throttling documentation](./throttling.md)
+- [Why is my Lighthouse score different from PageSpeed Insights?](https://www.debugbear.com/blog/why-is-my-lighthouse-score-different-from-pagespeed-insights)
\ No newline at end of file
diff --git a/lighthouse-core/audits/apple-touch-icon.js b/lighthouse-core/audits/apple-touch-icon.js
index eb5ad8486..43086ae53 100644
--- a/lighthouse-core/audits/apple-touch-icon.js
+++ b/lighthouse-core/audits/apple-touch-icon.js
@@ -20,7 +20,7 @@ const UIStrings = {
   /** Description of a Lighthouse audit that tells the user that having an apple-touch-icon allows websites to include an app icon to their installed progressive web apps, similar to a native app. This is displayed after a user expands the section to see more. No character length limits. 'Learn More' becomes link text to additional documentation. "apple-touch-icon" is an HTML attribute value and should not be translated. */
   description: 'For ideal appearance on iOS when users add a progressive web app to the home ' +
     'screen, define an `apple-touch-icon`. It must point to a non-transparent 192px (or 180px) ' +
-    'square PNG. [Learn More](https://developers.google.com/web/fundamentals/design-and-ux/browser-customization/).',
+    'square PNG. [Learn More](https://web.dev/apple-touch-icon/).',
   /** Warning that HTML attribute `apple-touch-icon-precomposed` should not be used in favor of `apple-touch-icon`.  "apple-touch-icon-precomposed" and "apple-touch-icon" are HTML attribute values and should not be translated. */
   precomposedWarning: '`apple-touch-icon-precomposed` is out of date; ' +
     '`apple-touch-icon` is preferred.',
diff --git a/lighthouse-core/audits/byte-efficiency/byte-efficiency-audit.js b/lighthouse-core/audits/byte-efficiency/byte-efficiency-audit.js
index 26cc33c16..333fdd05e 100644
--- a/lighthouse-core/audits/byte-efficiency/byte-efficiency-audit.js
+++ b/lighthouse-core/audits/byte-efficiency/byte-efficiency-audit.js
@@ -93,8 +93,10 @@ class UnusedBytes extends Audit {
       // This was an asset that was inlined in a different resource type (e.g. HTML document).
       // Use the compression ratio of the resource to estimate the total transferred bytes.
       const transferSize = networkRecord.transferSize || 0;
-      const resourceSize = networkRecord.resourceSize;
-      const compressionRatio = resourceSize !== undefined ? (transferSize / resourceSize) : 1;
+      const resourceSize = networkRecord.resourceSize || 0;
+      // Get the compression ratio, if it's an invalid number, assume no compression.
+      const compressionRatio = Number.isFinite(resourceSize) && resourceSize > 0 ?
+        (transferSize / resourceSize) : 1;
       return Math.round(totalBytes * compressionRatio);
     }
   }
diff --git a/lighthouse-core/audits/byte-efficiency/unused-css-rules.js b/lighthouse-core/audits/byte-efficiency/unused-css-rules.js
index d2b7344d7..2bb3d3a6b 100644
--- a/lighthouse-core/audits/byte-efficiency/unused-css-rules.js
+++ b/lighthouse-core/audits/byte-efficiency/unused-css-rules.js
@@ -47,6 +47,10 @@ class UnusedCSSRules extends ByteEfficiencyAudit {
    */
   static indexStylesheetsById(styles, networkRecords) {
     const indexedNetworkRecords = networkRecords
+        // Some phantom network records appear with a 0 resourceSize that aren't real.
+        // A network record that has no size data is just as good as no network record at all for our
+        // purposes, so we'll just filter them out. https://github.com/GoogleChrome/lighthouse/issues/9684#issuecomment-532381611
+        .filter(record => record.resourceSize > 0)
         .reduce((indexed, record) => {
           indexed[record.url] = record;
           return indexed;
diff --git a/lighthouse-core/gather/gatherers/script-elements.js b/lighthouse-core/gather/gatherers/script-elements.js
index d68023a5d..cb01c66c4 100644
--- a/lighthouse-core/gather/gatherers/script-elements.js
+++ b/lighthouse-core/gather/gatherers/script-elements.js
@@ -26,6 +26,7 @@ function collectAllScriptElements() {
     return {
       type: script.type || null,
       src: script.src || null,
+      id: script.id || null,
       async: script.async,
       defer: script.defer,
       source: /** @type {'head'|'body'} */ (script.closest('head') ? 'head' : 'body'),
@@ -81,6 +82,7 @@ class ScriptElements extends Gatherer {
             devtoolsNodePath: '',
             type: null,
             src: record.url,
+            id: null,
             async: false,
             defer: false,
             source: 'network',
diff --git a/lighthouse-core/lib/i18n/locales/en-US.json b/lighthouse-core/lib/i18n/locales/en-US.json
index eabd53c07..072110054 100644
--- a/lighthouse-core/lib/i18n/locales/en-US.json
+++ b/lighthouse-core/lib/i18n/locales/en-US.json
@@ -318,7 +318,7 @@
     "message": "`<video>` elements contain a `<track>` element with `[kind=\"description\"]`"
   },
   "lighthouse-core/audits/apple-touch-icon.js | description": {
-    "message": "For ideal appearance on iOS when users add a progressive web app to the home screen, define an `apple-touch-icon`. It must point to a non-transparent 192px (or 180px) square PNG. [Learn More](https://developers.google.com/web/fundamentals/design-and-ux/browser-customization/)."
+    "message": "For ideal appearance on iOS when users add a progressive web app to the home screen, define an `apple-touch-icon`. It must point to a non-transparent 192px (or 180px) square PNG. [Learn More](https://web.dev/apple-touch-icon/)."
   },
   "lighthouse-core/audits/apple-touch-icon.js | failureTitle": {
     "message": "Does not provide a valid `apple-touch-icon`"
diff --git a/lighthouse-core/lib/i18n/locales/en-XL.json b/lighthouse-core/lib/i18n/locales/en-XL.json
index 88ec3d321..618f5118c 100644
--- a/lighthouse-core/lib/i18n/locales/en-XL.json
+++ b/lighthouse-core/lib/i18n/locales/en-XL.json
@@ -318,7 +318,7 @@
     "message": "`<video>` êĺêḿêńt̂ś ĉón̂t́âín̂ á `<track>` êĺêḿêńt̂ ẃît́ĥ `[kind=\"description\"]`"
   },
   "lighthouse-core/audits/apple-touch-icon.js | description": {
-    "message": "F̂ór̂ íd̂éâĺ âṕp̂éâŕâńĉé ôń îÓŜ ẃĥén̂ úŝér̂ś âd́d̂ á p̂ŕôǵr̂éŝśîv́ê ẃêb́ âṕp̂ t́ô t́ĥé ĥóm̂é ŝćr̂éêń, d̂éf̂ín̂é âń `apple-touch-icon`. Ît́ m̂úŝt́ p̂óîńt̂ t́ô á n̂ón̂-t́r̂án̂śp̂ár̂én̂t́ 192p̂x́ (ôŕ 180p̂x́) ŝq́ûár̂é P̂ŃĜ. [Ĺêár̂ń M̂ór̂é](https://developers.google.com/web/fundamentals/design-and-ux/browser-customization/)."
+    "message": "F̂ór̂ íd̂éâĺ âṕp̂éâŕâńĉé ôń îÓŜ ẃĥén̂ úŝér̂ś âd́d̂ á p̂ŕôǵr̂éŝśîv́ê ẃêb́ âṕp̂ t́ô t́ĥé ĥóm̂é ŝćr̂éêń, d̂éf̂ín̂é âń `apple-touch-icon`. Ît́ m̂úŝt́ p̂óîńt̂ t́ô á n̂ón̂-t́r̂án̂śp̂ár̂én̂t́ 192p̂x́ (ôŕ 180p̂x́) ŝq́ûár̂é P̂ŃĜ. [Ĺêár̂ń M̂ór̂é](https://web.dev/apple-touch-icon/)."
   },
   "lighthouse-core/audits/apple-touch-icon.js | failureTitle": {
     "message": "D̂óêś n̂ót̂ ṕr̂óv̂íd̂é â v́âĺîd́ `apple-touch-icon`"
diff --git a/lighthouse-core/lib/tracehouse/main-thread-tasks.js b/lighthouse-core/lib/tracehouse/main-thread-tasks.js
index e1c53205c..78ce49637 100644
--- a/lighthouse-core/lib/tracehouse/main-thread-tasks.js
+++ b/lighthouse-core/lib/tracehouse/main-thread-tasks.js
@@ -44,104 +44,292 @@ const {taskGroups, taskNameToGroup} = require('./task-groups.js');
 class MainThreadTasks {
   /**
    * @param {LH.TraceEvent} event
-   * @param {TaskNode} [parent]
+   * @param {Pick<LH.TraceEvent, 'ph'|'ts'>} [endEvent]
    * @return {TaskNode}
    */
-  static _createNewTaskNode(event, parent) {
+  static _createNewTaskNode(event, endEvent) {
+    const isCompleteEvent = event.ph === 'X' && !endEvent;
+    const isStartEndEventPair = event.ph === 'B' && endEvent && endEvent.ph === 'E';
+    if (!isCompleteEvent && !isStartEndEventPair) {
+      throw new Error('Invalid parameters for _createNewTaskNode');
+    }
+
+    const startTime = event.ts;
+    const endTime = endEvent ? endEvent.ts : event.ts + Number(event.dur || 0);
+
     const newTask = {
       event,
-      startTime: event.ts,
-      endTime: event.ph === 'X' ? event.ts + Number(event.dur || 0) : NaN,
-      parent: parent,
-      children: [],
+      startTime,
+      endTime,
+      duration: endTime - startTime,
 
       // These properties will be filled in later
+      parent: undefined,
+      children: [],
       attributableURLs: [],
       group: taskGroups.other,
-      duration: NaN,
       selfTime: NaN,
     };
 
-    if (parent) {
-      parent.children.push(newTask);
-    }
-
     return newTask;
   }
 
   /**
-   * @param {LH.TraceEvent[]} mainThreadEvents
+   *
+   * @param {TaskNode} currentTask
+   * @param {number} stopTs
    * @param {PriorTaskData} priorTaskData
+   * @param {Array<LH.TraceEvent>} reverseEventsQueue
+   */
+  static _assignAllTimersUntilTs(
+      currentTask,
+      stopTs,
+      priorTaskData,
+      reverseEventsQueue
+  ) {
+    while (reverseEventsQueue.length) {
+      const nextTimerInstallEvent = reverseEventsQueue.pop();
+      // We're out of events to look at; we're done.
+      if (!nextTimerInstallEvent) break;
+
+      // Timer event is after our current task; push it back on for next time, and we're done.
+      if (nextTimerInstallEvent.ts > stopTs) {
+        reverseEventsQueue.push(nextTimerInstallEvent);
+        break;
+      }
+
+      // Timer event is before the current task, just skip it.
+      if (nextTimerInstallEvent.ts < currentTask.startTime) {
+        continue;
+      }
+
+      // We're right where we need to be, point the timerId to our `currentTask`
+      /** @type {string} */
+      // @ts-ignore - timerId exists on `TimerInstall` events.
+      const timerId = nextTimerInstallEvent.args.data.timerId;
+      priorTaskData.timers.set(timerId, currentTask);
+    }
+  }
+
+  /**
+   * This function takes the start and end events from a thread and creates tasks from them.
+   * We do this by iterating through the start and end event arrays simultaneously. For each start
+   * event we attempt to find its end event.
+   *
+   * Because of this matching of start/end events and the need to be mutating our end events queue,
+   * we reverse the array to more efficiently `.pop()` them off rather than `.shift()` them off.
+   * While it's true the worst case runtime here is O(n^2), ~99.999% of the time the reverse loop is O(1)
+   * because the overwhelmingly common case is that end event for a given start event is simply the very next event in our queue.
+   *
+   * @param {LH.TraceEvent[]} taskStartEvents
+   * @param {LH.TraceEvent[]} taskEndEvents
    * @param {number} traceEndTs
    * @return {TaskNode[]}
    */
-  static _createTasksFromEvents(mainThreadEvents, priorTaskData, traceEndTs) {
+  static _createTasksFromStartAndEndEvents(taskStartEvents, taskEndEvents, traceEndTs) {
     /** @type {TaskNode[]} */
     const tasks = [];
-    /** @type {TaskNode|undefined} */
-    let currentTask;
+    // Create a reversed copy of the array to avoid copying the rest of the queue on every mutation.
+    // i.e. pop() is O(1) while shift() is O(n), we take the earliest ts element off the queue *a lot*
+    // so we'll optimize for having the earliest timestamp events at the end of the array.
+    const taskEndEventsReverseQueue = taskEndEvents.slice().reverse();
+
+    for (let i = 0; i < taskStartEvents.length; i++) {
+      const taskStartEvent = taskStartEvents[i];
+      if (taskStartEvent.ph === 'X') {
+        // Task is a complete X event, we have all the information we need already.
+        tasks.push(MainThreadTasks._createNewTaskNode(taskStartEvent));
+        continue;
+      }
 
-    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);
+      // Task is a B/E event pair, we need to find the matching E event.
+      let matchedEventIndex = -1;
+      let matchingNestedEventCount = 0;
+      let matchingNestedEventIndex = i + 1;
+
+      // We loop through the reversed end events queue from back to front because we still want to
+      // see end events in increasing timestamp order.
+      // While worst case we will loop through all events, the overwhelmingly common case is that
+      // the immediate next event is our event of interest which makes this loop typically O(1).
+      for (let j = taskEndEventsReverseQueue.length - 1; j >= 0; j--) {
+        const endEvent = taskEndEventsReverseQueue[j];
+        // We are considering an end event, so we'll count how many nested events we saw along the way.
+        for (; matchingNestedEventIndex < taskStartEvents.length; matchingNestedEventIndex++) {
+          if (taskStartEvents[matchingNestedEventIndex].ts >= endEvent.ts) break;
+
+          if (taskStartEvents[matchingNestedEventIndex].name === taskStartEvent.name) {
+            matchingNestedEventCount++;
+          }
+        }
+
+        // The event doesn't have a matching name, skip it.
+        if (endEvent.name !== taskStartEvent.name) continue;
+        // The event has a timestamp that is too early, skip it.
+        if (endEvent.ts < taskStartEvent.ts) continue;
+
+        // The event matches our name and happened after start, the last thing to check is if it was for a nested event.
+        if (matchingNestedEventCount > 0) {
+          // If it was for a nested event, decrement our counter and move on.
+          matchingNestedEventCount--;
+          continue;
+        }
+
+        // If it wasn't, we found our matching E event! Mark the index and stop the loop.
+        matchedEventIndex = j;
+        break;
       }
 
-      // 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;
+      /** @type {Pick<LH.TraceEvent, 'ph'|'ts'>} */
+      let taskEndEvent;
+      if (matchedEventIndex === -1) {
+        // If we couldn't find an end event, we'll assume it's the end of the trace.
+        // If this creates invalid parent/child relationships it will be caught in the next step.
+        taskEndEvent = {ph: 'E', ts: traceEndTs};
+      } else if (matchedEventIndex === taskEndEventsReverseQueue.length - 1) {
+        // Use .pop() in the common case where the immediately next event is needed.
+        // It's ~25x faster, https://jsperf.com/pop-vs-splice.
+        taskEndEvent = /** @type {LH.TraceEvent} */ (taskEndEventsReverseQueue.pop());
+      } else {
+        taskEndEvent = taskEndEventsReverseQueue.splice(matchedEventIndex, 1)[0];
+      }
 
-      // Update currentTask based on the elapsed time.
-      // The next event may be after currentTask has ended.
+      tasks.push(MainThreadTasks._createNewTaskNode(taskStartEvent, taskEndEvent));
+    }
+
+    if (taskEndEventsReverseQueue.length) {
+      throw new Error(
+        `Fatal trace logic error - ${taskEndEventsReverseQueue.length} unmatched end events`
+      );
+    }
+
+    return tasks;
+  }
+
+  /**
+   * This function iterates through the tasks to set the `.parent`/`.children` properties of tasks
+   * according to their implied nesting structure. If any of these relationships seem impossible based on
+   * the timestamps, this method will throw.
+   *
+   * @param {TaskNode[]} sortedTasks
+   * @param {LH.TraceEvent[]} timerInstallEvents
+   * @param {PriorTaskData} priorTaskData
+   */
+  static _createTaskRelationships(sortedTasks, timerInstallEvents, priorTaskData) {
+    /** @type {TaskNode|undefined} */
+    let currentTask;
+    // Create a reversed copy of the array to avoid copying the rest of the queue on every mutation.
+    const timerInstallEventsReverseQueue = timerInstallEvents.slice().reverse();
+
+    for (let i = 0; i < sortedTasks.length; i++) {
+      const nextTask = sortedTasks[i];
+
+      // This inner loop updates what our `currentTask` is at `nextTask.startTime - ε`.
+      // While `nextTask` starts after our `currentTask`, close out the task, popup to the parent, and repeat.
+      // If at the end `currentTask` is undefined, then `nextTask` is a toplevel task.
+      // Otherwise, `nextTask` is a child of `currentTask`.
       while (
         currentTask &&
         Number.isFinite(currentTask.endTime) &&
-        currentTask.endTime <= event.ts
+        currentTask.endTime <= nextTask.startTime
       ) {
+        MainThreadTasks._assignAllTimersUntilTs(
+          currentTask,
+          currentTask.endTime,
+          priorTaskData,
+          timerInstallEventsReverseQueue
+        );
         currentTask = currentTask.parent;
       }
 
-      // If we don't have a current task, start a new one.
-      if (!currentTask) {
-        // We can't start a task with an end event
-        if (event.ph === 'E') {
-          throw new Error('Fatal trace logic error - unexpected end event');
+      // If there's a `currentTask`, `nextTask` must be a child.
+      // Set the `.parent`/`.children` relationships and timer bookkeeping accordingly.
+      if (currentTask) {
+        if (nextTask.endTime > currentTask.endTime) {
+          const timeDelta = nextTask.endTime - currentTask.endTime;
+          // The child task is taking longer than the parent task, which should be impossible.
+          //    If it's less than 1ms, we'll let it slide by increasing the duration of the parent.
+          //    If it's more, throw an error.
+          if (timeDelta < 1000) {
+            currentTask.endTime = nextTask.endTime;
+            currentTask.duration += timeDelta;
+          } else {
+            // If we fell into this error, it's usually because of one of three reasons.
+            //    - We were missing an E event for a child task and we assumed the child ended at the end of the trace.
+            //    - There was slop in the opposite direction (child started 1ms before parent) and the child was assumed to be parent instead.
+            //    - The child timestamp ended more than 1ms after tha parent.
+            // These have more complicated fixes, so handling separately https://github.com/GoogleChrome/lighthouse/pull/9491#discussion_r327331204.
+            throw new Error('Fatal trace logic error - child cannot end after parent');
+          }
         }
 
-        currentTask = MainThreadTasks._createNewTaskNode(event);
-        tasks.push(currentTask);
-
-        continue;
+        nextTask.parent = currentTask;
+        currentTask.children.push(nextTask);
+        MainThreadTasks._assignAllTimersUntilTs(
+          currentTask,
+          nextTask.startTime,
+          priorTaskData,
+          timerInstallEventsReverseQueue
+        );
       }
 
-      if (event.ph === 'X' || event.ph === 'B') {
-        // We're starting a nested event, create it as a child and make it the currentTask
-        const newTask = MainThreadTasks._createNewTaskNode(event, currentTask);
-        tasks.push(newTask);
-        currentTask = newTask;
-      } else {
-        if (currentTask.event.ph !== 'B') {
-          throw new Error(
-            `Fatal trace logic error - expected start event, got ${currentTask.event.ph}`);
-        }
+      currentTask = nextTask;
+    }
 
-        // We're ending an event, update the end time and the currentTask to its parent
-        currentTask.endTime = event.ts;
-        currentTask = currentTask.parent;
-      }
+    if (currentTask) {
+      MainThreadTasks._assignAllTimersUntilTs(
+        currentTask,
+        currentTask.endTime,
+        priorTaskData,
+        timerInstallEventsReverseQueue
+      );
     }
+  }
 
-    // Starting from the last and bottom-most task, we finish any tasks that didn't end yet.
-    while (currentTask && !Number.isFinite(currentTask.endTime)) {
-      // The last event didn't finish before tracing stopped, use traceEnd timestamp instead.
-      currentTask.endTime = traceEndTs;
-      currentTask = currentTask.parent;
+  /**
+   * This function takes the raw trace events sorted in increasing timestamp order and outputs connected task nodes.
+   * To create the task heirarchy we make several passes over the events.
+   *
+   *    1. Create three arrays of X/B events, E events, and TimerInstall events.
+   *    2. Create tasks for each X/B event, throwing if a matching E event cannot be found for a given B.
+   *    3. Sort the tasks by ↑ startTime, ↓ duration.
+   *    4. Match each task to its parent, throwing if there is any invalid overlap between tasks.
+   *
+   * @param {LH.TraceEvent[]} mainThreadEvents
+   * @param {PriorTaskData} priorTaskData
+   * @param {number} traceEndTs
+   * @return {TaskNode[]}
+   */
+  static _createTasksFromEvents(mainThreadEvents, priorTaskData, traceEndTs) {
+    /** @type {Array<LH.TraceEvent>} */
+    const taskStartEvents = [];
+    /** @type {Array<LH.TraceEvent>} */
+    const taskEndEvents = [];
+    /** @type {Array<LH.TraceEvent>} */
+    const timerInstallEvents = [];
+
+    // Phase 1 - Create three arrays of X/B events, E events, and TimerInstall events.
+    for (const event of mainThreadEvents) {
+      if (event.ph === 'X' || event.ph === 'B') taskStartEvents.push(event);
+      if (event.ph === 'E') taskEndEvents.push(event);
+      if (event.name === 'TimerInstall') timerInstallEvents.push(event);
     }
 
-    // At this point we expect all tasks to have a finite startTime and endTime.
-    return tasks;
+    // Phase 2 - Create tasks for each taskStartEvent.
+    const tasks = MainThreadTasks._createTasksFromStartAndEndEvents(
+      taskStartEvents,
+      taskEndEvents,
+      traceEndTs
+    );
+
+    // Phase 3 - Sort the tasks by increasing startTime, decreasing duration.
+    const sortedTasks = tasks.sort(
+      (taskA, taskB) => taskA.startTime - taskB.startTime || taskB.duration - taskA.duration
+    );
+
+    // Phase 4 - Match each task to its parent.
+    MainThreadTasks._createTaskRelationships(sortedTasks, timerInstallEvents, priorTaskData);
+
+    return sortedTasks;
   }
 
   /**
@@ -157,7 +345,6 @@ class MainThreadTasks {
     const childTime = task.children
       .map(child => MainThreadTasks._computeRecursiveSelfTime(child, task))
       .reduce((sum, child) => sum + child, 0);
-    task.duration = task.endTime - task.startTime;
     task.selfTime = task.duration - childTime;
     return task.duration;
   }
@@ -213,7 +400,8 @@ class MainThreadTasks {
 
     task.attributableURLs = attributableURLs;
     task.children.forEach(child =>
-      MainThreadTasks._computeRecursiveAttributableURLs(child, attributableURLs, priorTaskData));
+      MainThreadTasks._computeRecursiveAttributableURLs(child, attributableURLs, priorTaskData)
+    );
   }
 
   /**
@@ -227,14 +415,18 @@ class MainThreadTasks {
   }
 
   /**
-   * @param {LH.TraceEvent[]} traceEvents
+   * @param {LH.TraceEvent[]} mainThreadEvents
    * @param {number} traceEndTs
    * @return {TaskNode[]}
    */
-  static getMainThreadTasks(traceEvents, traceEndTs) {
+  static getMainThreadTasks(mainThreadEvents, traceEndTs) {
     const timers = new Map();
     const priorTaskData = {timers};
-    const tasks = MainThreadTasks._createTasksFromEvents(traceEvents, priorTaskData, traceEndTs);
+    const tasks = MainThreadTasks._createTasksFromEvents(
+      mainThreadEvents,
+      priorTaskData,
+      traceEndTs
+    );
 
     // Compute the recursive properties we couldn't compute earlier, starting at the toplevel tasks
     for (const task of tasks) {
diff --git a/lighthouse-core/scripts/dogfood-lhci.sh b/lighthouse-core/scripts/dogfood-lhci.sh
new file mode 100755
index 000000000..d07327ede
--- /dev/null
+++ b/lighthouse-core/scripts/dogfood-lhci.sh
@@ -0,0 +1,52 @@
+#!/bin/bash
+
+set -eox pipefail
+
+# This script requires LHCI_CANARY_SERVER_URL and LHCI_CANARY_SERVER_TOKEN variables to be set.
+
+if [[ -z "$LHCI_CANARY_SERVER_TOKEN" ]]; then
+  echo "No server token available, skipping.";
+  exit 0;
+fi
+
+if [[ "$TRAVIS_NODE_VERSION" != "10" ]]; then
+  echo "Not running dogfood script on node versions other than 10";
+  exit 0;
+fi
+
+
+SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+LH_ROOT_DIR="$SCRIPT_DIR/../.."
+
+# Testing lhci takes time and the server ain't massive, we'll only run the tests if we touched files that affect the report.
+CHANGED_FILES=""
+if [[ "$CI" ]]; then
+  CHANGED_FILES=$(git --no-pager diff --name-only "$TRAVIS_COMMIT_RANGE")
+else
+  CHANGED_FILES=$(git --no-pager diff --name-only master)
+fi
+
+printf "Determined the following files have been touched:\n\n$CHANGED_FILES\n\n"
+
+if ! echo "$CHANGED_FILES" | grep -E 'report|lhci' > /dev/null; then
+  echo "No report files affected, skipping lhci checks."
+  exit 0
+fi
+
+# Generate an HTML report and copy into static-server directory for serving.
+yarn now-build
+cp ./dist/now/english/index.html ./lighthouse-cli/test/fixtures/lhci.report.html
+
+# Install LHCI
+npm install -g @lhci/cli@next
+# Start up a test server.
+yarn static-server &
+# Wait for the server to start before hitting it with data.
+sleep 10
+# Collect our LHCI results.
+lhci collect --url=http://localhost:10200/lhci.report.html
+# Upload the results to our canary server.
+lhci upload --serverBaseUrl="$LHCI_CANARY_SERVER_URL" --token="$LHCI_CANARY_SERVER_TOKEN"
+
+# Kill the static server from earlier.
+kill $!
diff --git a/package.json b/package.json
index e58dabd87..56c82ebe1 100644
--- a/package.json
+++ b/package.json
@@ -49,6 +49,7 @@
     "deploy-viewer": "yarn build-viewer --deploy",
     "now-build": "node lighthouse-core/scripts/build-report-for-autodeployment.js",
     "bundlesize": "bundlesize",
+    "dogfood-lhci": "./lighthouse-core/scripts/dogfood-lhci.sh",
     "timing-trace": "node lighthouse-core/scripts/generate-timing-trace.js",
     "changelog": "conventional-changelog --config ./build/changelog-generator/index.js --infile changelog.md --same-file",
     "type-check": "tsc -p . && tsc -p lighthouse-viewer/",
diff --git a/proto/sample_v2_round_trip.json b/proto/sample_v2_round_trip.json
index ddaefce7f..c20f2fc2b 100644
--- a/proto/sample_v2_round_trip.json
+++ b/proto/sample_v2_round_trip.json
@@ -16,7 +16,7 @@
             "title": "Uses Application Cache"
         },
         "apple-touch-icon": {
-            "description": "For ideal appearance on iOS when users add a progressive web app to the home screen, define an `apple-touch-icon`. It must point to a non-transparent 192px (or 180px) square PNG. [Learn More](https://developers.google.com/web/fundamentals/design-and-ux/browser-customization/).",
+            "description": "For ideal appearance on iOS when users add a progressive web app to the home screen, define an `apple-touch-icon`. It must point to a non-transparent 192px (or 180px) square PNG. [Learn More](https://web.dev/apple-touch-icon/).",
             "id": "apple-touch-icon",
             "score": 0.0,
             "scoreDisplayMode": "binary",
diff --git a/readme.md b/readme.md
index 179652b2c..925c123e4 100644
--- a/readme.md
+++ b/readme.md
@@ -315,6 +315,7 @@ Other awesome open source projects that use Lighthouse.
 * **[lighthouse-badges](https://github.com/emazzotta/lighthouse-badges)** - Generate gh-badges (shields.io) based on Lighthouse performance.
 * **[lighthouse-batch](https://www.npmjs.com/package/lighthouse-batch)** - Run Lighthouse over a number of sites and generate a summary of their metrics/scores.
 * **[lighthouse-ci](https://github.com/andreasonny83/lighthouse-ci)** - Run Lighthouse and assert scores satisfy your custom thresholds.
+* **[lighthouse-ci-action](https://github.com/treosh/lighthouse-ci-action)** - A Github Action that makes it easy to run Lighthouse in CI and keep your pages small using performance budgets.
 * **[lighthouse-cron](https://github.com/thearegee/lighthouse-cron)** - Cron multiple batch Lighthouse audits and emit results for sending to remote server.
 * **[lighthouse-gh-reporter](https://github.com/carlesnunez/lighthouse-gh-reporter)** - Run Lighthouse in CI and report back in a comment on your pull requests
 * **[lighthouse-hue](https://github.com/ebidel/lighthouse-hue)** - Set the color of Philips Hue lights based on a Lighthouse score
diff --git a/types/artifacts.d.ts b/types/artifacts.d.ts
index 50b188531..762eb9714 100644
--- a/types/artifacts.d.ts
+++ b/types/artifacts.d.ts
@@ -225,6 +225,8 @@ declare global {
       export interface ScriptElement {
         type: string | null
         src: string | null
+        /** The `id` property of the script element; null if it had no `id` or if `source` is 'network'. */
+        id: string | null
         async: boolean
         defer: boolean
         /** Path that uniquely identifies the node in the DOM */
diff --git a/types/smokehouse.d.ts b/types/smokehouse.d.ts
index 7cc91133e..aff855384 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' | 'runtimeError'>
+  export type ExpectedLHR = Pick<LH.Result, 'audits' | 'finalUrl' | 'requestedUrl' | 'runWarnings' | 'runtimeError'>
 
   export type ExpectedRunnerResult = {
     lhr: ExpectedLHR,


Test patch:
diff --git a/lighthouse-cli/test/fixtures/dobetterweb/dbw_tester.html b/lighthouse-cli/test/fixtures/dobetterweb/dbw_tester.html
index 32e759932..308d9eb04 100644
--- a/lighthouse-cli/test/fixtures/dobetterweb/dbw_tester.html
+++ b/lighthouse-cli/test/fixtures/dobetterweb/dbw_tester.html
@@ -18,7 +18,7 @@
 
 <!-- AMP-style stylesheet script -->
 <!-- based on https://github.com/ampproject/amphtml/blob/38f1bdf4cc385f9a25cf2979abf215952880b876/src/font-stylesheet-timeout.js#L87-L103 -->
-<script type="text/javascript">
+<script type="text/javascript" id="amp-style-styling-script">
   setTimeout(() => {
     const stylesheet = document.getElementById('amp-style-link');
     stylesheet.media = 'not-matching';
@@ -48,13 +48,13 @@
 <link rel="alternate stylesheet" href="./empty.css">
 
 <!-- FAIL: block rendering -->
-<script src="./dbw_tester.js"></script>
+<script src="./dbw_tester.js" id="dbw-tester-script"></script>
 
 <!-- PASS: 'modules' are deferred by default and don't block rendering -->
 <script type="module" src="./empty_module.js?delay=500"></script>
 
 <!-- FAIL(errors-in-console): exception thrown -->
-<script type="text/javascript">throw new Error('An error');</script>
+<script type="text/javascript" id="error-time">throw new Error('A distinctive error');</script>
 
 <!-- Add non-functional script to appear to be a Wordpress page for Stack Packs. -->
 <script type="example" src="fake-script-wp-includes"></script>
diff --git a/lighthouse-cli/test/smokehouse/dobetterweb/dbw-expectations.js b/lighthouse-cli/test/smokehouse/dobetterweb/dbw-expectations.js
index c6a86c2d9..d04756bba 100644
--- a/lighthouse-cli/test/smokehouse/dobetterweb/dbw-expectations.js
+++ b/lighthouse-cli/test/smokehouse/dobetterweb/dbw-expectations.js
@@ -109,7 +109,7 @@ module.exports = [
               },
               {
                 source: 'Runtime.exception',
-                description: 'Error: An error\n    at http://localhost:10200/dobetterweb/dbw_tester.html:57:38',
+                description: /^Error: A distinctive error\s+at http:\/\/localhost:10200\/dobetterweb\/dbw_tester.html:\d+:\d+$/,
                 url: 'http://localhost:10200/dobetterweb/dbw_tester.html',
               },
             ],
diff --git a/lighthouse-cli/test/smokehouse/error-expectations.js b/lighthouse-cli/test/smokehouse/error-expectations.js
index a77071d2f..b462fd665 100644
--- a/lighthouse-cli/test/smokehouse/error-expectations.js
+++ b/lighthouse-cli/test/smokehouse/error-expectations.js
@@ -20,6 +20,7 @@ module.exports = [
       requestedUrl: 'http://localhost:10200/infinite-loop.html',
       finalUrl: 'http://localhost:10200/infinite-loop.html',
       runtimeError: {code: 'PAGE_HUNG'},
+      runWarnings: ['Lighthouse was unable to reliably load the URL you requested because the page stopped responding.'],
       audits: {
         'first-contentful-paint': {
           scoreDisplayMode: 'error',
@@ -42,6 +43,7 @@ module.exports = [
       requestedUrl: 'https://expired.badssl.com',
       finalUrl: 'https://expired.badssl.com/',
       runtimeError: {code: 'INSECURE_DOCUMENT_REQUEST'},
+      runWarnings: ['The URL you have provided does not have a valid security certificate. net::ERR_CERT_DATE_INVALID'],
       audits: {
         'first-contentful-paint': {
           scoreDisplayMode: 'error',
diff --git a/lighthouse-cli/test/smokehouse/seo/expectations.js b/lighthouse-cli/test/smokehouse/seo/expectations.js
index 5544b6091..18c66eef5 100644
--- a/lighthouse-cli/test/smokehouse/seo/expectations.js
+++ b/lighthouse-cli/test/smokehouse/seo/expectations.js
@@ -270,6 +270,7 @@ module.exports = [
         code: 'ERRORED_DOCUMENT_REQUEST',
         message: /Status code: 403/,
       },
+      runWarnings: ['Lighthouse was unable to reliably load the page you requested. Make sure you are testing the correct URL and that the server is properly responding to all requests. (Status code: 403)'],
       audits: {
         'http-status-code': {
           score: null,
diff --git a/lighthouse-cli/test/smokehouse/smokehouse-report.js b/lighthouse-cli/test/smokehouse/smokehouse-report.js
index 37fe6d26a..421764e8d 100644
--- a/lighthouse-cli/test/smokehouse/smokehouse-report.js
+++ b/lighthouse-cli/test/smokehouse/smokehouse-report.js
@@ -143,6 +143,10 @@ function collateResults(actual, expected) {
   const runtimeErrorAssertion = makeComparison('runtimeError', actual.lhr.runtimeError,
       expected.lhr.runtimeError);
 
+  // Same for warnings.
+  const runWarningsAssertion = makeComparison('runWarnings', actual.lhr.runWarnings,
+      expected.lhr.runWarnings || []);
+
   /** @type {Smokehouse.Comparison[]} */
   let artifactAssertions = [];
   if (expected.artifacts) {
@@ -179,6 +183,7 @@ function collateResults(actual, expected) {
       equal: actual.lhr.finalUrl === expected.lhr.finalUrl,
     },
     runtimeErrorAssertion,
+    runWarningsAssertion,
     ...artifactAssertions,
     ...auditAssertions,
   ];
diff --git a/lighthouse-core/test/audits/byte-efficiency/byte-efficiency-audit-test.js b/lighthouse-core/test/audits/byte-efficiency/byte-efficiency-audit-test.js
index b6656de78..103ee8187 100644
--- a/lighthouse-core/test/audits/byte-efficiency/byte-efficiency-audit-test.js
+++ b/lighthouse-core/test/audits/byte-efficiency/byte-efficiency-audit-test.js
@@ -77,6 +77,12 @@ describe('Byte efficiency base audit', () => {
       const result = estimate({transferSize: 1000, resourceType}, 100);
       assert.equal(result, 100);
     });
+
+    it('should not error when resource size is 0', () => {
+      const resourceType = 'Other';
+      const result = estimate({transferSize: 1000, resourceSize: 0, resourceType}, 100);
+      assert.equal(result, 100);
+    });
   });
 
   it('should format details', () => {
diff --git a/lighthouse-core/test/audits/byte-efficiency/unused-css-rules-test.js b/lighthouse-core/test/audits/byte-efficiency/unused-css-rules-test.js
index ff3c7c236..be8b64b28 100644
--- a/lighthouse-core/test/audits/byte-efficiency/unused-css-rules-test.js
+++ b/lighthouse-core/test/audits/byte-efficiency/unused-css-rules-test.js
@@ -122,6 +122,7 @@ describe('Best Practices: unused css rules audit', () => {
       {
         url: 'file://a.css',
         transferSize: 100 * 1024,
+        resourceSize: 100 * 1024,
         resourceType: 'Stylesheet',
       },
     ];
@@ -191,6 +192,36 @@ describe('Best Practices: unused css rules audit', () => {
       });
     });
 
+    it('handles phantom network records without size data', async () => {
+      const result = await UnusedCSSAudit.audit_(getArtifacts({
+        CSSUsage: {rules: [
+          {styleSheetId: 'a', used: true, startOffset: 0, endOffset: 60000}, // 40000 * 3 * 50% = 60000
+        ], stylesheets: [
+          {
+            header: {styleSheetId: 'a', sourceURL: 'file://a.html'},
+            content: `${generate('123', 40000)}`, // stylesheet size of 40000 * 3 uncompressed bytes
+          },
+        ]},
+      }), [
+        {
+          url: 'file://a.html',
+          transferSize: 100 * 1024 * 0.5, // compression ratio of 0.5
+          resourceSize: 100 * 1024,
+          resourceType: 'Document', // this is a document so it'll use the compressionRatio but not the raw size
+        },
+        {
+          url: 'file://a.html',
+          transferSize: 0,
+          resourceSize: 0,
+          resourceType: 'Document',
+        },
+      ]);
+
+      expect(result.items).toMatchObject([
+        {totalBytes: 40000 * 3 * 0.5, wastedPercent: 50},
+      ]);
+    });
+
     it('does not include empty or small sheets', () => {
       return UnusedCSSAudit.audit_(getArtifacts({
         CSSUsage: {rules: [
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 a4346ebc6..a4a28283c 100644
--- a/lighthouse-core/test/lib/tracehouse/main-thread-tasks-test.js
+++ b/lighthouse-core/test/lib/tracehouse/main-thread-tasks-test.js
@@ -11,6 +11,7 @@ const MainThreadTasks = require('../../../lib/tracehouse/main-thread-tasks.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 noTracingStartedTrace = require('../../fixtures/traces/no-tracingstarted-m74.json');
 const TracingProcessor = require('../../../lib/tracehouse/trace-processor.js');
 const assert = require('assert');
 
@@ -59,6 +60,11 @@ describe('Main Thread Tasks', () => {
     assert.equal(Math.round(totalTime), 396);
   });
 
+  it('should handle slightly trace events that slightly overlap', () => {
+    const tasks = run(noTracingStartedTrace);
+    expect(tasks).toHaveLength(425);
+  });
+
   it('should compute parent/child correctly', () => {
     /*
     An artistic rendering of the below trace:
@@ -77,12 +83,12 @@ describe('Main Thread Tasks', () => {
     traceEvents.forEach(evt => Object.assign(evt, {cat: 'devtools.timeline'}));
 
     const tasks = run({traceEvents});
-    assert.equal(tasks.length, 3);
+    expect(tasks).toHaveLength(3);
 
     const taskA = tasks.find(task => task.event.name === 'TaskA');
     const taskB = tasks.find(task => task.event.name === 'TaskB');
     const taskC = tasks.find(task => task.event.name === 'TaskC');
-    assert.deepStrictEqual(taskA, {
+    expect(taskA).toEqual({
       parent: undefined,
       attributableURLs: [],
 
@@ -95,7 +101,7 @@ describe('Main Thread Tasks', () => {
       group: taskGroups.other,
     });
 
-    assert.deepStrictEqual(taskB, {
+    expect(taskB).toEqual({
       parent: taskA,
       attributableURLs: [],
 
@@ -147,11 +153,54 @@ describe('Main Thread Tasks', () => {
     assert.deepStrictEqual(taskD.attributableURLs, ['urlB.1', 'urlB.2', 'urlC', 'urlD']);
   });
 
+  it('should compute attributableURLs correctly across timers', () => {
+    const baseTs = 1241250325;
+    const url = s => ({args: {data: {url: s}}});
+    const stackFrames = f => ({args: {data: {stackTrace: f.map(url => ({url}))}}});
+    const timerId = id => ({args: {data: {timerId: id}}});
+
+    /*
+    An artistic rendering of the below trace:
+    █████████████████████████████TaskA██████████████████████████████████████████████
+          ████████████████TaskB███████████████████                  █Timer Fire█
+               ████EvaluateScript██████                               █TaskE█
+                   | <-- Timer Install
+    */
+    const traceEvents = [
+      ...boilerplateTrace,
+      {ph: 'X', name: 'TaskA', pid, tid, ts: baseTs, dur: 100e3, ...url('about:blank')},
+      {ph: 'B', name: 'TaskB', pid, tid, ts: baseTs + 5e3, ...stackFrames(['urlB.1', 'urlB.2'])},
+      {ph: 'X', name: 'EvaluateScript', pid, tid, ts: baseTs + 10e3, dur: 30e3, ...url('urlC')},
+      {ph: 'I', name: 'TimerInstall', pid, tid, ts: baseTs + 15e3, ...timerId(1)},
+      {ph: 'E', name: 'TaskB', pid, tid, ts: baseTs + 55e3},
+      {ph: 'X', name: 'TimerFire', pid, tid, ts: baseTs + 75e3, dur: 10e3, ...timerId(1)},
+      {ph: 'X', name: 'TaskE', pid, tid, ts: baseTs + 80e3, dur: 5e3, ...stackFrames(['urlD'])},
+    ];
+
+    traceEvents.forEach(evt => {
+      evt.cat = 'devtools.timeline';
+      evt.args = evt.args || args;
+    });
+
+    const tasks = run({traceEvents});
+    const taskA = tasks.find(task => task.event.name === 'TaskA');
+    const taskB = tasks.find(task => task.event.name === 'TaskB');
+    const taskC = tasks.find(task => task.event.name === 'EvaluateScript');
+    const taskD = tasks.find(task => task.event.name === 'TimerFire');
+    const taskE = tasks.find(task => task.event.name === 'TaskE');
+
+    expect(taskA.attributableURLs).toEqual([]);
+    expect(taskB.attributableURLs).toEqual(['urlB.1', 'urlB.2']);
+    expect(taskC.attributableURLs).toEqual(['urlB.1', 'urlB.2', 'urlC']);
+    expect(taskD.attributableURLs).toEqual(['urlB.1', 'urlB.2', 'urlC']);
+    expect(taskE.attributableURLs).toEqual(['urlB.1', 'urlB.2', 'urlC', 'urlD']);
+  });
+
   it('should handle the last trace event not ending', () => {
     /*
     An artistic rendering of the below trace:
     █████████████████████████████TaskA████████████|
-          ████████████████TaskB███████████████████|
+      ████████████████████TaskB███████████████████|
                                             █TaskC|
                                                   ^ trace abruptly ended
     */
@@ -161,6 +210,7 @@ describe('Main Thread Tasks', () => {
       {ph: 'B', name: 'TaskA', pid, tid, ts: baseTs, args},
       {ph: 'B', name: 'TaskB', pid, tid, ts: baseTs + 5e3, args},
       {ph: 'B', name: 'TaskC', pid, tid, ts: baseTs + 100e3, args},
+      {ph: 'I', name: 'MarkerToPushOutTraceEnd', pid, tid, ts: baseTs + 110e3, args},
     ];
 
     traceEvents.forEach(evt => Object.assign(evt, {cat: 'devtools.timeline'}));
@@ -178,8 +228,8 @@ describe('Main Thread Tasks', () => {
       children: [taskB],
       event: traceEvents[3],
       startTime: 0,
-      endTime: 100,
-      duration: 100,
+      endTime: 110,
+      duration: 110,
       selfTime: 5,
       group: taskGroups.other,
     });
@@ -191,14 +241,22 @@ describe('Main Thread Tasks', () => {
       children: [taskC],
       event: traceEvents[4],
       startTime: 5,
-      endTime: 100,
-      duration: 95,
+      endTime: 110,
+      duration: 105,
       selfTime: 95,
       group: taskGroups.other,
     });
   });
 
   it('should handle nested events *starting* at the same timestamp correctly', () => {
+    /*
+    An artistic rendering of the below trace:
+    █████████████TaskA█████████████
+    ███████TaskB██████
+    █TaskC█
+                                   █████████████TaskD█████████████
+
+    */
     const traceEvents = [
       ...boilerplateTrace,
       {ph: 'B', name: 'TaskB', pid, tid, ts: baseTs, args},
@@ -221,6 +279,13 @@ describe('Main Thread Tasks', () => {
   });
 
   it('should handle nested events *ending* at the same timestamp correctly', () => {
+    /*
+    An artistic rendering of the below trace:
+    █████████████████████████████TaskA████████████|
+      ████████████████████TaskB███████████████████|
+                                            █TaskC|
+                                                  ^ trace abruptly ended
+    */
     const traceEvents = [
       ...boilerplateTrace,
       {ph: 'B', name: 'TaskA', pid, tid, ts: baseTs, args},
@@ -268,6 +333,141 @@ describe('Main Thread Tasks', () => {
     ]);
   });
 
+  it('should handle incorrectly sorted events at task start', () => {
+    /*
+    An artistic rendering of the below trace:
+    █████████████████████████████TaskA██████████████████████████████████████████████
+    █████████████████TaskB███████████████████
+    */
+    const traceEvents = [
+      ...boilerplateTrace,
+      {ph: 'B', name: 'TaskB', pid, tid, ts: baseTs, args},
+      {ph: 'X', name: 'TaskA', pid, tid, ts: baseTs, dur: 100e3, args},
+      {ph: 'E', name: 'TaskB', pid, tid, ts: baseTs + 50e3, args},
+    ];
+
+    traceEvents.forEach(evt => Object.assign(evt, {cat: 'devtools.timeline'}));
+
+    const tasks = run({traceEvents});
+    const [taskA, taskB] = tasks;
+    expect(tasks).toEqual([
+      {
+        parent: undefined,
+        attributableURLs: [],
+
+        children: [taskB],
+        event: traceEvents.find(event => event.name === 'TaskA'),
+        startTime: 0,
+        endTime: 100,
+        duration: 100,
+        selfTime: 50,
+        group: taskGroups.other,
+      },
+      {
+        parent: taskA,
+        attributableURLs: [],
+
+        children: [],
+        event: traceEvents.find(event => event.name === 'TaskB'),
+        startTime: 0,
+        endTime: 50,
+        duration: 50,
+        selfTime: 50,
+        group: taskGroups.other,
+      },
+    ]);
+  });
+
+  it('should handle out-of-order 0 duration tasks', () => {
+    /*
+    An artistic rendering of the below trace:
+    █████████████████████████████TaskA██████████████████|█TaskB█  <-- duration of 0
+    */
+    const traceEvents = [
+      ...boilerplateTrace,
+      {ph: 'B', name: 'TaskA', pid, tid, ts: baseTs, args},
+      {ph: 'E', name: 'TaskA', pid, tid, ts: baseTs + 100e3, args},
+      {ph: 'E', name: 'TaskB', pid, tid, ts: baseTs + 100e3, args},
+      {ph: 'B', name: 'TaskB', pid, tid, ts: baseTs + 100e3, args},
+    ];
+
+    traceEvents.forEach(evt => Object.assign(evt, {cat: 'devtools.timeline'}));
+
+    const tasks = run({traceEvents});
+    expect(tasks).toEqual([
+      {
+        parent: undefined,
+        attributableURLs: [],
+
+        children: [],
+        event: traceEvents.find(event => event.name === 'TaskA'),
+        startTime: 0,
+        endTime: 100,
+        duration: 100,
+        selfTime: 100,
+        group: taskGroups.other,
+      },
+      {
+        parent: undefined,
+        attributableURLs: [],
+
+        children: [],
+        event: traceEvents.find(event => event.name === 'TaskB' && event.ph === 'B'),
+        startTime: 100,
+        endTime: 100,
+        duration: 0,
+        selfTime: 0,
+        group: taskGroups.other,
+      },
+    ]);
+  });
+
+  it('should handle child events that extend <1ms beyond parent event', () => {
+    /*
+    An artistic rendering of the below trace:
+    ████████████████TaskA██████████████████
+            █████████TaskB██████████████████
+    */
+    const traceEvents = [
+      ...boilerplateTrace,
+      {ph: 'B', name: 'TaskA', pid, tid, ts: baseTs, args},
+      {ph: 'B', name: 'TaskB', pid, tid, ts: baseTs + 25e3, args},
+      {ph: 'E', name: 'TaskA', pid, tid, ts: baseTs + 100e3 - 50, args}, // this is invalid, but happens in practice
+      {ph: 'E', name: 'TaskB', pid, tid, ts: baseTs + 100e3, args},
+    ];
+
+    traceEvents.forEach(evt => Object.assign(evt, {cat: 'devtools.timeline'}));
+
+    const tasks = run({traceEvents});
+    const [taskA, taskB] = tasks;
+    expect(tasks).toEqual([
+      {
+        parent: undefined,
+        attributableURLs: [],
+
+        children: [taskB],
+        event: traceEvents.find(event => event.name === 'TaskA'),
+        startTime: 0,
+        endTime: 100,
+        duration: 100,
+        selfTime: 25,
+        group: taskGroups.other,
+      },
+      {
+        parent: taskA,
+        attributableURLs: [],
+
+        children: [],
+        event: traceEvents.find(event => event.name === 'TaskB' && event.ph === 'B'),
+        startTime: 25,
+        endTime: 100,
+        duration: 75,
+        selfTime: 75,
+        group: taskGroups.other,
+      },
+    ]);
+  });
+
   const invalidEventSets = [
     [
       // TaskA overlaps with TaskB, X first
@@ -281,6 +481,13 @@ describe('Main Thread Tasks', () => {
       {ph: 'X', name: 'TaskB', pid, tid, ts: baseTs + 5e3, dur: 100e3, args},
       {ph: 'E', name: 'TaskA', pid, tid, ts: baseTs + 90e3, args},
     ],
+    [
+      // TaskA overlaps with TaskB, both B/E
+      {ph: 'B', name: 'TaskA', pid, tid, ts: baseTs, args},
+      {ph: 'B', name: 'TaskB', pid, tid, ts: baseTs + 5e3, args},
+      {ph: 'E', name: 'TaskA', pid, tid, ts: baseTs + 90e3, args},
+      {ph: 'E', name: 'TaskB', pid, tid, ts: baseTs + 95e3, args},
+    ],
     [
       // TaskA is missing a B event
       {ph: 'E', name: 'TaskA', pid, tid, ts: baseTs, args},
@@ -292,6 +499,12 @@ describe('Main Thread Tasks', () => {
       {ph: 'X', name: 'TaskA', pid, tid, ts: baseTs, dur: 100e3, args},
       {ph: 'E', name: 'TaskB', pid, tid, ts: baseTs + 10e3, args},
     ],
+    [
+      {ph: 'I', name: 'MarkerToPushOutTraceEnd', pid, tid, ts: baseTs + 110e3, args},
+      // TaskB is missing an E event within an X
+      {ph: 'X', name: 'TaskA', pid, tid, ts: baseTs, dur: 100e3, args},
+      {ph: 'B', name: 'TaskB', pid, tid, ts: baseTs + 10e3, args},
+    ],
   ];
 
   for (const invalidEvents of invalidEventSets) {
diff --git a/lighthouse-core/test/report/html/renderer/report-renderer-test.js b/lighthouse-core/test/report/html/renderer/report-renderer-test.js
index 85ac26f97..821e65aa4 100644
--- a/lighthouse-core/test/report/html/renderer/report-renderer-test.js
+++ b/lighthouse-core/test/report/html/renderer/report-renderer-test.js
@@ -220,15 +220,16 @@ describe('ReportRenderer', () => {
     const container = renderer._dom._document.body;
     const output = renderer.renderReport(sampleResults, container);
 
+    const DOCS_ORIGINS = ['https://developers.google.com', 'https://web.dev'];
     const utmChannels = [...output.querySelectorAll('a[href*="utm_source=lighthouse"')]
       .map(a => new URL(a.href))
-      .filter(url => url.origin === 'https://developers.google.com')
+      .filter(url => DOCS_ORIGINS.includes(url.origin))
       .map(url => url.searchParams.get('utm_medium'));
 
-    assert.ok(utmChannels.length > 20);
-    utmChannels.forEach(anchorChannel => {
-      assert.strictEqual(anchorChannel, lhrChannel);
-    });
+    assert.ok(utmChannels.length > 100);
+    for (const utmChannel of utmChannels) {
+      assert.strictEqual(utmChannel, lhrChannel);
+    }
   });
 
   it('renders `not_applicable` audits as `notApplicable`', () => {
diff --git a/lighthouse-core/test/results/artifacts/artifacts.json b/lighthouse-core/test/results/artifacts/artifacts.json
index 7c3f04e18..c6d9e7eca 100644
--- a/lighthouse-core/test/results/artifacts/artifacts.json
+++ b/lighthouse-core/test/results/artifacts/artifacts.json
@@ -243,6 +243,7 @@
     {
       "type": "text/javascript",
       "src": null,
+      "id": "amp-style-styling-script",
       "async": false,
       "defer": false,
       "source": "head",
@@ -253,6 +254,7 @@
     {
       "type": null,
       "src": "http://localhost:10200/dobetterweb/dbw_tester.js",
+      "id": "dbw-tester-script",
       "async": false,
       "defer": false,
       "source": "head",
@@ -263,6 +265,7 @@
     {
       "type": "module",
       "src": "http://localhost:10200/dobetterweb/empty_module.js?delay=500",
+      "id": null,
       "async": false,
       "defer": false,
       "source": "head",
@@ -273,6 +276,7 @@
     {
       "type": "text/javascript",
       "src": null,
+      "id": "error-time",
       "async": false,
       "defer": false,
       "source": "head",
@@ -283,6 +287,7 @@
     {
       "type": null,
       "src": "http://localhost:10200/dobetterweb/fcp-delayer.js?delay=5000",
+      "id": null,
       "async": false,
       "defer": false,
       "source": "head",
@@ -293,6 +298,7 @@
     {
       "type": null,
       "src": null,
+      "id": null,
       "async": false,
       "defer": false,
       "source": "body",
@@ -303,6 +309,7 @@
     {
       "type": null,
       "src": null,
+      "id": null,
       "async": false,
       "defer": false,
       "source": "body",
@@ -313,6 +320,7 @@
     {
       "type": null,
       "src": null,
+      "id": null,
       "async": false,
       "defer": false,
       "source": "body",
@@ -323,6 +331,7 @@
     {
       "type": null,
       "src": "http://localhost:10200/zone.js",
+      "id": null,
       "async": false,
       "defer": false,
       "source": "body",
@@ -333,6 +342,7 @@
     {
       "type": null,
       "src": "http://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js",
+      "id": null,
       "async": false,
       "defer": false,
       "source": "body",
diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json
index a36375242..3ea69984e 100644
--- a/lighthouse-core/test/results/sample_v2.json
+++ b/lighthouse-core/test/results/sample_v2.json
@@ -488,7 +488,7 @@
     "apple-touch-icon": {
       "id": "apple-touch-icon",
       "title": "Does not provide a valid `apple-touch-icon`",
-      "description": "For ideal appearance on iOS when users add a progressive web app to the home screen, define an `apple-touch-icon`. It must point to a non-transparent 192px (or 180px) square PNG. [Learn More](https://developers.google.com/web/fundamentals/design-and-ux/browser-customization/).",
+      "description": "For ideal appearance on iOS when users add a progressive web app to the home screen, define an `apple-touch-icon`. It must point to a non-transparent 192px (or 180px) square PNG. [Learn More](https://web.dev/apple-touch-icon/).",
       "score": 0,
       "scoreDisplayMode": "binary",
       "warnings": []