Repository: GoogleChrome/lighthouse
Pull request #9460 (2019-07-25 21:47:07)

i18n: fix custom formatted ICU within plurals
<!-- Thank you for submitting a pull request! -->

**Summary**
Allow custom formatted ICU `{var, number, bytes}` to be nested inside `{var, plural, ...}` ICU blocks, and have it be formatted properly

**Related Issues/PRs**
fixes: #9378 



Patch:
diff --git a/lighthouse-core/audits/performance-budget.js b/lighthouse-core/audits/performance-budget.js
index 9d520df6f..14f357add 100644
--- a/lighthouse-core/audits/performance-budget.js
+++ b/lighthouse-core/audits/performance-budget.js
@@ -7,6 +7,8 @@
 
 const Audit = require('./audit.js');
 const ResourceSummary = require('../computed/resource-summary.js');
+const MainResource = require('../computed/main-resource.js');
+const Budget = require('../config/budget.js');
 const i18n = require('../lib/i18n/i18n.js');
 
 const UIStrings = {
@@ -121,7 +123,14 @@ class ResourceBudget extends Audit {
   static async audit(artifacts, context) {
     const devtoolsLog = artifacts.devtoolsLogs[Audit.DEFAULT_PASS];
     const summary = await ResourceSummary.request({devtoolsLog, URL: artifacts.URL}, context);
-    const budget = context.settings.budgets ? context.settings.budgets[0] : undefined;
+    const mainResource = await MainResource.request({URL: artifacts.URL, devtoolsLog}, context);
+    // Clones budget so that the user-supplied version is not mutated.
+    /** @type {Array<LH.Budget>} */
+    const budgets = Array.from(context.settings.budgets || []);
+    // Applies the LAST matching budget
+    const budget = budgets ? budgets.reverse().find((b) => {
+      return Budget.urlMatchesPattern(mainResource.url, b.path);
+    }) : undefined;
 
     if (!budget) {
       return {
@@ -130,7 +139,7 @@ class ResourceBudget extends Audit {
       };
     }
 
-    /** @type { LH.Audit.Details.Table['headings'] } */
+    /** @type {LH.Audit.Details.Table['headings']} */
     const headers = [
       {key: 'label', itemType: 'text', text: str_(i18n.UIStrings.columnResourceType)},
       {key: 'requestCount', itemType: 'numeric', text: str_(i18n.UIStrings.columnRequests)},
diff --git a/lighthouse-core/audits/resource-summary.js b/lighthouse-core/audits/resource-summary.js
index 1c45de740..2413e18d3 100644
--- a/lighthouse-core/audits/resource-summary.js
+++ b/lighthouse-core/audits/resource-summary.js
@@ -16,8 +16,9 @@ const UIStrings = {
   description: 'To set budgets for the quantity and size of page resources,' +
     ' add a budget.json file. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/budgets).',
   /** [ICU Syntax] Label for an audit identifying the number of requests and kilobytes used to load the page. */
-  displayValue: `{requestCount, plural, =1 {1 request} other {# requests}}` +
-    ` • {byteCount, number, bytes} KB`,
+  displayValue: `{requestCount, plural, ` +
+    `=1 {1 request • {byteCount, number, bytes} KB} ` +
+    `other {# requests • {byteCount, number, bytes} KB}}`,
 };
 
 const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
diff --git a/lighthouse-core/audits/seo/robots-txt.js b/lighthouse-core/audits/seo/robots-txt.js
index 94a9ffc6c..f2a16c12e 100644
--- a/lighthouse-core/audits/seo/robots-txt.js
+++ b/lighthouse-core/audits/seo/robots-txt.js
@@ -45,7 +45,7 @@ const UIStrings = {
    * @description Label for the audit identifying that the robots.txt request has returned a specific HTTP status code. Note: "robots.txt" is a canonical filename and should not be translated.
    * @example {500} statusCode
    * */
-  displayValueHttpBadCode: 'request for robots.txt returned HTTP status: {statusCode}',
+  displayValueHttpBadCode: 'Request for robots.txt returned HTTP status: {statusCode}',
   /** [ICU Syntax] Label for the audit identifying the number of errors that occured while validating the robots.txt file. "itemCount" will be replaced by the integer count of errors encountered. */
   displayValueValidationError: `{itemCount, plural,
     =1 {1 error found}
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/config/budget.js b/lighthouse-core/config/budget.js
index 0f5d1553a..91b85c978 100644
--- a/lighthouse-core/config/budget.js
+++ b/lighthouse-core/config/budget.js
@@ -96,6 +96,98 @@ class Budget {
     };
   }
 
+  /**
+   * @param {unknown} path
+   * @param {string} error
+   */
+  static throwInvalidPathError(path, error) {
+    throw new Error(`Invalid path ${path}. ${error}\n` +
+      `'Path' should be specified using the 'robots.txt' format.\n` +
+      `Learn more about the 'robots.txt' format here:\n` +
+      `https://developers.google.com/search/reference/robots_txt#url-matching-based-on-path-values`);
+  }
+
+
+  /**
+   * Validates that path is either: a) undefined or ) properly formed.
+   * Verifies the quantity and location of the two robot.txt regex characters: $, *
+   * @param {unknown} path
+   * @return {undefined|string}
+   */
+  static validatePath(path) {
+    if (path === undefined) {
+      return undefined;
+    } else if (typeof path !== 'string') {
+      this.throwInvalidPathError(path, `Path should be a string.`);
+      return;
+    } else if (!path.startsWith('/')) {
+      this.throwInvalidPathError(path, `Path should start with '/'.`);
+    } else if ((path.match(/\*/g) || []).length > 1) {
+      this.throwInvalidPathError(path, `Path should only contain one '*'.`);
+    } else if ((path.match(/\$/g) || []).length > 1) {
+      this.throwInvalidPathError(path, `Path should only contain one '$' character.`);
+    } else if (path.includes('$') && !path.endsWith('$')) {
+      this.throwInvalidPathError(path, `'$' character should only occur at end of path.`);
+    }
+    return path;
+  }
+
+  /**
+   * Determines whether a URL matches against a robots.txt-style "path".
+   * Pattern should use the robots.txt format. E.g. "/*-article.html" or "/". Reference:
+   * https://developers.google.com/search/reference/robots_txt#url-matching-based-on-path-values
+   * @param {string} url
+   * @param {string=} pattern
+   * @return {boolean}
+   */
+  static urlMatchesPattern(url, pattern = '/') {
+    const urlObj = new URL(url);
+    const urlPath = urlObj.pathname + urlObj.search;
+
+    const hasWildcard = pattern.includes('*');
+    const hasDollarSign = pattern.includes('$');
+
+    /**
+     * There are 4 different cases of path strings.
+     * Paths should have already been validated with #validatePath.
+     *
+     * Case #1: No special characters
+     * Example: "/cat"
+     * Behavior: URL should start with given pattern.
+     */
+    if (!hasWildcard && !hasDollarSign) {
+      return urlPath.startsWith(pattern);
+    /**
+     * Case #2: $ only
+     * Example: "/js$"
+     * Behavior: URL should be identical to pattern.
+     */
+    } else if (!hasWildcard && hasDollarSign) {
+      return urlPath === pattern.slice(0, -1);
+    /**
+     * Case #3: * only
+     * Example: "/vendor*chunk"
+     * Behavior: URL should start with the string pattern that comes before the wildcard
+     * & later in the string contain the string pattern that comes after the wildcard.
+     */
+    } else if (hasWildcard && !hasDollarSign) {
+      const [beforeWildcard, afterWildcard] = pattern.split('*');
+      const remainingUrl = urlPath.slice(beforeWildcard.length);
+      return urlPath.startsWith(beforeWildcard) && remainingUrl.includes(afterWildcard);
+    /**
+     * Case #4: $ and *
+     * Example: "/vendor*chunk.js$"
+     * Behavior: URL should start with the string pattern that comes before the wildcard
+     * & later in the string end with the string pattern that comes after the wildcard.
+     */
+    } else if (hasWildcard && hasDollarSign) {
+      const [beforeWildcard, afterWildcard] = pattern.split('*');
+      const urlEnd = urlPath.slice(beforeWildcard.length);
+      return urlPath.startsWith(beforeWildcard) && urlEnd.endsWith(afterWildcard.slice(0, -1));
+    }
+    return false;
+  }
+
   /**
    * @param {Record<string, unknown>} timingBudget
    * @return {LH.Budget.TimingBudget}
@@ -147,9 +239,11 @@ class Budget {
       /** @type {LH.Budget} */
       const budget = {};
 
-      const {resourceSizes, resourceCounts, timings, ...invalidRest} = b;
+      const {path, resourceSizes, resourceCounts, timings, ...invalidRest} = b;
       Budget.assertNoExcessProperties(invalidRest, 'Budget');
 
+      budget.path = Budget.validatePath(path);
+
       if (isArrayOfUnknownObjects(resourceSizes)) {
         budget.resourceSizes = resourceSizes.map(Budget.validateResourceBudget);
         Budget.assertNoDuplicateStrings(budget.resourceSizes.map(r => r.resourceType),
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/i18n.js b/lighthouse-core/lib/i18n/i18n.js
index 53c8dfd1d..8547f1b07 100644
--- a/lighthouse-core/lib/i18n/i18n.js
+++ b/lighthouse-core/lib/i18n/i18n.js
@@ -132,8 +132,60 @@ function lookupLocale(locale) {
 function _preprocessMessageValues(icuMessage, values = {}) {
   const clonedValues = JSON.parse(JSON.stringify(values));
   const parsed = MessageParser.parse(icuMessage);
+
+  const elements = _collectAllCustomElementsFromICU(parsed.elements);
+
+  return _processParsedElements(Array.from(elements.values()), clonedValues);
+}
+
+/**
+ * Function to retrieve all 'argumentElement's from an ICU message. An argumentElement
+ * is an ICU element with an argument in it, like '{varName}' or '{varName, number, bytes}'. This
+ * differs from 'messageElement's which are just arbitrary text in a message.
+ *
+ * Notes:
+ *  This function will recursively inspect plural elements for nested argumentElements.
+ *
+ *  We need to find all the elements from the plural format sections, but
+ *  they need to be deduplicated. I.e. "=1{hello {icu}} =other{hello {icu}}"
+ *  the variable "icu" would appear twice if it wasn't de duplicated. And they cannot
+ *  be stored in a set because they are not equal since their locations are different,
+ *  thus they are stored via a Map keyed on the "id" which is the ICU varName.
+ *
+ * @param {Array<import('intl-messageformat-parser').Element>} icuElements
+ * @param {Map<string, import('intl-messageformat-parser').Element>} seenElementsById
+ */
+function _collectAllCustomElementsFromICU(icuElements, seenElementsById = new Map()) {
+  for (const el of icuElements) {
+    // We are only interested in elements that need ICU formatting (argumentElements)
+    if (el.type !== 'argumentElement') continue;
+    // @ts-ignore - el.id is always defined when el.format is defined
+    seenElementsById.set(el.id, el);
+
+    // Plurals need to be inspected recursively
+    if (!el.format || el.format.type !== 'pluralFormat') continue;
+    // Look at all options of the plural (=1{} =other{}...)
+    for (const option of el.format.options) {
+      // Run collections on each option's elements
+      _collectAllCustomElementsFromICU(option.value.elements,
+        seenElementsById);
+    }
+  }
+
+  return seenElementsById;
+}
+
+/**
+ * This function takes a list of ICU argumentElements and a map of values and
+ * will apply Lighthouse custom formatting to the values based on the argumentElement
+ * format style.
+ *
+ * @param {Array<import('intl-messageformat-parser').Element>} argumentElements
+ * @param {Record<string, string | number>} [values]
+ */
+function _processParsedElements(argumentElements, values = {}) {
   // Throw an error if a message's value isn't provided
-  parsed.elements
+  argumentElements
     .filter(el => el.type === 'argumentElement')
     .forEach(el => {
       if (el.id && (el.id in values) === false) {
@@ -142,24 +194,24 @@ function _preprocessMessageValues(icuMessage, values = {}) {
     });
 
   // Round all milliseconds to the nearest 10
-  parsed.elements
+  argumentElements
     .filter(el => el.format && el.format.style === 'milliseconds')
     // @ts-ignore - el.id is always defined when el.format is defined
-    .forEach(el => (clonedValues[el.id] = Math.round(clonedValues[el.id] / 10) * 10));
+    .forEach(el => (values[el.id] = Math.round(values[el.id] / 10) * 10));
 
   // Convert all seconds to the correct unit
-  parsed.elements
+  argumentElements
     .filter(el => el.format && el.format.style === 'seconds' && el.id === 'timeInMs')
     // @ts-ignore - el.id is always defined when el.format is defined
-    .forEach(el => (clonedValues[el.id] = Math.round(clonedValues[el.id] / 100) / 10));
+    .forEach(el => (values[el.id] = Math.round(values[el.id] / 100) / 10));
 
   // Replace all the bytes with KB
-  parsed.elements
+  argumentElements
     .filter(el => el.format && el.format.style === 'bytes')
     // @ts-ignore - el.id is always defined when el.format is defined
-    .forEach(el => (clonedValues[el.id] = clonedValues[el.id] / 1024));
+    .forEach(el => (values[el.id] = values[el.id] / 1024));
 
-  return clonedValues;
+  return values;
 }
 
 /**
diff --git a/lighthouse-core/lib/i18n/locales/en-US.json b/lighthouse-core/lib/i18n/locales/en-US.json
index 096c96bc7..e3415493d 100644
--- a/lighthouse-core/lib/i18n/locales/en-US.json
+++ b/lighthouse-core/lib/i18n/locales/en-US.json
@@ -879,7 +879,7 @@
     "message": "To set budgets for the quantity and size of page resources, add a budget.json file. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/budgets)."
   },
   "lighthouse-core/audits/resource-summary.js | displayValue": {
-    "message": "{requestCount, plural, =1 {1 request} other {# requests}} • {byteCount, number, bytes} KB"
+    "message": "{requestCount, plural, =1 {1 request • {byteCount, number, bytes} KB} other {# requests • {byteCount, number, bytes} KB}}"
   },
   "lighthouse-core/audits/resource-summary.js | title": {
     "message": "Keep request counts low and transfer sizes small"
@@ -999,7 +999,7 @@
     "message": "If your robots.txt file is malformed, crawlers may not be able to understand how you want your website to be crawled or indexed. [Learn more](https://web.dev/robots-txt)."
   },
   "lighthouse-core/audits/seo/robots-txt.js | displayValueHttpBadCode": {
-    "message": "request for robots.txt returned HTTP status: {statusCode}"
+    "message": "Request for robots.txt returned HTTP status: {statusCode}"
   },
   "lighthouse-core/audits/seo/robots-txt.js | displayValueValidationError": {
     "message": "{itemCount, plural,\n    =1 {1 error found}\n    other {# errors found}\n    }"
@@ -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..0ad7f8875 100644
--- a/lighthouse-core/lib/i18n/locales/en-XL.json
+++ b/lighthouse-core/lib/i18n/locales/en-XL.json
@@ -879,7 +879,7 @@
     "message": "T̂ó ŝét̂ b́ûd́ĝét̂ś f̂ór̂ t́ĥé q̂úâńt̂ít̂ý âńd̂ śîźê óf̂ ṕâǵê ŕêśôúr̂ćêś, âd́d̂ á b̂úd̂ǵêt́.ĵśôń f̂íl̂é. [L̂éâŕn̂ ḿôŕê](https://developers.google.com/web/tools/lighthouse/audits/budgets)."
   },
   "lighthouse-core/audits/resource-summary.js | displayValue": {
-    "message": "{requestCount, plural, =1 {1 r̂éq̂úêśt̂} other {# ŕêq́ûéŝt́ŝ}} • {byteCount, number, bytes} ḰB̂"
+    "message": "{requestCount, plural, =1 {1 r̂éq̂úêśt̂ • {byteCount, number, bytes} ḰB̂} other {# ŕêq́ûéŝt́ŝ • {byteCount, number, bytes} ḰB̂}}"
   },
   "lighthouse-core/audits/resource-summary.js | title": {
     "message": "K̂éêṕ r̂éq̂úêśt̂ ćôún̂t́ŝ ĺôẃ âńd̂ t́r̂án̂śf̂ér̂ śîźêś ŝḿâĺl̂"
@@ -999,7 +999,7 @@
     "message": "Îf́ ŷóûŕ r̂ób̂ót̂ś.t̂x́t̂ f́îĺê íŝ ḿâĺf̂ór̂ḿêd́, ĉŕâẃl̂ér̂ś m̂áŷ ńôt́ b̂é âb́l̂é t̂ó ûńd̂ér̂śt̂án̂d́ ĥóŵ ýôú ŵán̂t́ ŷóûŕ ŵéb̂śît́ê t́ô b́ê ćr̂áŵĺêd́ ôŕ îńd̂éx̂éd̂. [Ĺêár̂ń m̂ór̂é](https://web.dev/robots-txt)."
   },
   "lighthouse-core/audits/seo/robots-txt.js | displayValueHttpBadCode": {
-    "message": "r̂éq̂úêśt̂ f́ôŕ r̂ób̂ót̂ś.t̂x́t̂ ŕêt́ûŕn̂éd̂ H́T̂T́P̂ śt̂át̂úŝ: {statusCode}"
+    "message": "R̂éq̂úêśt̂ f́ôŕ r̂ób̂ót̂ś.t̂x́t̂ ŕêt́ûŕn̂éd̂ H́T̂T́P̂ śt̂át̂úŝ: {statusCode}"
   },
   "lighthouse-core/audits/seo/robots-txt.js | displayValueValidationError": {
     "message": "{itemCount, plural,\n    =1 {1 êŕr̂ór̂ f́ôún̂d́}\n    other {# êŕr̂ór̂ś f̂óûńd̂}\n    }"
@@ -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/performance-category-renderer.js b/lighthouse-core/report/html/renderer/performance-category-renderer.js
index 52c0975f0..51e2b6301 100644
--- a/lighthouse-core/report/html/renderer/performance-category-renderer.js
+++ b/lighthouse-core/report/html/renderer/performance-category-renderer.js
@@ -127,8 +127,8 @@ class PerformanceCategoryRenderer extends CategoryRenderer {
 
     // Metric descriptions toggle.
     const toggleTmpl = this.dom.cloneTemplate('#tmpl-lh-metrics-toggle', this.templateContext);
-    const toggleEl = this.dom.find('.lh-metrics-toggle', toggleTmpl);
-    metricAuditsEl.append(...toggleEl.childNodes);
+    const _toggleEl = this.dom.find('.lh-metrics-toggle', toggleTmpl);
+    metricAuditsEl.append(..._toggleEl.childNodes);
 
     const metricAudits = category.auditRefs.filter(audit => audit.group === 'metrics');
     const keyMetrics = metricAudits.filter(a => a.weight >= 3);
diff --git a/lighthouse-core/report/html/renderer/report-ui-features.js b/lighthouse-core/report/html/renderer/report-ui-features.js
index 41be88539..7de3a0566 100644
--- a/lighthouse-core/report/html/renderer/report-ui-features.js
+++ b/lighthouse-core/report/html/renderer/report-ui-features.js
@@ -46,11 +46,11 @@ class ReportUIFeatures {
     this._document = this._dom.document();
     /** @type {ParentNode} */
     this._templateContext = this._dom.document();
+    /** @type {DropDown} */
+    this._dropDown = new DropDown(this._dom);
     /** @type {boolean} */
     this._copyAttempt = false;
     /** @type {HTMLElement} */
-    this.toolsButton; // 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
@@ -61,9 +61,7 @@ class ReportUIFeatures {
 
     this.onMediaQueryChange = this.onMediaQueryChange.bind(this);
     this.onCopy = this.onCopy.bind(this);
-    this.onToolsButtonClick = this.onToolsButtonClick.bind(this);
-    this.onToolAction = this.onToolAction.bind(this);
-    this.onKeyDown = this.onKeyDown.bind(this);
+    this.onDropDownMenuClick = this.onDropDownMenuClick.bind(this);
     this.onKeyUp = this.onKeyUp.bind(this);
     this.onChevronClick = this.onChevronClick.bind(this);
     this.collapseAllDetails = this.collapseAllDetails.bind(this);
@@ -81,7 +79,7 @@ class ReportUIFeatures {
     this.json = report;
 
     this._setupMediaQueryListeners();
-    this._setupToolsButton();
+    this._dropDown.setup(this.onDropDownMenuClick);
     this._setupThirdPartyFilter();
     this._setUpCollapseDetailsAfterPrinting();
     this._resetUIState();
@@ -205,14 +203,6 @@ class ReportUIFeatures {
     root.classList.toggle('lh-narrow', mql.matches);
   }
 
-  _setupToolsButton() {
-    this.toolsButton = this._dom.find('.lh-tools__button', this._document);
-    this.toolsButton.addEventListener('click', this.onToolsButtonClick);
-
-    const dropdown = this._dom.find('.lh-tools__dropdown', this._document);
-    dropdown.addEventListener('click', this.onToolAction);
-  }
-
   _setupThirdPartyFilter() {
     // Some audits should not display the third party filter option.
     const thirdPartyFilterAuditExclusions = [
@@ -381,27 +371,13 @@ class ReportUIFeatures {
     }
   }
 
-  closeToolsDropdown() {
-    this.toolsButton.classList.remove('active');
-  }
-
-  /**
-   * Click handler for tools button.
-   * @param {Event} e
-   */
-  onToolsButtonClick(e) {
-    e.preventDefault();
-    this.toolsButton.classList.toggle('active');
-    this._document.addEventListener('keydown', this.onKeyDown);
-  }
-
   /**
    * Resets the state of page before capturing the page for export.
    * When the user opens the exported HTML page, certain UI elements should
    * be in their closed state (not opened) and the templates should be unstamped.
    */
   _resetUIState() {
-    this.closeToolsDropdown();
+    this._dropDown.close();
     this._dom.resetTemplates();
   }
 
@@ -409,7 +385,7 @@ class ReportUIFeatures {
    * Handler for tool button.
    * @param {Event} e
    */
-  onToolAction(e) {
+  onDropDownMenuClick(e) {
     e.preventDefault();
 
     const el = /** @type {?Element} */ (e.target);
@@ -424,12 +400,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': {
@@ -463,24 +437,13 @@ class ReportUIFeatures {
       }
     }
 
-    this.closeToolsDropdown();
-    this._document.removeEventListener('keydown', this.onKeyDown);
+    this._dropDown.close();
   }
 
   _print() {
     self.print();
   }
 
-  /**
-   * Keydown handler for the document.
-   * @param {KeyboardEvent} e
-   */
-  onKeyDown(e) {
-    if (e.keyCode === 27) { // ESC
-      this.closeToolsDropdown();
-    }
-  }
-
   /**
    * Keyup handler for the document.
    * @param {KeyboardEvent} e
@@ -488,7 +451,7 @@ class ReportUIFeatures {
   onKeyUp(e) {
     // Ctrl+P - Expands audit details when user prints via keyboard shortcut.
     if ((e.ctrlKey || e.metaKey) && e.keyCode === 80) {
-      this.closeToolsDropdown();
+      this._dropDown.close();
     }
   }
 
@@ -652,6 +615,196 @@ class ReportUIFeatures {
   }
 }
 
+class DropDown {
+  /**
+   * @param {DOM} dom
+   */
+  constructor(dom) {
+    /** @type {DOM} */
+    this._dom = dom;
+    /** @type {HTMLElement} */
+    this._toggleEl; // eslint-disable-line no-unused-expressions
+    /** @type {HTMLElement} */
+    this._menuEl; // eslint-disable-line no-unused-expressions
+
+    this.onDocumentKeyDown = this.onDocumentKeyDown.bind(this);
+    this.onToggleClick = this.onToggleClick.bind(this);
+    this.onToggleKeydown = this.onToggleKeydown.bind(this);
+    this.onMenuKeydown = this.onMenuKeydown.bind(this);
+
+    this._getNextMenuItem = this._getNextMenuItem.bind(this);
+    this._getNextSelectableNode = this._getNextSelectableNode.bind(this);
+    this._getPreviousMenuItem = this._getPreviousMenuItem.bind(this);
+  }
+
+  /**
+   * @param {function(MouseEvent): any} menuClickHandler
+   */
+  setup(menuClickHandler) {
+    this._toggleEl = this._dom.find('.lh-tools__button', this._dom.document());
+    this._toggleEl.addEventListener('click', this.onToggleClick);
+    this._toggleEl.addEventListener('keydown', this.onToggleKeydown);
+
+    this._menuEl = this._dom.find('.lh-tools__dropdown', this._dom.document());
+    this._menuEl.addEventListener('keydown', this.onMenuKeydown);
+    this._menuEl.addEventListener('click', menuClickHandler);
+  }
+
+  close() {
+    this._toggleEl.classList.remove('active');
+    this._toggleEl.setAttribute('aria-expanded', 'false');
+    if (this._menuEl.contains(this._dom.document().activeElement)) {
+      // Refocus on the tools button if the drop down last had focus
+      this._toggleEl.focus();
+    }
+    this._dom.document().removeEventListener('keydown', this.onDocumentKeyDown);
+  }
+
+  /**
+   * @param {HTMLElement} firstFocusElement
+   */
+  open(firstFocusElement) {
+    if (this._toggleEl.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._menuEl.addEventListener('transitionend', () => {
+        firstFocusElement.focus();
+      }, {once: true});
+    }
+
+    this._toggleEl.classList.add('active');
+    this._toggleEl.setAttribute('aria-expanded', 'true');
+    this._dom.document().addEventListener('keydown', this.onDocumentKeyDown);
+  }
+
+  /**
+   * Click handler for tools button.
+   * @param {Event} e
+   */
+  onToggleClick(e) {
+    e.preventDefault();
+    e.stopImmediatePropagation();
+
+    if (this._toggleEl.classList.contains('active')) {
+      this.close();
+    } else {
+      this.open(this._getNextMenuItem());
+    }
+  }
+
+  /**
+   * Handler for tool button.
+   * @param {KeyboardEvent} e
+   */
+  onToggleKeydown(e) {
+    switch (e.code) {
+      case 'ArrowUp':
+        e.preventDefault();
+        this.open(this._getPreviousMenuItem());
+        break;
+      case 'ArrowDown':
+      case 'Enter':
+      case ' ':
+        e.preventDefault();
+        this.open(this._getNextMenuItem());
+        break;
+      default:
+       // no op
+    }
+  }
+
+  /**
+   * Handler for tool DropDown.
+   * @param {KeyboardEvent} e
+   */
+  onMenuKeydown(e) {
+    const el = /** @type {?HTMLElement} */ (e.target);
+
+    switch (e.code) {
+      case 'ArrowUp':
+        e.preventDefault();
+        this._getPreviousMenuItem(el).focus();
+        break;
+      case 'ArrowDown':
+        e.preventDefault();
+        this._getNextMenuItem(el).focus();
+        break;
+      case 'Home':
+        e.preventDefault();
+        this._getNextMenuItem().focus();
+        break;
+      case 'End':
+        e.preventDefault();
+        this._getPreviousMenuItem().focus();
+        break;
+      default:
+       // no op
+    }
+  }
+
+  /**
+   * Keydown handler for the document.
+   * @param {KeyboardEvent} e
+   */
+  onDocumentKeyDown(e) {
+    if (e.keyCode === 27) { // ESC
+      this.close();
+    }
+  }
+
+  /**
+   * @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}
+   */
+  _getNextMenuItem(startEl) {
+    const nodes = Array.from(this._menuEl.childNodes);
+    return /** @type {HTMLElement} */ (this._getNextSelectableNode(nodes, startEl));
+  }
+
+  /**
+   * @param {?Element=} startEl
+   * @returns {HTMLElement}
+   */
+  _getPreviousMenuItem(startEl) {
+    const nodes = Array.from(this._menuEl.childNodes).reverse();
+    return /** @type {HTMLElement} */ (this._getNextSelectableNode(nodes, startEl));
+  }
+}
+
 if (typeof module !== 'undefined' && module.exports) {
   module.exports = ReportUIFeatures;
 } else {
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
diff --git a/types/budget.d.ts b/types/budget.d.ts
index 4c1d058b2..b0387851d 100644
--- a/types/budget.d.ts
+++ b/types/budget.d.ts
@@ -11,6 +11,12 @@ declare global {
      * More info: https://github.com/GoogleChrome/lighthouse/issues/6053#issuecomment-428385930
      */
     export interface Budget {
+      /**
+       * Indicates which pages a budget applies to. Uses the robots.txt format.
+       * If it is not supplied, the budget applies to all pages.
+       * More info on robots.txt: https://developers.google.com/search/reference/robots_txt#url-matching-based-on-path-values
+       */
+      path?: string;
       /** Budgets based on resource count. */
       resourceCounts?: Array<Budget.ResourceBudget>;
       /** Budgets based on resource size. */
diff --git a/types/intl-messageformat-parser/index.d.ts b/types/intl-messageformat-parser/index.d.ts
index 8f0f2b380..02df41d18 100644
--- a/types/intl-messageformat-parser/index.d.ts
+++ b/types/intl-messageformat-parser/index.d.ts
@@ -1,9 +1,9 @@
 declare module 'intl-messageformat-parser' {
-  interface Element {
+  export interface Element {
     type: 'messageTextElement'|'argumentElement';
     id?: string
     value?: string
-    format?: null | {type: string; style?: string};
+    format?: null | {type: string; style?: string; options?: any};
   }
   function parse(message: string): {elements: Element[]};
   export {parse};


Test patch:
diff --git a/lighthouse-cli/test/smokehouse/perf/perf-config.js b/lighthouse-cli/test/smokehouse/perf/perf-config.js
index f253a0502..be96152df 100644
--- a/lighthouse-cli/test/smokehouse/perf/perf-config.js
+++ b/lighthouse-cli/test/smokehouse/perf/perf-config.js
@@ -14,6 +14,7 @@ const perfConfig = {
 
     // A mixture of under, over, and meeting budget to exercise all paths.
     budgets: [{
+      path: '/',
       resourceCounts: [
         {resourceType: 'total', budget: 8},
         {resourceType: 'stylesheet', budget: 1}, // meets budget
diff --git a/lighthouse-core/test/audits/performance-budget-test.js b/lighthouse-core/test/audits/performance-budget-test.js
index 6cb3f09af..1aafcdb19 100644
--- a/lighthouse-core/test/audits/performance-budget-test.js
+++ b/lighthouse-core/test/audits/performance-budget-test.js
@@ -23,7 +23,7 @@ describe('Performance: Resource budgets audit', () => {
           {url: 'http://third-party.com/file.jpg', resourceType: 'Image', transferSize: 70},
         ]),
       },
-      URL: {requestedURL: 'http://example.com', finalURL: 'http://example.com'},
+      URL: {requestedUrl: 'http://example.com', finalUrl: 'http://example.com'},
     };
     context = {computedCache: new Map(), settings: {}};
   });
@@ -31,6 +31,7 @@ describe('Performance: Resource budgets audit', () => {
   describe('with a budget.json', () => {
     beforeEach(() => {
       context.settings.budgets = [{
+        path: '/',
         resourceSizes: [
           {
             resourceType: 'script',
@@ -86,6 +87,7 @@ describe('Performance: Resource budgets audit', () => {
 
       it('convert budgets from kilobytes to bytes during calculations', async () => {
         context.settings.budgets = [{
+          path: '/',
           resourceSizes: [
             {
               resourceType: 'document',
@@ -98,6 +100,13 @@ describe('Performance: Resource budgets audit', () => {
       });
     });
 
+    it('does not mutate the budget config', async () => {
+      const configBefore = JSON.parse(JSON.stringify(context.settings.budgets));
+      await ResourceBudgetAudit.audit(artifacts, context);
+      const configAfter = JSON.parse(JSON.stringify(context.settings.budgets));
+      expect(configBefore).toEqual(configAfter);
+    });
+
     it('only includes rows for resource types with budgets', async () => {
       const result = await ResourceBudgetAudit.audit(artifacts, context);
       expect(result.details.items).toHaveLength(2);
@@ -105,6 +114,7 @@ describe('Performance: Resource budgets audit', () => {
 
     it('sorts rows by descending file size overage', async () => {
       context.settings.budgets = [{
+        path: '/',
         resourceSizes: [
           {
             resourceType: 'document',
@@ -126,27 +136,54 @@ describe('Performance: Resource budgets audit', () => {
         expect(item.size).toBeGreaterThanOrEqual(items[index + 1].size);
       });
     });
-
-    it('uses the first budget in budgets', async () => {
-      context.settings.budgets = [{
-        resourceSizes: [
-          {
-            resourceType: 'image',
-            budget: 0,
-          },
-        ],
-      },
-      {
-        resourceSizes: [
-          {
-            resourceType: 'script',
-            budget: 0,
-          },
-        ],
-      },
-      ];
-      const result = await ResourceBudgetAudit.audit(artifacts, context);
-      expect(result.details.items[0].resourceType).toBe('image');
+    describe('budget path', () => {
+      it('applies the last matching budget', async () => {
+        context.settings.budgets = [{
+          path: '/',
+          resourceSizes: [
+            {
+              resourceType: 'script',
+              budget: 0,
+            },
+          ],
+        },
+        {
+          path: '/file.html',
+          resourceSizes: [
+            {
+              resourceType: 'image',
+              budget: 0,
+            },
+          ],
+        },
+        {
+          path: '/not-a-match',
+          resourceSizes: [
+            {
+              resourceType: 'document',
+              budget: 0,
+            },
+          ],
+        },
+        ];
+        const result = await ResourceBudgetAudit.audit(artifacts, context);
+        expect(result.details.items[0].resourceType).toBe('image');
+      });
+      it('returns "audit does not apply" if no budget matches', async () => {
+        context.settings.budgets = [{
+          path: '/not-a-match',
+          resourceSizes: [
+            {
+              resourceType: 'script',
+              budget: 0,
+            },
+          ],
+        },
+        ];
+        const result = await ResourceBudgetAudit.audit(artifacts, context);
+        expect(result.details).toBeUndefined();
+        expect(result.notApplicable).toBe(true);
+      });
     });
   });
 
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/config/budget-test.js b/lighthouse-core/test/config/budget-test.js
index 20b4843e8..ab3a5c40b 100644
--- a/lighthouse-core/test/config/budget-test.js
+++ b/lighthouse-core/test/config/budget-test.js
@@ -10,9 +10,9 @@ const assert = require('assert');
 /* eslint-env jest */
 
 describe('Budget', () => {
-  let budget;
+  let budgets;
   beforeEach(() => {
-    budget = [
+    budgets = [
       {
         resourceSizes: [
           {
@@ -48,6 +48,7 @@ describe('Budget', () => {
         ],
       },
       {
+        path: '/second-path',
         resourceSizes: [
           {
             resourceType: 'script',
@@ -59,136 +60,254 @@ describe('Budget', () => {
   });
 
   it('initializes correctly', () => {
-    const budgets = Budget.initializeBudget(budget);
-    assert.equal(budgets.length, 2);
+    const result = Budget.initializeBudget(budgets);
+    assert.equal(result.length, 2);
+
+    // Missing paths are not overwritten
+    assert.equal(result[0].path, undefined);
+    // Sets path correctly
+    assert.equal(result[1].path, '/second-path');
 
     // Sets resources sizes correctly
-    assert.equal(budgets[0].resourceSizes.length, 2);
-    assert.equal(budgets[0].resourceSizes[0].resourceType, 'script');
-    assert.equal(budgets[0].resourceSizes[0].budget, 123);
+    assert.equal(result[0].resourceSizes.length, 2);
+    assert.equal(result[0].resourceSizes[0].resourceType, 'script');
+    assert.equal(result[0].resourceSizes[0].budget, 123);
 
     // Sets resource counts correctly
-    assert.equal(budgets[0].resourceCounts.length, 2);
-    assert.equal(budgets[0].resourceCounts[0].resourceType, 'total');
-    assert.equal(budgets[0].resourceCounts[0].budget, 100);
+    assert.equal(result[0].resourceCounts.length, 2);
+    assert.equal(result[0].resourceCounts[0].resourceType, 'total');
+    assert.equal(result[0].resourceCounts[0].budget, 100);
 
     // Sets timings correctly
-    assert.equal(budgets[0].timings.length, 2);
-    assert.deepStrictEqual(budgets[0].timings[0], {
+    assert.equal(result[0].timings.length, 2);
+    assert.deepStrictEqual(result[0].timings[0], {
       metric: 'interactive',
       budget: 2000,
       tolerance: 1000,
     });
-    assert.equal(budgets[0].timings[1].metric, 'first-contentful-paint');
-    assert.equal(budgets[0].timings[1].budget, 1000);
+    assert.deepStrictEqual(result[0].timings[1], {
+      metric: 'first-contentful-paint',
+      budget: 1000,
+      tolerance: undefined,
+    });
 
-    // Does not set unsupplied budgets
-    assert.equal(budgets[1].timings, null);
+    // Does not set unsupplied result
+    assert.equal(result[1].timings, null);
   });
 
   it('accepts an empty array', () => {
-    const budgets = Budget.initializeBudget([]);
-    assert.deepStrictEqual(budgets, []);
+    const result = Budget.initializeBudget([]);
+    assert.deepStrictEqual(result, []);
   });
 
   it('throws error if an unsupported budget property is used', () => {
-    budget[0].sizes = [];
-    assert.throws(_ => Budget.initializeBudget(budget),
+    budgets[0].sizes = [];
+    assert.throws(_ => Budget.initializeBudget(budgets),
       /Budget has unrecognized properties: \[sizes\]/);
   });
 
   describe('top-level validation', () => {
     it('throws when provided an invalid budget array', () => {
       assert.throws(_ => Budget.initializeBudget(55),
-        /Budget file is not defined as an array of budgets/);
+        /Budget file is not defined as an array of/);
 
       assert.throws(_ => Budget.initializeBudget(['invalid123']),
-        /Budget file is not defined as an array of budgets/);
+        /Budget file is not defined as an array of/);
 
       assert.throws(_ => Budget.initializeBudget([null]),
-        /Budget file is not defined as an array of budgets/);
+        /Budget file is not defined as an array of/);
     });
 
     it('throws when budget contains invalid resourceSizes entry', () => {
-      budget[0].resourceSizes = 55;
-      assert.throws(_ => Budget.initializeBudget(budget),
+      budgets[0].resourceSizes = 55;
+      assert.throws(_ => Budget.initializeBudget(budgets),
         /^Error: Invalid resourceSizes entry in budget at index 0$/);
     });
 
     it('throws when budget contains invalid resourceCounts entry', () => {
-      budget[0].resourceCounts = 'A string';
-      assert.throws(_ => Budget.initializeBudget(budget),
+      budgets[0].resourceCounts = 'A string';
+      assert.throws(_ => Budget.initializeBudget(budgets),
         /^Error: Invalid resourceCounts entry in budget at index 0$/);
     });
 
     it('throws when budget contains invalid timings entry', () => {
-      budget[1].timings = false;
-      assert.throws(_ => Budget.initializeBudget(budget),
+      budgets[1].timings = false;
+      assert.throws(_ => Budget.initializeBudget(budgets),
         /^Error: Invalid timings entry in budget at index 1$/);
     });
   });
 
   describe('resource budget validation', () => {
     it('throws when an invalid resource type is supplied', () => {
-      budget[0].resourceSizes[0].resourceType = 'movies';
-      assert.throws(_ => Budget.initializeBudget(budget),
+      budgets[0].resourceSizes[0].resourceType = 'movies';
+      assert.throws(_ => Budget.initializeBudget(budgets),
         // eslint-disable-next-line max-len
         /Invalid resource type: movies. \nValid resource types are: total, document,/);
     });
 
     it('throws when an invalid budget is supplied', () => {
-      budget[0].resourceSizes[0].budget = '100 MB';
-      assert.throws(_ => Budget.initializeBudget(budget), /Invalid budget: 100 MB/);
+      budgets[0].resourceSizes[0].budget = '100 MB';
+      assert.throws(_ => Budget.initializeBudget(budgets), /Invalid budget: 100 MB/);
     });
 
     it('throws when an invalid property is supplied', () => {
-      budget[0].resourceSizes[0].browser = 'Chrome';
-      assert.throws(_ => Budget.initializeBudget(budget),
+      budgets[0].resourceSizes[0].browser = 'Chrome';
+      assert.throws(_ => Budget.initializeBudget(budgets),
         /Resource Budget has unrecognized properties: \[browser\]/);
     });
 
     it('throws when a duplicate resourceType is specified in resourceSizes', () => {
-      budget[1].resourceSizes.push({resourceType: 'script', budget: 100});
-      assert.throws(_ => Budget.initializeBudget(budget),
-        /budgets\[1\]\.resourceSizes has duplicate entry of type 'script'/);
+      budgets[1].resourceSizes.push({resourceType: 'script', budget: 100});
+      assert.throws(_ => Budget.initializeBudget(budgets),
+        /has duplicate entry of type 'script'/);
     });
 
     it('throws when a duplicate resourceType is specified in resourceCounts', () => {
-      budget[0].resourceCounts.push({resourceType: 'third-party', budget: 100});
-      assert.throws(_ => Budget.initializeBudget(budget),
-        /budgets\[0\]\.resourceCounts has duplicate entry of type 'third-party'/);
+      budgets[0].resourceCounts.push({resourceType: 'third-party', budget: 100});
+      assert.throws(_ => Budget.initializeBudget(budgets),
+        /has duplicate entry of type 'third-party'/);
     });
   });
 
   describe('timing budget validation', () => {
     it('throws when an invalid metric is supplied', () => {
-      budget[0].timings[0].metric = 'medianMeaningfulPaint';
-      assert.throws(_ => Budget.initializeBudget(budget),
+      budgets[0].timings[0].metric = 'medianMeaningfulPaint';
+      assert.throws(_ => Budget.initializeBudget(budgets),
         // eslint-disable-next-line max-len
         /Invalid timing metric: medianMeaningfulPaint. \nValid timing metrics are: first-contentful-paint, /);
     });
 
     it('throws when an invalid budget is supplied', () => {
-      budget[0].timings[0].budget = '100KB';
-      assert.throws(_ => Budget.initializeBudget(budget), /Invalid budget: 100KB/);
+      budgets[0].timings[0].budget = '100KB';
+      assert.throws(_ => Budget.initializeBudget(budgets), /Invalid budget: 100KB/);
     });
 
     it('throws when an invalid tolerance is supplied', () => {
-      budget[0].timings[0].tolerance = '100ms';
-      assert.throws(_ => Budget.initializeBudget(budget), /Invalid tolerance: 100ms/);
+      budgets[0].timings[0].tolerance = '100ms';
+      assert.throws(_ => Budget.initializeBudget(budgets), /Invalid tolerance: 100ms/);
     });
 
     it('throws when an invalid property is supplied', () => {
-      budget[0].timings[0].device = 'Phone';
-      budget[0].timings[0].location = 'The middle somewhere, I don\'t know';
-      assert.throws(_ => Budget.initializeBudget(budget),
+      budgets[0].timings[0].device = 'Phone';
+      budgets[0].timings[0].location = 'The middle somewhere, I don\'t know';
+      assert.throws(_ => Budget.initializeBudget(budgets),
         /Timing Budget has unrecognized properties: \[device, location\]/);
     });
 
     it('throws when a duplicate metric type is specified in timings', () => {
-      budget[0].timings.push({metric: 'interactive', budget: 1000});
-      assert.throws(_ => Budget.initializeBudget(budget),
-        /budgets\[0\]\.timings has duplicate entry of type 'interactive'/);
+      budgets[0].timings.push({metric: 'interactive', budget: 1000});
+      assert.throws(_ => Budget.initializeBudget(budgets),
+        /has duplicate entry of type 'interactive'/);
+    });
+  });
+
+  describe('path validation', () => {
+    it('recognizes valid budgets', () => {
+      let budgets = [{path: '/'}];
+      let result = Budget.initializeBudget(budgets);
+      assert.equal(budgets[0].path, result[0].path);
+
+      budgets = [{path: '/*'}];
+      result = Budget.initializeBudget(budgets);
+      assert.equal(budgets[0].path, result[0].path);
+
+      budgets = [{path: '/end$'}];
+      result = Budget.initializeBudget(budgets);
+      assert.equal(budgets[0].path, result[0].path);
+
+      budgets = [{path: '/fish*.php'}];
+      result = Budget.initializeBudget(budgets);
+      assert.equal(budgets[0].path, result[0].path);
+
+      budgets = [{path: '/*.php$'}];
+      result = Budget.initializeBudget(budgets);
+      assert.equal(budgets[0].path, result[0].path);
+    });
+
+    it('invalidates paths missing leading "/"', () => {
+      let budgets = [{path: ''}];
+      assert.throws(_ => Budget.initializeBudget(budgets), /Invalid path/);
+
+      budgets = [{path: 'cat'}];
+      assert.throws(_ => Budget.initializeBudget(budgets), /Invalid path/);
+    });
+
+    it('invalidates paths with multiple * characters', () => {
+      budgets = [{path: '/cat*cat*cat'}];
+      assert.throws(_ => Budget.initializeBudget(budgets), /Invalid path/);
+    });
+
+    it('invalidates paths with multiple $ characters', () => {
+      budgets = [{path: '/cat$cat$'}];
+      assert.throws(_ => Budget.initializeBudget(budgets), /Invalid path/);
+    });
+
+    it('invalidates paths with $ character in the wrong location', () => {
+      budgets = [{path: '/cat$html'}];
+      assert.throws(_ => Budget.initializeBudget(budgets), /Invalid path/);
+    });
+
+    it('does not throw if no path is specified', () => {
+      const budgets = [{}];
+      const result = Budget.initializeBudget(budgets);
+      assert.equal(result[0].path, undefined);
+    });
+  });
+
+  describe('path matching', () => {
+    const pathMatch = (path, pattern) => {
+      const origin = 'https://example.com';
+      return Budget.urlMatchesPattern(origin + path, pattern);
+    };
+
+    it('matches root', () => {
+      assert.ok(Budget.urlMatchesPattern('https://google.com', '/'));
+    });
+
+    it('ignores origin', () => {
+      assert.equal(Budget.urlMatchesPattern('https://go.com/dogs', '/go'), false);
+      assert.equal(Budget.urlMatchesPattern('https://yt.com/videos?id=', '/videos'), true);
+    });
+
+    it('is case-sensitive', () => {
+      assert.equal(Budget.urlMatchesPattern('https://abc.com/aaa', '/aaa'), true);
+      assert.equal(Budget.urlMatchesPattern('https://abc.com/AAA', '/aaa'), false);
+      assert.equal(Budget.urlMatchesPattern('https://abc.com/aaa', '/AAA'), false);
+    });
+
+    it('matches all pages if path is not defined', () => {
+      assert.ok(Budget.urlMatchesPattern('https://example.com', undefined), true);
+      assert.ok(Budget.urlMatchesPattern('https://example.com/dogs', undefined), true);
+    });
+
+    it('handles patterns that do not contain * or $', () => {
+      assert.equal(pathMatch('/anything', '/'), true);
+      assert.equal(pathMatch('/anything', '/any'), true);
+      assert.equal(pathMatch('/anything', '/anything'), true);
+      assert.equal(pathMatch('/anything', '/anything1'), false);
+    });
+
+    it('handles patterns that do not contain * but contain $', () => {
+      assert.equal(pathMatch('/fish.php', '/fish.php$'), true);
+      assert.equal(pathMatch('/Fish.PHP', '/fish.php$'), false);
+    });
+
+    it('handles patterns that contain * but do not contain $', () => {
+      assert.equal(pathMatch('/anything', '/*'), true);
+      assert.equal(pathMatch('/fish', '/fish*'), true);
+      assert.equal(pathMatch('/fishfood', '/*food'), true);
+      assert.equal(pathMatch('/fish/food/and/other/things', '/*food'), true);
+      assert.equal(pathMatch('/fis/', '/fish*'), false);
+      assert.equal(pathMatch('/fish', '/fish*fish'), false);
+    });
+
+    it('handles patterns that contain * and $', () => {
+      assert.equal(pathMatch('/fish.php', '/*.php$'), true);
+      assert.equal(pathMatch('/folder/filename.php', '/folder*.php$'), true);
+      assert.equal(pathMatch('/folder/filename.php', '/folder/filename*.php$'), true);
+      assert.equal(pathMatch('/fish.php?species=', '/*.php$'), false);
+      assert.equal(pathMatch('/filename.php/', '/folder*.php$'), false);
+      assert.equal(pathMatch('/folder', '/folder*folder$'), false);
     });
   });
 });
diff --git a/lighthouse-core/test/config/config-test.js b/lighthouse-core/test/config/config-test.js
index c3700008f..712fab886 100644
--- a/lighthouse-core/test/config/config-test.js
+++ b/lighthouse-core/test/config/config-test.js
@@ -796,6 +796,7 @@ describe('Config', () => {
         const configJson = {
           settings: {
             budgets: [{
+              path: '/',
               resourceCounts: [{
                 resourceType: 'image',
                 budget: 500,
diff --git a/lighthouse-core/test/fixtures/simple-budget.json b/lighthouse-core/test/fixtures/simple-budget.json
index e8f3030e6..0146f9977 100644
--- a/lighthouse-core/test/fixtures/simple-budget.json
+++ b/lighthouse-core/test/fixtures/simple-budget.json
@@ -1,5 +1,6 @@
 [
     {
+        "path": "/",
         "resourceSizes": [
             {
                 "resourceType": "script",
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/lib/i18n/i18n-test.js b/lighthouse-core/test/lib/i18n/i18n-test.js
index 2782a2bb1..53c5789db 100644
--- a/lighthouse-core/test/lib/i18n/i18n-test.js
+++ b/lighthouse-core/test/lib/i18n/i18n-test.js
@@ -103,6 +103,17 @@ describe('i18n', () => {
       helloTimeInMsWorld: 'Hello {timeInMs, number, seconds} World',
       helloPercentWorld: 'Hello {in, number, extendedPercent} World',
       helloWorldMultiReplace: '{hello} {world}',
+      helloPlural: '{itemCount, plural, =1{1 hello} other{hellos}}',
+      helloPluralNestedICU: '{itemCount, plural, ' +
+        '=1{1 hello {in, number, bytes}} ' +
+        'other{hellos {in, number, bytes}}}',
+      helloPluralNestedPluralAndICU: '{itemCount, plural, ' +
+        '=1{{innerItemCount, plural, ' +
+          '=1{1 hello 1 goodbye {in, number, bytes}} ' +
+          'other{1 hello, goodbyes {in, number, bytes}}}} ' +
+        'other{{innerItemCount, plural, ' +
+          '=1{hellos 1 goodbye {in, number, bytes}} ' +
+          'other{hellos, goodbyes {in, number, bytes}}}}}',
     };
     const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
 
@@ -146,5 +157,27 @@ describe('i18n', () => {
         {hello: 'hello'}), 'en-US'))
       .toThrow(`ICU Message contains a value reference ("world") that wasn't provided`);
     });
+
+    it('formats a message with plurals', () => {
+      const helloStr = str_(UIStrings.helloPlural, {itemCount: 3});
+      expect(helloStr).toBeDisplayString('hellos');
+    });
+
+    it('throws an error when a plural control value is missing', () => {
+      expect(_ => i18n.getFormatted(str_(UIStrings.helloPlural), 'en-US'))
+      .toThrow(`ICU Message contains a value reference ("itemCount") that wasn't provided`);
+    });
+
+    it('formats a message with plurals and nested custom ICU', () => {
+      const helloStr = str_(UIStrings.helloPluralNestedICU, {itemCount: 3, in: 1875});
+      expect(helloStr).toBeDisplayString('hellos 2');
+    });
+
+    it('formats a message with plurals and nested custom ICU and nested plural', () => {
+      const helloStr = str_(UIStrings.helloPluralNestedPluralAndICU, {itemCount: 3,
+        innerItemCount: 1,
+        in: 1875});
+      expect(helloStr).toBeDisplayString('hellos 1 goodbye 2');
+    });
   });
 });
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..0617e471c 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,131 @@ describe('ReportUIFeatures', () => {
       assert.ok(container.querySelector('.lh-metrics-toggle__input').checked);
     });
   });
+
+  describe('tools button', () => {
+    let window;
+    let dropDown;
+
+    beforeEach(() => {
+      window = dom.document().defaultView;
+      const features = new ReportUIFeatures(dom);
+      features.initFeatures(sampleResults);
+      dropDown = features._dropDown;
+    });
+
+    it('click should toggle active class', () => {
+      dropDown._toggleEl.click();
+      assert.ok(dropDown._toggleEl.classList.contains('active'));
+
+      dropDown._toggleEl.click();
+      assert.ok(!dropDown._toggleEl.classList.contains('active'));
+    });
+
+
+    it('Escape key removes active class', () => {
+      dropDown._toggleEl.click();
+      assert.ok(dropDown._toggleEl.classList.contains('active'));
+
+      const escape = new window.KeyboardEvent('keydown', {keyCode: /* ESC */ 27});
+      dom.document().dispatchEvent(escape);
+      assert.ok(!dropDown._toggleEl.classList.contains('active'));
+    });
+
+    ['ArrowUp', 'ArrowDown', 'Enter', ' '].forEach((code) => {
+      it(`'${code}' adds active class`, () => {
+        const event = new window.KeyboardEvent('keydown', {code});
+        dropDown._toggleEl.dispatchEvent(event);
+        assert.ok(dropDown._toggleEl.classList.contains('active'));
+      });
+    });
+
+    it('ArrowUp on the first menu element should focus the last element', () => {
+      dropDown._toggleEl.click();
+
+      const arrowUp = new window.KeyboardEvent('keydown', {bubbles: true, code: 'ArrowUp'});
+      dropDown._menuEl.firstElementChild.dispatchEvent(arrowUp);
+
+      assert.strictEqual(dom.document().activeElement, dropDown._menuEl.lastElementChild);
+    });
+
+    it('ArrowDown on the first menu element should focus the second element', () => {
+      dropDown._toggleEl.click();
+
+      const {nextElementSibling} = dropDown._menuEl.firstElementChild;
+      const arrowDown = new window.KeyboardEvent('keydown', {bubbles: true, code: 'ArrowDown'});
+      dropDown._menuEl.firstElementChild.dispatchEvent(arrowDown);
+
+      assert.strictEqual(dom.document().activeElement, nextElementSibling);
+    });
+
+    it('Home on the last menu element should focus the first element', () => {
+      dropDown._toggleEl.click();
+
+      const {firstElementChild} = dropDown._menuEl;
+      const home = new window.KeyboardEvent('keydown', {bubbles: true, code: 'Home'});
+      dropDown._menuEl.lastElementChild.dispatchEvent(home);
+
+      assert.strictEqual(dom.document().activeElement, firstElementChild);
+    });
+
+    it('End on the first menu element should focus the last element', () => {
+      dropDown._toggleEl.click();
+
+      const {lastElementChild} = dropDown._menuEl;
+      const end = new window.KeyboardEvent('keydown', {bubbles: true, code: 'End'});
+      dropDown._menuEl.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 = dropDown._getNextSelectableNode(nodes);
+
+        assert.strictEqual(nextNode, nodes[0]);
+      });
+
+      it('should return second node when start is first node', () => {
+        const nodes = [createDiv(), createDiv()];
+
+        const nextNode = dropDown._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 = dropDown._getNextSelectableNode(nodes, nodes[1]);
+
+        assert.strictEqual(nextNode, nodes[0]);
+      });
+
+      it('should skip the undefined node', () => {
+        const nodes = [createDiv(), undefined, createDiv()];
+
+        const nextNode = dropDown._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 = dropDown._getNextSelectableNode(nodes, nodes[0]);
+
+        assert.strictEqual(nextNode, nodes[2]);
+      });
+    });
+  });
 });
diff --git a/lighthouse-core/test/results/artifacts/artifacts.json b/lighthouse-core/test/results/artifacts/artifacts.json
index 871054f5d..7c3f04e18 100644
--- a/lighthouse-core/test/results/artifacts/artifacts.json
+++ b/lighthouse-core/test/results/artifacts/artifacts.json
@@ -31,27 +31,108 @@
     "channel": "cli",
     "budgets": [
       {
+        "path": "/",
         "resourceSizes": [
+          {
+            "resourceType": "total",
+            "budget": 100
+          },
+          {
+            "resourceType": "stylesheet",
+            "budget": 5
+          },
+          {
+            "resourceType": "image",
+            "budget": 30
+          },
+          {
+            "resourceType": "media",
+            "budget": 0
+          },
+          {
+            "resourceType": "font",
+            "budget": 20
+          },
           {
             "resourceType": "script",
-            "budget": 125
+            "budget": 30
           },
           {
-            "resourceType": "total",
-            "budget": 500
+            "resourceType": "document",
+            "budget": 15
+          },
+          {
+            "resourceType": "other",
+            "budget": 5
+          },
+          {
+            "resourceType": "third-party",
+            "budget": 25
           }
         ],
         "resourceCounts": [
           {
-            "resourceType": "third-party",
+            "resourceType": "total",
+            "budget": 10
+          },
+          {
+            "resourceType": "stylesheet",
+            "budget": 2
+          },
+          {
+            "resourceType": "image",
+            "budget": 2
+          },
+          {
+            "resourceType": "media",
             "budget": 0
+          },
+          {
+            "resourceType": "font",
+            "budget": 1
+          },
+          {
+            "resourceType": "script",
+            "budget": 2
+          },
+          {
+            "resourceType": "document",
+            "budget": 1
+          },
+          {
+            "resourceType": "other",
+            "budget": 2
+          },
+          {
+            "resourceType": "third-party",
+            "budget": 1
           }
         ],
         "timings": [
+          {
+            "metric": "first-contentful-paint",
+            "budget": 3000,
+            "tolerance": 100
+          },
+          {
+            "metric": "first-cpu-idle",
+            "budget": 2900,
+            "tolerance": 100
+          },
           {
             "metric": "interactive",
-            "budget": 5000,
-            "tolerance": 1000
+            "budget": 2900,
+            "tolerance": 100
+          },
+          {
+            "metric": "first-meaningful-paint",
+            "budget": 2000,
+            "tolerance": 100
+          },
+          {
+            "metric": "max-potential-fid",
+            "budget": 100,
+            "tolerance": 100
           }
         ]
       }
diff --git a/lighthouse-core/test/results/sample-config.js b/lighthouse-core/test/results/sample-config.js
index 8f63818ce..1a0272de1 100644
--- a/lighthouse-core/test/results/sample-config.js
+++ b/lighthouse-core/test/results/sample-config.js
@@ -15,6 +15,7 @@ const budgetedConfig = {
   settings: {
     throttlingMethod: 'devtools',
     budgets: [{
+      path: '/',
       resourceCounts: [
         {resourceType: 'total', budget: 10},
         {resourceType: 'stylesheet', budget: 2},
diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json
index afa314985..65827d9af 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
         }
       }
     },
@@ -3264,6 +3265,7 @@
     "channel": "cli",
     "budgets": [
       {
+        "path": "/",
         "resourceSizes": [
           {
             "resourceType": "total",
@@ -5603,7 +5605,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 +5624,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": [