Repository: GoogleChrome/lighthouse
Pull request #8999 (2019-05-20 17:30:46)

i18n: Minor translation corrections
<!-- Thank you for submitting a pull request! -->

**Summary**
Some minor corrections to :fr: :india: :sri_lanka:  translations.



Patch:
diff --git a/lighthouse-core/audits/accessibility/axe-audit.js b/lighthouse-core/audits/accessibility/axe-audit.js
index 56d418e84..b2f9c210a 100644
--- a/lighthouse-core/audits/accessibility/axe-audit.js
+++ b/lighthouse-core/audits/accessibility/axe-audit.js
@@ -54,6 +54,7 @@ class AxeAudit extends Audit {
           path: node.path,
           snippet: node.html || node.snippet,
           explanation: node.failureSummary,
+          nodeLabel: node.nodeLabel,
         }),
       }));
     }
diff --git a/lighthouse-core/audits/seo/tap-targets.js b/lighthouse-core/audits/seo/tap-targets.js
index 25afeaaf9..29009a568 100644
--- a/lighthouse-core/audits/seo/tap-targets.js
+++ b/lighthouse-core/audits/seo/tap-targets.js
@@ -251,6 +251,7 @@ function targetToTableNode(target) {
     snippet: target.snippet,
     path: target.path,
     selector: target.selector,
+    nodeLabel: target.nodeLabel,
   };
 }
 
diff --git a/lighthouse-core/gather/driver.js b/lighthouse-core/gather/driver.js
index 38fe38320..f238a1227 100644
--- a/lighthouse-core/gather/driver.js
+++ b/lighthouse-core/gather/driver.js
@@ -1476,15 +1476,20 @@ class Driver {
   }
 
   /**
-   * Emulate internet disconnection.
+   * Clear the network cache on disk and in memory.
    * @return {Promise<void>}
    */
-  cleanBrowserCaches() {
+  async cleanBrowserCaches() {
+    const status = {msg: 'Cleaning browser cache', id: 'lh:driver:cleanBrowserCaches'};
+    log.time(status);
+
     // Wipe entire disk cache
-    return this.sendCommand('Network.clearBrowserCache')
-      // Toggle 'Disable Cache' to evict the memory cache
-      .then(_ => this.sendCommand('Network.setCacheDisabled', {cacheDisabled: true}))
-      .then(_ => this.sendCommand('Network.setCacheDisabled', {cacheDisabled: false}));
+    await this.sendCommand('Network.clearBrowserCache');
+    // Toggle 'Disable Cache' to evict the memory cache
+    await this.sendCommand('Network.setCacheDisabled', {cacheDisabled: true});
+    await this.sendCommand('Network.setCacheDisabled', {cacheDisabled: false});
+
+    log.timeEnd(status);
   }
 
   /**
diff --git a/lighthouse-core/gather/gather-runner.js b/lighthouse-core/gather/gather-runner.js
index f7efa385f..9c637cfcb 100644
--- a/lighthouse-core/gather/gather-runner.js
+++ b/lighthouse-core/gather/gather-runner.js
@@ -13,7 +13,7 @@ const URL = require('../lib/url-shim.js');
 const NetworkRecorder = require('../lib/network-recorder.js');
 const constants = require('../config/constants.js');
 
-const Driver = require('../gather/driver.js'); // eslint-disable-line no-unused-vars
+/** @typedef {import('../gather/driver.js')} Driver */
 
 /** @typedef {import('./gatherers/gatherer.js').PhaseResult} PhaseResult */
 /**
@@ -23,44 +23,9 @@ const Driver = require('../gather/driver.js'); // eslint-disable-line no-unused-
  * @typedef {Record<keyof LH.GathererArtifacts, Array<PhaseResult|Promise<PhaseResult>>>} GathererResults
  */
 /** @typedef {Array<[keyof GathererResults, GathererResults[keyof GathererResults]]>} GathererResultsEntries */
+
 /**
  * Class that drives browser to load the page and runs gatherer lifecycle hooks.
- * Execution sequence when GatherRunner.run() is called:
- *
- * 1. Setup
- *   A. driver.connect()
- *   B. GatherRunner.setupDriver()
- *     i. assertNoSameOriginServiceWorkerClients
- *     ii. retrieve and save userAgent
- *     iii. beginEmulation
- *     iv. enableRuntimeEvents/enableAsyncStacks
- *     v. evaluateScriptOnLoad rescue native Promise from potential polyfill
- *     vi. register a performance observer
- *     vii. register dialog dismisser
- *     viii. clearDataForOrigin
- *
- * 2. For each pass in the config:
- *   A. GatherRunner.beforePass()
- *     i. navigate to about:blank
- *     ii. Enable network request blocking for specified patterns
- *     iii. all gatherers' beforePass()
- *   B. GatherRunner.pass()
- *     i. cleanBrowserCaches() (if it's a perf run)
- *     ii. beginDevtoolsLog()
- *     iii. beginTrace (if requested)
- *     iv. GatherRunner.loadPage()
- *       a. navigate to options.url (and wait for onload)
- *     v. all gatherers' pass()
- *   C. GatherRunner.afterPass()
- *     i. endTrace (if requested) & endDevtoolsLog & endThrottling
- *     ii. all gatherers' afterPass()
- *
- * 3. Teardown
- *   A. clearDataForOrigin
- *   B. GatherRunner.disposeDriver()
- *   C. collect all artifacts and return them
- *     i. collectArtifacts() from completed passes on each gatherer
- *     ii. add trace and devtoolsLog data
  */
 class GatherRunner {
   /**
@@ -89,12 +54,22 @@ class GatherRunner {
    * @return {Promise<void>}
    */
   static async loadPage(driver, passContext) {
+    const gatherers = passContext.passConfig.gatherers;
+    const status = {
+      msg: 'Loading page & waiting for onload',
+      id: `lh:gather:loadPage-${passContext.passConfig.passName}`,
+      args: [gatherers.map(g => g.instance.name).join(', ')],
+    };
+    log.time(status);
+
     const finalUrl = await driver.gotoURL(passContext.url, {
       waitForFCP: passContext.passConfig.recordTrace,
       waitForLoad: true,
       passContext,
     });
     passContext.url = finalUrl;
+
+    log.timeEnd(status);
   }
 
   /**
@@ -118,14 +93,20 @@ class GatherRunner {
   }
 
   /**
+   * Reset browser state where needed and release the connection.
    * @param {Driver} driver
+   * @param {{requestedUrl: string, settings: LH.Config.Settings}} options
    * @return {Promise<void>}
    */
-  static async disposeDriver(driver) {
+  static async disposeDriver(driver, options) {
     const status = {msg: 'Disconnecting from browser...', id: 'lh:gather:disconnect'};
 
     log.time(status);
     try {
+      // If storage was cleared for the run, clear at the end so Lighthouse specifics aren't cached.
+      const resetStorage = !options.settings.disableStorageReset;
+      if (resetStorage) await driver.clearDataForOrigin(options.requestedUrl);
+
       await driver.disconnect();
     } catch (err) {
       // Ignore disconnecting error if browser was already closed.
@@ -143,7 +124,7 @@ class GatherRunner {
    * @param {Array<LH.Artifacts.NetworkRequest>} networkRecords
    * @return {LHError|undefined}
    */
-  static getPageLoadError(url, networkRecords) {
+  static getNetworkError(url, networkRecords) {
     const mainRecord = networkRecords.find(record => {
       // record.url is actual request url, so needs to be compared without any URL fragment.
       return URL.equalWithExcludedFragments(record.url, url);
@@ -176,15 +157,33 @@ class GatherRunner {
   }
 
   /**
-   * Calls beforePass() on gatherers before tracing
-   * has started and before navigation to the target page.
+   * Returns an error if the page load should be considered failed, e.g. from a
+   * main document request failure, a security issue, etc.
+   * @param {LH.Gatherer.PassContext} passContext
+   * @param {LH.Gatherer.LoadData} loadData
+   */
+  static getPageLoadError(passContext, loadData) {
+    const networkError = GatherRunner.getNetworkError(passContext.url, loadData.networkRecords);
+
+    //  If the driver was offline, the load will fail without offline support. Ignore this case.
+    if (!passContext.driver.online) return;
+
+    return networkError;
+  }
+
+  /**
+   * Initialize network settings for the pass, e.g. throttling, blocked URLs,
+   * and manual request headers.
    * @param {LH.Gatherer.PassContext} passContext
-   * @param {Partial<GathererResults>} gathererResults
    * @return {Promise<void>}
    */
-  static async beforePass(passContext, gathererResults) {
-    const bpStatus = {msg: `Running beforePass methods`, id: `lh:gather:beforePass`};
-    log.time(bpStatus, 'verbose');
+  static async setupPassNetwork(passContext) {
+    const status = {msg: 'Setting up network for the pass trace', id: `lh:gather:setupPassNetwork`};
+    log.time(status);
+
+    const passConfig = passContext.passConfig;
+    await passContext.driver.setThrottling(passContext.settings, passConfig);
+
     const blockedUrls = (passContext.passConfig.blockedUrlPatterns || [])
       .concat(passContext.settings.blockedUrlPatterns || []);
 
@@ -194,6 +193,73 @@ class GatherRunner {
     await passContext.driver.blockUrlPatterns(blockedUrls);
     await passContext.driver.setExtraHTTPHeaders(passContext.settings.extraHeaders);
 
+    log.timeEnd(status);
+  }
+
+  /**
+   * Beging recording devtoolsLog and trace (if requested).
+   * @param {LH.Gatherer.PassContext} passContext
+   * @return {Promise<void>}
+   */
+  static async beginRecording(passContext) {
+    const status = {msg: 'Beginning devtoolsLog and trace', id: 'lh:gather:beginRecording'};
+    log.time(status);
+
+    const {driver, passConfig, settings} = passContext;
+
+    // Always record devtoolsLog
+    await driver.beginDevtoolsLog();
+
+    if (passConfig.recordTrace) {
+      await driver.beginTrace(settings);
+    }
+
+    log.timeEnd(status);
+  }
+
+  /**
+   * End recording devtoolsLog and trace (if requested), returning an
+   * `LH.Gatherer.LoadData` with the recorded data.
+   * @param {LH.Gatherer.PassContext} passContext
+   * @return {Promise<LH.Gatherer.LoadData>}
+   */
+  static async endRecording(passContext) {
+    const {driver, passConfig} = passContext;
+
+    let trace;
+    if (passConfig.recordTrace) {
+      const status = {msg: 'Retrieving trace', id: `lh:gather:getTrace`};
+      log.time(status);
+      trace = await driver.endTrace();
+      log.timeEnd(status);
+    }
+
+    const status = {
+      msg: 'Retrieving devtoolsLog & network records',
+      id: `lh:gather:getDevtoolsLog`,
+    };
+    log.time(status);
+    const devtoolsLog = driver.endDevtoolsLog();
+    const networkRecords = NetworkRecorder.recordsFromLogs(devtoolsLog);
+    log.timeEnd(status);
+
+    return {
+      networkRecords,
+      devtoolsLog,
+      trace,
+    };
+  }
+
+  /**
+   * Run beforePass() on gatherers.
+   * @param {LH.Gatherer.PassContext} passContext
+   * @param {Partial<GathererResults>} gathererResults
+   * @return {Promise<void>}
+   */
+  static async beforePass(passContext, gathererResults) {
+    const bpStatus = {msg: `Running beforePass methods`, id: `lh:gather:beforePass`};
+    log.time(bpStatus, 'verbose');
+
     for (const gathererDefn of passContext.passConfig.gatherers) {
       const gatherer = gathererDefn.instance;
       // Abuse the passContext to pass through gatherer options
@@ -212,41 +278,18 @@ class GatherRunner {
   }
 
   /**
-   * Navigates to requested URL and then runs pass() on gatherers while trace
-   * (if requested) is still being recorded.
+   * Run pass() on gatherers.
    * @param {LH.Gatherer.PassContext} passContext
    * @param {Partial<GathererResults>} gathererResults
    * @return {Promise<void>}
    */
   static async pass(passContext, gathererResults) {
-    const driver = passContext.driver;
     const config = passContext.passConfig;
-    const settings = passContext.settings;
     const gatherers = config.gatherers;
 
-    const recordTrace = config.recordTrace;
-    const isPerfRun = !settings.disableStorageReset && recordTrace && config.useThrottling;
-
-    const status = {
-      msg: 'Loading page & waiting for onload',
-      id: `lh:gather:loadPage-${passContext.passConfig.passName}`,
-      args: [gatherers.map(g => g.instance.name).join(', ')],
-    };
-    log.time(status);
-
-    // Clear disk & memory cache if it's a perf run
-    if (isPerfRun) await driver.cleanBrowserCaches();
-    // Always record devtoolsLog
-    await driver.beginDevtoolsLog();
-    // Begin tracing if requested by config.
-    if (recordTrace) await driver.beginTrace(settings);
-
-    // Navigate.
-    await GatherRunner.loadPage(driver, passContext);
-    log.timeEnd(status);
-
     const pStatus = {msg: `Running pass methods`, id: `lh:gather:pass`};
     log.time(pStatus, 'verbose');
+
     for (const gathererDefn of gatherers) {
       const gatherer = gathererDefn.instance;
       // Abuse the passContext to pass through gatherer options
@@ -263,60 +306,22 @@ class GatherRunner {
       gathererResults[gatherer.name] = gathererResult;
       await artifactPromise.catch(() => {});
     }
-    log.timeEnd(status);
+
     log.timeEnd(pStatus);
   }
 
   /**
-   * Ends tracing and collects trace data (if requested for this pass), and runs
-   * afterPass() on gatherers with trace data passed in. Promise resolves with
-   * object containing trace and network data.
+   * Run afterPass() on gatherers.
    * @param {LH.Gatherer.PassContext} passContext
+   * @param {LH.Gatherer.LoadData} loadData
    * @param {Partial<GathererResults>} gathererResults
-   * @return {Promise<LH.Gatherer.LoadData>}
+   * @return {Promise<void>}
    */
-  static async afterPass(passContext, gathererResults) {
-    const driver = passContext.driver;
+  static async afterPass(passContext, loadData, gathererResults) {
     const config = passContext.passConfig;
     const gatherers = config.gatherers;
 
-    let trace;
-    if (config.recordTrace) {
-      const status = {msg: 'Retrieving trace', id: `lh:gather:getTrace`};
-      log.time(status);
-      trace = await driver.endTrace();
-      log.timeEnd(status);
-    }
-
-    const status = {
-      msg: 'Retrieving devtoolsLog & network records',
-      id: `lh:gather:getDevtoolsLog`,
-    };
-    log.time(status);
-    const devtoolsLog = driver.endDevtoolsLog();
-    const networkRecords = NetworkRecorder.recordsFromLogs(devtoolsLog);
-    log.timeEnd(status);
-
-    let pageLoadError = GatherRunner.getPageLoadError(passContext.url, networkRecords);
-    // If the driver was offline, a page load error is expected, so do not save it.
-    if (!driver.online) pageLoadError = undefined;
-
-    if (pageLoadError) {
-      log.error('GatherRunner', pageLoadError.message, passContext.url);
-      passContext.LighthouseRunWarnings.push(pageLoadError.friendlyMessage);
-    }
-
-    // Expose devtoolsLog, networkRecords, and trace (if present) to gatherers
-    /** @type {LH.Gatherer.LoadData} */
-    const passData = {
-      networkRecords,
-      devtoolsLog,
-      trace,
-    };
-
     const apStatus = {msg: `Running afterPass methods`, id: `lh:gather:afterPass`};
-    // Disable throttling so the afterPass analysis isn't throttled
-    await driver.setThrottling(passContext.settings, {useThrottling: false});
     log.time(apStatus, 'verbose');
 
     for (const gathererDefn of gatherers) {
@@ -329,12 +334,8 @@ class GatherRunner {
 
       // Add gatherer options to the passContext.
       passContext.options = gathererDefn.options || {};
-
-      // If there was a pageLoadError, fail every afterPass with it rather than bail completely.
-      const artifactPromise = pageLoadError ?
-        Promise.reject(pageLoadError) :
-        // Wrap gatherer response in promise, whether rejected or not.
-        Promise.resolve().then(_ => gatherer.afterPass(passContext, passData));
+      const artifactPromise = Promise.resolve()
+        .then(_ => gatherer.afterPass(passContext, loadData));
 
       const gathererResult = gathererResults[gatherer.name] || [];
       gathererResult.push(artifactPromise);
@@ -343,9 +344,28 @@ class GatherRunner {
       log.timeEnd(status);
     }
     log.timeEnd(apStatus);
+  }
 
-    // Resolve on tracing data using passName from config.
-    return passData;
+  /**
+   * Generate a set of artfiacts for the given pass as if all the gatherers
+   * failed with the given pageLoadError.
+   * @param {LH.Gatherer.PassContext} passContext
+   * @param {LHError} pageLoadError
+   * @return {{pageLoadError: LHError, artifacts: Partial<LH.GathererArtifacts>}}
+   */
+  static generatePageLoadErrorArtifacts(passContext, pageLoadError) {
+    /** @type {Partial<Record<keyof LH.GathererArtifacts, LHError>>} */
+    const errorArtifacts = {};
+    for (const gathererDefn of passContext.passConfig.gatherers) {
+      const gatherer = gathererDefn.instance;
+      errorArtifacts[gatherer.name] = pageLoadError;
+    }
+
+    return {
+      pageLoadError,
+      // @ts-ignore - TODO(bckenny): figure out how to usefully type errored artifacts.
+      artifacts: errorArtifacts,
+    };
   }
 
   /**
@@ -354,17 +374,14 @@ class GatherRunner {
    * gatherer. If an error was rejected from a gatherer phase,
    * uses that error object as the artifact instead.
    * @param {Partial<GathererResults>} gathererResults
-   * @param {LH.BaseArtifacts} baseArtifacts
-   * @return {Promise<LH.Artifacts>}
+   * @return {Promise<{artifacts: Partial<LH.GathererArtifacts>}>}
    */
-  static async collectArtifacts(gathererResults, baseArtifacts) {
+  static async collectArtifacts(gathererResults) {
     /** @type {Partial<LH.GathererArtifacts>} */
     const gathererArtifacts = {};
 
     const resultsEntries = /** @type {GathererResultsEntries} */ (Object.entries(gathererResults));
     for (const [gathererName, phaseResultsPromises] of resultsEntries) {
-      if (gathererArtifacts[gathererName] !== undefined) continue;
-
       try {
         const phaseResults = await Promise.all(phaseResultsPromises);
         // Take last defined pass result as artifact.
@@ -382,21 +399,18 @@ class GatherRunner {
       }
     }
 
-    // Take only unique LighthouseRunWarnings.
-    baseArtifacts.LighthouseRunWarnings = Array.from(new Set(baseArtifacts.LighthouseRunWarnings));
-
-    // Take the timing entries we've gathered so far.
-    baseArtifacts.Timing = log.getTimeEntries();
-
-    // TODO(bckenny): correct Partial<LH.GathererArtifacts> at this point to drop cast.
-    return /** @type {LH.Artifacts} */ ({...baseArtifacts, ...gathererArtifacts});
+    return {
+      artifacts: gathererArtifacts,
+    };
   }
 
   /**
+   * Return an initialized but mostly empty set of base artifacts, to be
+   * populated as the run continues.
    * @param {{driver: Driver, requestedUrl: string, settings: LH.Config.Settings}} options
    * @return {Promise<LH.BaseArtifacts>}
    */
-  static async getBaseArtifacts(options) {
+  static async initializeBaseArtifacts(options) {
     const hostUserAgent = (await options.driver.getBrowserVersion()).userAgent;
 
     const {emulatedFormFactor} = options.settings;
@@ -422,6 +436,47 @@ class GatherRunner {
     };
   }
 
+  /**
+   * Populates the important base artifacts from a fully loaded test page.
+   * Currently must be run before `start-url` gatherer so that `WebAppManifest`
+   * will be available to it.
+   * @param {LH.Gatherer.PassContext} passContext
+   */
+  static async populateBaseArtifacts(passContext) {
+    const baseArtifacts = passContext.baseArtifacts;
+
+    // Copy redirected URL to artifact.
+    baseArtifacts.URL.finalUrl = passContext.url;
+
+    // Fetch the manifest, if it exists.
+    baseArtifacts.WebAppManifest = await GatherRunner.getWebAppManifest(passContext);
+
+    baseArtifacts.Stacks = await stacksGatherer(passContext);
+
+    // Find the NetworkUserAgent actually used in the devtoolsLogs.
+    const devtoolsLog = baseArtifacts.devtoolsLogs[passContext.passConfig.passName];
+    const userAgentEntry = devtoolsLog.find(entry =>
+      entry.method === 'Network.requestWillBeSent' &&
+      !!entry.params.request.headers['User-Agent']
+    );
+    if (userAgentEntry) {
+      // @ts-ignore - guaranteed to exist by the find above
+      baseArtifacts.NetworkUserAgent = userAgentEntry.params.request.headers['User-Agent'];
+    }
+  }
+
+  /**
+   * Finalize baseArtifacts after gathering is fully complete.
+   * @param {LH.BaseArtifacts} baseArtifacts
+   */
+  static finalizeBaseArtifacts(baseArtifacts) {
+    // Take only unique LighthouseRunWarnings.
+    baseArtifacts.LighthouseRunWarnings = Array.from(new Set(baseArtifacts.LighthouseRunWarnings));
+
+    // Take the timing entries we've gathered so far.
+    baseArtifacts.Timing = log.getTimeEntries();
+  }
+
   /**
    * Uses the debugger protocol to fetch the manifest from within the context of
    * the target page, reusing any credentials, emulation, etc, already established
@@ -441,91 +496,109 @@ class GatherRunner {
   }
 
   /**
-   * @param {Array<LH.Config.Pass>} passes
+   * @param {Array<LH.Config.Pass>} passConfigs
    * @param {{driver: Driver, requestedUrl: string, settings: LH.Config.Settings}} options
    * @return {Promise<LH.Artifacts>}
    */
-  static async run(passes, options) {
+  static async run(passConfigs, options) {
     const driver = options.driver;
 
-    /** @type {Partial<GathererResults>} */
-    const gathererResults = {};
+    /** @type {Partial<LH.GathererArtifacts>} */
+    const artifacts = {};
 
     try {
       await driver.connect();
-      const baseArtifacts = await GatherRunner.getBaseArtifacts(options);
       // In the devtools/extension case, we can't still be on the site while trying to clear state
       // So we first navigate to about:blank, then apply our emulation & setup
       await GatherRunner.loadBlank(driver);
+
+      const baseArtifacts = await GatherRunner.initializeBaseArtifacts(options);
       baseArtifacts.BenchmarkIndex = await options.driver.getBenchmarkIndex();
+
       await GatherRunner.setupDriver(driver, options);
 
-      // Run each pass
       let isFirstPass = true;
-      for (const passConfig of passes) {
+      for (const passConfig of passConfigs) {
+        /** @type {LH.Gatherer.PassContext} */
         const passContext = {
-          driver: options.driver,
-          // If the main document redirects, we'll update this to keep track
+          driver,
           url: options.requestedUrl,
           settings: options.settings,
           passConfig,
           baseArtifacts,
-          // *pass() functions and gatherers can push to this warnings array.
           LighthouseRunWarnings: baseArtifacts.LighthouseRunWarnings,
         };
-
-        await driver.setThrottling(options.settings, passConfig);
-        if (!isFirstPass) {
-          // Already on blank page if driver was just set up.
-          await GatherRunner.loadBlank(driver, passConfig.blankPage);
-        }
-        await GatherRunner.beforePass(passContext, gathererResults);
-        await GatherRunner.pass(passContext, gathererResults);
+        const passResults = await GatherRunner.runPass(passContext);
+        Object.assign(artifacts, passResults.artifacts);
 
         if (isFirstPass) {
-          // Fetch the manifest, if it exists. Currently must be fetched before gatherers' `afterPass`.
-          baseArtifacts.WebAppManifest = await GatherRunner.getWebAppManifest(passContext);
+          await GatherRunner.populateBaseArtifacts(passContext);
+          isFirstPass = false;
         }
+      }
 
-        const passData = await GatherRunner.afterPass(passContext, gathererResults);
+      await GatherRunner.disposeDriver(driver, options);
+      GatherRunner.finalizeBaseArtifacts(baseArtifacts);
+      return /** @type {LH.Artifacts} */ ({...baseArtifacts, ...artifacts}); // Cast to drop Partial<>.
+    } catch (err) {
+      // cleanup on error
+      GatherRunner.disposeDriver(driver, options);
+      throw err;
+    }
+  }
 
-        if (isFirstPass) {
-          baseArtifacts.Stacks = await stacksGatherer(passContext);
-        }
+  /**
+   * Returns whether this pass should be considered to be measuring performance.
+   * @param {LH.Gatherer.PassContext} passContext
+   * @return {boolean}
+   */
+  static isPerfPass(passContext) {
+    const {settings, passConfig} = passContext;
+    return !settings.disableStorageReset && passConfig.recordTrace && passConfig.useThrottling;
+  }
 
-        // Save devtoolsLog, but networkRecords are discarded and not added onto artifacts.
-        baseArtifacts.devtoolsLogs[passConfig.passName] = passData.devtoolsLog;
+  /**
+   * Starting from about:blank, load the page and run gatherers for this pass.
+   * @param {LH.Gatherer.PassContext} passContext
+   * @return {Promise<{artifacts: Partial<LH.GathererArtifacts>, pageLoadError?: LHError}>}
+   */
+  static async runPass(passContext) {
+    /** @type {Partial<GathererResults>} */
+    const gathererResults = {};
+    const {driver, passConfig} = passContext;
 
-        const userAgentEntry = passData.devtoolsLog.find(entry =>
-          entry.method === 'Network.requestWillBeSent' &&
-          !!entry.params.request.headers['User-Agent']
-        );
+    // Go to about:blank, set up, and run `beforePass()` on gatherers.
+    await GatherRunner.loadBlank(driver, passConfig.blankPage);
+    await GatherRunner.setupPassNetwork(passContext);
+    const isPerfPass = GatherRunner.isPerfPass(passContext);
+    if (isPerfPass) await driver.cleanBrowserCaches(); // Clear disk & memory cache if it's a perf run
+    await GatherRunner.beforePass(passContext, gathererResults);
 
-        if (userAgentEntry && !baseArtifacts.NetworkUserAgent) {
-          // @ts-ignore - guaranteed to exist by the find above
-          baseArtifacts.NetworkUserAgent = userAgentEntry.params.request.headers['User-Agent'];
-        }
+    // Navigate, start recording, and run `pass()` on gatherers.
+    await GatherRunner.beginRecording(passContext);
+    await GatherRunner.loadPage(driver, passContext);
+    await GatherRunner.pass(passContext, gathererResults);
+    const loadData = await GatherRunner.endRecording(passContext);
 
-        // If requested by config, save pass's trace.
-        if (passData.trace) {
-          baseArtifacts.traces[passConfig.passName] = passData.trace;
-        }
+    // Disable throttling so the afterPass analysis isn't throttled
+    await driver.setThrottling(passContext.settings, {useThrottling: false});
 
-        if (isFirstPass) {
-          // Copy redirected URL to artifact in the first pass only.
-          baseArtifacts.URL.finalUrl = passContext.url;
-          isFirstPass = false;
-        }
-      }
-      const resetStorage = !options.settings.disableStorageReset;
-      if (resetStorage) await driver.clearDataForOrigin(options.requestedUrl);
-      await GatherRunner.disposeDriver(driver);
-      return GatherRunner.collectArtifacts(gathererResults, baseArtifacts);
-    } catch (err) {
-      // cleanup on error
-      GatherRunner.disposeDriver(driver);
-      throw err;
+    // Save devtoolsLog and trace.
+    const baseArtifacts = passContext.baseArtifacts;
+    baseArtifacts.devtoolsLogs[passConfig.passName] = loadData.devtoolsLog;
+    if (loadData.trace) baseArtifacts.traces[passConfig.passName] = loadData.trace;
+
+    // If there were any load errors, treat all gatherers as if they errored.
+    const pageLoadError = GatherRunner.getPageLoadError(passContext, loadData);
+    if (pageLoadError) {
+      log.error('GatherRunner', pageLoadError.friendlyMessage, passContext.url);
+      passContext.LighthouseRunWarnings.push(pageLoadError.friendlyMessage);
+      return GatherRunner.generatePageLoadErrorArtifacts(passContext, pageLoadError);
     }
+
+    // If no error, run `afterPass()` on gatherers and return collected artifacts.
+    await GatherRunner.afterPass(passContext, loadData, gathererResults);
+    return GatherRunner.collectArtifacts(gathererResults);
   }
 }
 
diff --git a/lighthouse-core/gather/gatherers/accessibility.js b/lighthouse-core/gather/gatherers/accessibility.js
index 57cb71cfb..9d0e9165f 100644
--- a/lighthouse-core/gather/gatherers/accessibility.js
+++ b/lighthouse-core/gather/gatherers/accessibility.js
@@ -5,7 +5,7 @@
  */
 'use strict';
 
-/* global window, document, getOuterHTMLSnippet, getNodePath */
+/* global window, document, getOuterHTMLSnippet, getNodePath, getNodeLabel */
 
 const Gatherer = require('./gatherer');
 const fs = require('fs');
@@ -57,6 +57,8 @@ function runA11yChecks() {
       node.path = getNodePath(node.element);
       // @ts-ignore - getOuterHTMLSnippet put into scope via stringification
       node.snippet = getOuterHTMLSnippet(node.element);
+      // @ts-ignore - getNodeLabel put into scope via stringification
+      node.nodeLabel = getNodeLabel(node.element);
       // avoid circular JSON concerns
       node.element = node.any = node.all = node.none = undefined;
     }));
@@ -77,6 +79,7 @@ class Accessibility extends Gatherer {
     const expression = `(function () {
       ${pageFunctions.getOuterHTMLSnippetString};
       ${pageFunctions.getNodePathString};
+      ${pageFunctions.getNodeLabelString};
       ${axeLibSource};
       return (${runA11yChecks.toString()}());
     })()`;
diff --git a/lighthouse-core/gather/gatherers/seo/tap-targets.js b/lighthouse-core/gather/gatherers/seo/tap-targets.js
index 6314c60f7..6e5591782 100644
--- a/lighthouse-core/gather/gatherers/seo/tap-targets.js
+++ b/lighthouse-core/gather/gatherers/seo/tap-targets.js
@@ -5,7 +5,7 @@
  */
 'use strict';
 
-/* global getComputedStyle, getElementsInDocument, Node, getNodePath, getNodeSelector */
+/* global getComputedStyle, getElementsInDocument, Node, getNodePath, getNodeSelector, getNodeLabel */
 
 const Gatherer = require('../gatherer');
 const pageFunctions = require('../../../lib/page-functions.js');
@@ -239,7 +239,7 @@ function elementIsPositionFixedStickyOrAbsolute(element) {
 /**
  * @param {string} str
  * @param {number} maxLength
- * @returns {string}
+ * @return {string}
  */
 /* istanbul ignore next */
 function truncate(str, maxLength) {
@@ -294,6 +294,8 @@ function gatherTapTargets() {
       path: getNodePath(tapTargetElement),
       // @ts-ignore - getNodeSelector put into scope via stringification
       selector: getNodeSelector(tapTargetElement),
+      // @ts-ignore - getNodeLabel put into scope via stringification
+      nodeLabel: getNodeLabel(tapTargetElement),
       href: /** @type {HTMLAnchorElement} */(tapTargetElement)['href'] || '',
     });
   });
@@ -323,6 +325,7 @@ class TapTargets extends Gatherer {
       ${rectContainsString};
       ${pageFunctions.getNodePathString};
       ${pageFunctions.getNodeSelectorString};
+      ${pageFunctions.getNodeLabelString};
       ${gatherTapTargets.toString()};
 
       return gatherTapTargets();
diff --git a/lighthouse-core/lib/i18n/locales/fr.json b/lighthouse-core/lib/i18n/locales/fr.json
index 6cf45e349..beb56365a 100644
--- a/lighthouse-core/lib/i18n/locales/fr.json
+++ b/lighthouse-core/lib/i18n/locales/fr.json
@@ -447,7 +447,7 @@
     "message": "Valeur"
   },
   "lighthouse-core/audits/dobetterweb/dom-size.js | description": {
-    "message": "Les ingénieurs en navigation recommandent que les pages contiennent moins de 1 500 éléments DOM environ. La zone d'écoute idéale est une profondeur d'arborescence inférieure à 32 éléments et contenant moins de 60 éléments enfant/parent. Un grand DOM peut accroître l'utilisation de la mémoire, entraîner de plus longs [calculs de style](https://developers.google.com/web/fundamentals/performance/rendering/reduce-the-scope-and-complexity-of-style-calculations) et des [redispositions] coûteuses (https://developers.google.com/speed/articles/reflow). [En savoir plus](https://developers.google.com/web/tools/lighthouse/audits/dom-size))."
+    "message": "Les ingénieurs en navigation recommandent que les pages contiennent moins de 1 500 éléments DOM environ. La zone d'écoute idéale est une profondeur d'arborescence inférieure à 32 éléments et contenant moins de 60 éléments enfant/parent. Un grand DOM peut accroître l'utilisation de la mémoire, entraîner de plus longs [calculs de style](https://developers.google.com/web/fundamentals/performance/rendering/reduce-the-scope-and-complexity-of-style-calculations) et des [redispositions](https://developers.google.com/speed/articles/reflow) coûteuses. [En savoir plus](https://developers.google.com/web/tools/lighthouse/audits/dom-size)."
   },
   "lighthouse-core/audits/dobetterweb/dom-size.js | displayValue": {
     "message": "{itemCount,plural, =1{1 élément}one{# élément}other{# éléments}}"
@@ -1035,7 +1035,7 @@
     "message": "Vous pouvez envisager d'afficher des extraits dans vos listes d'articles (par exemple en utilisant la balise \"more\"), de réduire le nombre d'articles affichés dans une page donnée, de répartir vos articles longs sur plusieurs pages ou d'utiliser un plug-in qui charge de façon différée les commentaires."
   },
   "stack-packs/packs/wordpress.js | unminified_css": {
-    "message": "Un certain nombre de [plug-ins WordPress](https://wordpress.org/plugins/search/minify+css/) peuvent accélérer l'affichage de votre site en concaténant, en minimisant et en compressant vos styles. Vous pouvez aussi utiliser un processus de construction pour réaliser cette minimisation en amont, si possible."
+    "message": "Un certain nombre de [plug-ins WordPress](https://wordpress.org/plugins/search/minify+css/) peuvent accélérer l'affichage de votre site en concaténant, en minimisant et en compressant vos styles. Si possible, utilisez un processus de build pour réaliser cette minimisation en amont."
   },
   "stack-packs/packs/wordpress.js | unminified_javascript": {
     "message": "Un certain nombre de [plug-ins WordPress](https://wordpress.org/plugins/search/minify+javascript/) peuvent accélérer l'affichage de votre site en concaténant, en minimisant et en compressant vos scripts. Si possible, utilisez un processus de build pour réaliser cette minimisation en amont."
diff --git a/lighthouse-core/lib/i18n/locales/ta.json b/lighthouse-core/lib/i18n/locales/ta.json
index ba2e010b9..0c79af0e6 100644
--- a/lighthouse-core/lib/i18n/locales/ta.json
+++ b/lighthouse-core/lib/i18n/locales/ta.json
@@ -459,7 +459,7 @@
     "message": "அதிகபட்ச DOM கிளை அடுக்கு"
   },
   "lighthouse-core/audits/dobetterweb/dom-size.js | statisticDOMElements": {
-    "message": "மொத்த DOM உறுப்புக்கள்"
+    "message": "மொத்த DOM உறுப்புகள்"
   },
   "lighthouse-core/audits/dobetterweb/dom-size.js | statisticDOMWidth": {
     "message": "அதிகபட்சத் துணை உறுப்புகள்"
@@ -876,16 +876,16 @@
     "message": "SEO"
   },
   "lighthouse-core/config/default-config.js | seoContentGroupDescription": {
-    "message": "ஆப்ஸின் உள்ளடக்கத்தை crawlerகள் நன்கு புரிந்துகொள்ள வசதியாக HTMLலைப் பொருத்தமாக வடிவமைக்கவும்."
+    "message": "ஆப்ஸின் உள்ளடக்கத்தை கிராலர்கள் நன்கு புரிந்துகொள்ள வசதியாக HTMLலைப் பொருத்தமாக வடிவமைக்கவும்."
   },
   "lighthouse-core/config/default-config.js | seoContentGroupTitle": {
     "message": "உள்ளடக்கம் தொடர்பான சிறந்த நடைமுறைகள்"
   },
   "lighthouse-core/config/default-config.js | seoCrawlingGroupDescription": {
-    "message": "தேடல் முடிவுகளில் தோன்ற உங்கள் ஆப்ஸிற்கான அணுகல் crawlerகளுக்குத் தேவை."
+    "message": "தேடல் முடிவுகளில் தோன்ற உங்கள் ஆப்ஸிற்கான அணுகல் கிராலர்களுக்குத் தேவை."
   },
   "lighthouse-core/config/default-config.js | seoCrawlingGroupTitle": {
-    "message": "Crawling மற்றும் அட்டவணைப்படுத்துதல்"
+    "message": "கிராலிங் & அட்டவணைப்படுத்துதல்"
   },
   "lighthouse-core/config/default-config.js | seoMobileGroupDescription": {
     "message": "பக்கங்களின் உள்ளடக்கத்தைப் பயனர்கள் பின்ச் செய்தோ பெரிதாக்கியோ படிக்க வேண்டிய அவசியம் ஏற்படாதவாறு உங்கள் பக்கங்கள் மொபைலுக்கு ஏற்றவையாக இருக்க வேண்டும். [மேலும் அறிக](https://developers.google.com/search/mobile-sites/)."
@@ -1026,31 +1026,31 @@
     "message": "[lazy-load WordPress செருகுநிரலை] நிறுவி (https://wordpress.org/plugins/search/lazy+load/) திரைக்கு வெளியிலுள்ள படங்களைத் தேவையான போது ஏற்றும் திறனைப் பெறலாம் அல்லது அந்தத் திறனுள்ள தீமிற்கு மாறலாம். [AMP செருகுநிரலையும்](https://wordpress.org/plugins/amp/) பயன்படுத்திப் பார்க்கவும்."
   },
   "stack-packs/packs/wordpress.js | render_blocking_resources": {
-    "message": "[முக்கியமான சொத்துகளை முன்னிலைப்படுத்தவும்](https://wordpress.org/plugins/search/critical+css/) அல்லது [முக்கியத்துவம் குறைவான ஆதாரங்களைத் தவிர்க்கவும்](https://wordpress.org/plugins/search/defer+css+javascript/) உங்களுக்கு உதவக்கூடிய பல WordPress செருகுநிரல்கள் உள்ளன. இந்தச் செருகுநிரல்கள் வழங்கும் மேம்படுத்துதல்கள் உங்களின் தீம் அல்லது செருகுநிரலில் உள்ள அம்சத்தைப் பாதிக்கும். அதனால் நீங்கள் குறியீட்டில் சில மாற்றங்களைச் செய்ய வேண்டியிருக்கும்."
+    "message": "[முக்கியமான சொத்துகளை முன்னிலைப்படுத்தவும்](https://wordpress.org/plugins/search/critical+css/) அல்லது [முக்கியத்துவம் குறைவான ஆதாரங்களைத் தவிர்க்கவும்](https://wordpress.org/plugins/search/defer+css+javascript/) உங்களுக்கு உதவக்கூடிய பல WordPress செருகுநிரல்கள் உள்ளன. இந்தச் செருகுநிரல்கள் வழங்கும் மேம்படுத்துதல்கள் உங்கள் தீமிலோ செருகுநிரலிலோ உள்ள அம்சத்தைப் பாதிக்கலாம். அதனால் நீங்கள் குறியீட்டில் சில மாற்றங்களைச் செய்ய வேண்டியிருக்கும்."
   },
   "stack-packs/packs/wordpress.js | time_to_first_byte": {
-    "message": "தீம்கள், செருகுநிரல்கள் மற்றும் சேவையக விவரக்குறிப்புகள் அனைத்தும் சேவையகத்தின் வேகத்தை நிர்ணயிக்கும். இன்னும் பல மேம்படுத்தப்பட்ட தீம்மைக் கண்டறிந்து, மேம்படுத்தும் செருகுநிரலைக் கவனமாகத் தேர்ந்தெடுத்து மற்றும்/அல்லது சேவையகத்தை மேம்படுத்தவும்."
+    "message": "தீம்கள், செருகுநிரல்கள், சேவையக விவரக்குறிப்புகள் அனைத்தும் சேவையகத்தின் வேகத்தை நிர்ணயிக்கும். மேலும் மேம்படுத்தப்பட்ட தீமைக் கண்டறிந்து, மேம்படுத்தும் செருகுநிரலைக் கவனமாகத் தேர்ந்தெடுத்து மற்றும்/அல்லது சேவையகத்தை மேம்படுத்தவும்."
   },
   "stack-packs/packs/wordpress.js | total_byte_weight": {
-    "message": "இடுகை பட்டியல்களில் முக்கியமான பகுதியை மட்டும் காட்டலாம் (உதாரணமாக மேலும் என்ற குறிச்சொல்லுடன்), பக்கத்தில் இடுகைகளின் எண்ணிக்கையைக் குறைக்கலாம், ஒரு பெரிய இடுகையைப் பல சின்ன பக்கங்களாகப் பிரிக்கலாம் அல்லது தேவையுள்ள போது மட்டும் கருத்துகளைச் செருகுநிரல்கள் மூலம் ஏற்றலாம்."
+    "message": "இடுகைப் பட்டியல்களில் முக்கியமான பகுதியை மட்டும் காட்டலாம் (உதாரணமாக மேலும் என்ற குறிச்சொல்லுடன்), பக்கத்தில் இடுகைகளின் எண்ணிக்கையைக் குறைக்கலாம், ஒரு பெரிய இடுகையைப் பல சின்ன பக்கங்களாகப் பிரிக்கலாம் அல்லது தேவையுள்ள போது மட்டும் கருத்துகளைச் செருகுநிரல்கள் மூலம் ஏற்றலாம்."
   },
   "stack-packs/packs/wordpress.js | unminified_css": {
-    "message": "பல [WordPress செருகுநிரல்கள்](https://wordpress.org/plugins/search/minify+javascript/) உங்கள் இணையதளத்தில் உள்ள ஸ்டைல்களைச் சிறிதாக்கி சுருக்கி மற்றும் ஒன்றிணைத்து அதை வேகப்படுத்தலாம். ஸ்கிரிப்ட்டைச் சிறிதாக்க பதிப்பு முறைமையைப் பயன்படுத்த நேரிடலாம்."
+    "message": "பல [WordPress செருகுநிரல்கள்](https://wordpress.org/plugins/search/minify+javascript/) உங்கள் இணையதளத்தில் உள்ள ஸ்டைல்களைச் சிறிதாக்கியும், சுருக்கியும், ஒன்றிணைத்தும் அதை வேகப்படுத்த முடியும். முடிந்தால், ஸ்கிரிப்ட்களை முன்னதாகவே சிறிதாக்க பதிப்பு முறைமையைப் பயன்படுத்திப் பார்க்கலாம்."
   },
   "stack-packs/packs/wordpress.js | unminified_javascript": {
-    "message": "பல [WordPress செருகுநிரல்கள்](https://wordpress.org/plugins/search/minify+javascript/) உங்கள் இணையதளத்தில் உள்ள ஸ்கிரிப்ட்டைச் சிறிதாக்கி சுருக்கி மற்றும் ஒன்றிணைத்து அதை வேகப்படுத்தலாம். ஸ்கிரிப்ட்டைச் சிறிதாக்க பதிப்பு முறைமையைப் பயன்படுத்த நேரிடலாம்."
+    "message": "பல [WordPress செருகுநிரல்கள்](https://wordpress.org/plugins/search/minify+javascript/) உங்கள் இணையதளத்தில் உள்ள ஸ்கிரிப்ட்களைச் சிறிதாக்கியும், சுருக்கியும், ஒன்றிணைத்தும் அதை வேகப்படுத்த முடியும். முடிந்தால், ஸ்கிரிப்ட்களை முன்னதாகவே சிறிதாக்க பதிப்பு முறைமையைப் பயன்படுத்திப் பார்க்கலாம்."
   },
   "stack-packs/packs/wordpress.js | unused_css_rules": {
-    "message": "உங்கள் பக்கத்தில் [WordPress செருகுநிரல்கள்](https://wordpress.org/plugins/) ஏற்றும் பயன்படுத்தப்படாத CSSகளின் எண்ணிக்கையைக் குறைக்கவும் அல்லது மாற்றிப் பார்க்கவும். Chrome DevToolsஸில் [குறியீட்டுக் கவரேஜ்] (https://developers.google.com/web/updates/2017/04/devtools-release-notes#coverage) என்பதை இயக்கி தேவையற்ற CSSஸைச் சேர்க்கும் செருகுநிரல்களைக் கண்டறியலாம். stylesheetடின் URLலில் இதற்குக் காரணமான தீம்/செருகுநிரலைக் கண்டறியலாம். அதிகமாகச் சிகப்பில் உள்ள குறியீட்டு கவரேஜைக் கொண்ட பட்டியலில் பல stylesheetகளைக் கொண்ட செருகுநிரல்களைக் கண்டறியவும். இணையப் பக்கத்தில் பயன்படுத்தும் பட்சத்தில் ஒரு செருகுநிரல் ஒரு stylesheetடை மட்டுமே வரிசையில் சேர்க்க வேண்டும்."
+    "message": "உங்கள் பக்கத்தில் [WordPress செருகுநிரல்கள்](https://wordpress.org/plugins/) ஏற்றும் பயன்படுத்தப்படாத CSSகளின் எண்ணிக்கையைக் குறைக்கவும் அல்லது மாற்றிப் பார்க்கவும். Chrome DevToolsஸில் [குறியீட்டுக் கவரேஜ்] (https://developers.google.com/web/updates/2017/04/devtools-release-notes#coverage) என்பதை இயக்கி தேவையற்ற CSSஸைச் சேர்க்கும் செருகுநிரல்களைக் கண்டறியலாம். stylesheetடின் URLலில் இதற்குக் காரணமான தீம்/செருகுநிரலைக் கண்டறியலாம். அதிகமாகச் சிகப்பில் உள்ள குறியீட்டுக் கவரேஜைக் கொண்ட பட்டியலில் பல stylesheetகளைக் கொண்ட செருகுநிரல்களைக் கண்டறியவும். இணையப் பக்கத்தில் பயன்படுத்தும் பட்சத்தில் ஒரு செருகுநிரல் ஒரு stylesheetடை மட்டுமே வரிசையில் சேர்க்க வேண்டும்."
   },
   "stack-packs/packs/wordpress.js | unused_javascript": {
-    "message": "உங்கள் பக்கத்தில் [WordPress செருகுநிரல்கள்](https://wordpress.org/plugins/) ஏற்றும் பயன்படுத்தப்படாத JavaScriptகளின் எண்ணிக்கையைக் குறைக்கவும் அல்லது மாற்றிப் பார்க்கவும். Chrome DevToolsஸில் [குறியீட்டுக் கவரேஜ்](https://developers.google.com/web/updates/2017/04/devtools-release-notes#coverage) என்பதை இயக்கி தேவையற்ற JSகளைச் சேர்க்கும் செருகுநிரல்களைக் கண்டறியவும். ஸ்கிரிப்ட்டின் URLலில் இதற்குக் காரணமான தீம்/செருகுநிரலைக் கண்டறியலாம். அதிகமாகச் சிகப்பில் உள்ள குறியீட்டு கவரேஜைக் கொண்ட பட்டியலில் பல ஸ்கிரிப்ட்களைக் கொண்ட செருகுநிரல்களைக் கண்டறியவும். இணையப் பக்கத்தில் பயன்படுத்தும் பட்சத்தில் ஒரு செருகுநிரல் ஒரு ஸ்கிரிப்ட்டை மட்டுமே வரிசையில் சேர்க்க வேண்டும்."
+    "message": "உங்கள் பக்கத்தில் [WordPress செருகுநிரல்கள்](https://wordpress.org/plugins/) ஏற்றும் பயன்படுத்தப்படாத JavaScriptகளின் எண்ணிக்கையைக் குறைக்கவும் அல்லது மாற்றிப் பார்க்கவும். Chrome DevToolsஸில் [குறியீட்டுக் கவரேஜ்](https://developers.google.com/web/updates/2017/04/devtools-release-notes#coverage) என்பதை இயக்கி தேவையற்ற JSகளைச் சேர்க்கும் செருகுநிரல்களைக் கண்டறியவும். ஸ்கிரிப்ட்டின் URLலில் இதற்குக் காரணமான தீம்/செருகுநிரலைக் கண்டறியலாம். அதிகமாகச் சிகப்பில் உள்ள குறியீட்டுக் கவரேஜைக் கொண்ட பட்டியலில் பல ஸ்கிரிப்ட்களைக் கொண்ட செருகுநிரல்களைக் கண்டறியவும். இணையப் பக்கத்தில் பயன்படுத்தும் பட்சத்தில் ஒரு செருகுநிரல் ஒரு ஸ்கிரிப்ட்டை மட்டுமே வரிசையில் சேர்க்க வேண்டும்."
   },
   "stack-packs/packs/wordpress.js | uses_long_cache_ttl": {
     "message": "[WordPressஸில் உலாவியின் தற்காலிக சேமிப்பு] பற்றி அறிய: (https://codex.wordpress.org/WordPress_Optimization#Browser_Caching)."
   },
   "stack-packs/packs/wordpress.js | uses_optimized_images": {
-    "message": "[படங்களை மேம்படுத்தும் WordPress செருகுநிரல்](https://wordpress.org/plugins/search/optimize+images/) என்பதைப் பயன்படுத்தி உங்கள் படங்களின் தரத்தை இழக்காமல் சுருங்கச் செய்யலாம்."
+    "message": "இதைப் பயன்படுத்தி உங்கள் படங்களின் தரத்தை இழக்காமல் சுருக்கலாம்: [படங்களை மேம்படுத்தும் WordPress செருகுநிரல்](https://wordpress.org/plugins/search/optimize+images/)"
   },
   "stack-packs/packs/wordpress.js | uses_responsive_images": {
     "message": "[மீடியா லைப்ரரி](https://codex.wordpress.org/Media_Library_Screen) மூலம் படத்தை நேரடியாகப் பதிவேற்றி சரியான அளவில் கிடைக்கிறதா என்று உறுதிப்படுத்தலாம், பிறகு மீடியா லைப்ரரியிலிருந்தோ பட விட்ஜெட்டைப் பயன்படுத்தியோ மேம்படுத்தப்பட்ட அளவில் படங்கள் பயன்படுத்தப்படுகிறதா என்பதை உறுதி செய்யவும் (பதிலளிக்கும் அளவு வரம்புகள் உட்பட). பயன்படுத்துவதற்கு ஏதுவான அளவுகள் உள்ளபோது மட்டுமே 'முழு அளவு' படங்களைப் பயன்படுத்தவும். [மேலும் அறிக](https://codex.wordpress.org/Inserting_Images_into_Posts_and_Pages#Image_Size)."
diff --git a/lighthouse-core/lib/minify-devtoolslog.js b/lighthouse-core/lib/minify-devtoolslog.js
new file mode 100644
index 000000000..4f6e9451e
--- /dev/null
+++ b/lighthouse-core/lib/minify-devtoolslog.js
@@ -0,0 +1,87 @@
+/**
+ * @license Copyright 2019 Google Inc. All Rights Reserved.
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
+ */
+'use strict';
+
+/* eslint-disable no-console */
+
+/**
+ * @fileoverview Minifies a devtools log by removing noisy header values, eliminating data URIs, etc.
+ */
+
+const headersToKeep = new Set([
+  // Request headers
+  'accept',
+  'accept-encoding',
+  'accept-ranges',
+  // Response headers
+  'status',
+  'content-length',
+  'content-type',
+  'content-encoding',
+  'content-range',
+  'etag',
+  'cache-control',
+  'last-modified',
+  'link',
+  'x-robots-tag',
+]);
+
+/** @param {LH.Crdp.Network.Headers} [headers] */
+function cleanHeaders(headers) {
+  if (!headers) return;
+
+  for (const key of Object.keys(headers)) {
+    if (!headersToKeep.has(key.toLowerCase())) delete headers[key];
+  }
+}
+
+/** @param {{url: string}} obj */
+function cleanDataURI(obj) {
+  obj.url = obj.url.replace(/^(data:.*?base64,).*/, '$1FILLER');
+}
+
+/** @param {LH.Crdp.Network.Response} [response] */
+function cleanResponse(response) {
+  if (!response) return;
+  cleanDataURI(response);
+  cleanHeaders(response.requestHeaders);
+  cleanHeaders(response.headers);
+  response.securityDetails = undefined;
+  response.headersText = undefined;
+  response.requestHeadersText = undefined;
+
+  /** @type {any} */
+  const timing = response.timing || {};
+  for (const [k, v] of Object.entries(timing)) {
+    if (v === -1) timing[k] = undefined;
+  }
+}
+
+/**
+ * @param {LH.DevtoolsLog} log
+ * @return {LH.DevtoolsLog}
+ */
+function minifyDevtoolsLog(log) {
+  return log.map(original => {
+    /** @type {LH.Protocol.RawEventMessage} */
+    const entry = JSON.parse(JSON.stringify(original));
+
+    switch (entry.method) {
+      case 'Network.requestWillBeSent':
+        cleanDataURI(entry.params.request);
+        cleanHeaders(entry.params.request.headers);
+        cleanResponse(entry.params.redirectResponse);
+        break;
+      case 'Network.responseReceived':
+        cleanResponse(entry.params.response);
+        break;
+    }
+
+    return entry;
+  });
+}
+
+module.exports = {minifyDevtoolsLog};
diff --git a/lighthouse-core/lib/minify-trace.js b/lighthouse-core/lib/minify-trace.js
new file mode 100644
index 000000000..c6610a760
--- /dev/null
+++ b/lighthouse-core/lib/minify-trace.js
@@ -0,0 +1,149 @@
+/**
+ * @license Copyright 2019 Google Inc. All Rights Reserved.
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
+ */
+'use strict';
+
+/* eslint-disable no-console */
+
+/**
+ * @fileoverview Minifies a trace by removing unnecessary events, throttling screenshots, etc.
+ *  See the following files for necessary events:
+ *    - lighthouse-core/computed/trace-of-tab.js
+ *    - lighthouse-core/computed/page-dependency-graph.js
+ *    - lighthouse-core/lib/dependency-graph/cpu-node.js
+ *    - lighthouse-core/lib/traces/tracing-processor.js
+ */
+
+const TracingProcessor = require('./traces/tracing-processor.js');
+
+const toplevelTaskNames = new Set([
+  'RunTask', // m71+
+  'ThreadControllerImpl::RunTask', // m69-70
+  'ThreadControllerImpl::DoWork', // m66-68
+  'TaskQueueManager::ProcessTaskFromWorkQueue', // m65 and below
+]);
+
+const traceEventsToAlwaysKeep = new Set([
+  'Screenshot',
+  'TracingStartedInBrowser',
+  'TracingStartedInPage',
+  'navigationStart',
+  'ParseAuthorStyleSheet',
+  'ParseHTML',
+  'PlatformResourceSendRequest',
+  'ResourceSendRequest',
+  'ResourceReceiveResponse',
+  'ResourceFinish',
+  'ResourceReceivedData',
+  'EventDispatch',
+]);
+
+const traceEventsToKeepInToplevelTask = new Set([
+  // Needed for CPU node timing simulations
+  'Layout',
+  // All of these are needed to create graph relationships
+  'TimerInstall',
+  'TimerFire',
+  'InvalidateLayout',
+  'ScheduleStyleRecalculation',
+  'EvaluateScript',
+  'XHRReadyStateChange',
+  'FunctionCall',
+  'v8.compile',
+  'ParseAuthorStyleSheet',
+  'ResourceSendRequest',
+]);
+
+const traceEventsToKeepInProcess = new Set([
+  ...toplevelTaskNames,
+  ...traceEventsToKeepInToplevelTask,
+  'firstPaint',
+  'firstContentfulPaint',
+  'firstMeaningfulPaint',
+  'firstMeaningfulPaintCandidate',
+  'loadEventEnd',
+  'domContentLoadedEventEnd',
+]);
+
+/**
+ * @param {LH.TraceEvent[]} events
+ */
+function filterOutUnnecessaryTasksByNameAndDuration(events) {
+  const {pid} = TracingProcessor.findMainFrameIds(events);
+
+  return events.filter(evt => {
+    if (toplevelTaskNames.has(evt.name) && evt.dur < 1000) return false;
+    if (evt.pid === pid && traceEventsToKeepInProcess.has(evt.name)) return true;
+    return traceEventsToAlwaysKeep.has(evt.name);
+  });
+}
+
+/**
+ * Filters out tasks that are not within a toplevel task.
+ * @param {LH.TraceEvent[]} events
+ */
+function filterOutOrphanedTasks(events) {
+  const toplevelRanges = events
+    .filter(evt => toplevelTaskNames.has(evt.name))
+    .map(evt => [evt.ts, evt.ts + evt.dur]);
+
+  /** @param {LH.TraceEvent} e */
+  const isInToplevelTask = e => toplevelRanges.some(([start, end]) => e.ts >= start && e.ts <= end);
+
+  return events.filter((evt, index) => {
+    if (!traceEventsToKeepInToplevelTask.has(evt.name)) return true;
+    if (!isInToplevelTask(evt)) return false;
+
+    if (evt.ph === 'B') {
+      const endEvent = events.slice(index).find(e => e.name === evt.name && e.ph === 'E');
+      return endEvent && isInToplevelTask(endEvent);
+    } else {
+      return true;
+    }
+  });
+}
+
+/**
+ * Throttles screenshot events in the trace to 2fps.
+ * @param {LH.TraceEvent[]} events
+ */
+function filterOutExcessiveScreenshots(events) {
+  const screenshotTimestamps = events.filter(evt => evt.name === 'Screenshot').map(evt => evt.ts);
+
+  let lastScreenshotTs = -Infinity;
+  return events.filter(evt => {
+    if (evt.name !== 'Screenshot') return true;
+    const timeSinceLastScreenshot = evt.ts - lastScreenshotTs;
+    const nextScreenshotTs = screenshotTimestamps.find(ts => ts > evt.ts);
+    const timeUntilNextScreenshot = nextScreenshotTs ? nextScreenshotTs - evt.ts : Infinity;
+    const threshold = 500 * 1000; // Throttle to ~2fps
+    // Keep the frame if it's been more than 500ms since the last frame we kept or the next frame won't happen for at least 500ms
+    const shouldKeep = timeUntilNextScreenshot > threshold || timeSinceLastScreenshot > threshold;
+    if (shouldKeep) lastScreenshotTs = evt.ts;
+    return shouldKeep;
+  });
+}
+
+/**
+ * @param {LH.TraceEvent[]} events
+ */
+function filterTraceEvents(events) {
+  // Filter out event names we don't care about and tasks <1ms
+  let filtered = filterOutUnnecessaryTasksByNameAndDuration(events);
+  // Filter out events not inside a toplevel task
+  filtered = filterOutOrphanedTasks(filtered);
+  // Filter down the screenshots to key moments + 2fps animations
+  return filterOutExcessiveScreenshots(filtered);
+}
+
+/**
+ * @param {LH.Trace} inputTrace
+ * @return {LH.Trace}
+ */
+function minifyTrace(inputTrace) {
+  return {traceEvents: filterTraceEvents(inputTrace.traceEvents)};
+}
+
+module.exports = {minifyTrace};
diff --git a/lighthouse-core/lib/page-functions.js b/lighthouse-core/lib/page-functions.js
index 9e9b79d69..4a09367b8 100644
--- a/lighthouse-core/lib/page-functions.js
+++ b/lighthouse-core/lib/page-functions.js
@@ -187,7 +187,7 @@ function getNodePath(node) {
 
 /**
  * @param {Element} node
- * @returns {string}
+ * @return {string}
  */
 /* istanbul ignore next */
 function getNodeSelector(node) {
@@ -218,6 +218,47 @@ function getNodeSelector(node) {
   return parts.join(' > ');
 }
 
+/**
+ * Generate a human-readable label for the given element, based on end-user facing
+ * strings like the innerText or alt attribute.
+ * Falls back to the tagName if no useful label is found.
+ * @param {HTMLElement} node
+ * @return {string|null}
+ */
+/* istanbul ignore next */
+function getNodeLabel(node) {
+  // Inline so that audits that import getNodeLabel don't
+  // also need to import truncate
+  /**
+   * @param {string} str
+   * @param {number} maxLength
+   * @return {string}
+   */
+  function truncate(str, maxLength) {
+    if (str.length <= maxLength) {
+      return str;
+    }
+    return str.slice(0, maxLength - 1) + '…';
+  }
+
+  const tagName = node.tagName.toLowerCase();
+  // html and body content is too broad to be useful, since they contain all page content
+  if (tagName !== 'html' && tagName !== 'body') {
+    const nodeLabel = node.innerText || node.getAttribute('alt') || node.getAttribute('aria-label');
+    if (nodeLabel) {
+      return truncate(nodeLabel, 80);
+    } else {
+      // If no useful label was found then try to get one from a child.
+      // E.g. if an a tag contains an image but no text we want the image alt/aria-label attribute.
+      const nodeToUseForLabel = node.querySelector('[alt], [aria-label]');
+      if (nodeToUseForLabel) {
+        return getNodeLabel(/** @type {HTMLElement} */ (nodeToUseForLabel));
+      }
+    }
+  }
+  return tagName;
+}
+
 module.exports = {
   wrapRuntimeEvalErrorInBrowserString: wrapRuntimeEvalErrorInBrowser.toString(),
   registerPerformanceObserverInPageString: registerPerformanceObserverInPage.toString(),
@@ -230,4 +271,6 @@ module.exports = {
   getNodePathString: getNodePath.toString(),
   getNodeSelectorString: getNodeSelector.toString(),
   getNodeSelector: getNodeSelector,
+  getNodeLabel: getNodeLabel,
+  getNodeLabelString: getNodeLabel.toString(),
 };
diff --git a/lighthouse-core/report/html/renderer/details-renderer.js b/lighthouse-core/report/html/renderer/details-renderer.js
index 67ac6b389..d7cb6abe0 100644
--- a/lighthouse-core/report/html/renderer/details-renderer.js
+++ b/lighthouse-core/report/html/renderer/details-renderer.js
@@ -352,8 +352,16 @@ class DetailsRenderer {
    */
   renderNode(item) {
     const element = this._dom.createElement('span', 'lh-node');
+    if (item.nodeLabel) {
+      const nodeLabelEl = this._dom.createElement('div');
+      nodeLabelEl.textContent = item.nodeLabel;
+      element.appendChild(nodeLabelEl);
+    }
     if (item.snippet) {
-      element.textContent = item.snippet;
+      const snippetEl = this._dom.createElement('div');
+      snippetEl.classList.add('lh-node__snippet');
+      snippetEl.textContent = item.snippet;
+      element.appendChild(snippetEl);
     }
     if (item.selector) {
       element.title = item.selector;
@@ -361,6 +369,7 @@ class DetailsRenderer {
     if (item.path) element.setAttribute('data-path', item.path);
     if (item.selector) element.setAttribute('data-selector', item.selector);
     if (item.snippet) element.setAttribute('data-snippet', item.snippet);
+
     return element;
   }
 
diff --git a/lighthouse-core/report/html/renderer/psi.js b/lighthouse-core/report/html/renderer/psi.js
index 718581ea7..8066fdfd9 100644
--- a/lighthouse-core/report/html/renderer/psi.js
+++ b/lighthouse-core/report/html/renderer/psi.js
@@ -43,7 +43,7 @@ function prepareLabData(LHResult, document) {
   dom.resetTemplates();
 
   const reportLHR = Util.prepareReportResult(lhResult);
-  const perfCategory = reportLHR.reportCategories.find(cat => cat.id === 'performance');
+  const perfCategory = reportLHR.categories.performance;
   if (!perfCategory) throw new Error(`No performance category. Can't make lab data section`);
   if (!reportLHR.categoryGroups) throw new Error(`No category groups found.`);
 
diff --git a/lighthouse-core/report/html/renderer/report-renderer.js b/lighthouse-core/report/html/renderer/report-renderer.js
index 928dc4742..3c7e61051 100644
--- a/lighthouse-core/report/html/renderer/report-renderer.js
+++ b/lighthouse-core/report/html/renderer/report-renderer.js
@@ -169,7 +169,7 @@ class ReportRenderer {
     const customGauges = []; // PWA.
     const pluginGauges = [];
 
-    for (const category of report.reportCategories) {
+    for (const category of Object.values(report.categories)) {
       const renderer = specificCategoryRenderers[category.id] || categoryRenderer;
       const categoryGauge = renderer.renderScoreGauge(category, report.categoryGroups || {});
 
@@ -212,7 +212,7 @@ class ReportRenderer {
     reportSection.appendChild(this._renderReportWarnings(report));
 
     let scoreHeader;
-    const isSoloCategory = report.reportCategories.length === 1;
+    const isSoloCategory = Object.keys(report.categories).length === 1;
     if (!isSoloCategory) {
       scoreHeader = this._dom.createElement('div', 'lh-scores-header');
     } else {
@@ -234,7 +234,7 @@ class ReportRenderer {
 
     const categories = reportSection.appendChild(this._dom.createElement('div', 'lh-categories'));
 
-    for (const category of report.reportCategories) {
+    for (const category of Object.values(report.categories)) {
       const renderer = specificCategoryRenderers[category.id] || categoryRenderer;
       // .lh-category-wrapper is full-width and provides horizontal rules between categories.
       // .lh-category within has the max-width: var(--report-width);
diff --git a/lighthouse-core/report/html/renderer/util.js b/lighthouse-core/report/html/renderer/util.js
index 519225fd8..f29b62933 100644
--- a/lighthouse-core/report/html/renderer/util.js
+++ b/lighthouse-core/report/html/renderer/util.js
@@ -99,12 +99,9 @@ class Util {
       Util.updateAllUIStrings(clone.i18n.rendererFormattedStrings);
     }
 
-    // Transform object of categories into array of categories.
-    if (typeof clone.categories !== 'object') throw new Error('No categories provided.');
-    clone.reportCategories = Object.values(clone.categories);
-
     // For convenience, smoosh all AuditResults into their auditRef (which has just weight & group)
-    for (const category of clone.reportCategories) {
+    if (typeof clone.categories !== 'object') throw new Error('No categories provided.');
+    for (const category of Object.values(clone.categories)) {
       category.auditRefs.forEach(auditRef => {
         const result = clone.audits[auditRef.id];
         auditRef.result = result;
diff --git a/lighthouse-core/report/html/report-styles.css b/lighthouse-core/report/html/report-styles.css
index 7e9703b35..be3769bc1 100644
--- a/lighthouse-core/report/html/report-styles.css
+++ b/lighthouse-core/report/html/report-styles.css
@@ -80,7 +80,8 @@
   --chevron-size: 12px;
   --inner-audit-left-padding: calc(var(--score-shape-size) + var(--score-shape-margin-left) + var(--score-shape-margin-right));
 
-  /* Palette. */
+  /* Palette using Material Design Colors
+   * https://www.materialui.co/colors */
   --color-black-100: #F5F5F5;
   --color-black-200: #E0E0E0;
   --color-black-400: #BDBDBD;
@@ -100,6 +101,8 @@
   --color-white: #FFFFFF;
   --color-blue-200: #90CAF9;
   --color-blue-900: #0D47A1;
+  --color-cyan-500: #00BCD4;
+  --color-teal-600: #00897B;
 
 
   /* TODO(cjamcl) clean up unused variables. */
@@ -175,6 +178,7 @@
 .lh-vars.dark {
   --color-red-700: var(--color-red);
   --color-green-700: var(--color-green);
+  --color-teal-600: var(--color-cyan-500);
   --color-orange-700: var(--color-orange);
 
   --color-black-200: var(--color-black-800);
@@ -388,10 +392,11 @@
 }
 
 /* Node */
-.lh-node {
-  display: block;
+.lh-node__snippet {
   font-family: var(--monospace-font-family);
-  color: hsl(174, 100%, 27%);
+  color: var(--color-teal-600);
+  font-size: 12px;
+  line-height: 1.5em;
 }
 
 /* Score */
diff --git a/lighthouse-core/scripts/lantern/minify-devtoolslog.js b/lighthouse-core/scripts/lantern/minify-devtoolslog.js
index 878e5243e..d1e6b3a1b 100755
--- a/lighthouse-core/scripts/lantern/minify-devtoolslog.js
+++ b/lighthouse-core/scripts/lantern/minify-devtoolslog.js
@@ -14,6 +14,7 @@
 
 const fs = require('fs');
 const path = require('path');
+const {minifyDevtoolsLog} = require('../../lib/minify-devtoolslog.js');
 
 if (process.argv.length !== 4) {
   console.error('Usage $0: <input file> <output file>');
@@ -26,79 +27,9 @@ const inputDevtoolsLogRaw = fs.readFileSync(inputDevtoolsLogPath, 'utf8');
 /** @type {LH.DevtoolsLog} */
 const inputDevtoolsLog = JSON.parse(inputDevtoolsLogRaw);
 
-const headersToKeep = new Set([
-  // Request headers
-  'accept',
-  'accept-encoding',
-  'accept-ranges',
-  // Response headers
-  'status',
-  'content-length',
-  'content-type',
-  'content-encoding',
-  'content-range',
-  'etag',
-  'cache-control',
-  'last-modified',
-  'link',
-  'x-robots-tag',
-]);
-
-/** @param {Partial<LH.Crdp.Network.Headers>} [headers] */
-function cleanHeaders(headers) {
-  if (!headers) return;
-
-  for (const key of Object.keys(headers)) {
-    if (!headersToKeep.has(key.toLowerCase())) headers[key] = undefined;
-  }
-}
-
-/** @param {{url: string}} obj */
-function cleanDataURI(obj) {
-  obj.url = obj.url.replace(/^(data:.*?base64,).*/, '$1FILLER');
-}
-
-/** @param {LH.Crdp.Network.Response} [response] */
-function cleanResponse(response) {
-  if (!response) return;
-  cleanDataURI(response);
-  cleanHeaders(response.requestHeaders);
-  cleanHeaders(response.headers);
-  response.securityDetails = undefined;
-  response.headersText = undefined;
-  response.requestHeadersText = undefined;
-
-  /** @type {any} */
-  const timing = response.timing || {};
-  for (const [k, v] of Object.entries(timing)) {
-    if (v === -1) timing[k] = undefined;
-  }
-}
-
-/** @param {LH.DevtoolsLog} log */
-function filterDevtoolsLogEvents(log) {
-  return log.map(original => {
-    /** @type {LH.Protocol.RawEventMessage} */
-    const entry = JSON.parse(JSON.stringify(original));
-
-    switch (entry.method) {
-      case 'Network.requestWillBeSent':
-        cleanDataURI(entry.params.request);
-        cleanHeaders(entry.params.request.headers);
-        cleanResponse(entry.params.redirectResponse);
-        break;
-      case 'Network.responseReceived':
-        cleanResponse(entry.params.response);
-        break;
-    }
-
-    return entry;
-  });
-}
-
-const filteredLog = filterDevtoolsLogEvents(inputDevtoolsLog);
+const outputDevtoolsLog = minifyDevtoolsLog(inputDevtoolsLog);
 const output = `[
-${filteredLog.map(e => '  ' + JSON.stringify(e)).join(',\n')}
+${outputDevtoolsLog.map(e => '  ' + JSON.stringify(e)).join(',\n')}
 ]`;
 
 /** @param {string} s */
diff --git a/lighthouse-core/scripts/lantern/minify-trace.js b/lighthouse-core/scripts/lantern/minify-trace.js
index c41f5d3c9..4f930158d 100755
--- a/lighthouse-core/scripts/lantern/minify-trace.js
+++ b/lighthouse-core/scripts/lantern/minify-trace.js
@@ -8,17 +8,9 @@
 
 /* eslint-disable no-console */
 
-/**
- * @fileoverview Minifies a trace by removing unnecessary events, throttling screenshots, etc.
- *  See the following files for necessary events:
- *    - lighthouse-core/computed/trace-of-tab.js
- *    - lighthouse-core/computed/page-dependency-graph.js
- *    - lighthouse-core/lib/traces/tracing-processor.js
- */
-
 const fs = require('fs');
 const path = require('path');
-const TracingProcessor = require('../../lib/traces/tracing-processor');
+const {minifyTrace} = require('../../lib/minify-trace.js');
 
 if (process.argv.length !== 4) {
   console.error('Usage $0: <input file> <output file>');
@@ -31,129 +23,16 @@ const inputTraceRaw = fs.readFileSync(inputTracePath, 'utf8');
 /** @type {LH.Trace} */
 const inputTrace = JSON.parse(inputTraceRaw);
 
-const toplevelTaskNames = new Set([
-  'TaskQueueManager::ProcessTaskFromWorkQueue',
-  'ThreadControllerImpl::DoWork',
-  'ThreadControllerImpl::RunTask',
-]);
-
-const traceEventsToAlwaysKeep = new Set([
-  'Screenshot',
-  'TracingStartedInBrowser',
-  'TracingStartedInPage',
-  'navigationStart',
-  'ParseAuthorStyleSheet',
-  'ParseHTML',
-  'PlatformResourceSendRequest',
-  'ResourceSendRequest',
-  'ResourceReceiveResponse',
-  'ResourceFinish',
-  'ResourceReceivedData',
-  'EventDispatch',
-]);
-
-const traceEventsToKeepInToplevelTask = new Set([
-  'TimerInstall',
-  'TimerFire',
-  'InvalidateLayout',
-  'ScheduleStyleRecalculation',
-  'EvaluateScript',
-  'XHRReadyStateChange',
-  'FunctionCall',
-  'v8.compile',
-]);
-
-const traceEventsToKeepInProcess = new Set([
-  ...toplevelTaskNames,
-  ...traceEventsToKeepInToplevelTask,
-  'firstPaint',
-  'firstContentfulPaint',
-  'firstMeaningfulPaint',
-  'firstMeaningfulPaintCandidate',
-  'loadEventEnd',
-  'domContentLoadedEventEnd',
-]);
-
-/**
- * @param {LH.TraceEvent[]} events
- */
-function filterOutUnnecessaryTasksByNameAndDuration(events) {
-  const {pid} = TracingProcessor.findMainFrameIds(events);
-
-  return events.filter(evt => {
-    if (toplevelTaskNames.has(evt.name) && evt.dur < 1000) return false;
-    if (evt.pid === pid && traceEventsToKeepInProcess.has(evt.name)) return true;
-    return traceEventsToAlwaysKeep.has(evt.name);
-  });
-}
-
-/**
- * Filters out tasks that are not within a toplevel task.
- * @param {LH.TraceEvent[]} events
- */
-function filterOutOrphanedTasks(events) {
-  const toplevelRanges = events
-    .filter(evt => toplevelTaskNames.has(evt.name))
-    .map(evt => [evt.ts, evt.ts + evt.dur]);
-
-  /** @param {LH.TraceEvent} e */
-  const isInToplevelTask = e => toplevelRanges.some(([start, end]) => e.ts >= start && e.ts <= end);
-
-  return events.filter((evt, index) => {
-    if (!traceEventsToKeepInToplevelTask.has(evt.name)) return true;
-    if (!isInToplevelTask(evt)) return false;
-
-    if (evt.ph === 'B') {
-      const endEvent = events.slice(index).find(e => e.name === evt.name && e.ph === 'E');
-      return endEvent && isInToplevelTask(endEvent);
-    } else {
-      return true;
-    }
-  });
-}
-
-/**
- * Throttles screenshot events in the trace to 2fps.
- * @param {LH.TraceEvent[]} events
- */
-function filterOutExcessiveScreenshots(events) {
-  const screenshotTimestamps = events.filter(evt => evt.name === 'Screenshot').map(evt => evt.ts);
-
-  let lastScreenshotTs = -Infinity;
-  return events.filter(evt => {
-    if (evt.name !== 'Screenshot') return true;
-    const timeSinceLastScreenshot = evt.ts - lastScreenshotTs;
-    const nextScreenshotTs = screenshotTimestamps.find(ts => ts > evt.ts);
-    const timeUntilNextScreenshot = nextScreenshotTs ? nextScreenshotTs - evt.ts : Infinity;
-    const threshold = 500 * 1000; // Throttle to ~2fps
-    // Keep the frame if it's been more than 500ms since the last frame we kept or the next frame won't happen for at least 500ms
-    const shouldKeep = timeUntilNextScreenshot > threshold || timeSinceLastScreenshot > threshold;
-    if (shouldKeep) lastScreenshotTs = evt.ts;
-    return shouldKeep;
-  });
-}
-
-/**
- * @param {LH.TraceEvent[]} events
- */
-function filterTraceEvents(events) {
-  // Filter out event names we don't care about and tasks <1ms
-  let filtered = filterOutUnnecessaryTasksByNameAndDuration(events);
-  // Filter out events not inside a toplevel task
-  filtered = filterOutOrphanedTasks(filtered);
-  // Filter down the screenshots to key moments + 2fps animations
-  return filterOutExcessiveScreenshots(filtered);
-}
-
-const filteredEvents = filterTraceEvents(inputTrace.traceEvents);
+const outputTrace = minifyTrace(inputTrace);
 const output = `{
   "traceEvents": [
-${filteredEvents.map(e => '    ' + JSON.stringify(e)).join(',\n')}
+${outputTrace.traceEvents.map(e => '    ' + JSON.stringify(e)).join(',\n')}
   ]
 }`;
 
 /** @param {string} s */
 const size = s => Math.round(s.length / 1024) + 'kb';
+const eventDelta = inputTrace.traceEvents.length - outputTrace.traceEvents.length;
 console.log(`Reduced trace from ${size(inputTraceRaw)} to ${size(output)}`);
-console.log(`Filtered out ${inputTrace.traceEvents.length - filteredEvents.length} trace events`);
+console.log(`Filtered out ${eventDelta} trace events`);
 fs.writeFileSync(outputTracePath, output);
diff --git a/lighthouse-core/scripts/roll-to-devtools.sh b/lighthouse-core/scripts/roll-to-devtools.sh
index 2e42c2cc3..fd1068566 100755
--- a/lighthouse-core/scripts/roll-to-devtools.sh
+++ b/lighthouse-core/scripts/roll-to-devtools.sh
@@ -21,6 +21,8 @@ else
   frontend_dir="$chromium_dir/third_party/blink/renderer/devtools/front_end"
 fi
 
+tests_dir="$frontend_dir/../../../web_tests/http/tests/devtools/audits2"
+
 if [[ ! -d "$frontend_dir" || ! -a "$frontend_dir/Runtime.js" ]]; then
   echo -e "\033[31m✖ Error!\033[39m"
   echo "This script requires a devtools frontend folder. We didn't find one here:"
@@ -42,3 +44,9 @@ echo -e "\033[96m ✓\033[39m (Potentially stale) lighthouse-dt-bundle copied."
 
 # copy report generator + cached resources into $fe_lh_dir
 cp -r dist/dt-report-resources/ $fe_lh_dir
+
+# update expected version string in tests
+VERSION=$(node -e "console.log(require('./package.json').version)")
+sed -i '' -e "s/Version:.*/Version: $VERSION/g" "$tests_dir"/*-expected.txt
+
+echo "Done. To rebase the test expectations, run: ~/chromium/src/third_party/blink/tools/run_web_tests.py --no-retry 'http/tests/devtools/audits2/*' --reset-results"
diff --git a/proto/sample_v2_round_trip.json b/proto/sample_v2_round_trip.json
index 3ba37e774..0196d5311 100644
--- a/proto/sample_v2_round_trip.json
+++ b/proto/sample_v2_round_trip.json
@@ -199,6 +199,7 @@
                     {
                         "node": {
                             "explanation": "Fix any of the following:\n  Element has insufficient color contrast of 1.32 (foreground color: #ffc0cb, background color: #eeeeee, font size: 18.0pt, font weight: bold). Expected contrast ratio of 3:1",
+                            "nodeLabel": "Do better web tester page",
                             "path": "3,HTML,1,BODY,5,DIV,0,H2",
                             "selector": "h2",
                             "snippet": "<h2>Do better web tester page</h2>",
@@ -208,6 +209,7 @@
                     {
                         "node": {
                             "explanation": "Fix any of the following:\n  Element has insufficient color contrast of 1.32 (foreground color: #ffc0cb, background color: #eeeeee, font size: 12.0pt, font weight: normal). Expected contrast ratio of 4.5:1",
+                            "nodeLabel": "Hi there!",
                             "path": "3,HTML,1,BODY,5,DIV,1,SPAN",
                             "selector": "span",
                             "snippet": "<span>Hi there!</span>",
@@ -871,6 +873,7 @@
                     {
                         "node": {
                             "explanation": "Fix any of the following:\n  The <html> element does not have a lang attribute",
+                            "nodeLabel": "html",
                             "path": "3,HTML",
                             "selector": "html",
                             "snippet": "<html manifest=\"clock.appcache\">",
@@ -924,6 +927,7 @@
                     {
                         "node": {
                             "explanation": "Fix any of the following:\n  Element does not have an alt attribute\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Element has no title attribute or the title attribute is empty\n  Element's default semantics were not overridden with role=\"presentation\"\n  Element's default semantics were not overridden with role=\"none\"",
+                            "nodeLabel": "img",
                             "path": "3,HTML,1,BODY,10,IMG",
                             "selector": "img[height=\"\\35 7\"]",
                             "snippet": "<img src=\"lighthouse-480x318.jpg\" width=\"480\" height=\"57\">",
@@ -933,6 +937,7 @@
                     {
                         "node": {
                             "explanation": "Fix any of the following:\n  Element does not have an alt attribute\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Element has no title attribute or the title attribute is empty\n  Element's default semantics were not overridden with role=\"presentation\"\n  Element's default semantics were not overridden with role=\"none\"",
+                            "nodeLabel": "img",
                             "path": "3,HTML,1,BODY,12,IMG",
                             "selector": "img[height=\"\\33 18\"]",
                             "snippet": "<img src=\"lighthouse-480x318.jpg\" width=\"480\" height=\"318\">",
@@ -942,8 +947,9 @@
                     {
                         "node": {
                             "explanation": "Fix any of the following:\n  Element does not have an alt attribute\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Element has no title attribute or the title attribute is empty\n  Element's default semantics were not overridden with role=\"presentation\"\n  Element's default semantics were not overridden with role=\"none\"",
+                            "nodeLabel": "img",
                             "path": "3,HTML,1,BODY,14,IMG",
-                            "selector": "img[src$=\"lighthouse-rotating.gif\"]",
+                            "selector": "img[src$=\"lighthouse-rotating\\.gif\"]",
                             "snippet": "<img src=\"lighthouse-rotating.gif\" width=\"811\" height=\"462\">",
                             "type": "node"
                         }
@@ -951,8 +957,9 @@
                     {
                         "node": {
                             "explanation": "Fix any of the following:\n  Element does not have an alt attribute\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Element has no title attribute or the title attribute is empty\n  Element's default semantics were not overridden with role=\"presentation\"\n  Element's default semantics were not overridden with role=\"none\"",
-                            "path": "3,HTML,1,BODY,49,IMG",
-                            "selector": "img:nth-child(27)",
+                            "nodeLabel": "img",
+                            "path": "3,HTML,1,BODY,53,IMG",
+                            "selector": "img:nth-child(30)",
                             "snippet": "<img src=\"blob:http://localhost:62824/289254fd-ef1d-4c1a-96a8-ba291caa2140\">",
                             "type": "node"
                         }
@@ -1142,7 +1149,8 @@
                     {
                         "node": {
                             "explanation": "Fix any of the following:\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Form element does not have an implicit (wrapped) <label>\n  Form element does not have an explicit <label>\n  Element has no title attribute or the title attribute is empty",
-                            "path": "3,HTML,1,BODY,44,INPUT",
+                            "nodeLabel": "input",
+                            "path": "3,HTML,1,BODY,48,INPUT",
                             "selector": "input[onpaste=\"event\\.preventDefault\\(\\)\\;\"]",
                             "snippet": "<input type=\"password\" onpaste=\"event.preventDefault();\">",
                             "type": "node"
@@ -1151,8 +1159,9 @@
                     {
                         "node": {
                             "explanation": "Fix any of the following:\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Form element does not have an implicit (wrapped) <label>\n  Form element does not have an explicit <label>\n  Element has no title attribute or the title attribute is empty",
-                            "path": "3,HTML,1,BODY,46,INPUT",
-                            "selector": "input:nth-child(25)",
+                            "nodeLabel": "input",
+                            "path": "3,HTML,1,BODY,50,INPUT",
+                            "selector": "input:nth-child(28)",
                             "snippet": "<input type=\"password\">",
                             "type": "node"
                         }
@@ -1160,7 +1169,8 @@
                     {
                         "node": {
                             "explanation": "Fix any of the following:\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Form element does not have an implicit (wrapped) <label>\n  Form element does not have an explicit <label>\n  Element has no title attribute or the title attribute is empty",
-                            "path": "3,HTML,1,BODY,48,INPUT",
+                            "nodeLabel": "input",
+                            "path": "3,HTML,1,BODY,52,INPUT",
                             "selector": "input[onpaste=\"return\\ false\\;\"]",
                             "snippet": "<input type=\"password\" onpaste=\"return false;\">",
                             "type": "node"
@@ -1207,8 +1217,9 @@
                     {
                         "node": {
                             "explanation": "Fix all of the following:\n  Element is in tab order and does not have accessible text\n\nFix any of the following:\n  Element does not have text that is visible to screen readers\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Element's default semantics were not overridden with role=\"presentation\"\n  Element's default semantics were not overridden with role=\"none\"",
-                            "path": "3,HTML,1,BODY,40,A",
-                            "selector": "a[href=\"javascript:void(0)\"]",
+                            "nodeLabel": "a",
+                            "path": "3,HTML,1,BODY,44,A",
+                            "selector": "a[href=\"javascript\\:void\\(0\\)\"]",
                             "snippet": "<a href=\"javascript:void(0)\" target=\"_blank\"></a>",
                             "type": "node"
                         }
@@ -1216,8 +1227,9 @@
                     {
                         "node": {
                             "explanation": "Fix all of the following:\n  Element is in tab order and does not have accessible text\n\nFix any of the following:\n  Element does not have text that is visible to screen readers\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Element's default semantics were not overridden with role=\"presentation\"\n  Element's default semantics were not overridden with role=\"none\"",
-                            "path": "3,HTML,1,BODY,42,A",
-                            "selector": "a[href$=\"mailto:inbox@email.com\"]",
+                            "nodeLabel": "a",
+                            "path": "3,HTML,1,BODY,46,A",
+                            "selector": "a[href$=\"mailto\\:inbox\\@email\\.com\"]",
                             "snippet": "<a href=\"mailto:inbox@email.com\" target=\"_blank\"></a>",
                             "type": "node"
                         }
@@ -1952,7 +1964,8 @@
                 "items": [
                     {
                         "node": {
-                            "explanation": "Fix any of the following:\n  Element does not have text that is visible to screen readers\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Element has no title attribute or the title attribute is empty",
+                            "explanation": "Fix any of the following:\n  Element does not have text that is visible to screen readers\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Element has no title attribute or the title attribute is empty\n  Element's default semantics were not overridden with role=\"presentation\"\n  Element's default semantics were not overridden with role=\"none\"",
+                            "nodeLabel": "object",
                             "path": "3,HTML,1,BODY,2,OBJECT",
                             "selector": "#\\35 934a",
                             "snippet": "<object id=\"5934a\"></object>",
@@ -1961,7 +1974,8 @@
                     },
                     {
                         "node": {
-                            "explanation": "Fix any of the following:\n  Element does not have text that is visible to screen readers\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Element has no title attribute or the title attribute is empty",
+                            "explanation": "Fix any of the following:\n  Element does not have text that is visible to screen readers\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Element has no title attribute or the title attribute is empty\n  Element's default semantics were not overridden with role=\"presentation\"\n  Element's default semantics were not overridden with role=\"none\"",
+                            "nodeLabel": "object",
                             "path": "3,HTML,1,BODY,3,OBJECT",
                             "selector": "#\\35 934b",
                             "snippet": "<object id=\"5934b\"></object>",
@@ -2481,6 +2495,7 @@
                         "height": 18.0,
                         "overlapScoreRatio": 0.8333333333333334,
                         "overlappingTarget": {
+                            "nodeLabel": "Do something else",
                             "path": "3,HTML,1,BODY,18,BUTTON",
                             "selector": "body > button.small-button",
                             "snippet": "<button class=\"small-button\">Do something else</button>",
@@ -2489,6 +2504,7 @@
                         "overlappingTargetScore": 720.0,
                         "size": "200x18",
                         "tapTarget": {
+                            "nodeLabel": "Do something",
                             "path": "3,HTML,1,BODY,17,BUTTON",
                             "selector": "body > button.small-button",
                             "snippet": "<button class=\"small-button\">Do something</button>",
diff --git a/types/artifacts.d.ts b/types/artifacts.d.ts
index f28e7b1dc..860a0520f 100644
--- a/types/artifacts.d.ts
+++ b/types/artifacts.d.ts
@@ -145,6 +145,7 @@ declare global {
             snippet: string;
             target: string[];
             failureSummary?: string;
+            nodeLabel?: string;
           }[];
         }[];
         notApplicable: {
@@ -337,11 +338,12 @@ declare global {
       }
 
       export interface TapTarget {
-        snippet: string,
-        selector: string,
-        path: string,
-        href: string,
-        clientRects: Rect[]
+        snippet: string;
+        selector: string;
+        nodeLabel?: string;
+        path: string;
+        href: string;
+        clientRects: Rect[];
       }
 
       export interface ViewportDimensions {
diff --git a/types/audit-details.d.ts b/types/audit-details.d.ts
index ca670c89a..155d52ae7 100644
--- a/types/audit-details.d.ts
+++ b/types/audit-details.d.ts
@@ -160,7 +160,10 @@ declare global {
         type: 'node';
         path?: string;
         selector?: string;
+        /** An HTML snippet used to identify the node. */
         snippet?: string;
+        /** A human-friendly text descriptor that's used to identify the node more quickly. */
+        nodeLabel?: string;
       }
 
       /**
diff --git a/types/gatherer.d.ts b/types/gatherer.d.ts
index 78e6c711c..21237d270 100644
--- a/types/gatherer.d.ts
+++ b/types/gatherer.d.ts
@@ -12,13 +12,14 @@ import Driver = require('../lighthouse-core/gather/driver');
 declare global {
   module LH.Gatherer {
     export interface PassContext {
+      /** The url of the currently loaded page. If the main document redirects, this will be updated to keep track. */
       url: string;
       driver: Driver;
       disableJavaScript?: boolean;
       passConfig: Config.Pass
       settings: Config.Settings;
       options?: object;
-      /** Push to this array to add top-level warnings to the LHR. */
+      /** Gatherers can push to this array to add top-level warnings to the LHR. */
       LighthouseRunWarnings: Array<string>;
       baseArtifacts: BaseArtifacts;
     }
diff --git a/types/html-renderer.d.ts b/types/html-renderer.d.ts
index 512329309..c7254a22e 100644
--- a/types/html-renderer.d.ts
+++ b/types/html-renderer.d.ts
@@ -47,11 +47,9 @@ declare global {
 
   module LH {
     // During report generation, the LHR object is transformed a bit for convenience
-    // Primarily, the auditResult is added as .result onto the auditRef.
-    // Also: a reportCategories property is added. We're lazy sometimes. It'll be removed in due time.
+    // Primarily, the auditResult is added as .result onto the auditRef. We're lazy sometimes. It'll be removed in due time.
     export interface ReportResult extends Result {
       categories: Record<string, ReportResult.Category>;
-      reportCategories: Array<ReportResult.Category>;
     }
     export module ReportResult {
       export interface Category extends Result.Category {


Test patch:
diff --git a/lighthouse-cli/test/smokehouse/a11y/expectations.js b/lighthouse-cli/test/smokehouse/a11y/expectations.js
index bc3e29fa3..9f29e47c3 100644
--- a/lighthouse-cli/test/smokehouse/a11y/expectations.js
+++ b/lighthouse-cli/test/smokehouse/a11y/expectations.js
@@ -5,6 +5,8 @@
  */
 'use strict';
 
+/* eslint-disable max-len */
+
 /**
  * Expected Lighthouse audit values for byte efficiency tests
  */
@@ -17,225 +19,442 @@ module.exports = [
         'aria-allowed-attr': {
           score: 0,
           details: {
-            items: {
-              length: 1,
-            },
+            items: [
+              {
+                node: {
+                  'type': 'node',
+                  'selector': '#aria-allowed-attr',
+                  'snippet': '<div id="aria-allowed-attr" role="alert" aria-checked="true">\n      </div>',
+                  'explanation': 'Fix any of the following:\n  ARIA attribute is not allowed: aria-checked="true"',
+                  'nodeLabel': 'div',
+                },
+              },
+            ],
           },
         },
         'aria-required-children': {
           score: 0,
           details: {
-            items: {
-              length: 1,
-            },
+            items: [
+              {
+                node: {
+                  'type': 'node',
+                  'selector': '#aria-required-children',
+                  'snippet': '<div id="aria-required-children" role="radiogroup">\n        <div></div>\n      </div>',
+                  'explanation': 'Fix any of the following:\n  Required ARIA child role not present: radio',
+                  'nodeLabel': 'div',
+                },
+              },
+            ],
           },
         },
         'aria-required-parent': {
           score: 0,
           details: {
-            items: {
-              length: 1,
-            },
+            items: [
+              {
+                node: {
+                  'type': 'node',
+                  'selector': '#aria-required-parent',
+                  'snippet': '<div id="aria-required-parent" role="option">\n        </div>',
+                  'explanation': 'Fix any of the following:\n  Required ARIA parent role not present: listbox',
+                  'nodeLabel': 'div',
+                },
+              },
+            ],
           },
         },
         'aria-roles': {
           score: 0,
           details: {
-            items: {
-              length: 1,
-            },
+            items: [
+              {
+                node: {
+                  'type': 'node',
+                  'selector': 'div[role="foo"]',
+                  'snippet': '<div role="foo"></div>',
+                  'explanation': 'Fix all of the following:\n  Role must be one of the valid ARIA roles',
+                  'nodeLabel': 'div',
+                },
+              },
+            ],
           },
         },
         'aria-valid-attr-value': {
           score: 0,
           details: {
-            items: {
-              length: 1,
-            },
+            items: [
+              {
+                node: {
+                  'type': 'node',
+                  'selector': '#aria-valid-attr-value',
+                  'snippet': '<div id="aria-valid-attr-value" role="checkbox" aria-checked="0">\n      </div>',
+                  'explanation': 'Fix all of the following:\n  Invalid ARIA attribute value: aria-checked="0"',
+                  'nodeLabel': 'div',
+                },
+              },
+            ],
           },
         },
         'aria-valid-attr': {
           score: 0,
           details: {
-            items: {
-              length: 1,
-            },
+            items: [
+              {
+                node: {
+                  'type': 'node',
+                  'selector': '#aria-valid-attr',
+                  'snippet': '<div id="aria-valid-attr" role="checkbox" aria-chked="true">\n      </div>',
+                  'explanation': 'Fix any of the following:\n  Invalid ARIA attribute name: aria-chked',
+                  'nodeLabel': 'div',
+                },
+              },
+            ],
           },
         },
         'button-name': {
           score: 0,
           details: {
-            items: {
-              length: 1,
-            },
+            items: [
+              {
+                node: {
+                  'type': 'node',
+                  'selector': '#button-name',
+                  'snippet': '<button id="button-name"></button>',
+                  'explanation': 'Fix all of the following:\n  Element is in tab order and does not have accessible text\n\nFix any of the following:\n  Element has a value attribute and the value attribute is empty\n  Element has no value attribute or the value attribute is empty\n  Element does not have inner text that is visible to screen readers\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Element\'s default semantics were not overridden with role="presentation"\n  Element\'s default semantics were not overridden with role="none"\n  Element has no title attribute or the title attribute is empty',
+                  'nodeLabel': 'button',
+                },
+              },
+            ],
           },
         },
         'bypass': {
           score: 0,
           details: {
-            items: {
-              length: 1,
-            },
+            items: [
+              {
+                node: {
+                  'type': 'node',
+                  'selector': 'html',
+                  'snippet': '<html>',
+                  'explanation': 'Fix any of the following:\n  No valid skip link found\n  Page does not have a header\n  Page does not have a landmark region',
+                  'nodeLabel': 'html',
+                },
+              },
+            ],
           },
         },
         'color-contrast': {
           score: 0,
           details: {
-            items: {
-              length: 1,
-            },
+            items: [
+              {
+                node: {
+                  'type': 'node',
+                  'selector': '#color-contrast',
+                  'snippet': '<div id="color-contrast" style="background-color: red; color: pink;">\n          Hello\n      </div>',
+                  'explanation': 'Fix any of the following:\n  Element has insufficient color contrast of 2.59 (foreground color: #ffc0cb, background color: #ff0000, font size: 28.5pt, font weight: normal). Expected contrast ratio of 3:1',
+                  'nodeLabel': 'Hello',
+                },
+              },
+            ],
           },
         },
         'definition-list': {
           score: 0,
           details: {
-            items: {
-              length: 1,
-            },
+            items: [
+              {
+                node: {
+                  'type': 'node',
+                  'selector': '#definition-list',
+                  'snippet': '<dl id="definition-list">\n        <p></p>\n      </dl>',
+                  'explanation': 'Fix all of the following:\n  List element has direct children that are not allowed inside <dt> or <dd> elements',
+                  'nodeLabel': 'dl',
+                },
+              },
+            ],
           },
         },
         'dlitem': {
           score: 0,
           details: {
-            items: {
-              length: 1,
-            },
+            items: [
+              {
+                node: {
+                  'type': 'node',
+                  'selector': 'dd',
+                  'snippet': '<dd></dd>',
+                  'explanation': 'Fix any of the following:\n  Description list item does not have a <dl> parent element',
+                  'nodeLabel': 'dd',
+                },
+              },
+            ],
           },
         },
         'document-title': {
           score: 0,
           details: {
-            items: {
-              length: 1,
-            },
+            items: [
+              {
+                node: {
+                  'type': 'node',
+                  'selector': 'html',
+                  'snippet': '<html>',
+                  'explanation': 'Fix any of the following:\n  Document does not have a non-empty <title> element',
+                  'nodeLabel': 'html',
+                },
+              },
+            ],
+
           },
         },
         'duplicate-id': {
           score: 0,
           details: {
-            items: {
-              length: 1,
-            },
+            items: [
+              {
+                node: {
+                  'type': 'node',
+                  'selector': 'section:nth-child(28) > div:nth-child(1)',
+                  'snippet': '<div id="duplicate-id"></div>',
+                  'explanation': 'Fix any of the following:\n  Document has multiple static elements with the same id attribute',
+                  'nodeLabel': 'div',
+                },
+              },
+            ],
           },
         },
         'frame-title': {
           score: 0,
           details: {
-            items: {
-              length: 1,
-            },
+            items: [
+              {
+                node: {
+                  'type': 'node',
+                  'selector': '#frame-title',
+                  'snippet': '<iframe id="frame-title"></iframe>',
+                  'explanation': 'Fix any of the following:\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Element has no title attribute or the title attribute is empty\n  Element\'s default semantics were not overridden with role="presentation"\n  Element\'s default semantics were not overridden with role="none"',
+                  'nodeLabel': 'iframe',
+                },
+              },
+            ],
           },
         },
         'html-has-lang': {
           score: 0,
           details: {
-            items: {
-              length: 1,
-            },
+            items: [
+              {
+                node: {
+                  'type': 'node',
+                  'selector': 'html',
+                  'snippet': '<html>',
+                  'explanation': 'Fix any of the following:\n  The <html> element does not have a lang attribute',
+                  'nodeLabel': 'html',
+                },
+              },
+            ],
           },
         },
         'image-alt': {
           score: 0,
           details: {
-            items: {
-              length: 1,
-            },
+            items: [
+              {
+                node: {
+                  'type': 'node',
+                  'selector': '#image-alt',
+                  'snippet': '<img id="image-alt" src="./bogus.jpg">',
+                  'explanation': 'Fix any of the following:\n  Element does not have an alt attribute\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Element has no title attribute or the title attribute is empty\n  Element\'s default semantics were not overridden with role="presentation"\n  Element\'s default semantics were not overridden with role="none"',
+                  'nodeLabel': 'img',
+                },
+              },
+            ],
           },
         },
         'input-image-alt': {
           score: 0,
           details: {
-            items: {
-              length: 1,
-            },
+            items: [
+              {
+                node: {
+                  'type': 'node',
+                  'selector': '#input-image-alt',
+                  'snippet': '<input type="image" id="input-image-alt">',
+                  'explanation': 'Fix any of the following:\n  Element has no alt attribute or the alt attribute is empty\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Element has no title attribute or the title attribute is empty',
+                  'nodeLabel': 'input',
+                },
+              },
+            ],
           },
         },
         'label': {
           score: 0,
           details: {
-            items: {
-              length: 1,
-            },
+            items: [
+              {
+                node: {
+                  'type': 'node',
+                  'selector': '#label',
+                  'snippet': '<input id="label" type="text">',
+                  'explanation': 'Fix any of the following:\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Form element does not have an implicit (wrapped) <label>\n  Form element does not have an explicit <label>\n  Element has no title attribute or the title attribute is empty',
+                  'nodeLabel': 'input',
+                },
+              },
+            ],
           },
         },
         'layout-table': {
           score: 1,
           details: {
-            items: {
-              length: 0,
-            },
+            'type': 'table',
+            'headings': [],
+            'items': [],
           },
         },
         'link-name': {
           score: 0,
           details: {
-            items: {
-              length: 1,
-            },
+            items: [
+              {
+                node: {
+                  'type': 'node',
+                  'selector': '#link-name',
+                  'snippet': '<a id="link-name" href="google.com"></a>',
+                  'explanation': 'Fix all of the following:\n  Element is in tab order and does not have accessible text\n\nFix any of the following:\n  Element does not have text that is visible to screen readers\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Element\'s default semantics were not overridden with role="presentation"\n  Element\'s default semantics were not overridden with role="none"',
+                  'nodeLabel': 'a',
+                },
+              },
+            ],
           },
         },
         'list': {
           score: 0,
           details: {
-            items: {
-              length: 1,
-            },
+            items: [
+              {
+                node: {
+                  'type': 'node',
+                  'selector': '#list',
+                  'snippet': '<ul id="list">\n        <p></p>\n      </ul>',
+                  'explanation': 'Fix all of the following:\n  List element has direct children that are not allowed inside <li> elements',
+                  'nodeLabel': 'ul',
+                },
+              },
+            ],
           },
         },
         'listitem': {
           score: 0,
           details: {
-            items: {
-              length: 1,
-            },
+            items: [
+              {
+                node: {
+                  'type': 'node',
+                  'selector': '#listitem',
+                  'snippet': '<li id="listitem"></li>',
+                  'explanation': 'Fix any of the following:\n  List item does not have a <ul>, <ol> or role="list" parent element',
+                  'nodeLabel': 'li',
+                },
+              },
+            ],
           },
         },
         'meta-viewport': {
           score: 0,
           details: {
-            items: {
-              length: 1,
-            },
+            items: [
+              {
+                node: {
+                  'type': 'node',
+                  'selector': 'meta[name="viewport"]',
+                  'snippet': '<meta name="viewport" content="user-scalable=no, maximum-scale=1.0">',
+                  'explanation': 'Fix any of the following:\n  user-scalable=no on <meta> tag disables zooming on mobile devices',
+                  'nodeLabel': 'meta',
+                },
+              },
+            ],
           },
         },
         'object-alt': {
           score: 0,
           details: {
-            items: {
-              length: 1,
-            },
+            items: [
+              {
+                node: {
+                  'type': 'node',
+                  'selector': '#object-alt',
+                  'snippet': '<object id="object-alt"></object>',
+                  'explanation': 'Fix any of the following:\n  Element does not have text that is visible to screen readers\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Element has no title attribute or the title attribute is empty\n  Element\'s default semantics were not overridden with role="presentation"\n  Element\'s default semantics were not overridden with role="none"',
+                  'nodeLabel': 'object',
+                },
+              },
+            ],
           },
         },
         'tabindex': {
           score: 0,
           details: {
-            items: {
-              length: 1,
-            },
+            items: [
+              {
+                node: {
+                  'type': 'node',
+                  'selector': '#tabindex',
+                  'snippet': '<div id="tabindex" tabindex="10">\n      </div>',
+                  'explanation': 'Fix any of the following:\n  Element has a tabindex greater than 0',
+                  'nodeLabel': 'div',
+                },
+              },
+            ],
           },
         },
         'td-headers-attr': {
           score: 0,
           details: {
-            items: {
-              length: 1,
-            },
+            items: [
+              {
+                node: {
+                  'type': 'node',
+                  'selector': '#td-headers-attr',
+                  'snippet': '<table id="td-headers-attr">\n\t\t\t  <tbody><tr><th>FOO</th></tr>\n\t\t\t  <tr><td headers="bogus-td-headers-attr">foo</td></tr>\n\t\t\t</tbody></table>',
+                  'explanation': 'Fix all of the following:\n  The headers attribute is not exclusively used to refer to other cells in the table',
+                  'nodeLabel': 'FOO\nfoo',
+                },
+              },
+            ],
           },
         },
         'valid-lang': {
           score: 0,
           details: {
-            items: {
-              length: 1,
-            },
+            items: [
+              {
+                node: {
+                  'type': 'node',
+                  'selector': '#valid-lang',
+                  'snippet': '<p id="valid-lang" lang="foo">foo</p>',
+                  'explanation': 'Fix all of the following:\n  Value of lang attribute not included in the list of valid languages',
+                  'nodeLabel': 'foo',
+                },
+              },
+            ],
           },
         },
         'accesskeys': {
           score: 0,
           details: {
-            items: {
-              length: 1,
-            },
+            items: [
+              {
+                node: {
+                  'type': 'node',
+                  'selector': '#accesskeys1',
+                  'snippet': '<button id="accesskeys1" accesskey="s">Foo</button>',
+                  'explanation': 'Fix all of the following:\n  Document has multiple elements with the same accesskey',
+                  'nodeLabel': 'Foo',
+                },
+              },
+            ],
           },
         },
       },
diff --git a/lighthouse-cli/test/smokehouse/seo/expectations.js b/lighthouse-cli/test/smokehouse/seo/expectations.js
index 4057997f1..8b8e5f71a 100644
--- a/lighthouse-cli/test/smokehouse/seo/expectations.js
+++ b/lighthouse-cli/test/smokehouse/seo/expectations.js
@@ -205,6 +205,7 @@ module.exports = [
                  '\n        too small target\n      </a>',
                   'path': '2,HTML,1,BODY,3,DIV,21,DIV,0,A',
                   'selector': 'body > div > div > a',
+                  'nodeLabel': 'too small target',
                 },
                 'overlappingTarget': {
                   'type': 'node',
@@ -213,6 +214,7 @@ module.exports = [
                   '\n        big enough target\n      </a>',
                   'path': '2,HTML,1,BODY,3,DIV,21,DIV,1,A',
                   'selector': 'body > div > div > a',
+                  'nodeLabel': 'big enough target',
                 },
                 'size': '100x30',
                 'width': 100,
diff --git a/lighthouse-core/test/gather/gather-runner-test.js b/lighthouse-core/test/gather/gather-runner-test.js
index 36ab4f962..90b09daa4 100644
--- a/lighthouse-core/test/gather/gather-runner-test.js
+++ b/lighthouse-core/test/gather/gather-runner-test.js
@@ -102,7 +102,9 @@ describe('GatherRunner', function() {
     const passContext = {
       requestedUrl: url1,
       settings: {},
-      passConfig: {},
+      passConfig: {
+        gatherers: [],
+      },
     };
 
     return GatherRunner.loadPage(driver, passContext).then(_ => {
@@ -352,7 +354,7 @@ describe('GatherRunner', function() {
     });
   });
 
-  it('clears the disk & memory cache on a perf run', () => {
+  it('clears the disk & memory cache on a perf run', async () => {
     const asyncFunc = () => Promise.resolve();
     const tests = {
       calledCleanBrowserCaches: false,
@@ -366,8 +368,15 @@ describe('GatherRunner', function() {
       beginTrace: asyncFunc,
       gotoURL: asyncFunc,
       cleanBrowserCaches: createCheck('calledCleanBrowserCaches'),
+      setThrottling: asyncFunc,
+      blockUrlPatterns: asyncFunc,
+      setExtraHTTPHeaders: asyncFunc,
+      endTrace: asyncFunc,
+      endDevtoolsLog: () => [],
+      getBrowserVersion: async () => ({userAgent: ''}),
     };
     const passConfig = {
+      passName: 'default',
       recordTrace: true,
       useThrottling: true,
       gatherers: [],
@@ -375,9 +384,16 @@ describe('GatherRunner', function() {
     const settings = {
       disableStorageReset: false,
     };
-    return GatherRunner.pass({driver, passConfig, settings}, {TestGatherer: []}).then(_ => {
-      assert.equal(tests.calledCleanBrowserCaches, true);
-    });
+    const requestedUrl = 'https://example.com';
+    const passContext = {
+      driver,
+      passConfig,
+      settings,
+      baseArtifacts: await GatherRunner.initializeBaseArtifacts({driver, settings, requestedUrl}),
+    };
+
+    await GatherRunner.runPass(passContext, {TestGatherer: []});
+    assert.equal(tests.calledCleanBrowserCaches, true);
   });
 
   it('does not clear origin storage with flag --disable-storage-reset', () => {
@@ -420,7 +436,7 @@ describe('GatherRunner', function() {
       receivedUrlPatterns = params.urls;
     });
 
-    return GatherRunner.beforePass({
+    return GatherRunner.setupPassNetwork({
       driver,
       settings: {
         blockedUrlPatterns: ['http://*.evil.com', '.jpg', '.woff2'],
@@ -441,7 +457,7 @@ describe('GatherRunner', function() {
       receivedUrlPatterns = params.urls;
     });
 
-    return GatherRunner.beforePass({
+    return GatherRunner.setupPassNetwork({
       driver,
       settings: {},
       passConfig: {gatherers: []},
@@ -459,7 +475,7 @@ describe('GatherRunner', function() {
       'x-men': 'wolverine',
     };
 
-    return GatherRunner.beforePass({
+    return GatherRunner.setupPassNetwork({
       driver,
       settings: {
         extraHeaders: headers,
@@ -471,7 +487,7 @@ describe('GatherRunner', function() {
       ));
   });
 
-  it('tells the driver to begin tracing', () => {
+  it('tells the driver to begin tracing', async () => {
     let calledTrace = false;
     const driver = {
       beginTrace() {
@@ -494,9 +510,8 @@ describe('GatherRunner', function() {
     };
     const settings = {};
 
-    return GatherRunner.pass({driver, passConfig, settings}, {TestGatherer: []}).then(_ => {
-      assert.equal(calledTrace, true);
-    });
+    await GatherRunner.beginRecording({driver, passConfig, settings}, {TestGatherer: []});
+    assert.equal(calledTrace, true);
   });
 
   it('tells the driver to end tracing', () => {
@@ -518,13 +533,13 @@ describe('GatherRunner', function() {
       ],
     };
 
-    return GatherRunner.afterPass({url, driver, passConfig}, {TestGatherer: []}).then(passData => {
+    return GatherRunner.endRecording({url, driver, passConfig}).then(passData => {
       assert.equal(calledTrace, true);
       assert.equal(passData.trace, fakeTraceData);
     });
   });
 
-  it('tells the driver to begin devtoolsLog collection', () => {
+  it('tells the driver to begin devtoolsLog collection', async () => {
     let calledDevtoolsLogCollect = false;
     const driver = {
       beginDevtoolsLog() {
@@ -543,9 +558,8 @@ describe('GatherRunner', function() {
     };
     const settings = {};
 
-    return GatherRunner.pass({driver, passConfig, settings}, {TestGatherer: []}).then(_ => {
-      assert.equal(calledDevtoolsLogCollect, true);
-    });
+    await GatherRunner.beginRecording({driver, passConfig, settings}, {TestGatherer: []});
+    assert.equal(calledDevtoolsLogCollect, true);
   });
 
   it('tells the driver to end devtoolsLog collection', () => {
@@ -568,7 +582,7 @@ describe('GatherRunner', function() {
       ],
     };
 
-    return GatherRunner.afterPass({url, driver, passConfig}, {TestGatherer: []}).then(passData => {
+    return GatherRunner.endRecording({url, driver, passConfig}).then(passData => {
       assert.equal(calledDevtoolsLogCollect, true);
       assert.strictEqual(passData.devtoolsLog[0], fakeDevtoolsMessage);
     });
@@ -643,19 +657,19 @@ describe('GatherRunner', function() {
       });
   });
 
-  describe('#getPageLoadError', () => {
+  describe('#getNetworkError', () => {
     it('passes when the page is loaded', () => {
       const url = 'http://the-page.com';
       const mainRecord = new NetworkRequest();
       mainRecord.url = url;
-      assert.ok(!GatherRunner.getPageLoadError(url, [mainRecord]));
+      assert.ok(!GatherRunner.getNetworkError(url, [mainRecord]));
     });
 
     it('passes when the page is loaded, ignoring any fragment', () => {
       const url = 'http://example.com/#/page/list';
       const mainRecord = new NetworkRequest();
       mainRecord.url = 'http://example.com';
-      assert.ok(!GatherRunner.getPageLoadError(url, [mainRecord]));
+      assert.ok(!GatherRunner.getNetworkError(url, [mainRecord]));
     });
 
     it('fails when page fails to load', () => {
@@ -664,7 +678,7 @@ describe('GatherRunner', function() {
       mainRecord.url = url;
       mainRecord.failed = true;
       mainRecord.localizedFailDescription = 'foobar';
-      const error = GatherRunner.getPageLoadError(url, [mainRecord]);
+      const error = GatherRunner.getNetworkError(url, [mainRecord]);
       assert.equal(error.message, 'FAILED_DOCUMENT_REQUEST');
       assert.equal(error.code, 'FAILED_DOCUMENT_REQUEST');
       expect(error.friendlyMessage)
@@ -674,7 +688,7 @@ describe('GatherRunner', function() {
     it('fails when page times out', () => {
       const url = 'http://the-page.com';
       const records = [];
-      const error = GatherRunner.getPageLoadError(url, records);
+      const error = GatherRunner.getNetworkError(url, records);
       assert.equal(error.message, 'NO_DOCUMENT_REQUEST');
       assert.equal(error.code, 'NO_DOCUMENT_REQUEST');
       expect(error.friendlyMessage).toBeDisplayString(/^Lighthouse was unable to reliably load/);
@@ -685,7 +699,7 @@ describe('GatherRunner', function() {
       const mainRecord = new NetworkRequest();
       mainRecord.url = url;
       mainRecord.statusCode = 404;
-      const error = GatherRunner.getPageLoadError(url, [mainRecord]);
+      const error = GatherRunner.getNetworkError(url, [mainRecord]);
       assert.equal(error.message, 'ERRORED_DOCUMENT_REQUEST');
       assert.equal(error.code, 'ERRORED_DOCUMENT_REQUEST');
       expect(error.friendlyMessage)
@@ -697,7 +711,7 @@ describe('GatherRunner', function() {
       const mainRecord = new NetworkRequest();
       mainRecord.url = url;
       mainRecord.statusCode = 500;
-      const error = GatherRunner.getPageLoadError(url, [mainRecord]);
+      const error = GatherRunner.getNetworkError(url, [mainRecord]);
       assert.equal(error.message, 'ERRORED_DOCUMENT_REQUEST');
       assert.equal(error.code, 'ERRORED_DOCUMENT_REQUEST');
       expect(error.friendlyMessage)
@@ -710,7 +724,7 @@ describe('GatherRunner', function() {
       mainRecord.url = url;
       mainRecord.failed = true;
       mainRecord.localizedFailDescription = 'net::ERR_NAME_NOT_RESOLVED';
-      const error = GatherRunner.getPageLoadError(url, [mainRecord]);
+      const error = GatherRunner.getNetworkError(url, [mainRecord]);
       assert.equal(error.message, 'DNS_FAILURE');
       assert.equal(error.code, 'DNS_FAILURE');
       expect(error.friendlyMessage).toBeDisplayString(/^DNS servers could not resolve/);
@@ -929,7 +943,7 @@ describe('GatherRunner', function() {
         ],
       };
 
-      return GatherRunner.collectArtifacts(gathererResults, {}).then(artifacts => {
+      return GatherRunner.collectArtifacts(gathererResults, {}).then(({artifacts}) => {
         assert.strictEqual(artifacts.AfterGatherer, 97);
         assert.strictEqual(artifacts.PassGatherer, 284);
         assert.strictEqual(artifacts.SingleErrorGatherer, recoverableError);
@@ -937,20 +951,32 @@ describe('GatherRunner', function() {
       });
     });
 
-    it('produces a LighthouseRunWarnings artifact from array of warnings', () => {
-      const LighthouseRunWarnings = [
+    it('produces a deduped LighthouseRunWarnings artifact from array of warnings', async () => {
+      const runWarnings = [
         'warning0',
         'warning1',
         'warning2',
       ];
 
-      const baseArtifacts = {
-        LighthouseRunWarnings,
-      };
+      class WarningGatherer extends Gatherer {
+        afterPass(passContext) {
+          passContext.LighthouseRunWarnings.push(...runWarnings, ...runWarnings);
+          assert.strictEqual(passContext.LighthouseRunWarnings.length, runWarnings.length * 2);
+
+          return '';
+        }
+      }
 
-      return GatherRunner.collectArtifacts({}, baseArtifacts).then(artifacts => {
-        assert.deepStrictEqual(artifacts.LighthouseRunWarnings, LighthouseRunWarnings);
+      const passes = [{
+        gatherers: [{instance: new WarningGatherer()}],
+      }];
+      const artifacts = await GatherRunner.run(passes, {
+        driver: fakeDriver,
+        requestedUrl: 'https://example.com',
+        settings: {},
+        config: new Config({}),
       });
+      assert.deepStrictEqual(artifacts.LighthouseRunWarnings, runWarnings);
     });
 
     it('supports sync and async throwing of errors from gatherers', () => {
diff --git a/lighthouse-core/test/lib/minify-devtoolslog-test.js b/lighthouse-core/test/lib/minify-devtoolslog-test.js
new file mode 100644
index 000000000..dbd2f3a6a
--- /dev/null
+++ b/lighthouse-core/test/lib/minify-devtoolslog-test.js
@@ -0,0 +1,33 @@
+/**
+ * @license Copyright 2019 Google Inc. All Rights Reserved.
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
+ */
+'use strict';
+
+const {minifyDevtoolsLog} = require('../../lib/minify-devtoolslog.js');
+const trace = require('../fixtures/traces/progressive-app-m60.json');
+const devtoolsLog = require('../fixtures/traces/progressive-app-m60.devtools.log.json');
+const MetricsAudit = require('../../audits/metrics.js');
+
+/* eslint-env jest */
+
+describe('minify-devtoolslog', () => {
+  it('has identical metrics to unminified', async () => {
+    const artifacts = {traces: {defaultPass: trace}, devtoolsLogs: {defaultPass: devtoolsLog}};
+    const context = {settings: {throttlingMethod: 'simulate'}, computedCache: new Map()};
+    const {details: {items: [before]}} = await MetricsAudit.audit(artifacts, context);
+    const beforeSize = JSON.stringify(devtoolsLog).length;
+
+    const minifiedDevtoolsLog = minifyDevtoolsLog(devtoolsLog);
+    artifacts.devtoolsLogs.defaultPass = minifiedDevtoolsLog;
+    context.computedCache.clear(); // not strictly necessary, but we'll be safe
+    const {details: {items: [after]}} = await MetricsAudit.audit(artifacts, context);
+    const afterSize = JSON.stringify(minifiedDevtoolsLog).length;
+
+    // It should reduce the size of the log.
+    expect(afterSize).toBeLessThan(beforeSize * 0.5);
+    // And not affect the metrics.
+    expect(after).toEqual(before);
+  });
+});
diff --git a/lighthouse-core/test/lib/minify-trace-test.js b/lighthouse-core/test/lib/minify-trace-test.js
new file mode 100644
index 000000000..6bc1f1abf
--- /dev/null
+++ b/lighthouse-core/test/lib/minify-trace-test.js
@@ -0,0 +1,43 @@
+/**
+ * @license Copyright 2019 Google Inc. All Rights Reserved.
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
+ */
+'use strict';
+
+const minifyTrace = require('../../lib/minify-trace.js').minifyTrace;
+const trace = require('../fixtures/traces/progressive-app-m60.json');
+const devtoolsLog = require('../fixtures/traces/progressive-app-m60.devtools.log.json');
+const MetricsAudit = require('../../audits/metrics.js');
+
+/* eslint-env jest */
+
+describe('minify-trace', () => {
+  it('has identical metrics to unminified', async () => {
+    const artifacts = {traces: {defaultPass: trace}, devtoolsLogs: {defaultPass: devtoolsLog}};
+    const context = {settings: {throttlingMethod: 'simulate'}, computedCache: new Map()};
+    const {details: {items: [before]}} = await MetricsAudit.audit(artifacts, context);
+    const beforeSize = JSON.stringify(trace).length;
+
+    const minifiedTrace = minifyTrace(trace);
+    artifacts.traces.defaultPass = minifiedTrace;
+    context.computedCache.clear();
+    const {details: {items: [after]}} = await MetricsAudit.audit(artifacts, context);
+    const afterSize = JSON.stringify(minifiedTrace).length;
+
+    for (const key of Object.keys(after)) {
+      // Speed Index is expected to be different because of screenshot throttling.
+      // Trace End can also be different if the last event was unimportant.
+      // There are many different `observed*` and `*Ts` versions, so remove with a regex.
+      if (/speedIndex|traceEnd/i.test(key)) {
+        delete before[key];
+        delete after[key];
+      }
+    }
+
+    // It should greatly reduce the size of the trace.
+    expect(afterSize).toBeLessThan(beforeSize * 0.2);
+    // And not affect the metrics.
+    expect(after).toEqual(before);
+  });
+});
diff --git a/lighthouse-core/test/lib/page-functions-test.js b/lighthouse-core/test/lib/page-functions-test.js
index d04114a0c..8906eda99 100644
--- a/lighthouse-core/test/lib/page-functions-test.js
+++ b/lighthouse-core/test/lib/page-functions-test.js
@@ -59,4 +59,37 @@ describe('Page Functions', () => {
       assert.equal(pageFunctions.getNodeSelector(childEl), 'div#wrapper > div.child');
     });
   });
+
+  describe('getNodeLabel', () => {
+    it('Returns innerText if element has visible text', () => {
+      const el = dom.createElement('div');
+      el.innerText = 'Hello';
+      assert.equal(pageFunctions.getNodeLabel(el), 'Hello');
+    });
+
+    it('Falls back to children and alt/aria-label if a title can\'t be determined', () => {
+      const el = dom.createElement('div');
+      const childEl = dom.createElement('div', '', {'aria-label': 'Something'});
+      el.appendChild(childEl);
+      assert.equal(pageFunctions.getNodeLabel(el), 'Something');
+    });
+
+    it('Truncates long text', () => {
+      const el = dom.createElement('div');
+      el.setAttribute('alt', Array(100).fill('a').join(''));
+      assert.equal(pageFunctions.getNodeLabel(el).length, 80);
+    });
+
+    it('Uses tag name for html tags', () => {
+      const el = dom.createElement('html');
+      assert.equal(pageFunctions.getNodeLabel(el), 'html');
+    });
+
+    it('Uses tag name if there is no better label', () => {
+      const el = dom.createElement('div');
+      const childEl = dom.createElement('span');
+      el.appendChild(childEl);
+      assert.equal(pageFunctions.getNodeLabel(el), 'div');
+    });
+  });
 });
diff --git a/lighthouse-core/test/report/html/renderer/category-renderer-test.js b/lighthouse-core/test/report/html/renderer/category-renderer-test.js
index b18f27521..bc2ac0b45 100644
--- a/lighthouse-core/test/report/html/renderer/category-renderer-test.js
+++ b/lighthouse-core/test/report/html/renderer/category-renderer-test.js
@@ -46,8 +46,7 @@ describe('CategoryRenderer', () => {
   });
 
   it('renders an audit', () => {
-    const auditRef = sampleResults.reportCategories
-      .find(c => c.id === 'pwa').auditRefs
+    const auditRef = sampleResults.categories.pwa.auditRefs
       .find(a => a.id === 'works-offline');
 
     const auditDOM = renderer.renderAudit(auditRef);
@@ -157,7 +156,7 @@ describe('CategoryRenderer', () => {
   });
 
   it('renders a category', () => {
-    const category = sampleResults.reportCategories.find(c => c.id === 'pwa');
+    const category = sampleResults.categories.pwa;
     const categoryDOM = renderer.render(category, sampleResults.categoryGroups);
 
     const categoryEl = categoryDOM.querySelector('.lh-category-header');
@@ -179,7 +178,7 @@ describe('CategoryRenderer', () => {
 
   it('plugin category has plugin badge', () => {
     const category = JSON.parse(
-      JSON.stringify(sampleResults.reportCategories.find(c => c.id === 'seo')));
+      JSON.stringify(sampleResults.categories.seo));
     category.id = 'lighthouse-plugin-someplugin';
     category.title = 'Some Plugin';
     const categoryDOM = renderer.render(category, sampleResults.categoryGroups);
@@ -189,7 +188,7 @@ describe('CategoryRenderer', () => {
   });
 
   it('handles markdown in category descriptions a category', () => {
-    const category = sampleResults.reportCategories.find(c => c.id === 'pwa');
+    const category = sampleResults.categories.pwa;
     const prevDesc = category.description;
     category.description += ' [link text](http://example.com).';
     const categoryDOM = renderer.render(category, sampleResults.categoryGroups);
@@ -199,7 +198,7 @@ describe('CategoryRenderer', () => {
   });
 
   it('renders manual audits if the category contains them', () => {
-    const pwaCategory = sampleResults.reportCategories.find(cat => cat.id === 'pwa');
+    const pwaCategory = sampleResults.categories.pwa;
     const categoryDOM = renderer.render(pwaCategory, sampleResults.categoryGroups);
     assert.ok(categoryDOM.querySelector('.lh-clump--manual .lh-audit-group__summary'));
     assert.equal(categoryDOM.querySelectorAll('.lh-audit--manual').length, 3,
@@ -214,7 +213,7 @@ describe('CategoryRenderer', () => {
   });
 
   it('renders not applicable audits if the category contains them', () => {
-    const a11yCategory = sampleResults.reportCategories.find(cat => cat.id === 'accessibility');
+    const a11yCategory = sampleResults.categories.accessibility;
     const categoryDOM = renderer.render(a11yCategory, sampleResults.categoryGroups);
     assert.ok(categoryDOM.querySelector(
         '.lh-clump--notapplicable .lh-audit-group__summary'));
@@ -227,7 +226,7 @@ describe('CategoryRenderer', () => {
       'score shows informative and dash icon'
     );
 
-    const bestPracticeCat = sampleResults.reportCategories.find(cat => cat.id === 'best-practices');
+    const bestPracticeCat = sampleResults.categories['best-practices'];
     const categoryDOM2 = renderer.render(bestPracticeCat, sampleResults.categoryGroups);
     assert.ok(!categoryDOM2.querySelector('.lh-clump--notapplicable'));
   });
@@ -236,7 +235,7 @@ describe('CategoryRenderer', () => {
     let category;
 
     beforeEach(() => {
-      category = sampleResults.reportCategories.find(cat => cat.id === 'accessibility');
+      category = sampleResults.categories.accessibility;
     });
 
     it('renders the category header', () => {
@@ -352,7 +351,7 @@ describe('CategoryRenderer', () => {
 
   describe('clumping passed/failed/warning/manual', () => {
     it('separates audits in the DOM', () => {
-      const category = sampleResults.reportCategories.find(c => c.id === 'pwa');
+      const category = sampleResults.categories.pwa;
       const categoryClone = JSON.parse(JSON.stringify(category));
       // Give the first two passing grades warnings
       const passingRefs = categoryClone.auditRefs.filter(ref => ref.result.score === 1);
@@ -372,7 +371,7 @@ describe('CategoryRenderer', () => {
     });
 
     it('doesnt create a passed section if there were 0 passed', () => {
-      const origCategory = sampleResults.reportCategories.find(c => c.id === 'pwa');
+      const origCategory = sampleResults.categories.pwa;
       const category = JSON.parse(JSON.stringify(origCategory));
       category.auditRefs.forEach(audit => audit.result.score = 0);
       const elem = renderer.render(category, sampleResults.categoryGroups);
@@ -384,7 +383,7 @@ describe('CategoryRenderer', () => {
     });
 
     it('expands warning audit group', () => {
-      const category = sampleResults.reportCategories.find(c => c.id === 'pwa');
+      const category = sampleResults.categories.pwa;
       const categoryClone = JSON.parse(JSON.stringify(category));
       categoryClone.auditRefs[0].result.warnings = ['Some warning'];
 
diff --git a/lighthouse-core/test/report/html/renderer/performance-category-renderer-test.js b/lighthouse-core/test/report/html/renderer/performance-category-renderer-test.js
index 42c115c40..3ed9636e6 100644
--- a/lighthouse-core/test/report/html/renderer/performance-category-renderer-test.js
+++ b/lighthouse-core/test/report/html/renderer/performance-category-renderer-test.js
@@ -43,7 +43,7 @@ describe('PerfCategoryRenderer', () => {
 
     // TODO: don't call a LH.ReportResult `sampleResults`, which is typically always LH.Result
     sampleResults = Util.prepareReportResult(sampleResultsOrig);
-    category = sampleResults.reportCategories.find(cat => cat.id === 'performance');
+    category = sampleResults.categories.performance;
   });
 
   afterAll(() => {
diff --git a/lighthouse-core/test/report/html/renderer/pwa-category-renderer-test.js b/lighthouse-core/test/report/html/renderer/pwa-category-renderer-test.js
index 5698032e9..8bbe83b1d 100644
--- a/lighthouse-core/test/report/html/renderer/pwa-category-renderer-test.js
+++ b/lighthouse-core/test/report/html/renderer/pwa-category-renderer-test.js
@@ -43,7 +43,7 @@ describe('PwaCategoryRenderer', () => {
 
   beforeEach(() => {
     // Clone category to allow modifications.
-    const pwaCategory = sampleResults.reportCategories.find(cat => cat.id === 'pwa');
+    const pwaCategory = sampleResults.categories.pwa;
     category = JSON.parse(JSON.stringify(pwaCategory));
   });
 
diff --git a/lighthouse-core/test/report/html/renderer/report-renderer-test.js b/lighthouse-core/test/report/html/renderer/report-renderer-test.js
index 9e4836459..21400f531 100644
--- a/lighthouse-core/test/report/html/renderer/report-renderer-test.js
+++ b/lighthouse-core/test/report/html/renderer/report-renderer-test.js
@@ -81,7 +81,7 @@ describe('ReportRenderer', () => {
       assert.ok(output.querySelector('.lh-report'), 'has report body');
       // 3 sets of gauges - one in sticky header, one in scores header, and one in each section.
       assert.equal(output.querySelectorAll('.lh-gauge__wrapper, .lh-gauge--pwa__wrapper').length,
-          sampleResults.reportCategories.length * 3, 'renders category gauges');
+          Object.keys(sampleResults.categories).length * 3, 'renders category gauges');
     });
 
     it('renders additional reports by replacing the existing one', () => {
diff --git a/lighthouse-core/test/results/artifacts/artifacts.json b/lighthouse-core/test/results/artifacts/artifacts.json
index 93732cd0f..66b699a1f 100644
--- a/lighthouse-core/test/results/artifacts/artifacts.json
+++ b/lighthouse-core/test/results/artifacts/artifacts.json
@@ -1096,7 +1096,7 @@
         ],
         "description": "Ensures the contrast between foreground and background colors meets WCAG 2 AA contrast ratio thresholds",
         "help": "Elements must have sufficient color contrast",
-        "helpUrl": "https://dequeuniversity.com/rules/axe/3.1/color-contrast?application=axeAPI",
+        "helpUrl": "https://dequeuniversity.com/rules/axe/3.2/color-contrast?application=axeAPI",
         "nodes": [
           {
             "impact": "serious",
@@ -1106,7 +1106,8 @@
             ],
             "failureSummary": "Fix any of the following:\n  Element has insufficient color contrast of 1.32 (foreground color: #ffc0cb, background color: #eeeeee, font size: 18.0pt, font weight: bold). Expected contrast ratio of 3:1",
             "path": "3,HTML,1,BODY,5,DIV,0,H2",
-            "snippet": "<h2>"
+            "snippet": "<h2>",
+            "nodeLabel": "Do better web tester page"
           },
           {
             "impact": "serious",
@@ -1116,7 +1117,8 @@
             ],
             "failureSummary": "Fix any of the following:\n  Element has insufficient color contrast of 1.32 (foreground color: #ffc0cb, background color: #eeeeee, font size: 12.0pt, font weight: normal). Expected contrast ratio of 4.5:1",
             "path": "3,HTML,1,BODY,5,DIV,1,SPAN",
-            "snippet": "<span>"
+            "snippet": "<span>",
+            "nodeLabel": "Hi there!"
           }
         ]
       },
@@ -1130,7 +1132,7 @@
         ],
         "description": "Ensures every HTML document has a lang attribute",
         "help": "<html> element must have a lang attribute",
-        "helpUrl": "https://dequeuniversity.com/rules/axe/3.1/html-has-lang?application=axeAPI",
+        "helpUrl": "https://dequeuniversity.com/rules/axe/3.2/html-has-lang?application=axeAPI",
         "nodes": [
           {
             "impact": "serious",
@@ -1140,7 +1142,8 @@
             ],
             "failureSummary": "Fix any of the following:\n  The <html> element does not have a lang attribute",
             "path": "3,HTML",
-            "snippet": "<html manifest=\"clock.appcache\">"
+            "snippet": "<html manifest=\"clock.appcache\">",
+            "nodeLabel": "html"
           }
         ]
       },
@@ -1156,7 +1159,7 @@
         ],
         "description": "Ensures <img> elements have alternate text or a role of none or presentation",
         "help": "Images must have alternate text",
-        "helpUrl": "https://dequeuniversity.com/rules/axe/3.1/image-alt?application=axeAPI",
+        "helpUrl": "https://dequeuniversity.com/rules/axe/3.2/image-alt?application=axeAPI",
         "nodes": [
           {
             "impact": "critical",
@@ -1166,7 +1169,8 @@
             ],
             "failureSummary": "Fix any of the following:\n  Element does not have an alt attribute\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Element has no title attribute or the title attribute is empty\n  Element's default semantics were not overridden with role=\"presentation\"\n  Element's default semantics were not overridden with role=\"none\"",
             "path": "3,HTML,1,BODY,10,IMG",
-            "snippet": "<img src=\"lighthouse-480x318.jpg\" width=\"480\" height=\"57\">"
+            "snippet": "<img src=\"lighthouse-480x318.jpg\" width=\"480\" height=\"57\">",
+            "nodeLabel": "img"
           },
           {
             "impact": "critical",
@@ -1176,27 +1180,30 @@
             ],
             "failureSummary": "Fix any of the following:\n  Element does not have an alt attribute\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Element has no title attribute or the title attribute is empty\n  Element's default semantics were not overridden with role=\"presentation\"\n  Element's default semantics were not overridden with role=\"none\"",
             "path": "3,HTML,1,BODY,12,IMG",
-            "snippet": "<img src=\"lighthouse-480x318.jpg\" width=\"480\" height=\"318\">"
+            "snippet": "<img src=\"lighthouse-480x318.jpg\" width=\"480\" height=\"318\">",
+            "nodeLabel": "img"
           },
           {
             "impact": "critical",
             "html": "<img src=\"lighthouse-rotating.gif\" width=\"811\" height=\"462\">",
             "target": [
-              "img[src$=\"lighthouse-rotating.gif\"]"
+              "img[src$=\"lighthouse-rotating\\.gif\"]"
             ],
             "failureSummary": "Fix any of the following:\n  Element does not have an alt attribute\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Element has no title attribute or the title attribute is empty\n  Element's default semantics were not overridden with role=\"presentation\"\n  Element's default semantics were not overridden with role=\"none\"",
             "path": "3,HTML,1,BODY,14,IMG",
-            "snippet": "<img src=\"lighthouse-rotating.gif\" width=\"811\" height=\"462\">"
+            "snippet": "<img src=\"lighthouse-rotating.gif\" width=\"811\" height=\"462\">",
+            "nodeLabel": "img"
           },
           {
             "impact": "critical",
             "html": "<img src=\"blob:http://localhost:62824/289254fd-ef1d-4c1a-96a8-ba291caa2140\">",
             "target": [
-              "img:nth-child(27)"
+              "img:nth-child(30)"
             ],
             "failureSummary": "Fix any of the following:\n  Element does not have an alt attribute\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Element has no title attribute or the title attribute is empty\n  Element's default semantics were not overridden with role=\"presentation\"\n  Element's default semantics were not overridden with role=\"none\"",
-            "path": "3,HTML,1,BODY,49,IMG",
-            "snippet": "<img src=\"blob:http://localhost:62824/289254fd-ef1d-4c1a-96a8-ba291caa2140\">"
+            "path": "3,HTML,1,BODY,53,IMG",
+            "snippet": "<img src=\"blob:http://localhost:52281/459dbcc0-ca5f-47ef-b78f-e04d79c9e3fa\">",
+            "nodeLabel": "img"
           }
         ]
       },
@@ -1213,7 +1220,7 @@
         ],
         "description": "Ensures every form element has a label",
         "help": "Form elements must have labels",
-        "helpUrl": "https://dequeuniversity.com/rules/axe/3.1/label?application=axeAPI",
+        "helpUrl": "https://dequeuniversity.com/rules/axe/3.2/label?application=axeAPI",
         "nodes": [
           {
             "impact": "critical",
@@ -1222,18 +1229,20 @@
               "input[onpaste=\"event\\.preventDefault\\(\\)\\;\"]"
             ],
             "failureSummary": "Fix any of the following:\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Form element does not have an implicit (wrapped) <label>\n  Form element does not have an explicit <label>\n  Element has no title attribute or the title attribute is empty",
-            "path": "3,HTML,1,BODY,44,INPUT",
-            "snippet": "<input type=\"password\" onpaste=\"event.preventDefault();\">"
+            "path": "3,HTML,1,BODY,48,INPUT",
+            "snippet": "<input type=\"password\" onpaste=\"event.preventDefault();\">",
+            "nodeLabel": "input"
           },
           {
             "impact": "critical",
             "html": "<input type=\"password\">",
             "target": [
-              "input:nth-child(25)"
+              "input:nth-child(28)"
             ],
             "failureSummary": "Fix any of the following:\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Form element does not have an implicit (wrapped) <label>\n  Form element does not have an explicit <label>\n  Element has no title attribute or the title attribute is empty",
-            "path": "3,HTML,1,BODY,46,INPUT",
-            "snippet": "<input type=\"password\">"
+            "path": "3,HTML,1,BODY,50,INPUT",
+            "snippet": "<input type=\"password\">",
+            "nodeLabel": "input"
           },
           {
             "impact": "critical",
@@ -1242,8 +1251,9 @@
               "input[onpaste=\"return\\ false\\;\"]"
             ],
             "failureSummary": "Fix any of the following:\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Form element does not have an implicit (wrapped) <label>\n  Form element does not have an explicit <label>\n  Element has no title attribute or the title attribute is empty",
-            "path": "3,HTML,1,BODY,48,INPUT",
-            "snippet": "<input type=\"password\" onpaste=\"return false;\">"
+            "path": "3,HTML,1,BODY,52,INPUT",
+            "snippet": "<input type=\"password\" onpaste=\"return false;\">",
+            "nodeLabel": "input"
           }
         ]
       },
@@ -1260,27 +1270,29 @@
         ],
         "description": "Ensures links have discernible text",
         "help": "Links must have discernible text",
-        "helpUrl": "https://dequeuniversity.com/rules/axe/3.1/link-name?application=axeAPI",
+        "helpUrl": "https://dequeuniversity.com/rules/axe/3.2/link-name?application=axeAPI",
         "nodes": [
           {
             "impact": "serious",
             "html": "<a href=\"javascript:void(0)\" target=\"_blank\"></a>",
             "target": [
-              "a[href=\"javascript:void(0)\"]"
+              "a[href=\"javascript\\:void\\(0\\)\"]"
             ],
             "failureSummary": "Fix all of the following:\n  Element is in tab order and does not have accessible text\n\nFix any of the following:\n  Element does not have text that is visible to screen readers\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Element's default semantics were not overridden with role=\"presentation\"\n  Element's default semantics were not overridden with role=\"none\"",
-            "path": "3,HTML,1,BODY,40,A",
-            "snippet": "<a href=\"javascript:void(0)\" target=\"_blank\">"
+            "path": "3,HTML,1,BODY,44,A",
+            "snippet": "<a href=\"javascript:void(0)\" target=\"_blank\">",
+            "nodeLabel": "a"
           },
           {
             "impact": "serious",
             "html": "<a href=\"mailto:inbox@email.com\" target=\"_blank\"></a>",
             "target": [
-              "a[href$=\"mailto:inbox@email.com\"]"
+              "a[href$=\"mailto\\:inbox\\@email\\.com\"]"
             ],
             "failureSummary": "Fix all of the following:\n  Element is in tab order and does not have accessible text\n\nFix any of the following:\n  Element does not have text that is visible to screen readers\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Element's default semantics were not overridden with role=\"presentation\"\n  Element's default semantics were not overridden with role=\"none\"",
-            "path": "3,HTML,1,BODY,42,A",
-            "snippet": "<a href=\"mailto:inbox@email.com\" target=\"_blank\">"
+            "path": "3,HTML,1,BODY,46,A",
+            "snippet": "<a href=\"mailto:inbox@email.com\" target=\"_blank\">",
+            "nodeLabel": "a"
           }
         ]
       },
@@ -1296,7 +1308,7 @@
         ],
         "description": "Ensures <object> elements have alternate text",
         "help": "<object> elements must have alternate text",
-        "helpUrl": "https://dequeuniversity.com/rules/axe/3.1/object-alt?application=axeAPI",
+        "helpUrl": "https://dequeuniversity.com/rules/axe/3.2/object-alt?application=axeAPI",
         "nodes": [
           {
             "impact": "serious",
@@ -1304,9 +1316,10 @@
             "target": [
               "#\\35 934a"
             ],
-            "failureSummary": "Fix any of the following:\n  Element does not have text that is visible to screen readers\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Element has no title attribute or the title attribute is empty",
+            "failureSummary": "Fix any of the following:\n  Element does not have text that is visible to screen readers\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Element has no title attribute or the title attribute is empty\n  Element's default semantics were not overridden with role=\"presentation\"\n  Element's default semantics were not overridden with role=\"none\"",
             "path": "3,HTML,1,BODY,2,OBJECT",
-            "snippet": "<object id=\"5934a\">"
+            "snippet": "<object id=\"5934a\">",
+            "nodeLabel": "object"
           },
           {
             "impact": "serious",
@@ -1314,9 +1327,10 @@
             "target": [
               "#\\35 934b"
             ],
-            "failureSummary": "Fix any of the following:\n  Element does not have text that is visible to screen readers\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Element has no title attribute or the title attribute is empty",
+            "failureSummary": "Fix any of the following:\n  Element does not have text that is visible to screen readers\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Element has no title attribute or the title attribute is empty\n  Element's default semantics were not overridden with role=\"presentation\"\n  Element's default semantics were not overridden with role=\"none\"",
             "path": "3,HTML,1,BODY,3,OBJECT",
-            "snippet": "<object id=\"5934b\">"
+            "snippet": "<object id=\"5934b\">",
+            "nodeLabel": "object"
           }
         ]
       }
@@ -1358,7 +1372,7 @@
         ],
         "description": "Ensures elements with ARIA roles have all required ARIA attributes",
         "help": "Required ARIA attributes must be provided",
-        "helpUrl": "https://dequeuniversity.com/rules/axe/3.1/aria-required-attr?application=axeAPI",
+        "helpUrl": "https://dequeuniversity.com/rules/axe/3.2/aria-required-attr?application=axeAPI",
         "nodes": []
       },
       {
@@ -1371,7 +1385,7 @@
         ],
         "description": "Ensures elements with an ARIA role that require child roles contain them",
         "help": "Certain ARIA roles must contain particular children",
-        "helpUrl": "https://dequeuniversity.com/rules/axe/3.1/aria-required-children?application=axeAPI",
+        "helpUrl": "https://dequeuniversity.com/rules/axe/3.2/aria-required-children?application=axeAPI",
         "nodes": []
       },
       {
@@ -1384,7 +1398,7 @@
         ],
         "description": "Ensures elements with an ARIA role that require parent roles are contained by them",
         "help": "Certain ARIA roles must be contained by particular parents",
-        "helpUrl": "https://dequeuniversity.com/rules/axe/3.1/aria-required-parent?application=axeAPI",
+        "helpUrl": "https://dequeuniversity.com/rules/axe/3.2/aria-required-parent?application=axeAPI",
         "nodes": []
       },
       {
@@ -1397,7 +1411,7 @@
         ],
         "description": "Ensures all elements with a role attribute use a valid value",
         "help": "ARIA roles used must conform to valid values",
-        "helpUrl": "https://dequeuniversity.com/rules/axe/3.1/aria-roles?application=axeAPI",
+        "helpUrl": "https://dequeuniversity.com/rules/axe/3.2/aria-roles?application=axeAPI",
         "nodes": []
       },
       {
@@ -1410,7 +1424,7 @@
         ],
         "description": "Ensures all ARIA attributes have valid values",
         "help": "ARIA attributes must conform to valid values",
-        "helpUrl": "https://dequeuniversity.com/rules/axe/3.1/aria-valid-attr-value?application=axeAPI",
+        "helpUrl": "https://dequeuniversity.com/rules/axe/3.2/aria-valid-attr-value?application=axeAPI",
         "nodes": []
       },
       {
@@ -1423,7 +1437,7 @@
         ],
         "description": "Ensures attributes that begin with aria- are valid ARIA attributes",
         "help": "ARIA attributes must conform to valid names",
-        "helpUrl": "https://dequeuniversity.com/rules/axe/3.1/aria-valid-attr?application=axeAPI",
+        "helpUrl": "https://dequeuniversity.com/rules/axe/3.2/aria-valid-attr?application=axeAPI",
         "nodes": []
       },
       {
@@ -1466,7 +1480,7 @@
         ],
         "description": "Ensures <dl> elements are structured correctly",
         "help": "<dl> elements must only directly contain properly-ordered <dt> and <dd> groups, <script> or <template> elements",
-        "helpUrl": "https://dequeuniversity.com/rules/axe/3.1/definition-list?application=axeAPI",
+        "helpUrl": "https://dequeuniversity.com/rules/axe/3.2/definition-list?application=axeAPI",
         "nodes": []
       },
       {
@@ -1521,7 +1535,7 @@
         ],
         "description": "Ensures <iframe> and <frame> elements contain a non-empty title attribute",
         "help": "Frames must have title attribute",
-        "helpUrl": "https://dequeuniversity.com/rules/axe/3.1/frame-title?application=axeAPI",
+        "helpUrl": "https://dequeuniversity.com/rules/axe/3.2/frame-title?application=axeAPI",
         "nodes": []
       },
       {
@@ -1562,7 +1576,7 @@
         ],
         "description": "Ensures <input type=\"image\"> elements have alternate text",
         "help": "Image buttons must have alternate text",
-        "helpUrl": "https://dequeuniversity.com/rules/axe/3.1/input-image-alt?application=axeAPI",
+        "helpUrl": "https://dequeuniversity.com/rules/axe/3.2/input-image-alt?application=axeAPI",
         "nodes": []
       },
       {
@@ -1575,7 +1589,7 @@
         ],
         "description": "Ensures presentational <table> elements do not use <th>, <caption> elements or the summary attribute",
         "help": "Layout tables must not use data table elements",
-        "helpUrl": "https://dequeuniversity.com/rules/axe/3.1/layout-table?application=axeAPI",
+        "helpUrl": "https://dequeuniversity.com/rules/axe/3.2/layout-table?application=axeAPI",
         "nodes": []
       },
       {
@@ -1588,7 +1602,7 @@
         ],
         "description": "Ensures that lists are structured correctly",
         "help": "<ul> and <ol> must only directly contain <li>, <script> or <template> elements",
-        "helpUrl": "https://dequeuniversity.com/rules/axe/3.1/list?application=axeAPI",
+        "helpUrl": "https://dequeuniversity.com/rules/axe/3.2/list?application=axeAPI",
         "nodes": []
       },
       {
@@ -1601,7 +1615,7 @@
         ],
         "description": "Ensures <li> elements are used semantically",
         "help": "<li> elements must be contained in a <ul> or <ol>",
-        "helpUrl": "https://dequeuniversity.com/rules/axe/3.1/listitem?application=axeAPI",
+        "helpUrl": "https://dequeuniversity.com/rules/axe/3.2/listitem?application=axeAPI",
         "nodes": []
       },
       {
@@ -1617,7 +1631,7 @@
         ],
         "description": "Ensures <meta http-equiv=\"refresh\"> is not used",
         "help": "Timed refresh must not exist",
-        "helpUrl": "https://dequeuniversity.com/rules/axe/3.1/meta-refresh?application=axeAPI",
+        "helpUrl": "https://dequeuniversity.com/rules/axe/3.2/meta-refresh?application=axeAPI",
         "nodes": []
       },
       {
@@ -1629,7 +1643,7 @@
         ],
         "description": "Ensures tabindex attribute values are not greater than 0",
         "help": "Elements should not have tabindex greater than zero",
-        "helpUrl": "https://dequeuniversity.com/rules/axe/3.1/tabindex?application=axeAPI",
+        "helpUrl": "https://dequeuniversity.com/rules/axe/3.2/tabindex?application=axeAPI",
         "nodes": []
       },
       {
@@ -1644,7 +1658,7 @@
         ],
         "description": "Ensure that each cell in a table using the headers refers to another cell in that table",
         "help": "All cells in a table element that use the headers attribute must only refer to other cells of that same table",
-        "helpUrl": "https://dequeuniversity.com/rules/axe/3.1/td-headers-attr?application=axeAPI",
+        "helpUrl": "https://dequeuniversity.com/rules/axe/3.2/td-headers-attr?application=axeAPI",
         "nodes": []
       },
       {
@@ -1659,7 +1673,7 @@
         ],
         "description": "Ensure that each table header in a data table refers to data cells",
         "help": "All th elements and elements with role=columnheader/rowheader must have data cells they describe",
-        "helpUrl": "https://dequeuniversity.com/rules/axe/3.1/th-has-data-cells?application=axeAPI",
+        "helpUrl": "https://dequeuniversity.com/rules/axe/3.2/th-has-data-cells?application=axeAPI",
         "nodes": []
       },
       {
@@ -1672,7 +1686,7 @@
         ],
         "description": "Ensures lang attributes have valid values",
         "help": "lang attribute must have a valid value",
-        "helpUrl": "https://dequeuniversity.com/rules/axe/3.1/valid-lang?application=axeAPI",
+        "helpUrl": "https://dequeuniversity.com/rules/axe/3.2/valid-lang?application=axeAPI",
         "nodes": []
       },
       {
@@ -1687,7 +1701,7 @@
         ],
         "description": "Ensures <video> elements have captions",
         "help": "<video> elements must have captions",
-        "helpUrl": "https://dequeuniversity.com/rules/axe/3.1/video-caption?application=axeAPI",
+        "helpUrl": "https://dequeuniversity.com/rules/axe/3.2/video-caption?application=axeAPI",
         "nodes": []
       },
       {
@@ -1702,7 +1716,7 @@
         ],
         "description": "Ensures <video> elements have audio descriptions",
         "help": "<video> elements must have an audio description track",
-        "helpUrl": "https://dequeuniversity.com/rules/axe/3.1/video-description?application=axeAPI",
+        "helpUrl": "https://dequeuniversity.com/rules/axe/3.2/video-description?application=axeAPI",
         "nodes": []
       }
     ]
@@ -2017,6 +2031,7 @@
       "snippet": "<button class=\"small-button\">Do something</button>",
       "path": "3,HTML,1,BODY,17,BUTTON",
       "selector": "body > button.small-button",
+      "nodeLabel": "Do something",
       "href": ""
     },
     {
@@ -2033,6 +2048,7 @@
       "snippet": "<button class=\"small-button\">Do something else</button>",
       "path": "3,HTML,1,BODY,18,BUTTON",
       "selector": "body > button.small-button",
+      "nodeLabel": "Do something else",
       "href": ""
     }
   ],
diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json
index 89e0bd6c6..ad1b5d0b8 100644
--- a/lighthouse-core/test/results/sample_v2.json
+++ b/lighthouse-core/test/results/sample_v2.json
@@ -1598,7 +1598,8 @@
               "selector": "h2",
               "path": "3,HTML,1,BODY,5,DIV,0,H2",
               "snippet": "<h2>Do better web tester page</h2>",
-              "explanation": "Fix any of the following:\n  Element has insufficient color contrast of 1.32 (foreground color: #ffc0cb, background color: #eeeeee, font size: 18.0pt, font weight: bold). Expected contrast ratio of 3:1"
+              "explanation": "Fix any of the following:\n  Element has insufficient color contrast of 1.32 (foreground color: #ffc0cb, background color: #eeeeee, font size: 18.0pt, font weight: bold). Expected contrast ratio of 3:1",
+              "nodeLabel": "Do better web tester page"
             }
           },
           {
@@ -1607,7 +1608,8 @@
               "selector": "span",
               "path": "3,HTML,1,BODY,5,DIV,1,SPAN",
               "snippet": "<span>Hi there!</span>",
-              "explanation": "Fix any of the following:\n  Element has insufficient color contrast of 1.32 (foreground color: #ffc0cb, background color: #eeeeee, font size: 12.0pt, font weight: normal). Expected contrast ratio of 4.5:1"
+              "explanation": "Fix any of the following:\n  Element has insufficient color contrast of 1.32 (foreground color: #ffc0cb, background color: #eeeeee, font size: 12.0pt, font weight: normal). Expected contrast ratio of 4.5:1",
+              "nodeLabel": "Hi there!"
             }
           }
         ],
@@ -1689,7 +1691,8 @@
               "selector": "html",
               "path": "3,HTML",
               "snippet": "<html manifest=\"clock.appcache\">",
-              "explanation": "Fix any of the following:\n  The <html> element does not have a lang attribute"
+              "explanation": "Fix any of the following:\n  The <html> element does not have a lang attribute",
+              "nodeLabel": "html"
             }
           }
         ],
@@ -1733,7 +1736,8 @@
               "selector": "img[height=\"\\35 7\"]",
               "path": "3,HTML,1,BODY,10,IMG",
               "snippet": "<img src=\"lighthouse-480x318.jpg\" width=\"480\" height=\"57\">",
-              "explanation": "Fix any of the following:\n  Element does not have an alt attribute\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Element has no title attribute or the title attribute is empty\n  Element's default semantics were not overridden with role=\"presentation\"\n  Element's default semantics were not overridden with role=\"none\""
+              "explanation": "Fix any of the following:\n  Element does not have an alt attribute\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Element has no title attribute or the title attribute is empty\n  Element's default semantics were not overridden with role=\"presentation\"\n  Element's default semantics were not overridden with role=\"none\"",
+              "nodeLabel": "img"
             }
           },
           {
@@ -1742,25 +1746,28 @@
               "selector": "img[height=\"\\33 18\"]",
               "path": "3,HTML,1,BODY,12,IMG",
               "snippet": "<img src=\"lighthouse-480x318.jpg\" width=\"480\" height=\"318\">",
-              "explanation": "Fix any of the following:\n  Element does not have an alt attribute\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Element has no title attribute or the title attribute is empty\n  Element's default semantics were not overridden with role=\"presentation\"\n  Element's default semantics were not overridden with role=\"none\""
+              "explanation": "Fix any of the following:\n  Element does not have an alt attribute\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Element has no title attribute or the title attribute is empty\n  Element's default semantics were not overridden with role=\"presentation\"\n  Element's default semantics were not overridden with role=\"none\"",
+              "nodeLabel": "img"
             }
           },
           {
             "node": {
               "type": "node",
-              "selector": "img[src$=\"lighthouse-rotating.gif\"]",
+              "selector": "img[src$=\"lighthouse-rotating\\.gif\"]",
               "path": "3,HTML,1,BODY,14,IMG",
               "snippet": "<img src=\"lighthouse-rotating.gif\" width=\"811\" height=\"462\">",
-              "explanation": "Fix any of the following:\n  Element does not have an alt attribute\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Element has no title attribute or the title attribute is empty\n  Element's default semantics were not overridden with role=\"presentation\"\n  Element's default semantics were not overridden with role=\"none\""
+              "explanation": "Fix any of the following:\n  Element does not have an alt attribute\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Element has no title attribute or the title attribute is empty\n  Element's default semantics were not overridden with role=\"presentation\"\n  Element's default semantics were not overridden with role=\"none\"",
+              "nodeLabel": "img"
             }
           },
           {
             "node": {
               "type": "node",
-              "selector": "img:nth-child(27)",
-              "path": "3,HTML,1,BODY,49,IMG",
+              "selector": "img:nth-child(30)",
+              "path": "3,HTML,1,BODY,53,IMG",
               "snippet": "<img src=\"blob:http://localhost:62824/289254fd-ef1d-4c1a-96a8-ba291caa2140\">",
-              "explanation": "Fix any of the following:\n  Element does not have an alt attribute\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Element has no title attribute or the title attribute is empty\n  Element's default semantics were not overridden with role=\"presentation\"\n  Element's default semantics were not overridden with role=\"none\""
+              "explanation": "Fix any of the following:\n  Element does not have an alt attribute\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Element has no title attribute or the title attribute is empty\n  Element's default semantics were not overridden with role=\"presentation\"\n  Element's default semantics were not overridden with role=\"none\"",
+              "nodeLabel": "img"
             }
           }
         ],
@@ -1804,27 +1811,30 @@
             "node": {
               "type": "node",
               "selector": "input[onpaste=\"event\\.preventDefault\\(\\)\\;\"]",
-              "path": "3,HTML,1,BODY,44,INPUT",
+              "path": "3,HTML,1,BODY,48,INPUT",
               "snippet": "<input type=\"password\" onpaste=\"event.preventDefault();\">",
-              "explanation": "Fix any of the following:\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Form element does not have an implicit (wrapped) <label>\n  Form element does not have an explicit <label>\n  Element has no title attribute or the title attribute is empty"
+              "explanation": "Fix any of the following:\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Form element does not have an implicit (wrapped) <label>\n  Form element does not have an explicit <label>\n  Element has no title attribute or the title attribute is empty",
+              "nodeLabel": "input"
             }
           },
           {
             "node": {
               "type": "node",
-              "selector": "input:nth-child(25)",
-              "path": "3,HTML,1,BODY,46,INPUT",
+              "selector": "input:nth-child(28)",
+              "path": "3,HTML,1,BODY,50,INPUT",
               "snippet": "<input type=\"password\">",
-              "explanation": "Fix any of the following:\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Form element does not have an implicit (wrapped) <label>\n  Form element does not have an explicit <label>\n  Element has no title attribute or the title attribute is empty"
+              "explanation": "Fix any of the following:\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Form element does not have an implicit (wrapped) <label>\n  Form element does not have an explicit <label>\n  Element has no title attribute or the title attribute is empty",
+              "nodeLabel": "input"
             }
           },
           {
             "node": {
               "type": "node",
               "selector": "input[onpaste=\"return\\ false\\;\"]",
-              "path": "3,HTML,1,BODY,48,INPUT",
+              "path": "3,HTML,1,BODY,52,INPUT",
               "snippet": "<input type=\"password\" onpaste=\"return false;\">",
-              "explanation": "Fix any of the following:\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Form element does not have an implicit (wrapped) <label>\n  Form element does not have an explicit <label>\n  Element has no title attribute or the title attribute is empty"
+              "explanation": "Fix any of the following:\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Form element does not have an implicit (wrapped) <label>\n  Form element does not have an explicit <label>\n  Element has no title attribute or the title attribute is empty",
+              "nodeLabel": "input"
             }
           }
         ],
@@ -1868,19 +1878,21 @@
           {
             "node": {
               "type": "node",
-              "selector": "a[href=\"javascript:void(0)\"]",
-              "path": "3,HTML,1,BODY,40,A",
+              "selector": "a[href=\"javascript\\:void\\(0\\)\"]",
+              "path": "3,HTML,1,BODY,44,A",
               "snippet": "<a href=\"javascript:void(0)\" target=\"_blank\"></a>",
-              "explanation": "Fix all of the following:\n  Element is in tab order and does not have accessible text\n\nFix any of the following:\n  Element does not have text that is visible to screen readers\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Element's default semantics were not overridden with role=\"presentation\"\n  Element's default semantics were not overridden with role=\"none\""
+              "explanation": "Fix all of the following:\n  Element is in tab order and does not have accessible text\n\nFix any of the following:\n  Element does not have text that is visible to screen readers\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Element's default semantics were not overridden with role=\"presentation\"\n  Element's default semantics were not overridden with role=\"none\"",
+              "nodeLabel": "a"
             }
           },
           {
             "node": {
               "type": "node",
-              "selector": "a[href$=\"mailto:inbox@email.com\"]",
-              "path": "3,HTML,1,BODY,42,A",
+              "selector": "a[href$=\"mailto\\:inbox\\@email\\.com\"]",
+              "path": "3,HTML,1,BODY,46,A",
               "snippet": "<a href=\"mailto:inbox@email.com\" target=\"_blank\"></a>",
-              "explanation": "Fix all of the following:\n  Element is in tab order and does not have accessible text\n\nFix any of the following:\n  Element does not have text that is visible to screen readers\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Element's default semantics were not overridden with role=\"presentation\"\n  Element's default semantics were not overridden with role=\"none\""
+              "explanation": "Fix all of the following:\n  Element is in tab order and does not have accessible text\n\nFix any of the following:\n  Element does not have text that is visible to screen readers\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Element's default semantics were not overridden with role=\"presentation\"\n  Element's default semantics were not overridden with role=\"none\"",
+              "nodeLabel": "a"
             }
           }
         ],
@@ -1953,7 +1965,8 @@
               "selector": "#\\35 934a",
               "path": "3,HTML,1,BODY,2,OBJECT",
               "snippet": "<object id=\"5934a\"></object>",
-              "explanation": "Fix any of the following:\n  Element does not have text that is visible to screen readers\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Element has no title attribute or the title attribute is empty"
+              "explanation": "Fix any of the following:\n  Element does not have text that is visible to screen readers\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Element has no title attribute or the title attribute is empty\n  Element's default semantics were not overridden with role=\"presentation\"\n  Element's default semantics were not overridden with role=\"none\"",
+              "nodeLabel": "object"
             }
           },
           {
@@ -1962,7 +1975,8 @@
               "selector": "#\\35 934b",
               "path": "3,HTML,1,BODY,3,OBJECT",
               "snippet": "<object id=\"5934b\"></object>",
-              "explanation": "Fix any of the following:\n  Element does not have text that is visible to screen readers\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Element has no title attribute or the title attribute is empty"
+              "explanation": "Fix any of the following:\n  Element does not have text that is visible to screen readers\n  aria-label attribute does not exist or is empty\n  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n  Element has no title attribute or the title attribute is empty\n  Element's default semantics were not overridden with role=\"presentation\"\n  Element's default semantics were not overridden with role=\"none\"",
+              "nodeLabel": "object"
             }
           }
         ],
@@ -3113,13 +3127,15 @@
               "type": "node",
               "snippet": "<button class=\"small-button\">Do something</button>",
               "path": "3,HTML,1,BODY,17,BUTTON",
-              "selector": "body > button.small-button"
+              "selector": "body > button.small-button",
+              "nodeLabel": "Do something"
             },
             "overlappingTarget": {
               "type": "node",
               "snippet": "<button class=\"small-button\">Do something else</button>",
               "path": "3,HTML,1,BODY,18,BUTTON",
-              "selector": "body > button.small-button"
+              "selector": "body > button.small-button",
+              "nodeLabel": "Do something else"
             },
             "tapTargetScore": 864,
             "overlappingTargetScore": 720,