Repository: GoogleChrome/lighthouse (js)

Issue:
FR - Add better ways of visualizing perf impact of 3rd party scripts
**Feature request summary**

Today LH offers different ways of visualizing the amount of 3rd party scripts a page loads:

- The **Minimize main-thread work** suggestion shows the overall JS impact.
- The **Reduce JS execution time** suggestion shows a break down of JS libraries, and has a very handy switch to show/hide 3rd-party resources.

As a complement of this, it would be useful to be able to simulate how the Performance score or individual metrics would change, by not loading these scripts (similarly to what you'd do with the "Block" feature in WPT).

Ideas on how to achieve this:

- Turning the "Show 3rd party resources" off, shows how metrics like TTI, and others, would change as a result.
- Allowing the block requests feature in DevTools to impact the LH resulting report (already discussed in #9095). This one requires more work from the developer, and might look like a hidden feature and not very intuitive. Also harder to connect with the 3p JS breakdown, LH shows.

**Note:** Showing a page without any 3p scripts, also can lead to unintended scenarios:

- Some 3p scripts are meant to execute important functionality (i.e: libraries like JQuery, or libraries with business logic the developer has hosted in a different domain, like a CDN). Omitting them in the report might end up showing scores for an unusable site.
To mitigate that, maybe changing the list from "3p resources", to something more specific like "trackers/analytics" might help.

**What is the motivation or use case for changing this?**

3p scripts are among [the main reasons for slow websites today](https://twitter.com/katiehempenius/status/1097068591751389184). Anything that can be done to show the impact of these resources more clearly, can help teams communicate this internally and take action.

**How is this beneficial to Lighthouse?**

It makes the tool more actionable.


Pull request description:
core(third-party-summary): add blocking time impact
**Summary**
After some discussion, we settled on surfacing the blocking time of third parties rather than a full-fledged opportunity of TTI savings of removing all third-party resources. It shows up as failing if the impact to TBT is >250ms (which would push you out of a ~90 score range on TBT).

**Related Issues/PRs**
closes #9256

![image](https://user-images.githubusercontent.com/2301202/62165780-d22d9100-b2e4-11e9-9d1f-9e097af5c7d8.png)



Patch:
diff --git a/lighthouse-core/audits/third-party-summary.js b/lighthouse-core/audits/third-party-summary.js
index e63ed3112..9aac6184e 100644
--- a/lighthouse-core/audits/third-party-summary.js
+++ b/lighthouse-core/audits/third-party-summary.js
@@ -14,25 +14,28 @@ const NetworkRecords = require('../computed/network-records.js');
 const MainThreadTasks = require('../computed/main-thread-tasks.js');
 
 const UIStrings = {
-  /** Title of a Lighthouse audit that identifies the code on the page that the user doesn't control. This is shown in a list of audits that Lighthouse generates. */
-  title: 'Third-Party Usage',
+  /** Title of a diagnostic audit that provides details about the code on a web page that the user doesn't control (referred to as "third-party code"). This descriptive title is shown to users when the amount is acceptable and no user action is required. */
+  title: 'Third-Party usage',
+  /** Title of a diagnostic audit that provides details about the code on a web page that the user doesn't control (referred to as "third-party code"). This imperative title is shown to users when there is a significant amount of page execution time caused by third-party code that should be reduced. */
+  failureTitle: 'Reduce the impact of third-party code',
   /** Description of a Lighthouse audit that identifies the code on the page that the user doesn't control. This is displayed after a user expands the section to see more. No character length limits. 'Learn More' becomes link text to additional documentation. */
   description: 'Third-party code can significantly impact load performance. ' +
     'Limit the number of redundant third-party providers and try to load third-party code after ' +
     'your page has primarily finished loading. [Learn more](https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/loading-third-party-javascript/).',
   /** Label for a table column that displays the name of a third-party provider that potentially links to their website. */
   columnThirdParty: 'Third-Party',
-  /** Label for a table column that displays how much time each row spent executing on the main thread, entries will be the number of milliseconds spent. */
-  columnMainThreadTime: 'Main Thread Time',
-  /** Summary text for the result of a Lighthouse audit that identifies the code on the page that the user doesn't control. This text summarizes the number of distinct entities that were found on the page. */
-  displayValue: `{itemCount, plural,
-    =1 {1 Third-Party Found}
-    other {# Third-Parties Found}
-  }`,
+  /** Label for a table column that displays how much time each row spent blocking other work on the main thread, entries will be the number of milliseconds spent. */
+  columnBlockingTime: 'Main-Thread Blocking Time',
+  /** Summary text for the result of a Lighthouse audit that identifies the code on a web page that the user doesn't control (referred to as "third-party code"). This text summarizes the number of distinct entities that were found on the page. */
+  displayValue: 'Third-party code blocked the main thread for ' +
+    `{timeInMs, number, milliseconds}\xa0ms`,
 };
 
 const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
 
+// A page passes when all third-party code blocks for less than 250 ms.
+const PASS_THRESHOLD_IN_MS = 250;
+
 /** @typedef {import("third-party-web").IEntity} ThirdPartyEntity */
 
 class ThirdPartySummary extends Audit {
@@ -43,8 +46,8 @@ class ThirdPartySummary extends Audit {
     return {
       id: 'third-party-summary',
       title: str_(UIStrings.title),
+      failureTitle: str_(UIStrings.failureTitle),
       description: str_(UIStrings.description),
-      scoreDisplayMode: Audit.SCORING_MODES.INFORMATIVE,
       requiredArtifacts: ['traces', 'devtoolsLogs'],
     };
   }
@@ -69,17 +72,18 @@ class ThirdPartySummary extends Audit {
    * @param {Array<LH.Artifacts.NetworkRequest>} networkRecords
    * @param {Array<LH.Artifacts.TaskNode>} mainThreadTasks
    * @param {number} cpuMultiplier
-   * @return {Map<ThirdPartyEntity, {mainThreadTime: number, transferSize: number}>}
+   * @return {Map<ThirdPartyEntity, {mainThreadTime: number, transferSize: number, blockingTime: number}>}
    */
   static getSummaryByEntity(networkRecords, mainThreadTasks, cpuMultiplier) {
-    /** @type {Map<ThirdPartyEntity, {mainThreadTime: number, transferSize: number}>} */
+    /** @type {Map<ThirdPartyEntity, {mainThreadTime: number, transferSize: number, blockingTime: number}>} */
     const entities = new Map();
+    const defaultEntityStat = {mainThreadTime: 0, blockingTime: 0, transferSize: 0};
 
     for (const request of networkRecords) {
       const entity = ThirdPartySummary.getEntitySafe(request.url);
       if (!entity) continue;
 
-      const entityStats = entities.get(entity) || {mainThreadTime: 0, transferSize: 0};
+      const entityStats = entities.get(entity) || {...defaultEntityStat};
       entityStats.transferSize += request.transferSize;
       entities.set(entity, entityStats);
     }
@@ -91,8 +95,14 @@ class ThirdPartySummary extends Audit {
       const entity = ThirdPartySummary.getEntitySafe(attributeableURL);
       if (!entity) continue;
 
-      const entityStats = entities.get(entity) || {mainThreadTime: 0, transferSize: 0};
-      entityStats.mainThreadTime += task.selfTime * cpuMultiplier;
+      const entityStats = entities.get(entity) || {...defaultEntityStat};
+      const taskDuration = task.selfTime * cpuMultiplier;
+      // The amount of time spent on main thread is the sum of all durations.
+      entityStats.mainThreadTime += taskDuration;
+      // The amount of time spent *blocking* on main thread is the sum of all time longer than 50ms.
+      // Note that this is not totally equivalent to the TBT definition since it fails to account for FCP,
+      // but a majority of third-party work occurs after FCP and should yield largely similar numbers.
+      entityStats.blockingTime += Math.max(taskDuration - 50, 0);
       entities.set(entity, entityStats);
     }
 
@@ -117,15 +127,10 @@ class ThirdPartySummary extends Audit {
 
     const summary = {wastedBytes: 0, wastedMs: 0};
 
-    // Sort by a combined measure of bytes + main thread time.
-    // 1KB ~= 1 ms
-    /** @param {{transferSize: number, mainThreadTime: number}} stats */
-    const computeSortValue = stats => stats.transferSize / 1024 + stats.mainThreadTime;
-
     const results = Array.from(summaryByEntity.entries())
       .map(([entity, stats]) => {
         summary.wastedBytes += stats.transferSize;
-        summary.wastedMs += stats.mainThreadTime;
+        summary.wastedMs += stats.blockingTime;
 
         return {
           entity: /** @type {LH.Audit.Details.LinkValue} */ ({
@@ -135,17 +140,19 @@ class ThirdPartySummary extends Audit {
           }),
           transferSize: stats.transferSize,
           mainThreadTime: stats.mainThreadTime,
+          blockingTime: stats.blockingTime,
         };
       })
-      .sort((a, b) => computeSortValue(b) - computeSortValue(a));
+      // Sort by blocking time first, then transfer size to break ties.
+      .sort((a, b) => (b.blockingTime - a.blockingTime) || (b.transferSize - a.transferSize));
 
     /** @type {LH.Audit.Details.Table['headings']} */
     const headings = [
       {key: 'entity', itemType: 'link', text: str_(UIStrings.columnThirdParty)},
       {key: 'transferSize', granularity: 1, itemType: 'bytes',
         text: str_(i18n.UIStrings.columnSize)},
-      {key: 'mainThreadTime', granularity: 1, itemType: 'ms',
-        text: str_(UIStrings.columnMainThreadTime)},
+      {key: 'blockingTime', granularity: 1, itemType: 'ms',
+        text: str_(UIStrings.columnBlockingTime)},
     ];
 
     if (!results.length) {
@@ -156,8 +163,11 @@ class ThirdPartySummary extends Audit {
     }
 
     return {
-      score: Number(results.length === 0),
-      displayValue: str_(UIStrings.displayValue, {itemCount: results.length}),
+      score: Number(summary.wastedMs <= PASS_THRESHOLD_IN_MS),
+      displayValue: str_(UIStrings.displayValue, {
+        itemCount: results.length,
+        timeInMs: summary.wastedMs,
+      }),
       details: Audit.makeTableDetails(headings, results, summary),
     };
   }
diff --git a/lighthouse-core/gather/gatherers/offline.js b/lighthouse-core/gather/gatherers/offline.js
index 673f0d455..5b9814bc0 100644
--- a/lighthouse-core/gather/gatherers/offline.js
+++ b/lighthouse-core/gather/gatherers/offline.js
@@ -13,6 +13,9 @@ class Offline extends Gatherer {
    * @param {LH.Gatherer.PassContext} passContext
    */
   beforePass(passContext) {
+    // This call sets up the offline state for the page navigation of the `offlinePass` in gather-runner.
+    // gather-runner will automatically go back online before the `afterPass` phase, so no additional
+    // cleanup is necessary.
     return passContext.driver.goOffline();
   }
 
@@ -21,14 +24,13 @@ class Offline extends Gatherer {
    * @param {LH.Gatherer.LoadData} loadData
    * @return {Promise<LH.Artifacts['Offline']>}
    */
-  afterPass(passContext, loadData) {
+  async afterPass(passContext, loadData) {
     const navigationRecord = loadData.networkRecords.filter(record => {
       return URL.equalWithExcludedFragments(record.url, passContext.url) &&
         record.fetchedViaServiceWorker;
     }).pop(); // Take the last record that matches.
 
-    return passContext.driver.goOnline(passContext)
-      .then(_ => navigationRecord ? navigationRecord.statusCode : -1);
+    return navigationRecord ? navigationRecord.statusCode : -1;
   }
 }
 
diff --git a/lighthouse-core/gather/gatherers/start-url.js b/lighthouse-core/gather/gatherers/start-url.js
index e19e1effe..07c387fe7 100644
--- a/lighthouse-core/gather/gatherers/start-url.js
+++ b/lighthouse-core/gather/gatherers/start-url.js
@@ -11,20 +11,36 @@ const Gatherer = require('./gatherer.js');
 
 class StartUrl extends Gatherer {
   /**
-   * Grab the manifest, extract it's start_url, attempt to `fetch()` it while offline
+   * Go offline, assess the start url, go back online.
    * @param {LH.Gatherer.PassContext} passContext
    * @return {Promise<LH.Artifacts['StartUrl']>}
    */
   async afterPass(passContext) {
+    // `afterPass` is always online, so manually go offline to check start_url.
+    await passContext.driver.goOffline();
+    const result = await this._determineStartUrlAvailability(passContext);
+    await passContext.driver.goOnline(passContext);
+
+    return result;
+  }
+
+  /**
+   * Grab the manifest, extract its start_url, attempt to `fetch()` it while offline
+   * @param {LH.Gatherer.PassContext} passContext
+   * @return {Promise<LH.Artifacts['StartUrl']>}
+   */
+  async _determineStartUrlAvailability(passContext) {
     const manifest = passContext.baseArtifacts.WebAppManifest;
     const startUrlInfo = this._readManifestStartUrl(manifest);
     if (startUrlInfo.isReadFailure) {
       return {statusCode: -1, explanation: startUrlInfo.reason};
     }
 
-    return this._attemptStartURLFetch(passContext.driver, startUrlInfo.startUrl).catch(() => {
-      return {statusCode: -1, explanation: 'Unable to fetch start URL via service worker.'};
-    });
+    try {
+      return await this._attemptStartURLFetch(passContext.driver, startUrlInfo.startUrl);
+    } catch (err) {
+      return {statusCode: -1, explanation: 'Error while fetching start_url via service worker.'};
+    }
   }
 
   /**
@@ -59,7 +75,7 @@ class StartUrl extends Gatherer {
     // Wait up to 3s to get a matched network request from the fetch() to work
     const timeoutPromise = new Promise(resolve =>
       setTimeout(
-        () => resolve({statusCode: -1, explanation: 'Timed out waiting for fetched start_url.'}),
+        () => resolve({statusCode: -1, explanation: 'Timed out waiting for start_url to respond.'}),
         3000
       )
     );
@@ -77,7 +93,7 @@ class StartUrl extends Gatherer {
         if (!response.fromServiceWorker) {
           return resolve({
             statusCode: -1,
-            explanation: 'Unable to fetch start URL via service worker.',
+            explanation: 'The start_url did respond, but not via a service worker.',
           });
         }
         // Successful SW-served fetch of the start_URL
diff --git a/lighthouse-core/lib/i18n/locales/en-US.json b/lighthouse-core/lib/i18n/locales/en-US.json
index 096c96bc7..6727113c8 100644
--- a/lighthouse-core/lib/i18n/locales/en-US.json
+++ b/lighthouse-core/lib/i18n/locales/en-US.json
@@ -1073,8 +1073,8 @@
   "lighthouse-core/audits/themed-omnibox.js | title": {
     "message": "Sets a theme color for the address bar."
   },
-  "lighthouse-core/audits/third-party-summary.js | columnMainThreadTime": {
-    "message": "Main Thread Time"
+  "lighthouse-core/audits/third-party-summary.js | columnBlockingTime": {
+    "message": "Main-Thread Blocking Time"
   },
   "lighthouse-core/audits/third-party-summary.js | columnThirdParty": {
     "message": "Third-Party"
@@ -1083,10 +1083,13 @@
     "message": "Third-party code can significantly impact load performance. Limit the number of redundant third-party providers and try to load third-party code after your page has primarily finished loading. [Learn more](https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/loading-third-party-javascript/)."
   },
   "lighthouse-core/audits/third-party-summary.js | displayValue": {
-    "message": "{itemCount, plural,\n    =1 {1 Third-Party Found}\n    other {# Third-Parties Found}\n  }"
+    "message": "Third-party code blocked the main thread for {timeInMs, number, milliseconds} ms"
+  },
+  "lighthouse-core/audits/third-party-summary.js | failureTitle": {
+    "message": "Reduce the impact of third-party code"
   },
   "lighthouse-core/audits/third-party-summary.js | title": {
-    "message": "Third-Party Usage"
+    "message": "Third-Party usage"
   },
   "lighthouse-core/audits/time-to-first-byte.js | description": {
     "message": "Time To First Byte identifies the time at which your server sends a response. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/ttfb)."
diff --git a/lighthouse-core/lib/i18n/locales/en-XL.json b/lighthouse-core/lib/i18n/locales/en-XL.json
index 278731990..220fbd3b8 100644
--- a/lighthouse-core/lib/i18n/locales/en-XL.json
+++ b/lighthouse-core/lib/i18n/locales/en-XL.json
@@ -1073,8 +1073,8 @@
   "lighthouse-core/audits/themed-omnibox.js | title": {
     "message": "Ŝét̂ś â t́ĥém̂é ĉól̂ór̂ f́ôŕ t̂h́ê ád̂d́r̂éŝś b̂ár̂."
   },
-  "lighthouse-core/audits/third-party-summary.js | columnMainThreadTime": {
-    "message": "M̂áîń T̂h́r̂éâd́ T̂ím̂é"
+  "lighthouse-core/audits/third-party-summary.js | columnBlockingTime": {
+    "message": "M̂áîń-T̂h́r̂éâd́ B̂ĺôćk̂ín̂ǵ T̂ím̂é"
   },
   "lighthouse-core/audits/third-party-summary.js | columnThirdParty": {
     "message": "T̂h́îŕd̂-Ṕâŕt̂ý"
@@ -1083,10 +1083,13 @@
     "message": "T̂h́îŕd̂-ṕâŕt̂ý ĉód̂é ĉán̂ śîǵn̂íf̂íĉán̂t́l̂ý îḿp̂áĉt́ l̂óâd́ p̂ér̂f́ôŕm̂án̂ćê. Ĺîḿît́ t̂h́ê ńûḿb̂ér̂ óf̂ ŕêd́ûńd̂án̂t́ t̂h́îŕd̂-ṕâŕt̂ý p̂ŕôv́îd́êŕŝ án̂d́ t̂ŕŷ t́ô ĺôád̂ t́ĥír̂d́-p̂ár̂t́ŷ ćôd́ê áf̂t́êŕ ŷóûŕ p̂áĝé ĥáŝ ṕr̂ím̂ár̂íl̂ý f̂ín̂íŝh́êd́ l̂óâd́îńĝ. [Ĺêár̂ń m̂ór̂é](https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/loading-third-party-javascript/)."
   },
   "lighthouse-core/audits/third-party-summary.js | displayValue": {
-    "message": "{itemCount, plural,\n    =1 {1 T̂h́îŕd̂-Ṕâŕt̂ý F̂óûńd̂}\n    other {# T́ĥír̂d́-P̂ár̂t́îéŝ F́ôún̂d́}\n  }"
+    "message": "T̂h́îŕd̂-ṕâŕt̂ý ĉód̂é b̂ĺôćk̂éd̂ t́ĥé m̂áîń t̂h́r̂éâd́ f̂ór̂ {timeInMs, number, milliseconds} ḿŝ"
+  },
+  "lighthouse-core/audits/third-party-summary.js | failureTitle": {
+    "message": "R̂éd̂úĉé t̂h́ê ím̂ṕâćt̂ óf̂ t́ĥír̂d́-p̂ár̂t́ŷ ćôd́ê"
   },
   "lighthouse-core/audits/third-party-summary.js | title": {
-    "message": "T̂h́îŕd̂-Ṕâŕt̂ý Ûśâǵê"
+    "message": "T̂h́îŕd̂-Ṕâŕt̂ý ûśâǵê"
   },
   "lighthouse-core/audits/time-to-first-byte.js | description": {
     "message": "T̂ím̂é T̂ó F̂ír̂śt̂ B́ŷt́ê íd̂én̂t́îf́îéŝ t́ĥé t̂ím̂é ât́ ŵh́îćĥ ýôúr̂ śêŕv̂ér̂ śêńd̂ś â ŕêśp̂ón̂śê. [Ĺêár̂ń m̂ór̂é](https://developers.google.com/web/tools/lighthouse/audits/ttfb)."
diff --git a/lighthouse-core/lib/proto-preprocessor.js b/lighthouse-core/lib/proto-preprocessor.js
index 7f5142b95..6b7e943b4 100644
--- a/lighthouse-core/lib/proto-preprocessor.js
+++ b/lighthouse-core/lib/proto-preprocessor.js
@@ -29,10 +29,10 @@ function processForProto(lhr) {
   // 'ignore unknown fields' in the language of conversion.
   if (reportJson.configSettings) {
     // The settings that are in both proto and LHR
-    const {emulatedFormFactor, locale, onlyCategories} = reportJson.configSettings;
+    const {emulatedFormFactor, locale, onlyCategories, channel} = reportJson.configSettings;
 
     // @ts-ignore - intentionally only a subset of settings.
-    reportJson.configSettings = {emulatedFormFactor, locale, onlyCategories};
+    reportJson.configSettings = {emulatedFormFactor, locale, onlyCategories, channel};
   }
 
   // Remove runtimeError if it is NO_ERROR
diff --git a/lighthouse-core/report/html/renderer/dom.js b/lighthouse-core/report/html/renderer/dom.js
index 28de47bb2..4f7b0a94d 100644
--- a/lighthouse-core/report/html/renderer/dom.js
+++ b/lighthouse-core/report/html/renderer/dom.js
@@ -127,8 +127,8 @@ class DOM {
       // Otherwise, append any links found.
       const url = new URL(segment.linkHref);
 
-      const DEVELOPERS_GOOGLE_ORIGIN = 'https://developers.google.com';
-      if (url.origin === DEVELOPERS_GOOGLE_ORIGIN) {
+      const DOCS_ORIGINS = ['https://developers.google.com', 'https://web.dev'];
+      if (DOCS_ORIGINS.includes(url.origin)) {
         url.searchParams.set('utm_source', 'lighthouse');
         url.searchParams.set('utm_medium', this._lighthouseChannel);
       }
diff --git a/lighthouse-core/report/html/renderer/report-ui-features.js b/lighthouse-core/report/html/renderer/report-ui-features.js
index 41be88539..94a07a617 100644
--- a/lighthouse-core/report/html/renderer/report-ui-features.js
+++ b/lighthouse-core/report/html/renderer/report-ui-features.js
@@ -51,6 +51,8 @@ class ReportUIFeatures {
     /** @type {HTMLElement} */
     this.toolsButton; // eslint-disable-line no-unused-expressions
     /** @type {HTMLElement} */
+    this.toolsDropDown; // eslint-disable-line no-unused-expressions
+    /** @type {HTMLElement} */
     this.topbarEl; // eslint-disable-line no-unused-expressions
     /** @type {HTMLElement} */
     this.scoreScaleEl; // eslint-disable-line no-unused-expressions
@@ -62,6 +64,8 @@ class ReportUIFeatures {
     this.onMediaQueryChange = this.onMediaQueryChange.bind(this);
     this.onCopy = this.onCopy.bind(this);
     this.onToolsButtonClick = this.onToolsButtonClick.bind(this);
+    this.onToolsButtonKeydown = this.onToolsButtonKeydown.bind(this);
+    this.onToolsDropDownKeydown = this.onToolsDropDownKeydown.bind(this);
     this.onToolAction = this.onToolAction.bind(this);
     this.onKeyDown = this.onKeyDown.bind(this);
     this.onKeyUp = this.onKeyUp.bind(this);
@@ -70,6 +74,9 @@ class ReportUIFeatures {
     this.expandAllDetails = this.expandAllDetails.bind(this);
     this._toggleDarkTheme = this._toggleDarkTheme.bind(this);
     this._updateStickyHeaderOnScroll = this._updateStickyHeaderOnScroll.bind(this);
+    this._getNextDropDownItem = this._getNextDropDownItem.bind(this);
+    this._getNextSelectableNode = this._getNextSelectableNode.bind(this);
+    this._getPreviousDropDownItem = this._getPreviousDropDownItem.bind(this);
   }
 
   /**
@@ -208,9 +215,11 @@ class ReportUIFeatures {
   _setupToolsButton() {
     this.toolsButton = this._dom.find('.lh-tools__button', this._document);
     this.toolsButton.addEventListener('click', this.onToolsButtonClick);
+    this.toolsButton.addEventListener('keydown', this.onToolsButtonKeydown);
 
-    const dropdown = this._dom.find('.lh-tools__dropdown', this._document);
-    dropdown.addEventListener('click', this.onToolAction);
+    this.toolsDropDown = this._dom.find('.lh-tools__dropdown', this._document);
+    this.toolsDropDown.addEventListener('click', this.onToolAction);
+    this.toolsDropDown.addEventListener('keydown', this.onToolsDropDownKeydown);
   }
 
   _setupThirdPartyFilter() {
@@ -383,6 +392,31 @@ class ReportUIFeatures {
 
   closeToolsDropdown() {
     this.toolsButton.classList.remove('active');
+    this.toolsButton.setAttribute('aria-expanded', 'false');
+    this._document.removeEventListener('keydown', this.onKeyDown);
+    if (this.toolsDropDown.contains(this._document.activeElement)) {
+      // Refocus on the tools button if the drop down last had focus
+      this.toolsButton.focus();
+    }
+  }
+
+  /**
+   * @param {HTMLElement} firstFocusElement
+   */
+  openToolsDropDown(firstFocusElement) {
+    if (this.toolsButton.classList.contains('active')) {
+      // If the drop down is already open focus on the element
+      firstFocusElement.focus();
+    } else {
+      // Wait for drop down transition to complete so options are focusable.
+      this.toolsDropDown.addEventListener('transitionend', () => {
+        firstFocusElement.focus();
+      }, {once: true});
+    }
+
+    this.toolsButton.classList.add('active');
+    this.toolsButton.setAttribute('aria-expanded', 'true');
+    this._document.addEventListener('keydown', this.onKeyDown);
   }
 
   /**
@@ -391,8 +425,113 @@ class ReportUIFeatures {
    */
   onToolsButtonClick(e) {
     e.preventDefault();
-    this.toolsButton.classList.toggle('active');
-    this._document.addEventListener('keydown', this.onKeyDown);
+    e.stopImmediatePropagation();
+
+    if (this.toolsButton.classList.contains('active')) {
+      this.closeToolsDropdown();
+    } else {
+      this.openToolsDropDown(this._getNextDropDownItem());
+    }
+  }
+
+  /**
+   * Handler for tool button.
+   * @param {KeyboardEvent} e
+   */
+  onToolsButtonKeydown(e) {
+    switch (e.code) {
+      case 'ArrowUp':
+        e.preventDefault();
+        this.openToolsDropDown(this._getPreviousDropDownItem());
+        break;
+      case 'ArrowDown':
+      case 'Enter':
+      case ' ':
+        e.preventDefault();
+        this.openToolsDropDown(this._getNextDropDownItem());
+        break;
+      default:
+       // no op
+    }
+  }
+
+  /**
+   * Handler for tool DropDown.
+   * @param {KeyboardEvent} e
+   */
+  onToolsDropDownKeydown(e) {
+    const el = /** @type {?HTMLElement} */ (e.target);
+
+    switch (e.code) {
+      case 'ArrowUp':
+        e.preventDefault();
+        this._getPreviousDropDownItem(el).focus();
+        break;
+      case 'ArrowDown':
+        e.preventDefault();
+        this._getNextDropDownItem(el).focus();
+        break;
+      case 'Home':
+        e.preventDefault();
+        this._getNextDropDownItem().focus();
+        break;
+      case 'End':
+        e.preventDefault();
+        this._getPreviousDropDownItem().focus();
+        break;
+      default:
+       // no op
+    }
+  }
+
+  /**
+   * @param {Array<Node>} allNodes
+   * @param {?Node=} startNode
+   * @returns {Node}
+   */
+  _getNextSelectableNode(allNodes, startNode) {
+    const nodes = allNodes.filter((node) => {
+      if (!(node instanceof HTMLElement)) {
+        return false;
+      }
+
+      // 'Save as Gist' option may be disabled.
+      if (node.hasAttribute('disabled')) {
+        return false;
+      }
+
+      // 'Save as Gist' option may have display none.
+      if (window.getComputedStyle(node).display === 'none') {
+        return false;
+      }
+
+      return true;
+    });
+
+    let nextIndex = startNode ? (nodes.indexOf(startNode) + 1) : 0;
+    if (nextIndex >= nodes.length) {
+      nextIndex = 0;
+    }
+
+    return nodes[nextIndex];
+  }
+
+  /**
+   * @param {?Element=} startEl
+   * @returns {HTMLElement}
+   */
+  _getNextDropDownItem(startEl) {
+    const nodes = Array.from(this.toolsDropDown.childNodes);
+    return /** @type {HTMLElement} */ (this._getNextSelectableNode(nodes, startEl));
+  }
+
+  /**
+   * @param {?Element=} startEl
+   * @returns {HTMLElement}
+   */
+  _getPreviousDropDownItem(startEl) {
+    const nodes = Array.from(this.toolsDropDown.childNodes).reverse();
+    return /** @type {HTMLElement} */ (this._getNextSelectableNode(nodes, startEl));
   }
 
   /**
@@ -424,12 +563,10 @@ class ReportUIFeatures {
         break;
       case 'print-summary':
         this.collapseAllDetails();
-        this.closeToolsDropdown();
         this._print();
         break;
       case 'print-expanded':
         this.expandAllDetails();
-        this.closeToolsDropdown();
         this._print();
         break;
       case 'save-json': {
@@ -464,7 +601,6 @@ class ReportUIFeatures {
     }
 
     this.closeToolsDropdown();
-    this._document.removeEventListener('keydown', this.onKeyDown);
   }
 
   _print() {
diff --git a/lighthouse-core/report/html/templates.html b/lighthouse-core/report/html/templates.html
index fd08b45bd..3cd8cdefc 100644
--- a/lighthouse-core/report/html/templates.html
+++ b/lighthouse-core/report/html/templates.html
@@ -370,22 +370,22 @@ limitations under the License.
     <a href="" class="lh-topbar__url" target="_blank" rel="noopener"></a>
 
     <div class="lh-tools">
-      <button class="report-icon report-icon--share lh-tools__button" title="Tools menu" aria-label="Toggle report tools menu">
+      <button id="lh-tools-button" class="report-icon report-icon--share lh-tools__button" title="Tools menu" aria-label="Toggle report tools menu" aria-haspopup="menu" aria-expanded="false" aria-controls="lh-tools-dropdown">
         <svg width="100%" height="100%" viewBox="0 0 24 24">
             <path d="M0 0h24v24H0z" fill="none"/>
             <path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>
         </svg>
       </button>
-      <div class="lh-tools__dropdown">
+      <div id="lh-tools-dropdown" role="menu" class="lh-tools__dropdown" aria-labelledby="lh-tools-button">
          <!-- TODO(i18n): localize tools dropdown -->
-        <a href="#" class="report-icon report-icon--print" data-action="print-summary">Print Summary</a>
-        <a href="#" class="report-icon report-icon--print" data-action="print-expanded">Print Expanded</a>
-        <a href="#" class="report-icon report-icon--copy" data-action="copy">Copy JSON</a>
-        <a href="#" class="report-icon report-icon--download" data-action="save-html">Save as HTML</a>
-        <a href="#" class="report-icon report-icon--download" data-action="save-json">Save as JSON</a>
-        <a href="#" class="report-icon report-icon--open lh-tools--viewer" data-action="open-viewer">Open in Viewer</a>
-        <a href="#" class="report-icon report-icon--open lh-tools--gist" data-action="save-gist">Save as Gist</a>
-        <a href="#" class="report-icon report-icon--dark" data-action="toggle-dark">Toggle Dark Theme</a>
+        <a role="menuitem" tabindex="-1" href="#" class="report-icon report-icon--print" data-action="print-summary">Print Summary</a>
+        <a role="menuitem" tabindex="-1" href="#" class="report-icon report-icon--print" data-action="print-expanded">Print Expanded</a>
+        <a role="menuitem" tabindex="-1" href="#" class="report-icon report-icon--copy" data-action="copy">Copy JSON</a>
+        <a role="menuitem" tabindex="-1" href="#" class="report-icon report-icon--download" data-action="save-html">Save as HTML</a>
+        <a role="menuitem" tabindex="-1" href="#" class="report-icon report-icon--download" data-action="save-json">Save as JSON</a>
+        <a role="menuitem" tabindex="-1" href="#" class="report-icon report-icon--open lh-tools--viewer" data-action="open-viewer">Open in Viewer</a>
+        <a role="menuitem" tabindex="-1" href="#" class="report-icon report-icon--open lh-tools--gist" data-action="save-gist">Save as Gist</a>
+        <a role="menuitem" tabindex="-1" href="#" class="report-icon report-icon--dark" data-action="toggle-dark">Toggle Dark Theme</a>
       </div>
     </div>
   </div>
diff --git a/proto/sample_v2_round_trip.json b/proto/sample_v2_round_trip.json
index b26c834e3..8507ea2a3 100644
--- a/proto/sample_v2_round_trip.json
+++ b/proto/sample_v2_round_trip.json
@@ -2578,12 +2578,13 @@
                     {
                         "granularity": 1.0,
                         "itemType": "ms",
-                        "key": "mainThreadTime",
-                        "text": "Main Thread Time"
+                        "key": "blockingTime",
+                        "text": "Main-Thread Blocking Time"
                     }
                 ],
                 "items": [
                     {
+                        "blockingTime": 22.918000000000006,
                         "entity": {
                             "text": "Google CDN",
                             "type": "link",
@@ -2595,15 +2596,15 @@
                 ],
                 "summary": {
                     "wastedBytes": 30174.0,
-                    "wastedMs": 82.74100000000001
+                    "wastedMs": 22.918000000000006
                 },
                 "type": "table"
             },
-            "displayValue": "1 Third-Party Found",
+            "displayValue": "Third-party code blocked the main thread for 20\u00a0ms",
             "id": "third-party-summary",
-            "score": null,
-            "scoreDisplayMode": "informative",
-            "title": "Third-Party Usage"
+            "score": 1.0,
+            "scoreDisplayMode": "binary",
+            "title": "Third-Party usage"
         },
         "time-to-first-byte": {
             "description": "Time To First Byte identifies the time at which your server sends a response. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/ttfb).",
@@ -3947,6 +3948,7 @@
         }
     },
     "configSettings": {
+        "channel": "cli",
         "emulatedFormFactor": "mobile",
         "locale": "en-US",
         "onlyCategories": null


Test patch:
diff --git a/lighthouse-core/test/audits/third-party-summary-test.js b/lighthouse-core/test/audits/third-party-summary-test.js
index 7bded9424..da4c5cacc 100644
--- a/lighthouse-core/test/audits/third-party-summary-test.js
+++ b/lighthouse-core/test/audits/third-party-summary-test.js
@@ -22,7 +22,10 @@ describe('Third party summary', () => {
 
     const results = await ThirdPartySummary.audit(artifacts, {computedCache: new Map()});
 
-    expect(results.displayValue).toBeDisplayString('2 Third-Parties Found');
+    expect(results.score).toBe(1);
+    expect(results.displayValue).toBeDisplayString(
+      'Third-party code blocked the main thread for 20 ms'
+    );
     expect(results.details.items).toEqual([
       {
         entity: {
@@ -31,6 +34,7 @@ describe('Third party summary', () => {
           url: 'https://marketingplatform.google.com/about/tag-manager/',
         },
         mainThreadTime: 104.70300000000002,
+        blockingTime: 18.186999999999998,
         transferSize: 30827,
       },
       {
@@ -40,6 +44,7 @@ describe('Third party summary', () => {
           url: 'https://www.google.com/analytics/analytics/',
         },
         mainThreadTime: 87.576,
+        blockingTime: 0,
         transferSize: 20913,
       },
     ]);
@@ -54,9 +59,12 @@ describe('Third party summary', () => {
     const settings = {throttlingMethod: 'simulate', throttling: {cpuSlowdownMultiplier: 4}};
     const results = await ThirdPartySummary.audit(artifacts, {computedCache: new Map(), settings});
 
+    expect(results.score).toBe(0);
     expect(results.details.items).toHaveLength(2);
     expect(Math.round(results.details.items[0].mainThreadTime)).toEqual(419);
+    expect(Math.round(results.details.items[0].blockingTime)).toEqual(250);
     expect(Math.round(results.details.items[1].mainThreadTime)).toEqual(350);
+    expect(Math.round(results.details.items[1].blockingTime)).toEqual(157);
   });
 
   it('be not applicable when no third parties are present', async () => {
diff --git a/lighthouse-core/test/gather/gatherers/start-url-test.js b/lighthouse-core/test/gather/gatherers/start-url-test.js
index 3868ec625..113b93dea 100644
--- a/lighthouse-core/test/gather/gatherers/start-url-test.js
+++ b/lighthouse-core/test/gather/gatherers/start-url-test.js
@@ -7,168 +7,220 @@
 
 /* eslint-env jest */
 
+jest.useFakeTimers();
+
 const StartUrlGatherer = require('../../../gather/gatherers/start-url.js');
 const parseManifest = require('../../../lib/manifest-parser.js');
-const assert = require('assert');
-
-const mockDriver = {
-  goOffline() {
-    return Promise.resolve();
-  },
-  goOnline() {
-    return Promise.resolve();
-  },
-  off() {},
-};
-
-const wrapSendCommand = (mockDriver, url, status = 200, fromServiceWorker = false) => {
-  mockDriver = Object.assign({}, mockDriver);
-  mockDriver.evaluateAsync = () => Promise.resolve();
-  mockDriver.on = (name, cb) => {
-    cb({response: {status, url, fromServiceWorker}});
-  };
-
-  return mockDriver;
-};
-
-describe('Start-url gatherer', () => {
-  let baseArtifacts;
+
+describe('StartUrl Gatherer', () => {
+  let mockDriver;
+  let gatherer;
 
   function createArtifactsWithURL(url) {
     return {WebAppManifest: {value: {start_url: {value: url}}}};
   }
 
+  function unimplemented() {
+    throw new Error('Unimplemented');
+  }
+
   beforeEach(() => {
-    jest.useFakeTimers();
-    baseArtifacts = {WebAppManifest: null};
+    gatherer = new StartUrlGatherer();
+    mockDriver = {
+      goOffline: unimplemented,
+      goOnline: unimplemented,
+      evaluateAsync: unimplemented,
+      on: unimplemented,
+      off: unimplemented,
+    };
   });
 
   afterEach(() => {
     jest.advanceTimersByTime(5000);
   });
 
-  it('returns an artifact set to -1 when offline loading fails', () => {
-    const startUrlGatherer = new StartUrlGatherer();
-    const startUrlGathererWithQueryString = new StartUrlGatherer();
-    const startUrlGathererWithResponseNotFromSW = new StartUrlGatherer();
-    const throwOnEvaluate = (mockDriver) => {
-      mockDriver.on = () => {};
-      mockDriver.evaluateAsync = () => {
-        throw new Error({
-          TypeError: 'Failed to fetch',
-          __failedInBrowser: true,
-          name: 'TypeError',
-          message: 'Failed to fetch',
-        });
-      };
-
-      return mockDriver;
-    };
+  it('returns a explanation when manifest cannot be found', async () => {
+    mockDriver.goOffline = jest.fn();
+    mockDriver.goOnline = jest.fn();
 
     const passContext = {
-      url: 'https://do-not-match.com/',
-      baseArtifacts,
-      driver: throwOnEvaluate(wrapSendCommand(mockDriver, 'https://do-not-match.com/', -1)),
+      baseArtifacts: {WebAppManifest: null},
+      driver: mockDriver,
     };
-    const passContextWithFragment = {
-      baseArtifacts,
-      url: 'https://ifixit-pwa.appspot.com/?history',
-      driver: throwOnEvaluate(wrapSendCommand(mockDriver, 'https://ifixit-pwa.appspot.com/?history', -1)),
+
+    const result = await gatherer.afterPass(passContext);
+    expect(result).toEqual({
+      statusCode: -1,
+      explanation: 'No usable web app manifest found on page.',
+    });
+  });
+
+  it('returns a explanation when manifest cannot be parsed', async () => {
+    mockDriver.goOffline = jest.fn();
+    mockDriver.goOnline = jest.fn();
+
+    const passContext = {
+      baseArtifacts: {
+        WebAppManifest: parseManifest(
+          'this is invalid',
+          'https://example.com/manifest.json',
+          'https://example.com/'
+        ),
+      },
+      driver: mockDriver,
     };
-    const passContextWithResponseNotFromSW = {
-      url: 'https://do-not-match.com/',
-      baseArtifacts: createArtifactsWithURL('https://do-not-match.com/'),
-      driver: wrapSendCommand(mockDriver, 'https://do-not-match.com/', 200),
+
+    const result = await gatherer.afterPass(passContext);
+    expect(result).toEqual({
+      statusCode: -1,
+      explanation:
+        `Error fetching web app manifest: ERROR: file isn't valid JSON: ` +
+        `SyntaxError: Unexpected token h in JSON at position 1.`,
+    });
+  });
+
+  it('sets the status code to -1 when navigation fails', async () => {
+    mockDriver.goOffline = jest.fn();
+    mockDriver.goOnline = jest.fn();
+    mockDriver.evaluateAsync = jest.fn().mockRejectedValue(new Error('Fetch failed'));
+    mockDriver.on = jest.fn();
+    mockDriver.off = jest.fn();
+
+    const passContext = {
+      baseArtifacts: createArtifactsWithURL('https://example.com/'),
+      driver: mockDriver,
     };
 
-    return Promise.all([
-      startUrlGatherer.afterPass(passContext),
-      startUrlGathererWithQueryString.afterPass(passContextWithFragment),
-      startUrlGathererWithResponseNotFromSW.afterPass(passContextWithResponseNotFromSW),
-    ]).then(([artifact, artifactWithQueryString, artifactWithResponseNotFromSW]) => {
-      assert.equal(artifact.statusCode, -1);
-      assert.ok(artifact.explanation, 'did not set debug string');
-      assert.equal(artifactWithQueryString.statusCode, -1);
-      assert.ok(artifactWithQueryString.explanation, 'did not set debug string');
-      assert.equal(artifactWithResponseNotFromSW.statusCode, -1);
-      assert.equal(artifactWithResponseNotFromSW.explanation,
-          'Unable to fetch start URL via service worker.');
+    const result = await gatherer.afterPass(passContext);
+    expect(mockDriver.goOffline).toHaveBeenCalled();
+    expect(mockDriver.goOnline).toHaveBeenCalled();
+    expect(result).toEqual({
+      statusCode: -1,
+      explanation: 'Error while fetching start_url via service worker.',
     });
   });
 
-  it('returns an artifact set to 200 when offline loading from service worker succeeds', () => {
-    const startUrlGatherer = new StartUrlGatherer();
-    const startUrlGathererWithFragment = new StartUrlGatherer();
+  it('sets the status code to page status code', async () => {
+    mockDriver.goOffline = jest.fn();
+    mockDriver.goOnline = jest.fn();
+    mockDriver.evaluateAsync = jest.fn().mockResolvedValue();
+    mockDriver.on = jest.fn();
+    mockDriver.off = jest.fn();
+
     const passContext = {
-      url: 'https://ifixit-pwa.appspot.com/',
-      baseArtifacts: createArtifactsWithURL('https://ifixit-pwa.appspot.com/'),
-      driver: wrapSendCommand(mockDriver, 'https://ifixit-pwa.appspot.com/', 200, true),
+      baseArtifacts: createArtifactsWithURL('https://example.com/'),
+      driver: mockDriver,
     };
-    const passContextWithFragment = {
-      url: 'https://ifixit-pwa.appspot.com/#/history',
-      baseArtifacts: createArtifactsWithURL('https://ifixit-pwa.appspot.com/#/history'),
-      driver: wrapSendCommand(mockDriver, 'https://ifixit-pwa.appspot.com/#/history', 200, true),
+
+    const response = {
+      url: 'https://example.com/',
+      status: 200,
+      fromServiceWorker: true,
     };
 
-    return Promise.all([
-      startUrlGatherer.afterPass(passContext),
-      startUrlGathererWithFragment.afterPass(passContextWithFragment),
-    ]).then(([artifact, artifactWithFragment]) => {
-      assert.equal(artifact.statusCode, 200);
-      assert.equal(artifactWithFragment.statusCode, 200);
+    mockDriver.on.mockImplementation((_, onResponseReceived) => onResponseReceived({response}));
+
+    const result = await gatherer.afterPass(passContext);
+    expect(mockDriver.goOffline).toHaveBeenCalled();
+    expect(mockDriver.goOnline).toHaveBeenCalled();
+    expect(result).toEqual({
+      statusCode: 200,
     });
   });
 
-  it('returns a explanation when manifest cannot be found', () => {
-    const driver = Object.assign({}, mockDriver);
-    const startUrlGatherer = new StartUrlGatherer();
+  it('sets the status code to -1 when not from service worker', async () => {
+    mockDriver.goOffline = jest.fn();
+    mockDriver.goOnline = jest.fn();
+    mockDriver.evaluateAsync = jest.fn().mockResolvedValue();
+    mockDriver.on = jest.fn();
+    mockDriver.off = jest.fn();
+
     const passContext = {
-      baseArtifacts,
-      url: 'https://ifixit-pwa.appspot.com/',
-      driver,
+      baseArtifacts: createArtifactsWithURL('https://example.com/'),
+      driver: mockDriver,
     };
 
-    return startUrlGatherer.afterPass(passContext)
-      .then(artifact => {
-        assert.equal(artifact.explanation,
-          `No usable web app manifest found on page.`);
-      });
+    const response = {
+      url: 'https://example.com/',
+      status: 200,
+      fromServiceWorker: false,
+    };
+
+    mockDriver.on.mockImplementation((_, onResponseReceived) => onResponseReceived({response}));
+
+    const result = await gatherer.afterPass(passContext);
+    expect(mockDriver.goOffline).toHaveBeenCalled();
+    expect(mockDriver.goOnline).toHaveBeenCalled();
+    expect(result).toEqual({
+      statusCode: -1,
+      explanation: 'The start_url did respond, but not via a service worker.',
+    });
   });
 
-  it('returns a explanation when manifest cannot be parsed', () => {
-    const driver = Object.assign({}, mockDriver);
-    const startUrlGatherer = new StartUrlGatherer();
+  it('sets the status code to -1 when times out waiting for a matching url', async () => {
+    mockDriver.goOffline = jest.fn();
+    mockDriver.goOnline = jest.fn();
+    mockDriver.evaluateAsync = jest.fn().mockResolvedValue();
+    mockDriver.on = jest.fn();
+    mockDriver.off = jest.fn();
+
     const passContext = {
-      baseArtifacts,
-      url: 'https://ifixit-pwa.appspot.com/',
-      driver,
+      baseArtifacts: createArtifactsWithURL('https://example.com/'),
+      driver: mockDriver,
     };
 
-    baseArtifacts.WebAppManifest = parseManifest(
-      'this is invalid',
-      'https://ifixit-pwa.appspot.com/manifest.json',
-      passContext.url
-    );
-
-    return startUrlGatherer.afterPass(passContext)
-      .then(artifact => {
-        assert.equal(artifact.explanation,
-          `Error fetching web app manifest: ERROR: file isn't valid JSON: ` +
-          `SyntaxError: Unexpected token h in JSON at position 1.`);
-      });
+    const response = {
+      url: 'https://random-other-url.com/',
+      status: 200,
+      fromServiceWorker: true,
+    };
+
+    mockDriver.on.mockImplementation((_, onResponseReceived) => onResponseReceived({response}));
+
+    const resultPromise = gatherer.afterPass(passContext);
+    // Wait a tick for the evaluateAsync promise resolution to hold for the timer.
+    await Promise.resolve();
+    // Skip past our timeout of 3000.
+    jest.advanceTimersByTime(5000);
+    const result = await resultPromise;
+
+    expect(mockDriver.goOffline).toHaveBeenCalled();
+    expect(mockDriver.goOnline).toHaveBeenCalled();
+    expect(result).toEqual({
+      statusCode: -1,
+      explanation: 'Timed out waiting for start_url to respond.',
+    });
   });
 
-  it('times out when a start_url is too slow to respond', async () => {
-    const startUrlGatherer = new StartUrlGatherer();
+  it('navigates *while* offline', async () => {
+    const callsAtNavigationTime = [];
+    mockDriver.goOffline = jest.fn();
+    mockDriver.goOnline = jest.fn();
+    mockDriver.evaluateAsync = async () => {
+      callsAtNavigationTime.push(
+        ...mockDriver.goOffline.mock.calls.map(() => 'offline'),
+        ...mockDriver.goOnline.mock.calls.map(() => 'online')
+      );
+    };
+    mockDriver.on = jest.fn();
+    mockDriver.off = jest.fn();
+
     const passContext = {
-      url: 'https://ifixit-pwa.appspot.com/',
-      baseArtifacts: createArtifactsWithURL('https://ifixit-pwa.appspot.com/'),
-      driver: wrapSendCommand(mockDriver, ''),
+      baseArtifacts: createArtifactsWithURL('https://example.com/'),
+      driver: mockDriver,
     };
 
-    const resultPromise = startUrlGatherer.afterPass(passContext);
-    jest.advanceTimersByTime(5000);
-    const artifact = await resultPromise;
-    assert.equal(artifact.explanation, 'Timed out waiting for fetched start_url.');
+    const response = {
+      url: 'https://example.com/',
+      status: 200,
+      fromServiceWorker: true,
+    };
+
+    mockDriver.on.mockImplementation((_, onResponseReceived) => onResponseReceived({response}));
+    const result = await gatherer.afterPass(passContext);
+    expect(callsAtNavigationTime).toEqual(['offline']);
+    expect(result).toEqual({
+      statusCode: 200,
+    });
   });
 });
diff --git a/lighthouse-core/test/report/html/renderer/dom-test.js b/lighthouse-core/test/report/html/renderer/dom-test.js
index 39a35b960..63fc457c3 100644
--- a/lighthouse-core/test/report/html/renderer/dom-test.js
+++ b/lighthouse-core/test/report/html/renderer/dom-test.js
@@ -127,7 +127,14 @@ describe('DOM', () => {
       assert.equal(result.innerHTML, '<a rel="noopener" target="_blank" href="https://developers.google.com/web/tools/lighthouse/audits/description?utm_source=lighthouse&amp;utm_medium=someChannel">Learn more</a>.');
     });
 
-    it('doesn\'t append utm params to non https://developers.google.com origins', () => {
+    it('appends utm params to the URLs with https://web.dev origin', () => {
+      const text = '[Learn more](https://web.dev/tap-targets/).';
+
+      const result = dom.convertMarkdownLinkSnippets(text);
+      assert.equal(result.innerHTML, '<a rel="noopener" target="_blank" href="https://web.dev/tap-targets/?utm_source=lighthouse&amp;utm_medium=someChannel">Learn more</a>.');
+    });
+
+    it('doesn\'t append utm params to other (non-docs) origins', () => {
       const text = '[Learn more](https://example.com/info).';
 
       const result = dom.convertMarkdownLinkSnippets(text);
diff --git a/lighthouse-core/test/report/html/renderer/report-ui-features-test.js b/lighthouse-core/test/report/html/renderer/report-ui-features-test.js
index 577cbb2b6..9f93127e7 100644
--- a/lighthouse-core/test/report/html/renderer/report-ui-features-test.js
+++ b/lighthouse-core/test/report/html/renderer/report-ui-features-test.js
@@ -73,6 +73,7 @@ describe('ReportUIFeatures', () => {
       };
     };
 
+    global.HTMLElement = document.window.HTMLElement;
     global.HTMLInputElement = document.window.HTMLInputElement;
 
     global.window = document.window;
@@ -99,6 +100,7 @@ describe('ReportUIFeatures', () => {
     global.PerformanceCategoryRenderer = undefined;
     global.PwaCategoryRenderer = undefined;
     global.window = undefined;
+    global.HTMLElement = undefined;
     global.HTMLInputElement = undefined;
   });
 
@@ -264,4 +266,130 @@ describe('ReportUIFeatures', () => {
       assert.ok(container.querySelector('.lh-metrics-toggle__input').checked);
     });
   });
+
+  describe('tools button', () => {
+    let window;
+    let features;
+
+    beforeEach(() => {
+      window = dom.document().defaultView;
+      features = new ReportUIFeatures(dom);
+      features.initFeatures(sampleResults);
+    });
+
+    it('click should toggle active class', () => {
+      features.toolsButton.click();
+      assert.ok(features.toolsButton.classList.contains('active'));
+
+      features.toolsButton.click();
+      assert.ok(!features.toolsButton.classList.contains('active'));
+    });
+
+
+    it('Escape key removes active class', () => {
+      features.toolsButton.click();
+      assert.ok(features.toolsButton.classList.contains('active'));
+
+      const escape = new window.KeyboardEvent('keydown', {keyCode: /* ESC */ 27});
+      dom.document().dispatchEvent(escape);
+      assert.ok(!features.toolsButton.classList.contains('active'));
+    });
+
+    ['ArrowUp', 'ArrowDown', 'Enter', ' '].forEach((code) => {
+      it(`'${code}' adds active class`, () => {
+        const event = new window.KeyboardEvent('keydown', {code});
+        features.toolsButton.dispatchEvent(event);
+        assert.ok(features.toolsButton.classList.contains('active'));
+      });
+    });
+
+    it('ArrowUp on the first menu element should focus the last element', () => {
+      features.toolsButton.click();
+
+      const arrowUp = new window.KeyboardEvent('keydown', {bubbles: true, code: 'ArrowUp'});
+      features.toolsDropDown.firstElementChild.dispatchEvent(arrowUp);
+
+      assert.strictEqual(dom.document().activeElement, features.toolsDropDown.lastElementChild);
+    });
+
+    it('ArrowDown on the first menu element should focus the second element', () => {
+      features.toolsButton.click();
+
+      const {nextElementSibling} = features.toolsDropDown.firstElementChild;
+      const arrowDown = new window.KeyboardEvent('keydown', {bubbles: true, code: 'ArrowDown'});
+      features.toolsDropDown.firstElementChild.dispatchEvent(arrowDown);
+
+      assert.strictEqual(dom.document().activeElement, nextElementSibling);
+    });
+
+    it('Home on the last menu element should focus the first element', () => {
+      features.toolsButton.click();
+
+      const {firstElementChild} = features.toolsDropDown;
+      const home = new window.KeyboardEvent('keydown', {bubbles: true, code: 'Home'});
+      features.toolsDropDown.lastElementChild.dispatchEvent(home);
+
+      assert.strictEqual(dom.document().activeElement, firstElementChild);
+    });
+
+    it('End on the first menu element should focus the last element', () => {
+      features.toolsButton.click();
+
+      const {lastElementChild} = features.toolsDropDown;
+      const end = new window.KeyboardEvent('keydown', {bubbles: true, code: 'End'});
+      features.toolsDropDown.firstElementChild.dispatchEvent(end);
+
+      assert.strictEqual(dom.document().activeElement, lastElementChild);
+    });
+
+    describe('_getNextSelectableNode', () => {
+      let createDiv;
+
+      beforeAll(() => {
+        createDiv = () => dom.document().createElement('div');
+      });
+
+      it('should return first node when start is undefined', () => {
+        const nodes = [createDiv(), createDiv()];
+
+        const nextNode = features._getNextSelectableNode(nodes);
+
+        assert.strictEqual(nextNode, nodes[0]);
+      });
+
+      it('should return second node when start is first node', () => {
+        const nodes = [createDiv(), createDiv()];
+
+        const nextNode = features._getNextSelectableNode(nodes, nodes[0]);
+
+        assert.strictEqual(nextNode, nodes[1]);
+      });
+
+      it('should return first node when start is second node', () => {
+        const nodes = [createDiv(), createDiv()];
+
+        const nextNode = features._getNextSelectableNode(nodes, nodes[1]);
+
+        assert.strictEqual(nextNode, nodes[0]);
+      });
+
+      it('should skip the undefined node', () => {
+        const nodes = [createDiv(), undefined, createDiv()];
+
+        const nextNode = features._getNextSelectableNode(nodes, nodes[0]);
+
+        assert.strictEqual(nextNode, nodes[2]);
+      });
+
+      it('should skip the disabled node', () => {
+        const disabledNode = createDiv();
+        disabledNode.setAttribute('disabled', true);
+        const nodes = [createDiv(), disabledNode, createDiv()];
+
+        const nextNode = features._getNextSelectableNode(nodes, nodes[0]);
+
+        assert.strictEqual(nextNode, nodes[2]);
+      });
+    });
+  });
 });
diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json
index afa314985..c5bba298b 100644
--- a/lighthouse-core/test/results/sample_v2.json
+++ b/lighthouse-core/test/results/sample_v2.json
@@ -1481,11 +1481,11 @@
     },
     "third-party-summary": {
       "id": "third-party-summary",
-      "title": "Third-Party Usage",
+      "title": "Third-Party usage",
       "description": "Third-party code can significantly impact load performance. Limit the number of redundant third-party providers and try to load third-party code after your page has primarily finished loading. [Learn more](https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/loading-third-party-javascript/).",
-      "score": null,
-      "scoreDisplayMode": "informative",
-      "displayValue": "1 Third-Party Found",
+      "score": 1,
+      "scoreDisplayMode": "binary",
+      "displayValue": "Third-party code blocked the main thread for 20 ms",
       "details": {
         "type": "table",
         "headings": [
@@ -1501,10 +1501,10 @@
             "text": "Size"
           },
           {
-            "key": "mainThreadTime",
+            "key": "blockingTime",
             "granularity": 1,
             "itemType": "ms",
-            "text": "Main Thread Time"
+            "text": "Main-Thread Blocking Time"
           }
         ],
         "items": [
@@ -1515,12 +1515,13 @@
               "url": "https://developers.google.com/speed/libraries/"
             },
             "transferSize": 30174,
-            "mainThreadTime": 82.74100000000001
+            "mainThreadTime": 82.74100000000001,
+            "blockingTime": 22.918000000000006
           }
         ],
         "summary": {
           "wastedBytes": 30174,
-          "wastedMs": 82.74100000000001
+          "wastedMs": 22.918000000000006
         }
       }
     },
@@ -5603,7 +5604,8 @@
       "lighthouse-core/audits/third-party-summary.js | displayValue": [
         {
           "values": {
-            "itemCount": 1
+            "itemCount": 1,
+            "timeInMs": 22.918000000000006
           },
           "path": "audits[third-party-summary].displayValue"
         }
@@ -5621,7 +5623,7 @@
         "audits[uses-text-compression].details.headings[1].label",
         "audits[tap-targets].details.headings[1].text"
       ],
-      "lighthouse-core/audits/third-party-summary.js | columnMainThreadTime": [
+      "lighthouse-core/audits/third-party-summary.js | columnBlockingTime": [
         "audits[third-party-summary].details.headings[2].text"
       ],
       "lighthouse-core/audits/manual/pwa-cross-browser.js | title": [