Repository: GoogleChrome/lighthouse
Pull request #9001 (2019-05-20 21:23:02)

misc: remove unneeded reportCategories from LH.ReportResult
`Util.prepareReportResult` smooshes audit results onto audit refs, but it also creates a bonus array of the report categories via `Object.values()`. We barely use these and [we all agree they're technical debt](https://github.com/GoogleChrome/lighthouse/blob/0b96e7af1a73e34e84b6a17e5a69f1d77a12698a/lighthouse-core/report/html/renderer/util.js#L55), so this is an easy removal.

As a bonus, this PR clears up all the many places we're calling `.find()` on that array to find a particular category, when the original `categories` is already indexed by category id :)

(noticed while cleaning up `prepareReportResult` in #8822, but punted until after I/O)


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/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/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/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/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/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,