Repository: GoogleChrome/lighthouse
Pull request #6486 (2018-11-06 04:37:24)

report: add pwa-category-renderer
part of #6395

![screen shot 2018-11-06 at 11 38 58](https://user-images.githubusercontent.com/316891/48089138-997ae400-e1b8-11e8-95c9-3e1e3a4fac17.png)

adds the basic renderer for the revamped PWA section, with first icons and no longer collapsing behind passing, etc. Right now there are just the grayed out icons for each group (they don't fill in as the score increases).

brendankenny: @hwikyounglee fixed the icon sizing
brendankenny: > Ultra nit: When clicking a row the icons overlap the 1px blue border.

good catch. Normally a group description provides margin space between the group title and the audit results, but the PWA groups have no description. I added a bottom margin to group titles, which fixes the PWA section and collapses when paired with a description, leaving most of the rest of the report unchanged. 

It does leave the `Metrics` title way too high over the metric audits, though, so I overrode the margin just for that one group in the performance section (which seems reasonable, since there's a few lines in the same place entirely devoted to shrinking whitespace in just the metrics group)

Patch:
diff --git a/lighthouse-core/audits/seo/link-text.js b/lighthouse-core/audits/seo/link-text.js
index 63805fce3..d979e96fe 100644
--- a/lighthouse-core/audits/seo/link-text.js
+++ b/lighthouse-core/audits/seo/link-text.js
@@ -41,14 +41,22 @@ class LinkText extends Audit {
   static audit(artifacts) {
     const failingLinks = artifacts.CrawlableLinks
       .filter(link => {
+        const href = link.href.toLowerCase();
         if (
-          link.href.toLowerCase().startsWith('javascript:') ||
+          href.startsWith('javascript:') ||
+          href.startsWith('mailto:') ||
           URL.equalWithExcludedFragments(link.href, artifacts.URL.finalUrl)
         ) {
           return false;
         }
 
         return BLOCKLIST.has(link.text.trim().toLowerCase());
+      })
+      .map(link => {
+        return {
+          href: link.href,
+          text: link.text.trim(),
+        };
       });
 
     const headings = [
diff --git a/lighthouse-core/gather/gatherers/seo/crawlable-links.js b/lighthouse-core/gather/gatherers/seo/crawlable-links.js
index e0ead7b60..fb27136c1 100644
--- a/lighthouse-core/gather/gatherers/seo/crawlable-links.js
+++ b/lighthouse-core/gather/gatherers/seo/crawlable-links.js
@@ -13,19 +13,30 @@ class CrawlableLinks extends Gatherer {
    * @param {LH.Gatherer.PassContext} passContext
    * @return {Promise<LH.Artifacts['CrawlableLinks']>}
    */
-  afterPass(passContext) {
+  async afterPass(passContext) {
     const expression = `(function() {
       ${pageFunctions.getElementsInDocumentString}; // define function on page
+      const resolveURLOrNull = url => {
+        try { return new URL(url, window.location.href).href; }
+        catch (_) { return null; }
+      };
+
       const selector = 'a[href]:not([rel~="nofollow"])';
       const elements = getElementsInDocument(selector);
       return elements
         .map(node => ({
-          href: node.href,
-          text: node.innerText
+          href: node.href instanceof SVGAnimatedString ?
+            resolveURLOrNull(node.href.baseVal) :
+            node.href,
+          text: node.href instanceof SVGAnimatedString ?
+            node.textContent :
+            node.innerText,
         }));
     })()`;
 
-    return passContext.driver.evaluateAsync(expression);
+    /** @type {LH.Artifacts['CrawlableLinks']} */
+    const links = await passContext.driver.evaluateAsync(expression, {useIsolation: true});
+    return links.filter(link => typeof link.href === 'string' && link.href);
   }
 }
 
diff --git a/lighthouse-core/lib/page-functions.js b/lighthouse-core/lib/page-functions.js
index 1b4ec1647..960476f15 100644
--- a/lighthouse-core/lib/page-functions.js
+++ b/lighthouse-core/lib/page-functions.js
@@ -84,13 +84,14 @@ function checkTimeSinceLastLongTask() {
  */
 /* istanbul ignore next */
 function getElementsInDocument(selector) {
+  const realMatchesFn = window.__ElementMatches || window.Element.prototype.matches;
   /** @type {Array<Element>} */
   const results = [];
 
   /** @param {NodeListOf<Element>} nodes */
   const _findAllElements = nodes => {
     for (let i = 0, el; el = nodes[i]; ++i) {
-      if (!selector || window.__ElementMatches.call(el, selector)) {
+      if (!selector || realMatchesFn.call(el, selector)) {
         results.push(el);
       }
       // If the element has a shadow root, dig deeper.
diff --git a/lighthouse-core/report/html/html-report-assets.js b/lighthouse-core/report/html/html-report-assets.js
index 118de51e8..d4edd8906 100644
--- a/lighthouse-core/report/html/html-report-assets.js
+++ b/lighthouse-core/report/html/html-report-assets.js
@@ -21,6 +21,7 @@ const REPORT_JAVASCRIPT = [
   fs.readFileSync(__dirname + '/renderer/report-ui-features.js', 'utf8'),
   fs.readFileSync(__dirname + '/renderer/category-renderer.js', 'utf8'),
   fs.readFileSync(__dirname + '/renderer/performance-category-renderer.js', 'utf8'),
+  fs.readFileSync(__dirname + '/renderer/pwa-category-renderer.js', 'utf8'),
   fs.readFileSync(__dirname + '/renderer/report-renderer.js', 'utf8'),
 ].join(';\n');
 const REPORT_CSS = fs.readFileSync(__dirname + '/report-styles.css', 'utf8');
diff --git a/lighthouse-core/report/html/renderer/pwa-category-renderer.js b/lighthouse-core/report/html/renderer/pwa-category-renderer.js
new file mode 100644
index 000000000..72d3a6fd5
--- /dev/null
+++ b/lighthouse-core/report/html/renderer/pwa-category-renderer.js
@@ -0,0 +1,54 @@
+/**
+ * @license
+ * Copyright 2018 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS-IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+'use strict';
+
+/* globals self, CategoryRenderer */
+
+class PwaCategoryRenderer extends CategoryRenderer {
+  /**
+   * @param {LH.ReportResult.Category} category
+   * @param {Object<string, LH.Result.ReportGroup>} [groupDefinitions]
+   * @return {Element}
+   */
+  render(category, groupDefinitions = {}) {
+    const categoryElem = this.dom.createElement('div', 'lh-category');
+    this.createPermalinkSpan(categoryElem, category.id);
+    categoryElem.appendChild(this.renderCategoryHeader(category));
+
+    const auditRefs = category.auditRefs;
+
+    // Regular audits aren't split up into pass/fail/not-applicable clumps, they're
+    // all put in a top-level clump that isn't expandable/collapsable.
+    const regularAuditRefs = auditRefs.filter(ref => ref.result.scoreDisplayMode !== 'manual');
+    const auditsElem = this.renderUnexpandableClump(regularAuditRefs, groupDefinitions);
+    categoryElem.appendChild(auditsElem);
+
+    // Manual audits are still in a manual clump.
+    const manualAuditRefs = auditRefs.filter(ref => ref.result.scoreDisplayMode === 'manual');
+    const manualElem = this.renderClump('manual',
+      {auditRefs: manualAuditRefs, groupDefinitions, description: category.manualDescription});
+    categoryElem.appendChild(manualElem);
+
+    return categoryElem;
+  }
+}
+
+if (typeof module !== 'undefined' && module.exports) {
+  module.exports = PwaCategoryRenderer;
+} else {
+  self.PwaCategoryRenderer = PwaCategoryRenderer;
+}
diff --git a/lighthouse-core/report/html/renderer/report-renderer.js b/lighthouse-core/report/html/renderer/report-renderer.js
index 23e772fb3..31f48cec5 100644
--- a/lighthouse-core/report/html/renderer/report-renderer.js
+++ b/lighthouse-core/report/html/renderer/report-renderer.js
@@ -26,7 +26,7 @@
 /** @typedef {import('./dom.js')} DOM */
 /** @typedef {import('./details-renderer.js').DetailsJSON} DetailsJSON */
 
-/* globals self, Util, DetailsRenderer, CategoryRenderer, PerformanceCategoryRenderer */
+/* globals self, Util, DetailsRenderer, CategoryRenderer, PerformanceCategoryRenderer, PwaCategoryRenderer */
 
 class ReportRenderer {
   /**
@@ -188,8 +188,15 @@ class ReportRenderer {
     const detailsRenderer = new DetailsRenderer(this._dom);
     const categoryRenderer = new CategoryRenderer(this._dom, detailsRenderer);
     categoryRenderer.setTemplateContext(this._templateContext);
-    const perfCategoryRenderer = new PerformanceCategoryRenderer(this._dom, detailsRenderer);
-    perfCategoryRenderer.setTemplateContext(this._templateContext);
+
+    /** @type {Record<string, CategoryRenderer>} */
+    const specificCategoryRenderers = {
+      performance: new PerformanceCategoryRenderer(this._dom, detailsRenderer),
+      pwa: new PwaCategoryRenderer(this._dom, detailsRenderer),
+    };
+    Object.values(specificCategoryRenderers).forEach(renderer => {
+      renderer.setTemplateContext(this._templateContext);
+    });
 
     const categories = reportSection.appendChild(this._dom.createElement('div', 'lh-categories'));
 
@@ -198,10 +205,7 @@ class ReportRenderer {
         scoreHeader.appendChild(categoryRenderer.renderScoreGauge(category));
       }
 
-      let renderer = categoryRenderer;
-      if (category.id === 'performance') {
-        renderer = perfCategoryRenderer;
-      }
+      const renderer = specificCategoryRenderers[category.id] || categoryRenderer;
       categories.appendChild(renderer.render(category, report.categoryGroups));
     }
 
diff --git a/lighthouse-core/report/html/report-styles.css b/lighthouse-core/report/html/report-styles.css
index 55b1e64b3..eb3eddc61 100644
--- a/lighthouse-core/report/html/report-styles.css
+++ b/lighthouse-core/report/html/report-styles.css
@@ -64,6 +64,7 @@
   --navitem-vpadding: calc(var(--navitem-line-height) / 2);
   --lh-score-highlight-bg: hsla(0, 0%, 90%, 0.2);
   --lh-score-icon-background-size: 24px;
+  --lh-group-icon-background-size: var(--lh-score-icon-background-size);
   --lh-score-margin: 12px;
   --lh-table-header-bg: #f8f9fa;
   --lh-table-higlight-bg: hsla(0, 0%, 75%, 0.1);
@@ -92,6 +93,10 @@
 
   --search-icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48"><path d="M31 28h-1.59l-.55-.55C30.82 25.18 32 22.23 32 19c0-7.18-5.82-13-13-13S6 11.82 6 19s5.82 13 13 13c3.23 0 6.18-1.18 8.45-3.13l.55.55V31l10 9.98L40.98 38 31 28zm-12 0a9 9 0 1 1 .001-18.001A9 9 0 0 1 19 28z" fill="%235E6268"/><path d="M0 0h48v48H0z" fill="none" /></svg>');
   --remove-circle-icon-url: url('data:image/svg+xml;utf8,<svg height="24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="M0 0h24v24H0z" fill="none"/><path d="M7 11v2h10v-2H7zm5-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z" fill="%235E6268"/></svg>');
+
+  --pwa-fast-reliable-gray-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="nonzero"><circle fill="#DAE0E3" cx="12" cy="12" r="12"/><path d="M12.3 4l6.3 2.8V11c0 3.88-2.69 7.52-6.3 8.4C8.69 18.52 6 14.89 6 11V6.8L12.3 4zm-.56 12.88l3.3-5.79.04-.08c.05-.1.01-.29-.26-.29h-1.96l.56-3.92h-.56L9.6 12.52c0 .03.07-.12-.03.07-.11.2-.12.37.2.37h1.97l-.56 3.92h.56z" fill="#FFF"/></g></svg>');
+  --pwa-installable-gray-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="nonzero"><circle fill="#DAE0E3" cx="12" cy="12" r="12"/><path d="M12 5a7 7 0 1 0 0 14 7 7 0 0 0 0-14zm3.5 7.7h-2.8v2.8h-1.4v-2.8H8.5v-1.4h2.8V8.5h1.4v2.8h2.8v1.4z" fill="#FFF"/></g></svg>');
+  --pwa-optimized-gray-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><rect fill="#DAE0E3" width="24" height="24" rx="12"/><path fill="#FFF" d="M12 15.07l3.6 2.18-.95-4.1 3.18-2.76-4.2-.36L12 6.17l-1.64 3.86-4.2.36 3.2 2.76-.96 4.1z"/><path d="M5 5h14v14H5z"/></g></svg>');
 }
 
 .lh-vars.lh-devtools {
@@ -616,16 +621,36 @@ details, summary {
   background-image: var(--av-timer-icon-url);
 }
 
+.lh-audit-group--pwa-fast-reliable .lh-audit-group__header::before {
+  content: '';
+  background-image: var(--pwa-fast-reliable-gray-url);
+  background-size: var(--lh-group-icon-background-size);
+}
+.lh-audit-group--pwa-installable .lh-audit-group__header::before {
+  content: '';
+  background-image: var(--pwa-installable-gray-url);
+  background-size: var(--lh-group-icon-background-size);
+}
+.lh-audit-group--pwa-optimized .lh-audit-group__header::before {
+  content: '';
+  background-image: var(--pwa-optimized-gray-url);
+  background-size: var(--lh-group-icon-background-size);
+}
+
 /* Removing too much whitespace */
 .lh-audit-group--metrics {
   margin-top: calc(var(--circle-size)/2 * -1);
   border-bottom: none;
 }
+.lh-audit-group--metrics .lh-audit-group__summary {
+  margin-bottom: 0;
+}
 
 .lh-audit-group__summary {
   display: flex;
   justify-content: space-between;
   padding-right: var(--text-indent);
+  margin-bottom: var(--lh-audit-vpadding);
 }
 
 .lh-audit-group__itemcount {
@@ -645,7 +670,10 @@ details, summary {
 .lh-clump > .lh-audit-group__description,
 .lh-audit-group--diagnostics .lh-audit-group__description,
 .lh-audit-group--opportunities .lh-audit-group__description,
-.lh-audit-group--metrics .lh-audit-group__description {
+.lh-audit-group--metrics .lh-audit-group__description,
+.lh-audit-group--pwa-fast-reliable .lh-audit-group__description,
+.lh-audit-group--pwa-installable .lh-audit-group__description,
+.lh-audit-group--pwa-optimized .lh-audit-group__description {
   margin-top: var(--lh-audit-group-vpadding);
 }
 
diff --git a/typings/html-renderer.d.ts b/typings/html-renderer.d.ts
index 0ff1b6485..6dcc65d11 100644
--- a/typings/html-renderer.d.ts
+++ b/typings/html-renderer.d.ts
@@ -9,6 +9,7 @@ import _CriticalRequestChainRenderer = require('../lighthouse-core/report/html/r
 import _DetailsRenderer = require('../lighthouse-core/report/html/renderer/details-renderer.js');
 import _DOM = require('../lighthouse-core/report/html/renderer/dom.js');
 import _PerformanceCategoryRenderer = require('../lighthouse-core/report/html/renderer/performance-category-renderer.js');
+import _PwaCategoryRenderer = require('../lighthouse-core/report/html/renderer/pwa-category-renderer.js');
 import _ReportRenderer = require('../lighthouse-core/report/html/renderer/report-renderer.js');
 import _ReportUIFeatures = require('../lighthouse-core/report/html/renderer/report-ui-features.js');
 import _Util = require('../lighthouse-core/report/html/renderer/util.js');
@@ -22,6 +23,7 @@ declare global {
   var DOM: typeof _DOM;
   var getFilenamePrefix: typeof _FileNamer.getFilenamePrefix;
   var PerformanceCategoryRenderer: typeof _PerformanceCategoryRenderer;
+  var PwaCategoryRenderer: typeof _PwaCategoryRenderer;
   var ReportRenderer: typeof _ReportRenderer;
   var ReportUIFeatures: typeof _ReportUIFeatures;
   var Util: typeof _Util;
@@ -33,6 +35,7 @@ declare global {
     DetailsRenderer: typeof _DetailsRenderer;
     DOM: typeof _DOM;
     PerformanceCategoryRenderer: typeof _PerformanceCategoryRenderer;
+    PwaCategoryRenderer: typeof _PwaCategoryRenderer;
     ReportRenderer: typeof _ReportRenderer;
     ReportUIFeatures: typeof _ReportUIFeatures;
     Util: typeof _Util;


Test patch:
diff --git a/lighthouse-cli/test/fixtures/seo/seo-failure-cases.html b/lighthouse-cli/test/fixtures/seo/seo-failure-cases.html
index ed954bbf6..e5ad6d5d0 100644
--- a/lighthouse-cli/test/fixtures/seo/seo-failure-cases.html
+++ b/lighthouse-cli/test/fixtures/seo/seo-failure-cases.html
@@ -27,6 +27,12 @@
   <a href='https://example.com'>click this</a>
   <a href='/test.html'> click this </a>
   <a href='/test.html'>CLICK THIS</a>
+  <svg>
+    <a href='https://example.com/svg-link'>
+      <circle cx="50" cy="50" r="50"/>
+      <text x="0" y="0" >click this</text>
+    </a>
+  </svg>
 
   <!-- FAIL(plugins): java applet on page -->
   <applet code=HelloWorld.class width="200" height="200" id='applet'>
diff --git a/lighthouse-cli/test/smokehouse/seo/expectations.js b/lighthouse-cli/test/smokehouse/seo/expectations.js
index c9d180b11..7e36d3655 100644
--- a/lighthouse-cli/test/smokehouse/seo/expectations.js
+++ b/lighthouse-cli/test/smokehouse/seo/expectations.js
@@ -99,10 +99,10 @@ module.exports = [
       },
       'link-text': {
         score: 0,
-        displayValue: '3 links found',
+        displayValue: '4 links found',
         details: {
           items: {
-            length: 3,
+            length: 4,
           },
         },
       },
diff --git a/lighthouse-core/test/audits/seo/anchor-text-test.js b/lighthouse-core/test/audits/seo/link-text-test.js
similarity index 88%
rename from lighthouse-core/test/audits/seo/anchor-text-test.js
rename to lighthouse-core/test/audits/seo/link-text-test.js
index 9bb993401..6fce06b0b 100644
--- a/lighthouse-core/test/audits/seo/anchor-text-test.js
+++ b/lighthouse-core/test/audits/seo/link-text-test.js
@@ -64,6 +64,21 @@ describe('SEO: link text audit', () => {
     assert.equal(auditResult.rawValue, true);
   });
 
+  it('ignores mailto: links', () => {
+    const artifacts = {
+      URL: {
+        finalUrl: 'https://example.com/page.html',
+      },
+      CrawlableLinks: [
+        {href: 'mailto:info@example.com', text: 'click here'},
+        {href: 'mailto:mailmaster@localhost', text: 'click here'},
+      ],
+    };
+
+    const auditResult = LinkTextAudit.audit(artifacts);
+    assert.equal(auditResult.rawValue, true);
+  });
+
   it('passes when all links have descriptive texts', () => {
     const artifacts = {
       URL: {
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
new file mode 100644
index 000000000..92500c311
--- /dev/null
+++ b/lighthouse-core/test/report/html/renderer/pwa-category-renderer-test.js
@@ -0,0 +1,93 @@
+/**
+ * @license Copyright 2018 Google Inc. All Rights Reserved.
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
+ */
+'use strict';
+
+/* eslint-env jest, browser */
+
+const assert = require('assert');
+const fs = require('fs');
+const jsdom = require('jsdom');
+const Util = require('../../../../report/html/renderer/util.js');
+const URL = require('../../../../lib/url-shim.js');
+const DOM = require('../../../../report/html/renderer/dom.js');
+const DetailsRenderer = require('../../../../report/html/renderer/details-renderer.js');
+const CategoryRenderer = require('../../../../report/html/renderer/category-renderer.js');
+const sampleResultsOrig = require('../../../results/sample_v2.json');
+
+const TEMPLATE_FILE = fs.readFileSync(__dirname +
+    '/../../../../report/html/templates.html', 'utf8');
+
+describe('PwaCategoryRenderer', () => {
+  let category;
+  let pwaRenderer;
+  let sampleResults;
+
+  beforeAll(() => {
+    global.URL = URL;
+    global.Util = Util;
+    global.CategoryRenderer = CategoryRenderer;
+
+    const PwaCategoryRenderer =
+        require('../../../../report/html/renderer/pwa-category-renderer.js');
+
+    const {document} = new jsdom.JSDOM(TEMPLATE_FILE).window;
+    const dom = new DOM(document);
+    const detailsRenderer = new DetailsRenderer(dom);
+    pwaRenderer = new PwaCategoryRenderer(dom, detailsRenderer);
+
+    sampleResults = Util.prepareReportResult(sampleResultsOrig);
+    category = sampleResults.reportCategories.find(cat => cat.id === 'pwa');
+  });
+
+  afterAll(() => {
+    global.URL = undefined;
+    global.Util = undefined;
+    global.CategoryRenderer = undefined;
+  });
+
+  it('renders the regular audits', () => {
+    const categoryElem = pwaRenderer.render(category, sampleResults.categoryGroups);
+    const allAuditElements = Array.from(categoryElem.querySelectorAll('.lh-audit'));
+    const manualElements = Array.from(categoryElem.querySelectorAll('.lh-clump--manual .lh-audit'));
+    const regularAuditElements = allAuditElements.filter(el => !manualElements.includes(el));
+
+    const nonManualAudits = category.auditRefs
+      .filter(audit => audit.result.scoreDisplayMode !== 'manual');
+
+    assert.strictEqual(regularAuditElements.length, nonManualAudits.length);
+  });
+
+  it('renders the manual audits', () => {
+    const categoryElem = pwaRenderer.render(category, sampleResults.categoryGroups);
+    const manualElements = categoryElem.querySelectorAll('.lh-clump--manual .lh-audit');
+
+    const manualAudits = category.auditRefs
+      .filter(audit => audit.result.scoreDisplayMode === 'manual');
+
+    assert.strictEqual(manualElements.length, manualAudits.length);
+  });
+
+  it('manual audits are the only clump', () => {
+    const categoryElem = pwaRenderer.render(category, sampleResults.categoryGroups);
+    const clumpElems = categoryElem.querySelectorAll('.lh-clump');
+    assert.strictEqual(clumpElems.length, 1);
+    assert.ok(clumpElems[0].classList.contains('lh-clump--manual'));
+  });
+
+  it('renders the audit groups', () => {
+    const categoryGroupIds = new Set(category.auditRefs.filter(a => a.group).map(a => a.group));
+    assert.strictEqual(categoryGroupIds.size, 3); // Ensure there's something to test.
+
+    const categoryElem = pwaRenderer.render(category, sampleResults.categoryGroups);
+
+    categoryGroupIds.forEach(groupId => {
+      const selector = `.lh-audit-group--${groupId}`;
+      // Expected that only the non-manual audits will be grouped.
+      assert.strictEqual(categoryElem.querySelectorAll(selector).length, 1,
+        `trouble with selector '${selector}'`);
+    });
+  });
+});
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 58a2bcf26..87c072f2d 100644
--- a/lighthouse-core/test/report/html/renderer/report-renderer-test.js
+++ b/lighthouse-core/test/report/html/renderer/report-renderer-test.js
@@ -16,8 +16,9 @@ const DOM = require('../../../../report/html/renderer/dom.js');
 const DetailsRenderer = require('../../../../report/html/renderer/details-renderer.js');
 const ReportUIFeatures = require('../../../../report/html/renderer/report-ui-features.js');
 const CategoryRenderer = require('../../../../report/html/renderer/category-renderer.js');
-// lazy loaded because it depends on CategoryRenderer to be available globally
+// lazy loaded because they depend on CategoryRenderer to be available globally
 let PerformanceCategoryRenderer = null;
+let PwaCategoryRenderer = null;
 const CriticalRequestChainRenderer = require(
     '../../../../report/html/renderer/crc-details-renderer.js');
 const ReportRenderer = require('../../../../report/html/renderer/report-renderer.js');
@@ -43,6 +44,11 @@ describe('ReportRenderer', () => {
         require('../../../../report/html/renderer/performance-category-renderer.js');
     }
     global.PerformanceCategoryRenderer = PerformanceCategoryRenderer;
+    if (!PwaCategoryRenderer) {
+      PwaCategoryRenderer =
+        require('../../../../report/html/renderer/pwa-category-renderer.js');
+    }
+    global.PwaCategoryRenderer = PwaCategoryRenderer;
 
     // Stub out matchMedia for Node.
     global.matchMedia = function() {
@@ -71,6 +77,7 @@ describe('ReportRenderer', () => {
     global.DetailsRenderer = undefined;
     global.CategoryRenderer = undefined;
     global.PerformanceCategoryRenderer = undefined;
+    global.PwaCategoryRenderer = undefined;
   });
 
   describe('renderReport', () => {
diff --git a/lighthouse-core/test/report/html/renderer/report-ui-features-test.js b/lighthouse-core/test/report/html/renderer/report-ui-features-test.js
index c90888b9f..cea3db85e 100644
--- a/lighthouse-core/test/report/html/renderer/report-ui-features-test.js
+++ b/lighthouse-core/test/report/html/renderer/report-ui-features-test.js
@@ -16,8 +16,9 @@ const DOM = require('../../../../report/html/renderer/dom.js');
 const DetailsRenderer = require('../../../../report/html/renderer/details-renderer.js');
 const ReportUIFeatures = require('../../../../report/html/renderer/report-ui-features.js');
 const CategoryRenderer = require('../../../../report/html/renderer/category-renderer.js');
-// lazy loaded because it depends on CategoryRenderer to be available globally
+// lazy loaded because they depend on CategoryRenderer to be available globally
 let PerformanceCategoryRenderer = null;
+let PwaCategoryRenderer = null;
 const CriticalRequestChainRenderer = require(
     '../../../../report/html/renderer/crc-details-renderer.js');
 const ReportRenderer = require('../../../../report/html/renderer/report-renderer.js');
@@ -45,6 +46,11 @@ describe('ReportUIFeatures', () => {
         require('../../../../report/html/renderer/performance-category-renderer.js');
     }
     global.PerformanceCategoryRenderer = PerformanceCategoryRenderer;
+    if (!PwaCategoryRenderer) {
+      PwaCategoryRenderer =
+        require('../../../../report/html/renderer/pwa-category-renderer.js');
+    }
+    global.PwaCategoryRenderer = PwaCategoryRenderer;
 
     // Stub out matchMedia for Node.
     global.matchMedia = function() {
@@ -90,6 +96,7 @@ describe('ReportUIFeatures', () => {
     global.DetailsRenderer = undefined;
     global.CategoryRenderer = undefined;
     global.PerformanceCategoryRenderer = undefined;
+    global.PwaCategoryRenderer = undefined;
     global.window = undefined;
   });