Repository: GoogleChrome/lighthouse
Pull request #6465 (2018-11-04 01:35:14)

report: add details-element polyfill for Edge
Split this out of #6459 as it's more complex then the other fixes. Hope that's cool, @wardpeet 	!

Edge doesn't support `<details>` but it's [high priority for them](https://developer.microsoft.com/en-us/microsoft-edge/platform/status/detailssummary/?q=details).

### Our clients:

DevTools Audits panel: Only Chrome support
Chrome extension: Only Chrome support
CLI -> HTML: Should support all browsers
Viewer: Should support all browsers
PSI: Should support all browsers

Where "All browsers" == current chrome, saf, ff, edge, but not IE11.

### Polyfillin
This JS is essentially concatenated into the compiled-reportrenderer via the html-report-assets inclusion. It runs immediately and exits early if a feature test succeeds: https://github.com/GoogleChrome/lighthouse/blob/65f9d6b47cf03b56025658e01f8b3165d90ddf70/lighthouse-core/report/html/third_party/details-element-polyfill.js#L13-L39

Shipping this polyfill this way fixes the CLI and Viewer. PSI is configured separately, but has a similar list of files for inclusion. DevTools is also unaffected, so that's cool. Note, this will add the polyfill superfluously to the extension bundle, but... eh.

_____

@wardpeet 	note i removed these lines: https://github.com/GoogleChrome/lighthouse/blob/71501746d1dca08ff9602ae6883419ddaae2dc1d/lighthouse-core/report/html/report-styles.css#L128-L131  I think that's fine as the polyfill will add these styles, and I expect that to happen before we ever render a `<details>` but lmk wyt :)


Patch:
diff --git a/lighthouse-core/lib/timing-trace-saver.js b/lighthouse-core/lib/timing-trace-saver.js
index 078d4e2e3..106f69506 100644
--- a/lighthouse-core/lib/timing-trace-saver.js
+++ b/lighthouse-core/lib/timing-trace-saver.js
@@ -5,70 +5,50 @@
  */
 'use strict';
 
-/**
- * Technically, it's fine for usertiming measures to overlap, however non-async events make
- * for a much clearer UI in traceviewer. We do this check to make sure we aren't passing off
- * async-like measures as non-async.
- * prevEntries are expected to be sorted by startTime
- * @param {LH.Artifacts.MeasureEntry} entry user timing entry
- * @param {LH.Artifacts.MeasureEntry[]} prevEntries user timing entries
- */
-function checkEventOverlap(entry, prevEntries) {
-  for (const prevEntry of prevEntries) {
-    const prevEnd = prevEntry.startTime + prevEntry.duration;
-    const thisEnd = entry.startTime + entry.duration;
-    const isOverlap = prevEnd > entry.startTime && prevEnd < thisEnd;
-    if (isOverlap) {
-      // eslint-disable-next-line no-console
-      console.error(`Two measures overlap! ${prevEntry.name} & ${entry.name}`);
-    }
-  }
-}
-
 /**
  * Generates a chromium trace file from user timing measures
+ * `threadId` can be provided to separate a series of trace events into another thread, useful
+ * if timings do not share the same timeOrigin, but should both be "left-aligned".
  * Adapted from https://github.com/tdresser/performance-observer-tracing
  * @param {LH.Artifacts.MeasureEntry[]} entries user timing entries
- * @param {string=} trackName
+ * @param {number=} threadId
  */
-function generateTraceEvents(entries, trackName = 'measures') {
+function generateTraceEvents(entries, threadId = 0) {
   if (!Array.isArray(entries)) return [];
 
   /** @type {LH.TraceEvent[]} */
   const currentTrace = [];
-  let id = 0;
-
   entries.sort((a, b) => a.startTime - b.startTime);
   entries.forEach((entry, i) => {
-    checkEventOverlap(entry, entries.slice(0, i));
-
     /** @type {LH.TraceEvent} */
-    const traceEvent = {
-      name: entry.name,
-      cat: entry.entryType,
+    const startEvt = {
+      // 1) Remove 'lh:' for readability
+      // 2) Colons in user_timing names get special handling in traceviewer we don't want. https://goo.gl/m23Vz7
+      //    Replace with a 'Modifier letter colon' ;)
+      name: entry.name.replace('lh:', '').replace(/:/g, '\ua789'),
+      cat: 'blink.user_timing',
       ts: entry.startTime * 1000,
-      dur: entry.duration * 1000,
       args: {},
+      dur: 0,
       pid: 0,
-      tid: trackName === 'measures' ? 50 : 75,
-      ph: 'X',
-      id: '0x' + (id++).toString(16),
+      tid: threadId,
+      ph: 'b',
+      id: '0x' + (i++).toString(16),
     };
 
-    if (entry.entryType !== 'measure') throw new Error('Unexpected entryType!');
-    if (entry.duration === 0) {
-      traceEvent.ph = 'n';
-      traceEvent.s = 't';
-    }
+    const endEvt = JSON.parse(JSON.stringify(startEvt));
+    endEvt.ph = 'e';
+    endEvt.ts = startEvt.ts + (entry.duration * 1000);
 
-    currentTrace.push(traceEvent);
+    currentTrace.push(startEvt);
+    currentTrace.push(endEvt);
   });
 
   // Add labels
   /** @type {LH.TraceEvent} */
   const metaEvtBase = {
     pid: 0,
-    tid: trackName === 'measures' ? 50 : 75,
+    tid: threadId,
     ts: 0,
     dur: 0,
     ph: 'M',
@@ -77,8 +57,20 @@ function generateTraceEvents(entries, trackName = 'measures') {
     args: {labels: 'Default'},
   };
   currentTrace.push(Object.assign({}, metaEvtBase, {args: {labels: 'Lighthouse Timing'}}));
-  currentTrace.push(Object.assign({}, metaEvtBase, {name: 'thread_name', args: {name: trackName}}));
 
+  // Only inject TracingStartedInBrowser once
+  if (threadId === 0) {
+    currentTrace.push(Object.assign({}, metaEvtBase, {
+      'cat': 'disabled-by-default-devtools.timeline',
+      'name': 'TracingStartedInBrowser',
+      'ph': 'I',
+      'args': {'data': {
+        'frameTreeNodeId': 1,
+        'persistentIds': true,
+        'frames': [],
+      }},
+    }));
+  }
   return currentTrace;
 }
 
@@ -92,13 +84,12 @@ function createTraceString(lhr) {
   const entries = lhr.timing.entries.filter(entry => !gatherEntries.includes(entry));
 
   const auditEvents = generateTraceEvents(entries);
-  const gatherEvents = generateTraceEvents(gatherEntries, 'gather');
+  const gatherEvents = generateTraceEvents(gatherEntries, 10);
   const events = [...auditEvents, ...gatherEvents];
 
-  const jsonStr = `
-  { "traceEvents": [
-    ${events.map(evt => JSON.stringify(evt)).join(',\n')}
-  ]}`;
+  const jsonStr = `{"traceEvents":[
+${events.map(evt => JSON.stringify(evt)).join(',\n')}
+]}`;
 
   return jsonStr;
 }
diff --git a/lighthouse-core/report/html/html-report-assets.js b/lighthouse-core/report/html/html-report-assets.js
index a60a18bda..118de51e8 100644
--- a/lighthouse-core/report/html/html-report-assets.js
+++ b/lighthouse-core/report/html/html-report-assets.js
@@ -11,6 +11,9 @@ const REPORT_TEMPLATE = fs.readFileSync(__dirname + '/report-template.html', 'ut
 const REPORT_JAVASCRIPT = [
   fs.readFileSync(__dirname + '/renderer/util.js', 'utf8'),
   fs.readFileSync(__dirname + '/renderer/dom.js', 'utf8'),
+  // COMPAT: Remove when Microsoft Edge supports <details>/<summary>
+  // https://developer.microsoft.com/en-us/microsoft-edge/platform/status/detailssummary/?q=details
+  fs.readFileSync(__dirname + '/third_party/details-element-polyfill.js', 'utf8'),
   fs.readFileSync(__dirname + '/renderer/details-renderer.js', 'utf8'),
   fs.readFileSync(__dirname + '/renderer/crc-details-renderer.js', 'utf8'),
   fs.readFileSync(__dirname + '/../../lib/file-namer.js', 'utf8'),
diff --git a/lighthouse-core/report/html/readme.md b/lighthouse-core/report/html/readme.md
index c9e9ebc41..af2edcc05 100644
--- a/lighthouse-core/report/html/readme.md
+++ b/lighthouse-core/report/html/readme.md
@@ -34,3 +34,7 @@ The renderer was designed to be portable across various environments.
 1. _LH CLI_: It [creates the HTML as the runner finishes up](https://github.com/GoogleChrome/lighthouse/blob/440155cdda377c458c0efce006bc3a69ce2a351c/lighthouse-core/runner.js#L137-L138) and [saves it to disk](https://github.com/GoogleChrome/lighthouse/blob/440155cdda377c458c0efce006bc3a69ce2a351c/lighthouse-cli/printer.js#L71-L92).
 1. _Chrome DevTools Audits Panel_: The `renderer` files are rolled into the Chromium repo, and they execute within the DevTools context. The audits panel [receives the LHR object from a WebWorker](https://github.com/ChromeDevTools/devtools-frontend/blob/master/front_end/audits2/Audits2ProtocolService.js#L27-L35), through a `postMessage` and then runs [the renderer within DevTools UI](https://github.com/ChromeDevTools/devtools-frontend/blob/fee00605cada877c1f8e3aae758a0f8d05b64476/front_end/audits2/Audits2Panel.js#L519-L542), making a few upgrades ([one](https://github.com/ChromeDevTools/devtools-frontend/blob/fee00605cada877c1f8e3aae758a0f8d05b64476/front_end/audits2/Audits2Panel.js#L570-L583), [two](https://github.com/ChromeDevTools/devtools-frontend/blob/fee00605cada877c1f8e3aae758a0f8d05b64476/front_end/audits2/Audits2Panel.js#L596-L637)) and [simplifications](https://github.com/ChromeDevTools/devtools-frontend/blob/fee00605cada877c1f8e3aae758a0f8d05b64476/front_end/audits2/Audits2Panel.js#L275-L304).
 1. _Hosted [Lighthouse Viewer](https://googlechrome.github.io/lighthouse/viewer/)_: It's a webapp that has the renderer (along with some [additional features](https://github.com/GoogleChrome/lighthouse/blob/master/lighthouse-core/report/html/renderer/report-ui-features.js)) all compiled into a [`viewer.js`](https://googlechrome.github.io/lighthouse/viewer/src/viewer.js) file. Same [basic approach](https://github.com/GoogleChrome/lighthouse/blob/440155cdda377c458c0efce006bc3a69ce2a351c/lighthouse-viewer/app/src/lighthouse-report-viewer.js#L116-L117) there.
+
+### Polyfills
+
+The `details-element-polyfill` is pulled in to provide [support](https://caniuse.com/#feat=details) for Microsoft Edge. It's built [unminified](https://github.com/javan/details-element-polyfill/issues/12) and pulled into third_party.
diff --git a/lighthouse-core/report/html/renderer/category-renderer.js b/lighthouse-core/report/html/renderer/category-renderer.js
index 5e8e2210e..39efef223 100644
--- a/lighthouse-core/report/html/renderer/category-renderer.js
+++ b/lighthouse-core/report/html/renderer/category-renderer.js
@@ -22,6 +22,7 @@
 /** @typedef {import('./report-renderer.js')} ReportRenderer */
 /** @typedef {import('./details-renderer.js')} DetailsRenderer */
 /** @typedef {import('./util.js')} Util */
+/** @typedef {'failed'|'manual'|'passed'|'not-applicable'} TopLevelClumpId */
 
 class CategoryRenderer {
   /**
@@ -39,6 +40,29 @@ class CategoryRenderer {
     this.detailsRenderer.setTemplateContext(this.templateContext);
   }
 
+  /**
+   * Display info per top-level clump. Define on class to avoid race with Util init.
+   */
+  get _clumpDisplayInfo() {
+    return {
+      'failed': {
+        className: 'lh-failed-audits',
+      },
+      'manual': {
+        title: Util.UIStrings.manualAuditsGroupTitle,
+        className: 'lh-audit-group--manual',
+      },
+      'passed': {
+        title: Util.UIStrings.passedAuditsGroupTitle,
+        className: 'lh-passed-audits',
+      },
+      'not-applicable': {
+        title: Util.UIStrings.notApplicableAuditsGroupTitle,
+        className: 'lh-audit-group--not-applicable',
+      },
+    };
+  }
+
   /**
    * @param {LH.ReportResult.AuditRef} audit
    * @param {number} index
@@ -164,7 +188,7 @@ class CategoryRenderer {
   renderAuditGroup(group, opts) {
     const expandable = opts.expandable;
     const groupEl = this.dom.createElement(expandable ? 'details' : 'div', 'lh-audit-group');
-    const summaryEl = this.dom.createChildOf(groupEl, 'summary');
+    const summaryEl = this.dom.createChildOf(groupEl, expandable ? 'summary' : 'div');
     const summaryInnerEl = this.dom.createChildOf(summaryEl, 'div', 'lh-audit-group__summary');
     const headerEl = this.dom.createChildOf(summaryInnerEl, 'div', 'lh-audit-group__header');
     const itemCountEl = this.dom.createChildOf(summaryInnerEl, 'div', 'lh-audit-group__itemcount');
@@ -188,76 +212,111 @@ class CategoryRenderer {
   }
 
   /**
-   * Find the total number of audits contained within a section.
-   * Accounts for nested subsections like Accessibility.
-   * @param {Array<Element>} elements
-   * @return {number}
+   * Takes an array of auditRefs, groups them if requested, then returns an
+   * array of audit and audit-group elements.
+   * @param {Array<LH.ReportResult.AuditRef>} auditRefs
+   * @param {Object<string, LH.Result.ReportGroup>} groupDefinitions
+   * @param {{expandable: boolean}} opts
+   * @return {Array<Element>}
    */
-  _getTotalAuditsLength(elements) {
-    // Create a scratch element to append sections to so we can reuse querySelectorAll().
-    const scratch = this.dom.createElement('div');
-    elements.forEach(function(element) {
-      scratch.appendChild(element);
-    });
-    const subAudits = scratch.querySelectorAll('.lh-audit');
-    if (subAudits.length) {
-      return subAudits.length;
-    } else {
-      return elements.length;
+  _renderGroupedAudits(auditRefs, groupDefinitions, opts) {
+    // Audits grouped by their group (or under notAGroup).
+    /** @type {Map<string, Array<LH.ReportResult.AuditRef>>} */
+    const grouped = new Map();
+
+    // Add audits without a group first so they will appear first.
+    const notAGroup = 'NotAGroup';
+    grouped.set(notAGroup, []);
+
+    for (const auditRef of auditRefs) {
+      const groupId = auditRef.group || notAGroup;
+      const groupAuditRefs = grouped.get(groupId) || [];
+      groupAuditRefs.push(auditRef);
+      grouped.set(groupId, groupAuditRefs);
     }
-  }
 
-  /**
-   * @param {Array<Element>} elements
-   * @return {Element}
-   */
-  _renderFailedAuditsSection(elements) {
-    const failedElem = this.dom.createElement('div');
-    failedElem.classList.add('lh-failed-audits');
-    elements.forEach(elem => failedElem.appendChild(elem));
-    return failedElem;
-  }
+    /** @type {Array<Element>} */
+    const auditElements = [];
+    // Continuous numbering across all groups.
+    let index = 0;
 
-  /**
-   * @param {Array<Element>} elements
-   * @return {Element}
-   */
-  renderPassedAuditsSection(elements) {
-    const passedElem = this.renderAuditGroup({
-      title: Util.UIStrings.passedAuditsGroupTitle,
-    }, {expandable: true, itemCount: this._getTotalAuditsLength(elements)});
-    passedElem.classList.add('lh-passed-audits');
-    elements.forEach(elem => passedElem.appendChild(elem));
-    return passedElem;
+    for (const [groupId, groupAuditRefs] of grouped) {
+      if (groupId === notAGroup) {
+        // Push not-grouped audits individually.
+        for (const auditRef of groupAuditRefs) {
+          auditElements.push(this.renderAudit(auditRef, index++));
+        }
+        continue;
+      }
+
+      // Push grouped audits as a group.
+      const groupDef = groupDefinitions[groupId];
+      const auditGroupElem = this.renderAuditGroup(groupDef, opts);
+      for (const auditRef of groupAuditRefs) {
+        auditGroupElem.appendChild(this.renderAudit(auditRef, index++));
+      }
+      auditGroupElem.classList.add('lh-audit-group--unadorned');
+      auditElements.push(auditGroupElem);
+    }
+
+    return auditElements;
   }
 
   /**
-   * @param {Array<Element>} elements
+   * Take a set of audits, group them if they have groups, then render in a top-level
+   * clump that can't be expanded/collapsed.
+   * @param {Array<LH.ReportResult.AuditRef>} auditRefs
+   * @param {Object<string, LH.Result.ReportGroup>} groupDefinitions
    * @return {Element}
    */
-  _renderNotApplicableAuditsSection(elements) {
-    const notApplicableElem = this.renderAuditGroup({
-      title: Util.UIStrings.notApplicableAuditsGroupTitle,
-    }, {expandable: true, itemCount: this._getTotalAuditsLength(elements)});
-    notApplicableElem.classList.add('lh-audit-group--not-applicable');
-    elements.forEach(elem => notApplicableElem.appendChild(elem));
-    return notApplicableElem;
+  renderUnexpandableClump(auditRefs, groupDefinitions) {
+    const clumpElement = this.dom.createElement('div');
+    const elements = this._renderGroupedAudits(auditRefs, groupDefinitions, {expandable: false});
+    elements.forEach(elem => clumpElement.appendChild(elem));
+    return clumpElement;
   }
 
   /**
-   * @param {Array<LH.ReportResult.AuditRef>} manualAudits
-   * @param {string} [manualDescription]
+   * Renders a clump (a grouping of groups), under a status of failed, manual,
+   * passed, or not-applicable. The result ends up something like:
+   *
+   * clump (e.g. 'failed')
+   *   ├── audit 1 (w/o group)
+   *   ├── audit 2 (w/o group)
+   *   ├── audit group
+   *   |  ├── audit 3
+   *   |  └── audit 4
+   *   └── audit group
+   *      ├── audit 5
+   *      └── audit 6
+   * clump (e.g. 'manual')
+   *   ├── …
+   *   ⋮
+   * @param {TopLevelClumpId} clumpId
+   * @param {{auditRefs: Array<LH.ReportResult.AuditRef>, groupDefinitions: Object<string, LH.Result.ReportGroup>, description?: string}} clumpOpts
    * @return {Element}
    */
-  _renderManualAudits(manualAudits, manualDescription) {
-    const group = {title: Util.UIStrings.manualAuditsGroupTitle, description: manualDescription};
-    const auditGroupElem = this.renderAuditGroup(group,
-        {expandable: true, itemCount: manualAudits.length});
-    auditGroupElem.classList.add('lh-audit-group--manual');
-    manualAudits.forEach((audit, i) => {
-      auditGroupElem.appendChild(this.renderAudit(audit, i));
-    });
-    return auditGroupElem;
+  renderClump(clumpId, {auditRefs, groupDefinitions, description}) {
+    if (clumpId === 'failed') {
+      // Failed audit clump is always expanded and not nested in an lh-audit-group.
+      const failedElem = this.renderUnexpandableClump(auditRefs, groupDefinitions);
+      failedElem.classList.add(this._clumpDisplayInfo.failed.className);
+      return failedElem;
+    }
+
+    const expandable = true;
+    const elements = this._renderGroupedAudits(auditRefs, groupDefinitions, {expandable});
+
+    const clumpInfo = this._clumpDisplayInfo[clumpId];
+    // TODO: renderAuditGroup shouldn't be used to render a clump (since it *contains* audit groups).
+    const groupDef = {title: clumpInfo.title, description};
+    const opts = {expandable, itemCount: auditRefs.length};
+    const clumpElem = this.renderAuditGroup(groupDef, opts);
+    clumpElem.classList.add(clumpInfo.className);
+
+    elements.forEach(elem => clumpElem.appendChild(elem));
+
+    return clumpElem;
   }
 
   /**
@@ -302,105 +361,58 @@ class CategoryRenderer {
     return tmpl;
   }
 
+  /**
+   * Returns the id of the top-level clump to put this audit in.
+   * @param {LH.ReportResult.AuditRef} auditRef
+   * @return {TopLevelClumpId}
+   */
+  _getClumpIdForAuditRef(auditRef) {
+    const scoreDisplayMode = auditRef.result.scoreDisplayMode;
+    if (scoreDisplayMode === 'manual' || scoreDisplayMode === 'not-applicable') {
+      return scoreDisplayMode;
+    }
+
+    if (Util.showAsPassed(auditRef.result)) {
+      return 'passed';
+    } else {
+      return 'failed';
+    }
+  }
+
   /**
    * @param {LH.ReportResult.Category} category
    * @param {Object<string, LH.Result.ReportGroup>} [groupDefinitions]
    * @return {Element}
    */
-  render(category, groupDefinitions) {
+  render(category, groupDefinitions = {}) {
     const element = this.dom.createElement('div', 'lh-category');
     this.createPermalinkSpan(element, category.id);
     element.appendChild(this.renderCategoryHeader(category));
 
-    const auditRefs = category.auditRefs;
-    const manualAudits = auditRefs.filter(audit => audit.result.scoreDisplayMode === 'manual');
-    const nonManualAudits = auditRefs.filter(audit => !manualAudits.includes(audit));
-
-    /** @type {Object<string, {passed: Array<LH.ReportResult.AuditRef>, failed: Array<LH.ReportResult.AuditRef>, notApplicable: Array<LH.ReportResult.AuditRef>}>} */
-    const auditsGroupedByGroup = {};
-    const auditsUngrouped = {passed: [], failed: [], notApplicable: []};
-
-    nonManualAudits.forEach(auditRef => {
-      let group;
-
-      if (auditRef.group) {
-        const groupId = auditRef.group;
-
-        if (auditsGroupedByGroup[groupId]) {
-          group = auditsGroupedByGroup[groupId];
-        } else {
-          group = {passed: [], failed: [], notApplicable: []};
-          auditsGroupedByGroup[groupId] = group;
-        }
-      } else {
-        group = auditsUngrouped;
-      }
-
-      if (auditRef.result.scoreDisplayMode === 'not-applicable') {
-        group.notApplicable.push(auditRef);
-      } else if (Util.showAsPassed(auditRef.result)) {
-        group.passed.push(auditRef);
-      } else {
-        group.failed.push(auditRef);
-      }
-    });
-
-    const failedElements = /** @type {Array<Element>} */ ([]);
-    const passedElements = /** @type {Array<Element>} */ ([]);
-    const notApplicableElements = /** @type {Array<Element>} */ ([]);
-
-    auditsUngrouped.failed.forEach((audit, i) => failedElements.push(this.renderAudit(audit, i)));
-    auditsUngrouped.passed.forEach((audit, i) => passedElements.push(this.renderAudit(audit, i)));
-    auditsUngrouped.notApplicable.forEach((audit, i) => notApplicableElements.push(
-        this.renderAudit(audit, i)));
-
-    Object.keys(auditsGroupedByGroup).forEach(groupId => {
-      if (!groupDefinitions) return; // We never reach here if there aren't groups, but TSC needs convincing
-
-      const group = groupDefinitions[groupId];
-      const groups = auditsGroupedByGroup[groupId];
-
-      if (groups.failed.length) {
-        const auditGroupElem = this.renderAuditGroup(group, {expandable: false});
-        groups.failed.forEach((item, i) => auditGroupElem.appendChild(this.renderAudit(item, i)));
-        auditGroupElem.classList.add('lh-audit-group--unadorned');
-        failedElements.push(auditGroupElem);
-      }
-
-      if (groups.passed.length) {
-        const auditGroupElem = this.renderAuditGroup(group, {expandable: true});
-        groups.passed.forEach((item, i) => auditGroupElem.appendChild(this.renderAudit(item, i)));
-        auditGroupElem.classList.add('lh-audit-group--unadorned');
-        passedElements.push(auditGroupElem);
-      }
-
-      if (groups.notApplicable.length) {
-        const auditGroupElem = this.renderAuditGroup(group, {expandable: true});
-        groups.notApplicable.forEach((item, i) =>
-            auditGroupElem.appendChild(this.renderAudit(item, i)));
-        auditGroupElem.classList.add('lh-audit-group--unadorned');
-        notApplicableElements.push(auditGroupElem);
-      }
-    });
-
-    if (failedElements.length) {
-      const failedElem = this._renderFailedAuditsSection(failedElements);
-      element.appendChild(failedElem);
+    // Top level clumps for audits, in order they will appear in the report.
+    /** @type {Map<TopLevelClumpId, Array<LH.ReportResult.AuditRef>>} */
+    const clumps = new Map();
+    clumps.set('failed', []);
+    clumps.set('manual', []);
+    clumps.set('passed', []);
+    clumps.set('not-applicable', []);
+
+    // Sort audits into clumps.
+    for (const auditRef of category.auditRefs) {
+      const clumpId = this._getClumpIdForAuditRef(auditRef);
+      const clump = /** @type {Array<LH.ReportResult.AuditRef>} */ (clumps.get(clumpId)); // already defined
+      clump.push(auditRef);
+      clumps.set(clumpId, clump);
     }
 
-    if (manualAudits.length) {
-      const manualEl = this._renderManualAudits(manualAudits, category.manualDescription);
-      element.appendChild(manualEl);
-    }
-
-    if (passedElements.length) {
-      const passedElem = this.renderPassedAuditsSection(passedElements);
-      element.appendChild(passedElem);
-    }
+    // Render each clump.
+    for (const [clumpId, clumpRefs] of clumps) {
+      if (clumpRefs.length === 0) continue;
 
-    if (notApplicableElements.length) {
-      const notApplicableElem = this._renderNotApplicableAuditsSection(notApplicableElements);
-      element.appendChild(notApplicableElem);
+      const description = clumpId === 'manual' ? category.manualDescription : undefined;
+      const clumpElem = this.renderClump(clumpId, {auditRefs: clumpRefs, groupDefinitions,
+        description});
+      element.appendChild(clumpElem);
     }
 
     return element;
diff --git a/lighthouse-core/report/html/renderer/performance-category-renderer.js b/lighthouse-core/report/html/renderer/performance-category-renderer.js
index 873c0b765..7924f213f 100644
--- a/lighthouse-core/report/html/renderer/performance-category-renderer.js
+++ b/lighthouse-core/report/html/renderer/performance-category-renderer.js
@@ -209,14 +209,17 @@ class PerformanceCategoryRenderer extends CategoryRenderer {
     }
 
     // Passed audits
-    const passedElements = category.auditRefs
+    const passedAudits = category.auditRefs
         .filter(audit => (audit.group === 'load-opportunities' || audit.group === 'diagnostics') &&
-            Util.showAsPassed(audit.result))
-        .map((audit, i) => this.renderAudit(audit, i));
+            Util.showAsPassed(audit.result));
 
-    if (!passedElements.length) return element;
+    if (!passedAudits.length) return element;
 
-    const passedElem = this.renderPassedAuditsSection(passedElements);
+    const clumpOpts = {
+      auditRefs: passedAudits,
+      groupDefinitions: groups,
+    };
+    const passedElem = this.renderClump('passed', clumpOpts);
     element.appendChild(passedElem);
     return element;
   }
diff --git a/lighthouse-core/report/html/renderer/report-ui-features.js b/lighthouse-core/report/html/renderer/report-ui-features.js
index 04aaddc94..a9d294dd0 100644
--- a/lighthouse-core/report/html/renderer/report-ui-features.js
+++ b/lighthouse-core/report/html/renderer/report-ui-features.js
@@ -226,7 +226,7 @@ class ReportUIFeatures {
     this.headerSticky.style.transform = `translateY(${heightDiff * scrollPct * -1}px)`;
     this.headerBackground.style.transform = `translateY(${scrollPct * this.headerOverlap}px)`;
     this.lighthouseIcon.style.transform =
-      `translate3d(calc(var(--report-width) / 2),` +
+      `translate3d(var(--report-width-half),` +
       ` calc(-100% - ${scrollPct * this.headerOverlap * -1}px), 0) scale(${1 - scrollPct})`;
     this.lighthouseIcon.style.opacity = (1 - scrollPct).toString();
 
diff --git a/lighthouse-core/report/html/report-styles.css b/lighthouse-core/report/html/report-styles.css
index ad9fa10d6..335604b2d 100644
--- a/lighthouse-core/report/html/report-styles.css
+++ b/lighthouse-core/report/html/report-styles.css
@@ -54,6 +54,8 @@
   --metric-timeline-rule-color: #b3b3b3;
   --display-value-gray: hsl(216, 5%, 39%);
   --report-width: calc(60 * var(--body-font-size));
+  /* Edge doesn't support calc(var(calc())) */
+  --report-width-half: calc(30 * var(--body-font-size));
   --report-header-height: 161px;
   --report-header-color: #202124;
   --navitem-font-size: var(--body-font-size);
@@ -77,19 +79,19 @@
   --lh-section-vpadding: 12px;
   --chevron-size: 12px;
 
-  --pass-icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><title>check</title><path fill="hsl(139, 70%, 30%)" d="M24 4C12.95 4 4 12.95 4 24c0 11.04 8.95 20 20 20 11.04 0 20-8.96 20-20 0-11.05-8.96-20-20-20zm-4 30L10 24l2.83-2.83L20 28.34l15.17-15.17L38 16 20 34z"/></svg>');
-  --average-icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><title>info</title><path fill="hsl(31, 100%, 45%)" d="M24 4C12.95 4 4 12.95 4 24s8.95 20 20 20 20-8.95 20-20S35.05 4 24 4zm2 30h-4V22h4v12zm0-16h-4v-4h4v4z"/></svg>');
-  --fail-icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><title>warn</title><path fill="hsl(1, 73%, 45%)" d="M2 42h44L24 4 2 42zm24-6h-4v-4h4v4zm0-8h-4v-8h4v8z"/></svg>');
+  --pass-icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><title>check</title><path fill="%23178239" d="M24 4C12.95 4 4 12.95 4 24c0 11.04 8.95 20 20 20 11.04 0 20-8.96 20-20 0-11.05-8.96-20-20-20zm-4 30L10 24l2.83-2.83L20 28.34l15.17-15.17L38 16 20 34z"/></svg>');
+  --average-icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><title>info</title><path fill="%23E67700" d="M24 4C12.95 4 4 12.95 4 24s8.95 20 20 20 20-8.95 20-20S35.05 4 24 4zm2 30h-4V22h4v12zm0-16h-4v-4h4v4z"/></svg>');
+  --fail-icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><title>warn</title><path fill="%23C7221F" d="M2 42h44L24 4 2 42zm24-6h-4v-4h4v4zm0-8h-4v-8h4v8z"/></svg>');
 
-  --content-paste-icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"><path fill="hsl(216, 5%, 39%)" d="M19 2h-4.18C14.4.84 13.3 0 12 0c-1.3 0-2.4.84-2.82 2H5c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm7 18H5V4h2v3h10V4h2v16z"/><path d="M0 0h24v24H0z" fill="none"/></svg>');
-  --av-timer-icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="hsl(216, 5%, 39%)"><path d="M0 0h24v24H0z" fill="none"/><path d="M15 1H9v2h6V1zm-4 13h2V8h-2v6zm8.03-6.61l1.42-1.42c-.43-.51-.9-.99-1.41-1.41l-1.42 1.42A8.962 8.962 0 0 0 12 4c-4.97 0-9 4.03-9 9s4.02 9 9 9a8.994 8.994 0 0 0 7.03-14.61zM12 20c-3.87 0-7-3.13-7-7s3.13-7 7-7 7 3.13 7 7-3.13 7-7 7z"/></svg>');
-  --photo-filter-icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48"><path fill="none" d="M0 0h48v48H0V0z"/><path d="M38.04 20v18H10V10h18V6H10.04c-2.2 0-4 1.8-4 4v28c0 2.2 1.8 4 4 4h28c2.2 0 4-1.8 4-4V20h-4zM34 20l1.88-4.12L40 14l-4.12-1.88L34 8l-1.88 4.12L28 14l4.12 1.88zm-7.5 1.5L24 16l-2.5 5.5L16 24l5.5 2.5L24 32l2.5-5.5L32 24z" fill="hsl(216, 5%, 39%)"/></svg>');
-  --visibility-icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48"><path d="M0 0h48v48H0z" fill="none"/><path d="M24 9C14 9 5.46 15.22 2 24c3.46 8.78 12 15 22 15 10.01 0 18.54-6.22 22-15-3.46-8.78-11.99-15-22-15zm0 25c-5.52 0-10-4.48-10-10s4.48-10 10-10 10 4.48 10 10-4.48 10-10 10zm0-16c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6-2.69-6-6-6z" fill="hsl(216, 5%, 39%)"/></svg>');
-  --check-circle-icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48"><path d="M0 0h48v48H0z" fill="none"/><path d="M24 4C12.95 4 4 12.95 4 24c0 11.04 8.95 20 20 20 11.04 0 20-8.96 20-20 0-11.05-8.96-20-20-20zm-4 30L10 24l2.83-2.83L20 28.34l15.17-15.17L38 16 20 34z" fill="hsl(216, 5%, 39%)"/></svg>');
+  --content-paste-icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"><path fill="%235E6268" d="M19 2h-4.18C14.4.84 13.3 0 12 0c-1.3 0-2.4.84-2.82 2H5c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm7 18H5V4h2v3h10V4h2v16z"/><path d="M0 0h24v24H0z" fill="none"/></svg>');
+  --av-timer-icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="%235E6268"><path d="M0 0h24v24H0z" fill="none"/><path d="M15 1H9v2h6V1zm-4 13h2V8h-2v6zm8.03-6.61l1.42-1.42c-.43-.51-.9-.99-1.41-1.41l-1.42 1.42A8.962 8.962 0 0 0 12 4c-4.97 0-9 4.03-9 9s4.02 9 9 9a8.994 8.994 0 0 0 7.03-14.61zM12 20c-3.87 0-7-3.13-7-7s3.13-7 7-7 7 3.13 7 7-3.13 7-7 7z"/></svg>');
+  --photo-filter-icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48"><path fill="none" d="M0 0h48v48H0V0z"/><path d="M38.04 20v18H10V10h18V6H10.04c-2.2 0-4 1.8-4 4v28c0 2.2 1.8 4 4 4h28c2.2 0 4-1.8 4-4V20h-4zM34 20l1.88-4.12L40 14l-4.12-1.88L34 8l-1.88 4.12L28 14l4.12 1.88zm-7.5 1.5L24 16l-2.5 5.5L16 24l5.5 2.5L24 32l2.5-5.5L32 24z" fill="%235E6268"/></svg>');
+  --visibility-icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48"><path d="M0 0h48v48H0z" fill="none"/><path d="M24 9C14 9 5.46 15.22 2 24c3.46 8.78 12 15 22 15 10.01 0 18.54-6.22 22-15-3.46-8.78-11.99-15-22-15zm0 25c-5.52 0-10-4.48-10-10s4.48-10 10-10 10 4.48 10 10-4.48 10-10 10zm0-16c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6-2.69-6-6-6z" fill="%235E6268"/></svg>');
+  --check-circle-icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48"><path d="M0 0h48v48H0z" fill="none"/><path d="M24 4C12.95 4 4 12.95 4 24c0 11.04 8.95 20 20 20 11.04 0 20-8.96 20-20 0-11.05-8.96-20-20-20zm-4 30L10 24l2.83-2.83L20 28.34l15.17-15.17L38 16 20 34z" fill="%235E6268"/></svg>');
   --check-icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48"><path d="M0 0h48v48H0z" fill="none"/><path d="M18 32.34L9.66 24l-2.83 2.83L18 38l24-24-2.83-2.83z"/></svg>');
 
-  --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="hsl(216, 5%, 39%)"/><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="hsl(216, 5%, 39%)"/></svg>');
+  --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>');
 }
 
 .lh-vars.lh-devtools {
@@ -148,6 +150,11 @@
   display: none !important;
 }
 
+/* Edge doesn't recognize these, and the polyfill doesn't set block. https://github.com/javan/details-element-polyfill/issues/13 */
+details, summary {
+  display: block;
+}
+
 .lh-root details > summary {
   cursor: pointer;
 }
@@ -310,7 +317,8 @@
   background-color: #F8F9FA;
 }
 
-/* Hide the expandable arrow icon, cross-browser */
+/* Hide the expandable arrow icon, three ways: via the CSS Counter Styles spec, for webkit/blink browsers, hiding the polyfilled icon */
+/* https://github.com/javan/details-element-polyfill/blob/master/src/details-element-polyfill/polyfill.sass */
 .lh-audit-group > summary,
 .lh-expandable-details > summary {
   list-style-type: none;
@@ -319,6 +327,10 @@
 .lh-expandable-details > summary::-webkit-details-marker {
   display: none;
 }
+.lh-audit-group > summary:before,
+.lh-expandable-details > summary:before {
+  display: none;
+}
 
 
 /* Perf Metric */
@@ -966,6 +978,8 @@
  */
 .lh-chevron {
   --chevron-angle: 42deg;
+  /* Edge doesn't support transform: rotate(calc(...)), so we define it here */
+  --chevron-angle-right: -42deg;
   width: var(--chevron-size);
   height: var(--chevron-size);
   margin-top: calc((var(--body-line-height) - 12px) / 2);
@@ -988,7 +1002,7 @@
 .lh-audit-group[open] > summary > .lh-audit-group__summary > .lh-chevron .lh-chevron__line-left,
 .lh-audit > .lh-expandable-details .lh-chevron__line-right,
 .lh-audit > .lh-expandable-details[open] .lh-chevron__line-left {
- transform: rotate(calc(var(--chevron-angle) * -1));
+ transform: rotate(var(--chevron-angle-right));
 }
 
 .lh-audit-group[open] > summary > .lh-audit-group__summary > .lh-chevron .lh-chevron__line-right,
diff --git a/lighthouse-core/report/html/templates.html b/lighthouse-core/report/html/templates.html
index 6cb4d9979..d280232b2 100644
--- a/lighthouse-core/report/html/templates.html
+++ b/lighthouse-core/report/html/templates.html
@@ -37,8 +37,8 @@ limitations under the License.
 <template id="tmpl-lh-chevron">
   <svg class="lh-chevron" title="See audits" xmlns="http://www.w3.org/2000/svg"  viewbox="0 0 100 100">
     <g class="lh-chevron__lines">
-      <path class="lh-chevron__line lh-chevron__line-left" d="M10 50h40" stroke="#707173"/>
-      <path class="lh-chevron__line lh-chevron__line-right" d="M90 50H50" stroke="#707173"/>
+      <path class="lh-chevron__line lh-chevron__line-left" d="M10 50h40" stroke="#707173"></path>
+      <path class="lh-chevron__line lh-chevron__line-right" d="M90 50H50" stroke="#707173"></path>
     </g>
   </svg>
 </template>
@@ -168,7 +168,7 @@ limitations under the License.
       position: absolute;
       top: var(--report-header-height);
       right: 50%;
-      transform: translate3d(calc(var(--report-width) / 2), -100%, 0);
+      transform: translate3d(var(--report-width-half), -100%, 0);
       opacity: 1;
       transform-origin: bottom right;
       will-change: transform, opacity;
@@ -359,7 +359,7 @@ limitations under the License.
     <svg width="217" height="148" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
       <defs>
         <mask id="a" x="-56" y="-54" width="284" height="202" maskUnits="userSpaceOnUse">
-          <path d="M-56-54h284v202H-56z" fill="#fff"/>
+          <path d="M-56-54h284v202H-56z" fill="#fff"></path>
         </mask>
         <linearGradient id="b" x1="-525.16" y1="560.08" x2="-524.23" y2="560.08" gradientTransform="matrix(91 0 0 -77 47797 43181)" gradientUnits="userSpaceOnUse">
           <stop offset="0" stop-color="#f1f3f4"/>
diff --git a/lighthouse-core/report/html/third_party/LICENSE b/lighthouse-core/report/html/third_party/LICENSE
new file mode 100644
index 000000000..1695fd0a0
--- /dev/null
+++ b/lighthouse-core/report/html/third_party/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2018 Javan Makhmali
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/lighthouse-core/report/html/third_party/details-element-polyfill.js b/lighthouse-core/report/html/third_party/details-element-polyfill.js
new file mode 100644
index 000000000..9ce5eb588
--- /dev/null
+++ b/lighthouse-core/report/html/third_party/details-element-polyfill.js
@@ -0,0 +1,295 @@
+/*
+Details Element Polyfill 2.0.4
+Copyright © 2018 Javan Makhmali
+ */
+
+// COMPAT: Remove when Edge supports <details>/<summary>
+
+(function() {
+  var addAttributesForSummary, eventIsSignificant, findClosestElementWithTagName, findElementsWithTagName, onTogglingTrigger, polyfillFocusAndARIA, polyfillProperties, polyfillStyles, polyfillToggle, polyfillToggleEvent, support, triggerToggle, triggerToggleIfToggled;
+
+  // Exit early if we're not in a page
+  // This line added by Lighthouse
+  if (typeof document === 'undefined' || !('createElement' in document)) return;
+
+  support = {
+    element: (function() {
+      var closedHeight, element, openedHeight, parent, ref;
+      element = document.createElement("details");
+      if (!("open" in element)) {
+        return false;
+      }
+      element.innerHTML = "<summary>a</summary>b";
+      element.setAttribute("style", "position: absolute; left: -9999px");
+      parent = (ref = document.body) != null ? ref : document.documentElement;
+      parent.appendChild(element);
+      closedHeight = element.offsetHeight;
+      element.open = true;
+      openedHeight = element.offsetHeight;
+      parent.removeChild(element);
+      return closedHeight !== openedHeight;
+    })(),
+    toggleEvent: (function() {
+      var element;
+      element = document.createElement("details");
+      return "ontoggle" in element;
+    })()
+  };
+
+  if (support.element && support.toggleEvent) {
+    return;
+  }
+
+  polyfillStyles = function() {
+    return document.head.insertAdjacentHTML("afterbegin", "<style>@charset\"UTF-8\";details:not([open])>*:not(summary){display:none;}details>summary{display:block;}details>summary::before{content:\"►\";padding-right:0.3rem;font-size:0.6rem;cursor:default;}details[open]>summary::before{content:\"▼\";}</style>");
+  };
+
+  polyfillProperties = function() {
+    var open, prototype, removeAttribute, setAttribute;
+    prototype = document.createElement("details").constructor.prototype;
+    setAttribute = prototype.setAttribute, removeAttribute = prototype.removeAttribute;
+    open = Object.getOwnPropertyDescriptor(prototype, "open");
+    return Object.defineProperties(prototype, {
+      open: {
+        get: function() {
+          var ref;
+          if (this.tagName === "DETAILS") {
+            return this.hasAttribute("open");
+          } else {
+            return open != null ? (ref = open.get) != null ? ref.call(this) : void 0 : void 0;
+          }
+        },
+        set: function(value) {
+          var ref;
+          if (this.tagName === "DETAILS") {
+            if (value) {
+              this.setAttribute("open", "");
+            } else {
+              this.removeAttribute("open");
+            }
+            return value;
+          } else {
+            return open != null ? (ref = open.set) != null ? ref.call(this, value) : void 0 : void 0;
+          }
+        }
+      },
+      setAttribute: {
+        value: function(name, value) {
+          return triggerToggleIfToggled(this, (function(_this) {
+            return function() {
+              return setAttribute.call(_this, name, value);
+            };
+          })(this));
+        }
+      },
+      removeAttribute: {
+        value: function(name) {
+          return triggerToggleIfToggled(this, (function(_this) {
+            return function() {
+              return removeAttribute.call(_this, name);
+            };
+          })(this));
+        }
+      }
+    });
+  };
+
+  polyfillToggle = function() {
+    return onTogglingTrigger(function(element) {
+      var summary;
+      summary = element.querySelector("summary");
+      if (element.hasAttribute("open")) {
+        element.removeAttribute("open");
+        return summary.setAttribute("aria-expanded", false);
+      } else {
+        element.setAttribute("open", "");
+        return summary.setAttribute("aria-expanded", true);
+      }
+    });
+  };
+
+  polyfillFocusAndARIA = function() {
+    var i, len, observer, ref, summary;
+    ref = findElementsWithTagName(document.documentElement, "SUMMARY");
+    for (i = 0, len = ref.length; i < len; i++) {
+      summary = ref[i];
+      addAttributesForSummary(summary);
+    }
+    if (typeof MutationObserver !== "undefined" && MutationObserver !== null) {
+      observer = new MutationObserver(function(mutations) {
+        var addedNodes, j, len1, results, target;
+        results = [];
+        for (j = 0, len1 = mutations.length; j < len1; j++) {
+          addedNodes = mutations[j].addedNodes;
+          results.push((function() {
+            var k, len2, results1;
+            results1 = [];
+            for (k = 0, len2 = addedNodes.length; k < len2; k++) {
+              target = addedNodes[k];
+              results1.push((function() {
+                var l, len3, ref1, results2;
+                ref1 = findElementsWithTagName(target, "SUMMARY");
+                results2 = [];
+                for (l = 0, len3 = ref1.length; l < len3; l++) {
+                  summary = ref1[l];
+                  results2.push(addAttributesForSummary(summary));
+                }
+                return results2;
+              })());
+            }
+            return results1;
+          })());
+        }
+        return results;
+      });
+      return observer.observe(document.documentElement, {
+        subtree: true,
+        childList: true
+      });
+    } else {
+      return document.addEventListener("DOMNodeInserted", function(event) {
+        var j, len1, ref1, results;
+        ref1 = findElementsWithTagName(event.target, "SUMMARY");
+        results = [];
+        for (j = 0, len1 = ref1.length; j < len1; j++) {
+          summary = ref1[j];
+          results.push(addAttributesForSummary(summary));
+        }
+        return results;
+      });
+    }
+  };
+
+  addAttributesForSummary = function(summary, details) {
+    if (details == null) {
+      details = findClosestElementWithTagName(summary, "DETAILS");
+    }
+    summary.setAttribute("aria-expanded", details.hasAttribute("open"));
+    if (!summary.hasAttribute("tabindex")) {
+      summary.setAttribute("tabindex", "0");
+    }
+    if (!summary.hasAttribute("role")) {
+      return summary.setAttribute("role", "button");
+    }
+  };
+
+  polyfillToggleEvent = function() {
+    var observer;
+    if (typeof MutationObserver !== "undefined" && MutationObserver !== null) {
+      observer = new MutationObserver(function(mutations) {
+        var attributeName, i, len, ref, results, target;
+        results = [];
+        for (i = 0, len = mutations.length; i < len; i++) {
+          ref = mutations[i], target = ref.target, attributeName = ref.attributeName;
+          if (target.tagName === "DETAILS" && attributeName === "open") {
+            results.push(triggerToggle(target));
+          } else {
+            results.push(void 0);
+          }
+        }
+        return results;
+      });
+      return observer.observe(document.documentElement, {
+        attributes: true,
+        subtree: true
+      });
+    } else {
+      return onTogglingTrigger(function(element) {
+        var open;
+        open = element.getAttribute("open");
+        return setTimeout(function() {
+          if (element.getAttribute("open") !== open) {
+            return triggerToggle(element);
+          }
+        }, 1);
+      });
+    }
+  };
+
+  eventIsSignificant = function(event) {
+    return !(event.defaultPrevented || event.ctrlKey || event.metaKey || event.shiftKey || event.target.isContentEditable);
+  };
+
+  onTogglingTrigger = function(callback) {
+    addEventListener("click", function(event) {
+      var element, ref;
+      if (eventIsSignificant(event) && event.which <= 1) {
+        if (element = findClosestElementWithTagName(event.target, "SUMMARY")) {
+          if (((ref = element.parentElement) != null ? ref.tagName : void 0) === "DETAILS") {
+            return callback(element.parentElement);
+          }
+        }
+      }
+    }, false);
+    return addEventListener("keydown", function(event) {
+      var element, ref, ref1;
+      if (eventIsSignificant(event) && ((ref = event.keyCode) === 13 || ref === 32)) {
+        if (element = findClosestElementWithTagName(event.target, "SUMMARY")) {
+          if (((ref1 = element.parentElement) != null ? ref1.tagName : void 0) === "DETAILS") {
+            callback(element.parentElement);
+            return event.preventDefault();
+          }
+        }
+      }
+    }, false);
+  };
+
+  findElementsWithTagName = function(root, tagName) {
+    var elements;
+    elements = [];
+    if (root.nodeType === Node.ELEMENT_NODE) {
+      if (root.tagName === tagName) {
+        elements.push(root);
+      }
+      elements.push.apply(elements, root.getElementsByTagName(tagName));
+    }
+    return elements;
+  };
+
+  findClosestElementWithTagName = (function() {
+    if (typeof Element.prototype.closest === "function") {
+      return function(element, tagName) {
+        return element.closest(tagName);
+      };
+    } else {
+      return function(element, tagName) {
+        while (element) {
+          if (element.tagName === tagName) {
+            return element;
+          } else {
+            element = element.parentNode;
+          }
+        }
+      };
+    }
+  })();
+
+  triggerToggle = function(element) {
+    var event;
+    event = document.createEvent("Events");
+    event.initEvent("toggle", true, false);
+    return element.dispatchEvent(event);
+  };
+
+  triggerToggleIfToggled = function(element, fn) {
+    var open, result;
+    open = element.getAttribute("open");
+    result = fn();
+    if (element.getAttribute("open") !== open) {
+      triggerToggle(element);
+    }
+    return result;
+  };
+
+  if (!support.element) {
+    polyfillStyles();
+    polyfillProperties();
+    polyfillToggle();
+    polyfillFocusAndARIA();
+  }
+
+  if (support.element && !support.toggleEvent) {
+    polyfillToggleEvent();
+  }
+
+}).call(this);
diff --git a/lighthouse-core/scripts/generate-timing-trace.js b/lighthouse-core/scripts/generate-timing-trace.js
index 055311404..a4ad0c230 100644
--- a/lighthouse-core/scripts/generate-timing-trace.js
+++ b/lighthouse-core/scripts/generate-timing-trace.js
@@ -11,7 +11,7 @@ const {createTraceString} = require('../lib/timing-trace-saver');
 
 /**
  * @fileoverview This script takes the timing entries saved during a Lighthouse run and generates
- * a trace file that's readable in chrome://tracing.
+ * a trace file that's readable in DevTools perf panel or chrome://tracing.
  *
  * input = LHR.json
  * output = LHR.timing.trace.json
@@ -50,7 +50,7 @@ function saveTraceFromCLI() {
   // eslint-disable-next-line no-console
   console.log(`
   > Timing trace file saved to: ${traceFilePath}
-  > Open this file in chrome://tracing
+  > Open this file in DevTools perf panel   (For --audit-mode runs, use chrome://tracing instead)
 `);
 }
 
diff --git a/tsconfig.json b/tsconfig.json
index 88bcb7e99..76b4e9b74 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -25,6 +25,7 @@
   ],
   "exclude": [
     "lighthouse-cli/test/**/*.js",
+    "lighthouse-core/report/html/third_party/",
     "lighthouse-core/test/**/*.js",
     "clients/test/**/*.js",
   ]


Test patch:
diff --git a/lighthouse-core/test/lib/__snapshots__/timing-trace-saver-test.js.snap b/lighthouse-core/test/lib/__snapshots__/timing-trace-saver-test.js.snap
new file mode 100644
index 000000000..65eca3948
--- /dev/null
+++ b/lighthouse-core/test/lib/__snapshots__/timing-trace-saver-test.js.snap
@@ -0,0 +1,137 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`createTraceString creates a real trace 1`] = `
+Array [
+  Object {
+    "args": Object {},
+    "cat": "blink.user_timing",
+    "dur": 0,
+    "id": "0x0",
+    "name": "init꞉config",
+    "ph": "b",
+    "pid": 0,
+    "tid": 0,
+    "ts": 650000,
+  },
+  Object {
+    "args": Object {},
+    "cat": "blink.user_timing",
+    "dur": 0,
+    "id": "0x0",
+    "name": "init꞉config",
+    "ph": "e",
+    "pid": 0,
+    "tid": 0,
+    "ts": 860000,
+  },
+  Object {
+    "args": Object {},
+    "cat": "blink.user_timing",
+    "dur": 0,
+    "id": "0x1",
+    "name": "runner꞉run",
+    "ph": "b",
+    "pid": 0,
+    "tid": 0,
+    "ts": 870000,
+  },
+  Object {
+    "args": Object {},
+    "cat": "blink.user_timing",
+    "dur": 0,
+    "id": "0x1",
+    "name": "runner꞉run",
+    "ph": "e",
+    "pid": 0,
+    "tid": 0,
+    "ts": 990000,
+  },
+  Object {
+    "args": Object {},
+    "cat": "blink.user_timing",
+    "dur": 0,
+    "id": "0x2",
+    "name": "runner꞉auditing",
+    "ph": "b",
+    "pid": 0,
+    "tid": 0,
+    "ts": 990000,
+  },
+  Object {
+    "args": Object {},
+    "cat": "blink.user_timing",
+    "dur": 0,
+    "id": "0x2",
+    "name": "runner꞉auditing",
+    "ph": "e",
+    "pid": 0,
+    "tid": 0,
+    "ts": 1740000,
+  },
+  Object {
+    "args": Object {},
+    "cat": "blink.user_timing",
+    "dur": 0,
+    "id": "0x3",
+    "name": "audit꞉is-on-https",
+    "ph": "b",
+    "pid": 0,
+    "tid": 0,
+    "ts": 1010000,
+  },
+  Object {
+    "args": Object {},
+    "cat": "blink.user_timing",
+    "dur": 0,
+    "id": "0x3",
+    "name": "audit꞉is-on-https",
+    "ph": "e",
+    "pid": 0,
+    "tid": 0,
+    "ts": 1020000,
+  },
+  Object {
+    "args": Object {
+      "data": Object {
+        "frameTreeNodeId": 1,
+        "frames": Array [],
+        "persistentIds": true,
+      },
+    },
+    "cat": "disabled-by-default-devtools.timeline",
+    "dur": 0,
+    "name": "TracingStartedInBrowser",
+    "ph": "I",
+    "pid": 0,
+    "tid": 0,
+    "ts": 0,
+  },
+]
+`;
+
+exports[`generateTraceEvents generates a pair of trace events 1`] = `
+Array [
+  Object {
+    "args": Object {},
+    "cat": "blink.user_timing",
+    "dur": 0,
+    "id": "0x0",
+    "name": "init꞉config",
+    "ph": "b",
+    "pid": 0,
+    "tid": 0,
+    "ts": 650000,
+  },
+  Object {
+    "args": Object {},
+    "cat": "blink.user_timing",
+    "dur": 0,
+    "id": "0x0",
+    "name": "init꞉config",
+    "ph": "e",
+    "pid": 0,
+    "tid": 0,
+    "ts": 860000,
+  },
+]
+`;
diff --git a/lighthouse-core/test/lib/timing-trace-saver-test.js b/lighthouse-core/test/lib/timing-trace-saver-test.js
index be3749e9d..fa128c5a4 100644
--- a/lighthouse-core/test/lib/timing-trace-saver-test.js
+++ b/lighthouse-core/test/lib/timing-trace-saver-test.js
@@ -9,7 +9,6 @@
 
 /* eslint-disable no-console */
 
-const assert = require('assert');
 const {generateTraceEvents, createTraceString} = require('../../lib/timing-trace-saver');
 
 const mockEntries = [{
@@ -37,92 +36,11 @@ const mockEntries = [{
   entryType: 'measure',
 },
 ];
-const expectedTrace = {
-  traceEvents: [{
-    name: 'lh:init:config',
-    cat: 'measure',
-    ts: 650000,
-    dur: 210000,
-    args: {},
-    pid: 0,
-    tid: 50,
-    ph: 'X',
-    id: '0x0',
-  },
-  {
-    name: 'lh:runner:run',
-    cat: 'measure',
-    ts: 870000,
-    dur: 120000,
-    args: {},
-    pid: 0,
-    tid: 50,
-    ph: 'X',
-    id: '0x1',
-  },
-  {
-    name: 'lh:runner:auditing',
-    cat: 'measure',
-    ts: 990000,
-    dur: 750000,
-    args: {},
-    pid: 0,
-    tid: 50,
-    ph: 'X',
-    id: '0x2',
-  },
-  {
-    name: 'lh:audit:is-on-https',
-    cat: 'measure',
-    ts: 1010000,
-    dur: 10000,
-    args: {},
-    pid: 0,
-    tid: 50,
-    ph: 'X',
-    id: '0x3',
-  },
-  ],
-};
-
 
 describe('generateTraceEvents', () => {
-  let consoleError;
-  let origConsoleError;
-
-  beforeEach(() => {
-    origConsoleError = console.error;
-    consoleError = jest.fn();
-    console.error = consoleError;
-  });
-
-  afterEach(() => {
-    console.error = origConsoleError;
-  });
-
-  it('generates a single trace event', () => {
-    const event = generateTraceEvents(mockEntries);
-    assert.deepStrictEqual(event.slice(0, 1), expectedTrace.traceEvents.slice(0, 1));
-  });
-
-  it('doesn\'t allow overlapping events', () => {
-    const overlappingEntries = [{
-      startTime: 10,
-      name: 'overlap1',
-      duration: 100,
-      entryType: 'measure',
-    },
-    {
-      startTime: 30,
-      name: 'overlap2',
-      duration: 100,
-      entryType: 'measure',
-    },
-    ];
-
-    generateTraceEvents(overlappingEntries);
-    expect(consoleError).toHaveBeenCalled();
-    expect(consoleError.mock.calls[0][0]).toContain('measures overlap');
+  it('generates a pair of trace events', () => {
+    const events = generateTraceEvents([mockEntries[0]]);
+    expect(events.slice(0, 2)).toMatchSnapshot();
   });
 });
 
@@ -135,6 +53,6 @@ describe('createTraceString', () => {
     });
     const traceJson = JSON.parse(jsonStr);
     const eventsWithoutMetadata = traceJson.traceEvents.filter(e => e.cat !== '__metadata');
-    assert.deepStrictEqual(eventsWithoutMetadata, expectedTrace.traceEvents);
+    expect(eventsWithoutMetadata).toMatchSnapshot();
   });
 });
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 39bfbb9c8..dfa30610d 100644
--- a/lighthouse-core/test/report/html/renderer/category-renderer-test.js
+++ b/lighthouse-core/test/report/html/renderer/category-renderer-test.js
@@ -145,6 +145,13 @@ describe('CategoryRenderer', () => {
     assert.ok(categoryDOM.querySelector('.lh-audit-group--manual .lh-audit-group__summary'));
     assert.equal(categoryDOM.querySelectorAll('.lh-audit--manual').length, 3,
         'score shows informative and dash icon');
+
+    assert.ok(pwaCategory.manualDescription);
+    const description = categoryDOM
+      .querySelector('.lh-audit-group--manual .lh-audit-group__description').textContent;
+    // may need to be adjusted if description includes a link at the beginning
+    assert.ok(description.startsWith(pwaCategory.manualDescription.substring(0, 20)),
+        'no manual description');
   });
 
   it('renders not applicable audits if the category contains them', () => {
@@ -218,6 +225,55 @@ describe('CategoryRenderer', () => {
       const auditsElements = categoryDOM.querySelectorAll('.lh-audit');
       assert.equal(auditsElements.length, category.auditRefs.length);
     });
+
+    it('increments the audit index across groups', () => {
+      const elem = renderer.render(category, sampleResults.categoryGroups);
+
+      const passedAudits = elem.querySelectorAll('.lh-passed-audits .lh-audit__index');
+      const failedAudits = elem.querySelectorAll('.lh-failed-audits .lh-audit__index');
+      const manualAudits = elem.querySelectorAll('.lh-audit-group--manual .lh-audit__index');
+      const notApplicableAudits =
+        elem.querySelectorAll('.lh-audit-group--not-applicable .lh-audit__index');
+
+      const assertAllTheIndices = (nodeList) => {
+        // Must be at least one for a decent test.
+        assert.ok(nodeList.length > 0);
+
+        // Assert indices are continuous, starting at 1.
+        nodeList.forEach((node, i) => {
+          const auditIndex = Number.parseInt(node.textContent);
+          assert.strictEqual(auditIndex, i + 1);
+        });
+      };
+
+      assertAllTheIndices(passedAudits);
+      assertAllTheIndices(failedAudits);
+      assertAllTheIndices(manualAudits);
+      assertAllTheIndices(notApplicableAudits);
+    });
+
+    it('renders audits without a group before grouped ones', () => {
+      const categoryClone = JSON.parse(JSON.stringify(category));
+
+      // Remove groups from some audits.
+      const ungroupedAudits = ['color-contrast', 'image-alt', 'link-name'];
+      for (const auditRef of categoryClone.auditRefs) {
+        if (ungroupedAudits.includes(auditRef.id)) {
+          assert.ok(auditRef.group); // Make sure this will change something.
+          delete auditRef.group;
+        }
+      }
+
+      const elem = renderer.render(categoryClone, sampleResults.categoryGroups);
+
+      // Check that the first audits found are the ungrouped ones.
+      const auditElems = Array.from(elem.querySelectorAll('.lh-audit'));
+      const firstAuditElems = auditElems.slice(0, ungroupedAudits.length);
+      for (const auditElem of firstAuditElems) {
+        const auditId = auditElem.id;
+        assert.ok(ungroupedAudits.includes(auditId), auditId);
+      }
+    });
   });
 
   describe('grouping passed/failed/manual', () => {