Repository: GoogleChrome/lighthouse
Pull request #7177 (2019-02-07 06:07:37)

core(tsc): fix audit details type hierarchy
Finally maps out types for the `details` objects!

This PR was already getting long enough, so this (mostly) only touches the production of `details` object in audits. I'll follow up with a change to make `details-renderer.js` and etc use these types. I already have that working in a branch, so I promise it will work on that side as well :)

For the major thrust of things see `audit-details.d.ts` (and what was deleted from `audit.d.ts`). Everything else is mostly just switching over to use the types, not changes to the audits themselves. 

The new `details` structure just describes what we currently do: there are a few details object shapes that we use directly (`criticalrequestchain`, `filmstrip`, `opportunity`, `screenshot`, `table`, and now `diagnostic`), then a bunch of things that are only ever used inside those details (`text`, `url`, 'code', etc). If and when we need some of those `code` or whatever as top-level details, it's easy to add them to the union of `details` output by audits, just like it's easy to add additional properties.

There are two relatively small changes to audit output:
- no more `null` in `details`. This was a minor thing, but we only used it in two non-visible audits, 
- as discussed in chat, a new `'diagnostic'` type. There are plenty of places you can still stick values that aren't visible in the final report in `details` (like unreferenced properties on table `items`), but this is the place to stick full-on objects or entire `details` objects like `metrics.js` or `diagnostics.js`. It's an explicit signal that a property won't be in the html report, and it's a way of keeping the `details` types relatively narrow so that type checking is useful (you can still put anything in them, but they just have to be labeled with `type: 'diagnostic'`).

  In practice the effect of both are relatively minor (at most you get some `diagnostic` data one level deeper in the a11y audits). Check out the changes to `sample_v2.json` for all the LHR changes that occur.


Patch:
diff --git a/docs/variability.md b/docs/variability.md
new file mode 100644
index 000000000..6be848435
--- /dev/null
+++ b/docs/variability.md
@@ -0,0 +1,85 @@
+# Score Variability
+
+## Summary
+
+Lighthouse performance scores will change due to inherent variability in web and network technologies, even if there hasn't been a code change. Run Lighthouse multiple times and beware of variability before drawing conclusions about a performance-impacting change.
+
+## Sources of Variability
+
+Variability in performance measurement is introduced via a number of channels with different levels of impact. Below is a table containing several common sources of metric variability, the typical impact they have on results, and the extent to which they are likely to occur in different environments.
+
+| Source                      | Impact | Typical End User | PageSpeed Insights | Controlled Lab |
+| --------------------------- | ------ | ---------------- | ------------------ | -------------- |
+| Page nondeterminism         | High   | LIKELY           | LIKELY             | LIKELY         |
+| Local network variability   | High   | LIKELY           | UNLIKELY           | UNLIKELY       |
+| Tier-1 network variability  | Medium | POSSIBLE         | POSSIBLE           | POSSIBLE       |
+| Web server variability      | Low    | LIKELY           | LIKELY             | LIKELY         |
+| Client hardware variability | High   | LIKELY           | UNLIKELY           | UNLIKELY       |
+| Client resource contention  | High   | LIKELY           | POSSIBLE           | UNLIKELY       |
+| Browser nondeterminism      | Medium | CERTAIN          | CERTAIN            | CERTAIN        |
+
+Below are more detailed descriptions of the sources of variance and the impact they have on the most likely combinations of Lighthouse runtime + environment. While applied throttling and simulated throttling approaches could be used in any of these three environments, the typical end user uses simulated throttling.
+
+### Page Nondeterminism
+
+Pages can contain logic that is nondeterministic that changes the way a user experiences a page, i.e. an A/B test that changes the layout and assets loaded or a different ad experience based on campaign progress. This is an intentional and irremovable source of variance. If the page changes in a way that hurts performance, Lighthouse should be able to identify this case. The only mitigation here is on the part of the site owner in ensuring that the exact same version of the page is being tested between different runs.
+
+### Local Network Variability
+
+Local networks have inherent variability from packet loss, variable traffic prioritization, and last-mile network congestion. Users with cheap routers and many devices sharing limited bandwidth are usually the most susceptible to this. _Applied_ throttling partially mitigates these effects by applying a minimum request latency and maximum throughput that masks underlying retries. _Simulated_ throttling mitigates these effects by replaying network activity on its own.
+
+### Tier-1 Network Variability
+
+Network interconnects are generally very stable and have minimal impact but cross-geo requests, i.e. measuring performance of a Chinese site from the US, can start to experience a high degree of latency introduced from tier-1 network hops. _Applied_ throttling partially mask these effects with network throttling. _Simulated_ throttling mitigates these effects by replaying network activity on its own.
+
+### Web Server Variability
+
+Web servers have variable load and do not always respond with the same delay. Lower-traffic sites with shared hosting infrastructure are typically more susceptible to this. _Applied_ throttling partially masks these effects by applying a minimum request latency in its network throttling. _Simulated_ throttling is susceptible to this effect but the overall impact is usually low when compared to other network variability.
+
+### Client Hardware Variability
+
+The hardware on which the webpage is loading can greatly impact performance. _Applied_ throttling cannot do much to mitigate this issue. _Simulated_ throttling partially mitigates this issue by capping the theoretical execution time of CPU tasks during simulation.
+
+### Client Resource Contention
+
+Other applications running on the same machine while Lighthouse is running can cause contention for CPU, memory, and network resources. Malware, browser extensions, and anti-virus software have particularly strong impacts on web performance. Multi-tenant server environments (such as Travis, AWS, etc) can also suffer from these issues. Running multiple instances of Lighthouse at once also typically distorts results due to this problem. _Applied_ throttling is susceptible to this issue. _Simulated_ throttling partially mitigates this issue by replaying network activity on its own and capping CPU execution.
+
+### Browser Nondeterminism
+
+Browsers have inherent variability in their execution of tasks that impacts the way webpages are loaded. This is unavoidable for applied throttling as at the end of the day they are simply reporting whatever was observed by the browser. _Simulated_ throttling is able to partially mitigate this effect by simulating execution on its own, only re-using task execution times from the browser in its estimate.
+
+### Effect of Throttling Strategies
+
+Below is a table containing several common sources of metric variability, the typical impact they have on results, and the extent to which different Lighthouse throttling strategies are able to mitigate their effect. Learn more about different throttling strategies in our [throttling documentation](./throttling.md).
+
+| Source                      | Impact | Simulated Throttling | Applied Throttling  | No Throttling |
+| --------------------------- | ------ | -------------------- | ------------------- | ------------- |
+| Page nondeterminism         | High   | NO MITIGATION        | NO MITIGATION       | NO MITIGATION |
+| Local network variability   | High   | MITIGATED            | PARTIALLY MITIGATED | NO MITIGATION |
+| Tier-1 network variability  | Medium | MITIGATED            | PARTIALLY MITIGATED | NO MITIGATION |
+| Web server variability      | Low    | NO MITIGATION        | PARTIALLY MITIGATED | NO MITIGATION |
+| Client hardware variability | High   | PARTIALLY MITIGATED  | NO MITIGATION       | NO MITIGATION |
+| Client resource contention  | High   | PARTIALLY MITIGATED  | NO MITIGATION       | NO MITIGATION |
+| Browser nondeterminism      | Medium | PARTIALLY MITIGATED  | NO MITIGATION       | NO MITIGATION |
+
+## Strategies for Dealing With Variance
+
+### Isolate External Factors
+
+- Isolate your page from third-party influence as much as possible. It’s never fun to be blamed for someone else's variable failures.
+- Isolate your own code’s nondeterminism during testing. If you’ve got an animation that randomly shows up, your performance numbers might be random too!
+- Isolate your test server from as much network volatility as possible. Use localhost or a machine on the same exact network whenever stability is a concern.
+- Isolate your client environment from external influences like anti-virus software and browser extensions. Use a dedicated device for testing when possible.
+
+If your machine has really limited resources or creating a clean environment has been difficult, use a hosted lab environment like PageSpeed Insights or WebPageTest to run your tests for you. In continuous integration situations, use dedicated servers when possible. Free CI environments and “burstable” instances are typically quite volatile.
+
+### Run Lighthouse Multiple Times
+
+When creating your thresholds for failure, either mental or programmatic, use aggregate values like the median, 90th percentile, or even min instead of single tests.
+
+The median Lighthouse score of 5 runs is twice as stable as 1 run, and tools like [pwmetrics](https://github.com/paulirish/pwmetrics) can run Lighthouse for you automatically. Using the minimum value is also a big improvement over not testing at all and is incredibly simple to implement, just run Lighthouse up to 5 times until it passes!
+
+## Related Documentation
+
+- [Lighthouse Variability and Accuracy Analysis](https://docs.google.com/document/d/1BqtL-nG53rxWOI5RO0pItSRPowZVnYJ_gBEQCJ5EeUE/edit?usp=sharing)
+- [Throttling documentation](./throttling.md)
diff --git a/lighthouse-core/audits/accessibility/axe-audit.js b/lighthouse-core/audits/accessibility/axe-audit.js
index 1b2fefa6f..cef8c9896 100644
--- a/lighthouse-core/audits/accessibility/axe-audit.js
+++ b/lighthouse-core/audits/accessibility/axe-audit.js
@@ -44,11 +44,11 @@ class AxeAudit extends Audit {
     const impact = rule && rule.impact;
     const tags = rule && rule.tags;
 
-    /** @type {Array<{node: LH.Audit.DetailsRendererNodeDetailsJSON}>} */
+    /** @type {LH.Audit.Details.Table['items']}>} */
     let items = [];
     if (rule && rule.nodes) {
       items = rule.nodes.map(node => ({
-        node: /** @type {LH.Audit.DetailsRendererNodeDetailsJSON} */ ({
+        node: /** @type {LH.Audit.Details.NodeValue} */ ({
           type: 'node',
           selector: Array.isArray(node.target) ? node.target.join(' ') : '',
           path: node.path,
@@ -58,16 +58,27 @@ class AxeAudit extends Audit {
       }));
     }
 
+    /** @type {LH.Audit.Details.Table['headings']} */
     const headings = [
       {key: 'node', itemType: 'node', text: str_(UIStrings.failingElementsHeader)},
     ];
 
+    /** @type {LH.Audit.Details.Diagnostic|undefined} */
+    let diagnostic;
+    if (impact || tags) {
+      diagnostic = {
+        type: 'diagnostic',
+        impact,
+        tags,
+      };
+    }
+
     return {
       rawValue: typeof rule === 'undefined',
       extendedInfo: {
         value: rule,
       },
-      details: {...Audit.makeTableDetails(headings, items), impact, tags},
+      details: {...Audit.makeTableDetails(headings, items), diagnostic},
     };
   }
 }
diff --git a/lighthouse-core/audits/audit.js b/lighthouse-core/audits/audit.js
index 98b44ba30..ce6188de9 100644
--- a/lighthouse-core/audits/audit.js
+++ b/lighthouse-core/audits/audit.js
@@ -102,10 +102,10 @@ class Audit {
   }
 
   /**
-   * @param {Array<LH.Audit.Heading>} headings
-   * @param {Array<Object<string, LH.Audit.DetailsItem>>} results
-   * @param {LH.Audit.DetailsRendererDetailsSummary=} summary
-   * @return {LH.Audit.DetailsRendererDetailsJSON}
+   * @param {LH.Audit.Details.Table['headings']} headings
+   * @param {LH.Audit.Details.Table['items']} results
+   * @param {LH.Audit.Details.Table['summary']=} summary
+   * @return {LH.Audit.Details.Table}
    */
   static makeTableDetails(headings, results, summary) {
     if (results.length === 0) {
@@ -126,8 +126,8 @@ class Audit {
   }
 
   /**
-   * @param {Array<LH.Audit.Details.OpportunityColumnHeading>} headings
-   * @param {Array<LH.Audit.Details.OpportunityItem>} items
+   * @param {LH.Audit.Details.Opportunity['headings']} headings
+   * @param {LH.Audit.Details.Opportunity['items']} items
    * @param {number} overallSavingsMs
    * @param {number=} overallSavingsBytes
    * @return {LH.Audit.Details.Opportunity}
diff --git a/lighthouse-core/audits/bootup-time.js b/lighthouse-core/audits/bootup-time.js
index 1c74d3ac0..c709e5e03 100644
--- a/lighthouse-core/audits/bootup-time.js
+++ b/lighthouse-core/audits/bootup-time.js
@@ -159,6 +159,7 @@ class BootupTime extends Audit {
 
     const summary = {wastedMs: totalBootupTime};
 
+    /** @type {LH.Audit.Details.Table['headings']} */
     const headings = [
       {key: 'url', itemType: 'url', text: str_(i18n.UIStrings.columnURL)},
       {key: 'total', granularity: 1, itemType: 'ms', text: str_(UIStrings.columnTotal)},
diff --git a/lighthouse-core/audits/byte-efficiency/total-byte-weight.js b/lighthouse-core/audits/byte-efficiency/total-byte-weight.js
index 1a796153e..3cfc199c5 100644
--- a/lighthouse-core/audits/byte-efficiency/total-byte-weight.js
+++ b/lighthouse-core/audits/byte-efficiency/total-byte-weight.js
@@ -86,6 +86,7 @@ class TotalByteWeight extends ByteEfficiencyAudit {
       context.options.scoreMedian
     );
 
+    /** @type {LH.Audit.Details.Table['headings']} */
     const headings = [
       {key: 'url', itemType: 'url', text: str_(i18n.UIStrings.columnURL)},
       {key: 'totalBytes', itemType: 'bytes', text: str_(i18n.UIStrings.columnSize)},
diff --git a/lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js b/lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js
index 2ce5f806e..4fd21e9f3 100644
--- a/lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js
+++ b/lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js
@@ -175,7 +175,7 @@ class CacheHeaders extends Audit {
     // Ignore assets where policy implies they should not be cached long periods
     if (cacheControl &&
       (
-        cacheControl['must-validate'] ||
+        cacheControl['must-revalidate'] ||
         cacheControl['no-cache'] ||
         cacheControl['no-store'] ||
         cacheControl['private'])) {
@@ -237,12 +237,19 @@ class CacheHeaders extends Audit {
         totalWastedBytes += wastedBytes;
         if (url.includes('?')) queryStringCount++;
 
+        // Include cacheControl info (if it exists) per url as a diagnostic.
+        /** @type {LH.Audit.Details.Diagnostic|undefined} */
+        let diagnostic;
+        if (cacheControl) {
+          diagnostic = {
+            type: 'diagnostic',
+            ...cacheControl,
+          };
+        }
+
         results.push({
           url,
-          // Include cacheControl in results, but cast as any so table types
-          // are happy. cacheControl is not shown in the table so this is OK.
-          // TODO(bckenny): fix DetailsItem
-          cacheControl: /** @type {any} */ (cacheControl),
+          diagnostic,
           cacheLifetimeMs: cacheLifetimeInSeconds * 1000,
           cacheHitProbability,
           totalBytes,
@@ -260,6 +267,7 @@ class CacheHeaders extends Audit {
         context.options.scoreMedian
       );
 
+      /** @type {LH.Audit.Details.Table['headings']} */
       const headings = [
         {key: 'url', itemType: 'url', text: str_(i18n.UIStrings.columnURL)},
         // TODO(i18n): pre-compute localized duration
diff --git a/lighthouse-core/audits/critical-request-chains.js b/lighthouse-core/audits/critical-request-chains.js
index 3b02ddd40..08a45ddff 100644
--- a/lighthouse-core/audits/critical-request-chains.js
+++ b/lighthouse-core/audits/critical-request-chains.js
@@ -211,7 +211,7 @@ class CriticalRequestChains extends Audit {
           },
         },
         details: {
-          type: 'criticalrequestchain',
+          type: /** @type {'criticalrequestchain'} */('criticalrequestchain'),
           chains: flattenedChains,
           longestChain,
         },
diff --git a/lighthouse-core/audits/deprecations.js b/lighthouse-core/audits/deprecations.js
index 0df9375c9..3a47a712f 100644
--- a/lighthouse-core/audits/deprecations.js
+++ b/lighthouse-core/audits/deprecations.js
@@ -45,6 +45,7 @@ class Deprecations extends Audit {
       };
     });
 
+    /** @type {LH.Audit.Details.Table['headings']} */
     const headings = [
       {key: 'value', itemType: 'code', text: 'Deprecation / Warning'},
       {key: 'url', itemType: 'url', text: 'URL'},
diff --git a/lighthouse-core/audits/diagnostics.js b/lighthouse-core/audits/diagnostics.js
index 481bb1bfb..e4c17c788 100644
--- a/lighthouse-core/audits/diagnostics.js
+++ b/lighthouse-core/audits/diagnostics.js
@@ -67,7 +67,11 @@ class Diagnostics extends Audit {
     return {
       score: 1,
       rawValue: 1,
-      details: {items: [diagnostics]},
+      details: {
+        type: 'diagnostic',
+        // TODO: Consider not nesting diagnostics under `items`.
+        items: [diagnostics],
+      },
     };
   }
 }
diff --git a/lighthouse-core/audits/dobetterweb/dom-size.js b/lighthouse-core/audits/dobetterweb/dom-size.js
index fa34ae8b7..046d5a42c 100644
--- a/lighthouse-core/audits/dobetterweb/dom-size.js
+++ b/lighthouse-core/audits/dobetterweb/dom-size.js
@@ -101,13 +101,14 @@ class DOMSize extends Audit {
       context.options.scoreMedian
     );
 
+    /** @type {LH.Audit.Details.Table['headings']} */
     const headings = [
       {key: 'statistic', itemType: 'text', text: str_(UIStrings.columnStatistic)},
       {key: 'element', itemType: 'code', text: str_(UIStrings.columnElement)},
       {key: 'value', itemType: 'numeric', text: str_(UIStrings.columnValue)},
     ];
 
-    /** @type {Array<Object<string, LH.Audit.DetailsItem>>} */
+    /** @type {LH.Audit.Details.Table['items']} */
     const items = [
       {
         statistic: str_(UIStrings.statisticDOMNodes),
diff --git a/lighthouse-core/audits/dobetterweb/external-anchors-use-rel-noopener.js b/lighthouse-core/audits/dobetterweb/external-anchors-use-rel-noopener.js
index d518d8a7e..a599dde47 100644
--- a/lighthouse-core/audits/dobetterweb/external-anchors-use-rel-noopener.js
+++ b/lighthouse-core/audits/dobetterweb/external-anchors-use-rel-noopener.js
@@ -55,6 +55,7 @@ class ExternalAnchorsUseRelNoopenerAudit extends Audit {
         };
       });
 
+    /** @type {LH.Audit.Details.Table['headings']} */
     const headings = [
       {key: 'href', itemType: 'url', text: 'URL'},
       {key: 'target', itemType: 'text', text: 'Target'},
diff --git a/lighthouse-core/audits/dobetterweb/geolocation-on-start.js b/lighthouse-core/audits/dobetterweb/geolocation-on-start.js
index 56d727d95..1269e29cc 100644
--- a/lighthouse-core/audits/dobetterweb/geolocation-on-start.js
+++ b/lighthouse-core/audits/dobetterweb/geolocation-on-start.js
@@ -37,6 +37,7 @@ class GeolocationOnStart extends ViolationAudit {
     // 'Only request geolocation information in response to a user gesture.'
     const results = ViolationAudit.getViolationResults(artifacts, /geolocation/);
 
+    /** @type {LH.Audit.Details.Table['headings']} */
     const headings = [
       {key: 'url', itemType: 'url', text: 'URL'},
       {key: 'label', itemType: 'text', text: 'Location'},
diff --git a/lighthouse-core/audits/dobetterweb/js-libraries.js b/lighthouse-core/audits/dobetterweb/js-libraries.js
index 3a970ff7f..9e24a02aa 100644
--- a/lighthouse-core/audits/dobetterweb/js-libraries.js
+++ b/lighthouse-core/audits/dobetterweb/js-libraries.js
@@ -32,10 +32,11 @@ class JsLibrariesAudit extends Audit {
   static audit(artifacts) {
     const libDetails = artifacts.JSLibraries.map(lib => ({
       name: lib.name,
-      version: lib.version, // null if not detected
-      npm: lib.npmPkgName || null, // ~70% of libs come with this field
+      version: lib.version || undefined, // null if not detected
+      npm: lib.npmPkgName || undefined, // ~70% of libs come with this field
     }));
 
+    /** @type {LH.Audit.Details.Table['headings']} */
     const headings = [
       {key: 'name', itemType: 'text', text: 'Name'},
       {key: 'version', itemType: 'text', text: 'Version'},
diff --git a/lighthouse-core/audits/dobetterweb/no-document-write.js b/lighthouse-core/audits/dobetterweb/no-document-write.js
index fa741a343..d78c5bb42 100644
--- a/lighthouse-core/audits/dobetterweb/no-document-write.js
+++ b/lighthouse-core/audits/dobetterweb/no-document-write.js
@@ -35,6 +35,7 @@ class NoDocWriteAudit extends ViolationAudit {
   static audit(artifacts) {
     const results = ViolationAudit.getViolationResults(artifacts, /document\.write/);
 
+    /** @type {LH.Audit.Details.Table['headings']} */
     const headings = [
       {key: 'url', itemType: 'url', text: 'URL'},
       {key: 'label', itemType: 'text', text: 'Location'},
diff --git a/lighthouse-core/audits/dobetterweb/no-vulnerable-libraries.js b/lighthouse-core/audits/dobetterweb/no-vulnerable-libraries.js
index cfa7fdf4b..1230e5c69 100644
--- a/lighthouse-core/audits/dobetterweb/no-vulnerable-libraries.js
+++ b/lighthouse-core/audits/dobetterweb/no-vulnerable-libraries.js
@@ -135,7 +135,7 @@ class NoVulnerableLibrariesAudit extends Audit {
     }
 
     let totalVulns = 0;
-    /** @type {Array<{highestSeverity: string, vulnCount: number, detectedLib: LH.Audit.DetailsRendererLinkDetailsJSON}>} */
+    /** @type {Array<{highestSeverity: string, vulnCount: number, detectedLib: LH.Audit.Details.LinkValue}>} */
     const vulnerabilityResults = [];
 
     const libraryVulns = foundLibraries.map(lib => {
@@ -175,6 +175,7 @@ class NoVulnerableLibrariesAudit extends Audit {
       displayValue = `${totalVulns} vulnerability detected`;
     }
 
+    /** @type {LH.Audit.Details.Table['headings']} */
     const headings = [
       {key: 'detectedLib', itemType: 'link', text: 'Library Version'},
       {key: 'vulnCount', itemType: 'text', text: 'Vulnerability Count'},
diff --git a/lighthouse-core/audits/dobetterweb/notification-on-start.js b/lighthouse-core/audits/dobetterweb/notification-on-start.js
index 67d260315..101e0a7b6 100644
--- a/lighthouse-core/audits/dobetterweb/notification-on-start.js
+++ b/lighthouse-core/audits/dobetterweb/notification-on-start.js
@@ -36,6 +36,7 @@ class NotificationOnStart extends ViolationAudit {
   static audit(artifacts) {
     const results = ViolationAudit.getViolationResults(artifacts, /notification permission/);
 
+    /** @type {LH.Audit.Details.Table['headings']} */
     const headings = [
       {key: 'url', itemType: 'url', text: 'URL'},
       {key: 'label', itemType: 'text', text: 'Location'},
diff --git a/lighthouse-core/audits/dobetterweb/password-inputs-can-be-pasted-into.js b/lighthouse-core/audits/dobetterweb/password-inputs-can-be-pasted-into.js
index bac18a9e7..27c090a89 100644
--- a/lighthouse-core/audits/dobetterweb/password-inputs-can-be-pasted-into.js
+++ b/lighthouse-core/audits/dobetterweb/password-inputs-can-be-pasted-into.js
@@ -29,7 +29,7 @@ class PasswordInputsCanBePastedIntoAudit extends Audit {
   static audit(artifacts) {
     const passwordInputsWithPreventedPaste = artifacts.PasswordInputsWithPreventedPaste;
 
-    /** @type {Array<{node: LH.Audit.DetailsRendererNodeDetailsJSON}>} */
+    /** @type {LH.Audit.Details.Table['items']} */
     const items = [];
     passwordInputsWithPreventedPaste.forEach(input => {
       items.push({
@@ -37,6 +37,7 @@ class PasswordInputsCanBePastedIntoAudit extends Audit {
       });
     });
 
+    /** @type {LH.Audit.Details.Table['headings']} */
     const headings = [
       {key: 'node', itemType: 'node', text: 'Failing Elements'},
     ];
diff --git a/lighthouse-core/audits/dobetterweb/uses-http2.js b/lighthouse-core/audits/dobetterweb/uses-http2.js
index fd11ed12c..998167b6f 100644
--- a/lighthouse-core/audits/dobetterweb/uses-http2.js
+++ b/lighthouse-core/audits/dobetterweb/uses-http2.js
@@ -68,6 +68,7 @@ class UsesHTTP2Audit extends Audit {
         displayValue = `${resources.length} request not served via HTTP/2`;
       }
 
+      /** @type {LH.Audit.Details.Table['headings']} */
       const headings = [
         {key: 'url', itemType: 'url', text: 'URL'},
         {key: 'protocol', itemType: 'text', text: 'Protocol'},
diff --git a/lighthouse-core/audits/dobetterweb/uses-passive-event-listeners.js b/lighthouse-core/audits/dobetterweb/uses-passive-event-listeners.js
index eb84b183e..7d966aa13 100644
--- a/lighthouse-core/audits/dobetterweb/uses-passive-event-listeners.js
+++ b/lighthouse-core/audits/dobetterweb/uses-passive-event-listeners.js
@@ -36,6 +36,7 @@ class PassiveEventsAudit extends ViolationAudit {
   static audit(artifacts) {
     const results = ViolationAudit.getViolationResults(artifacts, /passive event listener/);
 
+    /** @type {LH.Audit.Details.Table['headings']} */
     const headings = [
       {key: 'url', itemType: 'url', text: 'URL'},
       {key: 'label', itemType: 'text', text: 'Location'},
diff --git a/lighthouse-core/audits/errors-in-console.js b/lighthouse-core/audits/errors-in-console.js
index 644ed4aa5..74195a8e6 100644
--- a/lighthouse-core/audits/errors-in-console.js
+++ b/lighthouse-core/audits/errors-in-console.js
@@ -60,6 +60,7 @@ class ErrorLogs extends Audit {
 
     const tableRows = consoleRows.concat(runtimeExRows);
 
+    /** @type {LH.Audit.Details.Table['headings']} */
     const headings = [
       {key: 'url', itemType: 'url', text: 'URL'},
       {key: 'description', itemType: 'code', text: 'Description'},
diff --git a/lighthouse-core/audits/font-display.js b/lighthouse-core/audits/font-display.js
index ae985bae2..2a1ce2609 100644
--- a/lighthouse-core/audits/font-display.js
+++ b/lighthouse-core/audits/font-display.js
@@ -123,6 +123,7 @@ class FontDisplay extends Audit {
         };
       });
 
+    /** @type {LH.Audit.Details.Table['headings']} */
     const headings = [
       {key: 'url', itemType: 'url', text: str_(i18n.UIStrings.columnURL)},
       {key: 'wastedMs', itemType: 'ms', text: str_(i18n.UIStrings.columnWastedMs)},
diff --git a/lighthouse-core/audits/image-aspect-ratio.js b/lighthouse-core/audits/image-aspect-ratio.js
index 37dafc981..a3fc6bdec 100644
--- a/lighthouse-core/audits/image-aspect-ratio.js
+++ b/lighthouse-core/audits/image-aspect-ratio.js
@@ -93,6 +93,7 @@ class ImageAspectRatio extends Audit {
       if (!processed.doRatiosMatch) results.push(processed);
     });
 
+    /** @type {LH.Audit.Details.Table['headings']} */
     const headings = [
       {key: 'url', itemType: 'thumbnail', text: ''},
       {key: 'url', itemType: 'url', text: 'URL'},
diff --git a/lighthouse-core/audits/is-on-https.js b/lighthouse-core/audits/is-on-https.js
index ccdecf0e0..d64cf8ed5 100644
--- a/lighthouse-core/audits/is-on-https.js
+++ b/lighthouse-core/audits/is-on-https.js
@@ -62,6 +62,7 @@ class HTTPS extends Audit {
 
       const items = Array.from(new Set(insecureURLs)).map(url => ({url}));
 
+      /** @type {LH.Audit.Details.Table['headings']} */
       const headings = [
         {key: 'url', itemType: 'url', text: 'Insecure URL'},
       ];
diff --git a/lighthouse-core/audits/main-thread-tasks.js b/lighthouse-core/audits/main-thread-tasks.js
index 8b044c20c..437b0bbef 100644
--- a/lighthouse-core/audits/main-thread-tasks.js
+++ b/lighthouse-core/audits/main-thread-tasks.js
@@ -41,6 +41,7 @@ class MainThreadTasks extends Audit {
         };
       });
 
+    /** @type {LH.Audit.Details.Table['headings']} */
     const headings = [
       {key: 'type', itemType: 'text', text: 'Task Type'},
       {key: 'startTime', itemType: 'ms', granularity: 1, text: 'Start Time'},
diff --git a/lighthouse-core/audits/mainthread-work-breakdown.js b/lighthouse-core/audits/mainthread-work-breakdown.js
index 9ebb56863..9325693ae 100644
--- a/lighthouse-core/audits/mainthread-work-breakdown.js
+++ b/lighthouse-core/audits/mainthread-work-breakdown.js
@@ -105,6 +105,7 @@ class MainThreadWorkBreakdown extends Audit {
       };
     });
 
+    /** @type {LH.Audit.Details.Table['headings']} */
     const headings = [
       {key: 'groupLabel', itemType: 'text', text: str_(UIStrings.columnCategory)},
       {key: 'duration', itemType: 'ms', granularity: 1, text: str_(i18n.UIStrings.columnTimeSpent)},
diff --git a/lighthouse-core/audits/metrics.js b/lighthouse-core/audits/metrics.js
index 06f22c2e1..d81691a34 100644
--- a/lighthouse-core/audits/metrics.js
+++ b/lighthouse-core/audits/metrics.js
@@ -108,8 +108,12 @@ class Metrics extends Audit {
       }
     }
 
-    /** @type {MetricsDetails} */
-    const details = {items: [metrics]};
+    /** @type {LH.Audit.Details.Diagnostic} */
+    const details = {
+      type: 'diagnostic',
+      // TODO: Consider not nesting metrics under `items`.
+      items: [metrics],
+    };
 
     return {
       score: 1,
@@ -155,6 +159,4 @@ class Metrics extends Audit {
  * @property {number} observedSpeedIndexTs
  */
 
-/** @typedef {{items: [UberMetricsItem]}} MetricsDetails */
-
 module.exports = Metrics;
diff --git a/lighthouse-core/audits/mixed-content.js b/lighthouse-core/audits/mixed-content.js
index f495f204a..979377518 100644
--- a/lighthouse-core/audits/mixed-content.js
+++ b/lighthouse-core/audits/mixed-content.js
@@ -129,6 +129,7 @@ class MixedContent extends Audit {
       const displayValue = `${Util.formatNumber(upgradeableResources.length)}
           ${upgradeableResources.length === 1 ? 'request' : 'requests'}`;
 
+      /** @type {LH.Audit.Details.Table['headings']} */
       const headings = [
         {key: 'fullUrl', itemType: 'url', text: 'URL'},
       ];
diff --git a/lighthouse-core/audits/multi-check-audit.js b/lighthouse-core/audits/multi-check-audit.js
index a41cb33e0..249d0c1e1 100644
--- a/lighthouse-core/audits/multi-check-audit.js
+++ b/lighthouse-core/audits/multi-check-audit.js
@@ -41,7 +41,13 @@ class MultiCheckAudit extends Audit {
       });
     }
 
-    const details = {items: [detailsItem]};
+    // Include the detailed pass/fail checklist as a diagnostic.
+    /** @type {LH.Audit.Details.Diagnostic} */
+    const details = {
+      type: 'diagnostic',
+      // TODO: Consider not nesting detailsItem under `items`.
+      items: [detailsItem],
+    };
 
     // If we fail, share the failures
     if (result.failures.length > 0) {
diff --git a/lighthouse-core/audits/network-requests.js b/lighthouse-core/audits/network-requests.js
index 5ef3c2e88..dd7c3da0f 100644
--- a/lighthouse-core/audits/network-requests.js
+++ b/lighthouse-core/audits/network-requests.js
@@ -53,6 +53,7 @@ class NetworkRequests extends Audit {
         };
       });
 
+      /** @type {LH.Audit.Details.Table['headings']} */
       const headings = [
         {key: 'url', itemType: 'url', text: 'URL'},
         {key: 'startTime', itemType: 'ms', granularity: 1, text: 'Start Time'},
diff --git a/lighthouse-core/audits/network-rtt.js b/lighthouse-core/audits/network-rtt.js
index 9afa8ce17..49986ca85 100644
--- a/lighthouse-core/audits/network-rtt.js
+++ b/lighthouse-core/audits/network-rtt.js
@@ -59,6 +59,7 @@ class NetworkRTT extends Audit {
 
     results.sort((a, b) => b.rtt - a.rtt);
 
+    /** @type {LH.Audit.Details.Table['headings']} */
     const headings = [
       {key: 'origin', itemType: 'text', text: str_(i18n.UIStrings.columnURL)},
       {key: 'rtt', itemType: 'ms', granularity: 1, text: str_(i18n.UIStrings.columnTimeSpent)},
diff --git a/lighthouse-core/audits/network-server-latency.js b/lighthouse-core/audits/network-server-latency.js
index 0a1748dd8..1f3b37853 100644
--- a/lighthouse-core/audits/network-server-latency.js
+++ b/lighthouse-core/audits/network-server-latency.js
@@ -57,6 +57,7 @@ class NetworkServerLatency extends Audit {
 
     results.sort((a, b) => b.serverReponseTime - a.serverReponseTime);
 
+    /** @type {LH.Audit.Details.Table['headings']} */
     const headings = [
       {key: 'origin', itemType: 'text', text: str_(i18n.UIStrings.columnURL)},
       {key: 'serverReponseTime', itemType: 'ms', granularity: 1,
diff --git a/lighthouse-core/audits/predictive-perf.js b/lighthouse-core/audits/predictive-perf.js
index 313b1432b..7051a8f7c 100644
--- a/lighthouse-core/audits/predictive-perf.js
+++ b/lighthouse-core/audits/predictive-perf.js
@@ -90,7 +90,11 @@ class PredictivePerf extends Audit {
       score,
       rawValue: values.roughEstimateOfTTI,
       displayValue: Util.formatMilliseconds(values.roughEstimateOfTTI),
-      details: {items: [values]},
+      details: {
+        type: 'diagnostic',
+        // TODO: Consider not nesting values under `items`.
+        items: [values],
+      },
     };
   }
 }
diff --git a/lighthouse-core/audits/screenshot-thumbnails.js b/lighthouse-core/audits/screenshot-thumbnails.js
index 1e65c3555..edb570260 100644
--- a/lighthouse-core/audits/screenshot-thumbnails.js
+++ b/lighthouse-core/audits/screenshot-thumbnails.js
@@ -117,8 +117,9 @@ class ScreenshotThumbnails extends Audit {
         });
       }
       let base64Data;
-      if (cachedThumbnails.has(frameForTimestamp)) {
-        base64Data = cachedThumbnails.get(frameForTimestamp);
+      const cachedThumbnail = cachedThumbnails.get(frameForTimestamp);
+      if (cachedThumbnail) {
+        base64Data = cachedThumbnail;
       } else {
         const imageData = frameForTimestamp.getParsedImage();
         const thumbnailImageData = ScreenshotThumbnails.scaleImageToThumbnail(imageData);
diff --git a/lighthouse-core/audits/seo/font-size.js b/lighthouse-core/audits/seo/font-size.js
index 591288ba9..bd569d01e 100644
--- a/lighthouse-core/audits/seo/font-size.js
+++ b/lighthouse-core/audits/seo/font-size.js
@@ -236,6 +236,7 @@ class FontSize extends Audit {
       (visitedTextLength - failingTextLength) / visitedTextLength * 100;
     const pageUrl = artifacts.URL.finalUrl;
 
+    /** @type {LH.Audit.Details.Table['headings']} */
     const headings = [
       {key: 'source', itemType: 'url', text: 'Source'},
       {key: 'selector', itemType: 'code', text: 'Selector'},
diff --git a/lighthouse-core/audits/seo/hreflang.js b/lighthouse-core/audits/seo/hreflang.js
index 2c1784171..b7749725f 100644
--- a/lighthouse-core/audits/seo/hreflang.js
+++ b/lighthouse-core/audits/seo/hreflang.js
@@ -105,6 +105,7 @@ class Hreflang extends Audit {
           .filter(h => h.name.toLowerCase() === LINK_HEADER && !headerHasValidHreflangs(h.value))
           .forEach(h => invalidHreflangs.push({source: `${h.name}: ${h.value}`}));
 
+        /** @type {LH.Audit.Details.Table['headings']} */
         const headings = [
           {key: 'source', itemType: 'code', text: 'Source'},
         ];
diff --git a/lighthouse-core/audits/seo/is-crawlable.js b/lighthouse-core/audits/seo/is-crawlable.js
index 12454799f..a61b6ff75 100644
--- a/lighthouse-core/audits/seo/is-crawlable.js
+++ b/lighthouse-core/audits/seo/is-crawlable.js
@@ -84,7 +84,7 @@ class IsCrawlable extends Audit {
 
     return MainResource.request({devtoolsLog, URL: artifacts.URL}, context)
       .then(mainResource => {
-        /** @type {Array<Object<string, LH.Audit.DetailsItem>>} */
+        /** @type {LH.Audit.Details.Table['items']} */
         const blockingDirectives = [];
 
         if (metaRobots) {
@@ -120,6 +120,7 @@ class IsCrawlable extends Audit {
           }
         }
 
+        /** @type {LH.Audit.Details.Table['headings']} */
         const headings = [
           {key: 'source', itemType: 'code', text: 'Blocking Directive Source'},
         ];
diff --git a/lighthouse-core/audits/seo/link-text.js b/lighthouse-core/audits/seo/link-text.js
index d979e96fe..fc95ebd28 100644
--- a/lighthouse-core/audits/seo/link-text.js
+++ b/lighthouse-core/audits/seo/link-text.js
@@ -59,6 +59,7 @@ class LinkText extends Audit {
         };
       });
 
+    /** @type {LH.Audit.Details.Table['headings']} */
     const headings = [
       {key: 'href', itemType: 'url', text: 'Link destination'},
       {key: 'text', itemType: 'text', text: 'Link Text'},
diff --git a/lighthouse-core/audits/seo/plugins.js b/lighthouse-core/audits/seo/plugins.js
index 30eff7e93..18bc35a1f 100644
--- a/lighthouse-core/audits/seo/plugins.js
+++ b/lighthouse-core/audits/seo/plugins.js
@@ -140,6 +140,7 @@ class Plugins extends Audit {
         };
       });
 
+    /** @type {LH.Audit.Details.Table['headings']} */
     const headings = [
       {key: 'source', itemType: 'code', text: 'Element source'},
     ];
diff --git a/lighthouse-core/audits/seo/robots-txt.js b/lighthouse-core/audits/seo/robots-txt.js
index bc7cdd25e..cd13d7431 100644
--- a/lighthouse-core/audits/seo/robots-txt.js
+++ b/lighthouse-core/audits/seo/robots-txt.js
@@ -205,6 +205,7 @@ class RobotsTxt extends Audit {
 
     const validationErrors = validateRobots(content);
 
+    /** @type {LH.Audit.Details.Table['headings']} */
     const headings = [
       {key: 'index', itemType: 'text', text: 'Line #'},
       {key: 'line', itemType: 'code', text: 'Content'},
diff --git a/lighthouse-core/audits/seo/tap-targets.js b/lighthouse-core/audits/seo/tap-targets.js
index b82e51fdb..63bdb29c6 100644
--- a/lighthouse-core/audits/seo/tap-targets.js
+++ b/lighthouse-core/audits/seo/tap-targets.js
@@ -243,7 +243,7 @@ function getTableItems(overlapFailures) {
 
 /**
  * @param {LH.Artifacts.TapTarget} target
- * @returns {LH.Audit.DetailsRendererNodeDetailsJSON}
+ * @returns {LH.Audit.Details.NodeValue}
  */
 function targetToTableNode(target) {
   return {
@@ -289,6 +289,7 @@ class TapTargets extends Audit {
     const overlapFailuresForDisplay = mergeSymmetricFailures(overlapFailures);
     const tableItems = getTableItems(overlapFailuresForDisplay);
 
+    /** @type {LH.Audit.Details.Table['headings']} */
     const headings = [
       {key: 'tapTarget', itemType: 'node', text: str_(UIStrings.tapTargetHeader)},
       {key: 'size', itemType: 'text', text: str_(UIStrings.sizeHeader)},
@@ -339,8 +340,8 @@ module.exports.UIStrings = UIStrings;
 }} BoundedTapTarget */
 
 /** @typedef {{
-  tapTarget: LH.Audit.DetailsRendererNodeDetailsJSON;
-  overlappingTarget: LH.Audit.DetailsRendererNodeDetailsJSON;
+  tapTarget: LH.Audit.Details.NodeValue;
+  overlappingTarget: LH.Audit.Details.NodeValue;
   size: string;
   overlapScoreRatio: number;
   height: number;
diff --git a/lighthouse-core/audits/user-timings.js b/lighthouse-core/audits/user-timings.js
index a9f1295c4..339b528a5 100644
--- a/lighthouse-core/audits/user-timings.js
+++ b/lighthouse-core/audits/user-timings.js
@@ -94,6 +94,7 @@ class UserTimings extends Audit {
         }
       });
 
+      /** @type {LH.Audit.Details.Table['headings']} */
       const headings = [
         {key: 'name', itemType: 'text', text: str_(UIStrings.columnName)},
         {key: 'timingType', itemType: 'text', text: str_(UIStrings.columnType)},
diff --git a/lighthouse-core/gather/driver.js b/lighthouse-core/gather/driver.js
index b2e26267f..299a8c78e 100644
--- a/lighthouse-core/gather/driver.js
+++ b/lighthouse-core/gather/driver.js
@@ -400,7 +400,20 @@ class Driver {
    */
   async getAppManifest() {
     this.setNextProtocolTimeout(3000);
-    const response = await this.sendCommand('Page.getAppManifest');
+    let response;
+    try {
+      response = await this.sendCommand('Page.getAppManifest');
+    } catch (err) {
+      if (err.code === 'PROTOCOL_TIMEOUT') {
+        // LR will timeout fetching the app manifest in some cases, move on without one.
+        // https://github.com/GoogleChrome/lighthouse/issues/7147#issuecomment-461210921
+        log.error('Driver', 'Failed fetching manifest', err);
+        return null;
+      }
+
+      throw err;
+    }
+
     let data = response.data;
 
     // We're not reading `response.errors` however it may contain critical and noncritical
diff --git a/lighthouse-core/lib/traces/pwmetrics-events.js b/lighthouse-core/lib/traces/pwmetrics-events.js
index 33fccecc6..4f57e8354 100644
--- a/lighthouse-core/lib/traces/pwmetrics-events.js
+++ b/lighthouse-core/lib/traces/pwmetrics-events.js
@@ -15,7 +15,7 @@ const log = require('lighthouse-logger');
  */
 function getUberMetrics(auditResults) {
   const metricsAudit = auditResults.metrics;
-  if (!metricsAudit || !metricsAudit.details || !metricsAudit.details.items) return;
+  if (!metricsAudit || !metricsAudit.details || !('items' in metricsAudit.details)) return;
 
   return metricsAudit.details.items[0];
 }
diff --git a/lighthouse-core/report/html/renderer/category-renderer.js b/lighthouse-core/report/html/renderer/category-renderer.js
index c20523bcf..8bbb753a7 100644
--- a/lighthouse-core/report/html/renderer/category-renderer.js
+++ b/lighthouse-core/report/html/renderer/category-renderer.js
@@ -86,9 +86,12 @@ class CategoryRenderer {
 
     const header = /** @type {HTMLDetailsElement} */ (this.dom.find('details', auditEl));
     if (audit.result.details && audit.result.details.type) {
+      // @ts-ignore TODO(bckenny): fix detailsRenderer.render input type
       const elem = this.detailsRenderer.render(audit.result.details);
-      elem.classList.add('lh-details');
-      header.appendChild(elem);
+      if (elem) {
+        elem.classList.add('lh-details');
+        header.appendChild(elem);
+      }
     }
     this.dom.find('.lh-audit__index', auditEl).textContent = `${index + 1}`;
 
diff --git a/lighthouse-core/report/html/renderer/details-renderer.js b/lighthouse-core/report/html/renderer/details-renderer.js
index 9526be130..91a8384b1 100644
--- a/lighthouse-core/report/html/renderer/details-renderer.js
+++ b/lighthouse-core/report/html/renderer/details-renderer.js
@@ -44,7 +44,7 @@ class DetailsRenderer {
 
   /**
    * @param {DetailsJSON|OpportunityDetails} details
-   * @return {Element}
+   * @return {Element|null}
    */
   render(details) {
     switch (details.type) {
@@ -81,6 +81,12 @@ class DetailsRenderer {
         return this._renderOpportunityTable(details);
       case 'numeric':
         return this._renderNumeric(/** @type {StringDetailsJSON} */ (details));
+
+      // Internal-only details, not for rendering.
+      case 'screenshot':
+      case 'diagnostic':
+        return null;
+
       default: {
         throw new Error(`Unknown type: ${details.type}`);
       }
@@ -218,6 +224,7 @@ class DetailsRenderer {
     for (const heading of details.headings) {
       const itemType = heading.itemType || 'text';
       const classes = `lh-table-column--${itemType}`;
+      // @ts-ignore TODO(bckenny): this can never be null
       this._dom.createChildOf(theadTrElem, 'th', classes).appendChild(this.render({
         type: 'text',
         value: heading.text || '',
@@ -241,6 +248,7 @@ class DetailsRenderer {
         if (value.type) {
           const valueAsDetails = /** @type {DetailsJSON} */ (value);
           const classes = `lh-table-column--${valueAsDetails.type}`;
+          // @ts-ignore TODO(bckenny): this can never be null
           this._dom.createChildOf(rowElem, 'td', classes).appendChild(this.render(valueAsDetails));
           continue;
         }
@@ -257,6 +265,7 @@ class DetailsRenderer {
         // @ts-ignore - TODO(bckenny): handle with refactoring above
         const valueType = value.type;
         const classes = `lh-table-column--${valueType || heading.itemType}`;
+        // @ts-ignore TODO(bckenny): this can never be null
         this._dom.createChildOf(rowElem, 'td', classes).appendChild(this.render(item));
       }
     }
diff --git a/lighthouse-core/report/html/renderer/performance-category-renderer.js b/lighthouse-core/report/html/renderer/performance-category-renderer.js
index 8d63094bc..0fe9f1da7 100644
--- a/lighthouse-core/report/html/renderer/performance-category-renderer.js
+++ b/lighthouse-core/report/html/renderer/performance-category-renderer.js
@@ -160,8 +160,9 @@ class PerformanceCategoryRenderer extends CategoryRenderer {
     const thumbnailResult = thumbnailAudit && thumbnailAudit.result;
     if (thumbnailResult && thumbnailResult.details) {
       timelineEl.id = thumbnailResult.id;
+      // @ts-ignore TODO(bckenny): fix detailsRenderer.render input type
       const filmstripEl = this.detailsRenderer.render(thumbnailResult.details);
-      timelineEl.appendChild(filmstripEl);
+      filmstripEl && timelineEl.appendChild(filmstripEl);
     }
 
     // Opportunities
diff --git a/lighthouse-core/report/html/renderer/psi.js b/lighthouse-core/report/html/renderer/psi.js
index a1b063cc7..35c4d104e 100644
--- a/lighthouse-core/report/html/renderer/psi.js
+++ b/lighthouse-core/report/html/renderer/psi.js
@@ -74,7 +74,7 @@ function prepareLabData(LHResult, document) {
 function _getFinalScreenshot(perfCategory) {
   const auditRef = perfCategory.auditRefs.find(audit => audit.id === 'final-screenshot');
   if (!auditRef || !auditRef.result || auditRef.result.scoreDisplayMode === 'error') return null;
-  return auditRef.result.details.data;
+  return /** @type {LH.Audit.Details.Screenshot} */ (auditRef.result.details).data;
 }
 
 if (typeof module !== 'undefined' && module.exports) {
diff --git a/lighthouse-core/report/html/renderer/report-renderer.js b/lighthouse-core/report/html/renderer/report-renderer.js
index cb013ef06..325c27dc5 100644
--- a/lighthouse-core/report/html/renderer/report-renderer.js
+++ b/lighthouse-core/report/html/renderer/report-renderer.js
@@ -24,7 +24,6 @@
  */
 
 /** @typedef {import('./dom.js')} DOM */
-/** @typedef {import('./details-renderer.js').DetailsJSON} DetailsJSON */
 
 /* globals self, Util, DetailsRenderer, CategoryRenderer, PerformanceCategoryRenderer, PwaCategoryRenderer */
 
diff --git a/proto/sample_v2_round_trip.json b/proto/sample_v2_round_trip.json
index 983063b7c..f365ce24c 100644
--- a/proto/sample_v2_round_trip.json
+++ b/proto/sample_v2_round_trip.json
@@ -159,6 +159,15 @@
         "color-contrast": {
             "description": "Low-contrast text is difficult or impossible for many users to read. [Learn more](https://dequeuniversity.com/rules/axe/3.1/color-contrast?application=lighthouse).", 
             "details": {
+                "diagnostic": {
+                    "impact": "serious", 
+                    "tags": [
+                        "cat.color", 
+                        "wcag2aa", 
+                        "wcag143"
+                    ], 
+                    "type": "diagnostic"
+                }, 
                 "headings": [
                     {
                         "itemType": "node", 
@@ -166,7 +175,6 @@
                         "text": "Failing Elements"
                     }
                 ], 
-                "impact": "serious", 
                 "items": [
                     {
                         "node": {
@@ -187,11 +195,6 @@
                         }
                     }
                 ], 
-                "tags": [
-                    "cat.color", 
-                    "wcag2aa", 
-                    "wcag143"
-                ], 
                 "type": "table"
             }, 
             "id": "color-contrast", 
@@ -442,7 +445,8 @@
                         "totalByteWeight": 160738.0, 
                         "totalTaskTime": 1548.5690000000002
                     }
-                ]
+                ], 
+                "type": "diagnostic"
             }, 
             "id": "diagnostics", 
             "score": null, 
@@ -817,6 +821,15 @@
         "html-has-lang": {
             "description": "If a page doesn't specify a lang attribute, a screen reader assumes that the page is in the default language that the user chose when setting up the screen reader. If the page isn't actually in the default language, then the screen reader might not announce the page's text correctly. [Learn more](https://dequeuniversity.com/rules/axe/3.1/html-has-lang?application=lighthouse).", 
             "details": {
+                "diagnostic": {
+                    "impact": "serious", 
+                    "tags": [
+                        "cat.language", 
+                        "wcag2a", 
+                        "wcag311"
+                    ], 
+                    "type": "diagnostic"
+                }, 
                 "headings": [
                     {
                         "itemType": "node", 
@@ -824,7 +837,6 @@
                         "text": "Failing Elements"
                     }
                 ], 
-                "impact": "serious", 
                 "items": [
                     {
                         "node": {
@@ -836,11 +848,6 @@
                         }
                     }
                 ], 
-                "tags": [
-                    "cat.language", 
-                    "wcag2a", 
-                    "wcag311"
-                ], 
                 "type": "table"
             }, 
             "id": "html-has-lang", 
@@ -865,6 +872,17 @@
         "image-alt": {
             "description": "Informative elements should aim for short, descriptive alternate text. Decorative elements can be ignored with an empty alt attribute. [Learn more](https://dequeuniversity.com/rules/axe/3.1/image-alt?application=lighthouse).", 
             "details": {
+                "diagnostic": {
+                    "impact": "critical", 
+                    "tags": [
+                        "cat.text-alternatives", 
+                        "wcag2a", 
+                        "wcag111", 
+                        "section508", 
+                        "section508.22.a"
+                    ], 
+                    "type": "diagnostic"
+                }, 
                 "headings": [
                     {
                         "itemType": "node", 
@@ -872,7 +890,6 @@
                         "text": "Failing Elements"
                     }
                 ], 
-                "impact": "critical", 
                 "items": [
                     {
                         "node": {
@@ -911,13 +928,6 @@
                         }
                     }
                 ], 
-                "tags": [
-                    "cat.text-alternatives", 
-                    "wcag2a", 
-                    "wcag111", 
-                    "section508", 
-                    "section508.22.a"
-                ], 
                 "type": "table"
             }, 
             "id": "image-alt", 
@@ -983,7 +993,8 @@
                         "isParseFailure": true, 
                         "parseFailureReason": "No manifest was fetched"
                     }
-                ]
+                ], 
+                "type": "diagnostic"
             }, 
             "explanation": "Failures: No manifest was fetched.", 
             "id": "installable-manifest", 
@@ -1070,6 +1081,18 @@
         "label": {
             "description": "Labels ensure that form controls are announced properly by assistive technologies, like screen readers. [Learn more](https://dequeuniversity.com/rules/axe/3.1/label?application=lighthouse).", 
             "details": {
+                "diagnostic": {
+                    "impact": "critical", 
+                    "tags": [
+                        "cat.forms", 
+                        "wcag2a", 
+                        "wcag332", 
+                        "wcag131", 
+                        "section508", 
+                        "section508.22.n"
+                    ], 
+                    "type": "diagnostic"
+                }, 
                 "headings": [
                     {
                         "itemType": "node", 
@@ -1077,7 +1100,6 @@
                         "text": "Failing Elements"
                     }
                 ], 
-                "impact": "critical", 
                 "items": [
                     {
                         "node": {
@@ -1107,14 +1129,6 @@
                         }
                     }
                 ], 
-                "tags": [
-                    "cat.forms", 
-                    "wcag2a", 
-                    "wcag332", 
-                    "wcag131", 
-                    "section508", 
-                    "section508.22.n"
-                ], 
                 "type": "table"
             }, 
             "id": "label", 
@@ -1132,6 +1146,18 @@
         "link-name": {
             "description": "Link text (and alternate text for images, when used as links) that is discernible, unique, and focusable improves the navigation experience for screen reader users. [Learn more](https://dequeuniversity.com/rules/axe/3.1/link-name?application=lighthouse).", 
             "details": {
+                "diagnostic": {
+                    "impact": "serious", 
+                    "tags": [
+                        "cat.name-role-value", 
+                        "wcag2a", 
+                        "wcag412", 
+                        "wcag244", 
+                        "section508", 
+                        "section508.22.a"
+                    ], 
+                    "type": "diagnostic"
+                }, 
                 "headings": [
                     {
                         "itemType": "node", 
@@ -1139,7 +1165,6 @@
                         "text": "Failing Elements"
                     }
                 ], 
-                "impact": "serious", 
                 "items": [
                     {
                         "node": {
@@ -1160,14 +1185,6 @@
                         }
                     }
                 ], 
-                "tags": [
-                    "cat.name-role-value", 
-                    "wcag2a", 
-                    "wcag412", 
-                    "wcag244", 
-                    "section508", 
-                    "section508.22.a"
-                ], 
                 "type": "table"
             }, 
             "id": "link-name", 
@@ -1581,7 +1598,8 @@
                         "speedIndex": 4417.0, 
                         "speedIndexTs": 185607736912.0
                     }
-                ]
+                ], 
+                "type": "diagnostic"
             }, 
             "id": "metrics", 
             "score": null, 
@@ -2010,6 +2028,17 @@
         "object-alt": {
             "description": "Screen readers cannot translate non-text content. Adding alt text to `<object>` elements helps screen readers convey meaning to users. [Learn more](https://dequeuniversity.com/rules/axe/3.1/object-alt?application=lighthouse).", 
             "details": {
+                "diagnostic": {
+                    "impact": "serious", 
+                    "tags": [
+                        "cat.text-alternatives", 
+                        "wcag2a", 
+                        "wcag111", 
+                        "section508", 
+                        "section508.22.a"
+                    ], 
+                    "type": "diagnostic"
+                }, 
                 "headings": [
                     {
                         "itemType": "node", 
@@ -2017,7 +2046,6 @@
                         "text": "Failing Elements"
                     }
                 ], 
-                "impact": "serious", 
                 "items": [
                     {
                         "node": {
@@ -2038,13 +2066,6 @@
                         }
                     }
                 ], 
-                "tags": [
-                    "cat.text-alternatives", 
-                    "wcag2a", 
-                    "wcag111", 
-                    "section508", 
-                    "section508.22.a"
-                ], 
                 "type": "table"
             }, 
             "id": "object-alt", 
@@ -2319,7 +2340,8 @@
                         "isParseFailure": true, 
                         "parseFailureReason": "No manifest was fetched"
                     }
-                ]
+                ], 
+                "type": "diagnostic"
             }, 
             "explanation": "Failures: No manifest was fetched.", 
             "id": "splash-screen", 
@@ -2376,7 +2398,8 @@
                         "parseFailureReason": "No manifest was fetched", 
                         "themeColor": null
                     }
-                ]
+                ], 
+                "type": "diagnostic"
             }, 
             "explanation": "Failures: No manifest was fetched,\nNo `<meta name=\"theme-color\">` tag found.", 
             "id": "themed-omnibox", 
@@ -2659,7 +2682,6 @@
                 ], 
                 "items": [
                     {
-                        "cacheControl": null, 
                         "cacheHitProbability": 0.0, 
                         "cacheLifetimeMs": 0.0, 
                         "totalBytes": 71654.0, 
@@ -2667,7 +2689,6 @@
                         "wastedBytes": 71654.0
                     }, 
                     {
-                        "cacheControl": null, 
                         "cacheHitProbability": 0.0, 
                         "cacheLifetimeMs": 0.0, 
                         "totalBytes": 24741.0, 
@@ -2675,7 +2696,6 @@
                         "wastedBytes": 24741.0
                     }, 
                     {
-                        "cacheControl": null, 
                         "cacheHitProbability": 0.0, 
                         "cacheLifetimeMs": 0.0, 
                         "totalBytes": 1703.0, 
@@ -2683,7 +2703,6 @@
                         "wastedBytes": 1703.0
                     }, 
                     {
-                        "cacheControl": null, 
                         "cacheHitProbability": 0.0, 
                         "cacheLifetimeMs": 0.0, 
                         "totalBytes": 1108.0, 
@@ -2691,7 +2710,6 @@
                         "wastedBytes": 1108.0
                     }, 
                     {
-                        "cacheControl": null, 
                         "cacheHitProbability": 0.0, 
                         "cacheLifetimeMs": 0.0, 
                         "totalBytes": 821.0, 
@@ -2699,7 +2717,6 @@
                         "wastedBytes": 821.0
                     }, 
                     {
-                        "cacheControl": null, 
                         "cacheHitProbability": 0.0, 
                         "cacheLifetimeMs": 0.0, 
                         "totalBytes": 821.0, 
@@ -2707,7 +2724,6 @@
                         "wastedBytes": 821.0
                     }, 
                     {
-                        "cacheControl": null, 
                         "cacheHitProbability": 0.0, 
                         "cacheLifetimeMs": 0.0, 
                         "totalBytes": 821.0, 
@@ -2715,7 +2731,6 @@
                         "wastedBytes": 821.0
                     }, 
                     {
-                        "cacheControl": null, 
                         "cacheHitProbability": 0.0, 
                         "cacheLifetimeMs": 0.0, 
                         "totalBytes": 821.0, 
@@ -2723,7 +2738,6 @@
                         "wastedBytes": 821.0
                     }, 
                     {
-                        "cacheControl": null, 
                         "cacheHitProbability": 0.0, 
                         "cacheLifetimeMs": 0.0, 
                         "totalBytes": 821.0, 
@@ -2731,7 +2745,6 @@
                         "wastedBytes": 821.0
                     }, 
                     {
-                        "cacheControl": null, 
                         "cacheHitProbability": 0.0, 
                         "cacheLifetimeMs": 0.0, 
                         "totalBytes": 144.0, 
@@ -2739,7 +2752,6 @@
                         "wastedBytes": 144.0
                     }, 
                     {
-                        "cacheControl": null, 
                         "cacheHitProbability": 0.0, 
                         "cacheLifetimeMs": 0.0, 
                         "totalBytes": 0.0, 
diff --git a/readme.md b/readme.md
index 7ffe97c9a..2990366a7 100644
--- a/readme.md
+++ b/readme.md
@@ -191,6 +191,7 @@ Useful documentation, examples, and recipes to get you started.
 
 **Docs**
 
+- [Dealing with variance](./docs/variability.md)
 - [Using Lighthouse programmatically](./docs/readme.md#using-programmatically)
 - [Testing a site with authentication](./docs/readme.md#testing-on-a-site-with-authentication)
 - [Testing on a mobile device](./docs/readme.md#testing-on-a-mobile-device)
diff --git a/types/audit-details.d.ts b/types/audit-details.d.ts
index dda07549b..f2dd8a329 100644
--- a/types/audit-details.d.ts
+++ b/types/audit-details.d.ts
@@ -5,63 +5,166 @@
  */
 
 declare global {
-  module LH.Audit.Details {
-    export interface Filmstrip {
-      type: 'filmstrip';
-      scale: number;
-      items: {
-        /** The relative time from navigationStart to this frame, in milliseconds. */
-        timing: number;
-        /** The raw timestamp of this frame, in microseconds. */
+  module LH.Audit {
+    export type Details =
+      Details.CriticalRequestChain |
+      Details.Diagnostic |
+      Details.Filmstrip |
+      Details.Opportunity |
+      Details.Screenshot |
+      Details.Table;
+
+    // Details namespace.
+    export module Details {
+      export interface CriticalRequestChain {
+        type: 'criticalrequestchain';
+        longestChain: {
+          duration: number;
+          length: number;
+          transferSize: number;
+        };
+        chains: Audit.SimpleCriticalRequestNode;
+      }
+
+      export interface Filmstrip {
+        type: 'filmstrip';
+        scale: number;
+        items: {
+          /** The relative time from navigationStart to this frame, in milliseconds. */
+          timing: number;
+          /** The raw timestamp of this frame, in microseconds. */
+          timestamp: number;
+          /** The data URL encoding of this frame. */
+          data: string;
+        }[];
+      }
+
+      export interface Opportunity {
+        type: 'opportunity';
+        overallSavingsMs: number;
+        overallSavingsBytes?: number;
+        headings: OpportunityColumnHeading[];
+        items: OpportunityItem[];
+        diagnostic?: Diagnostic;
+      }
+
+      export interface Screenshot {
+        type: 'screenshot';
         timestamp: number;
-        /** The data URL encoding of this frame. */
         data: string;
-      }[];
-    }
+      }
 
-    export interface Screenshot {
-      type: 'screenshot';
-      timestamp: number;
-      data: string;
-    }
+      export interface Table {
+        type: 'table';
+        headings: TableColumnHeading[];
+        items: TableItem[];
+        summary?: {
+          wastedMs?: number;
+          wastedBytes?: number;
+        };
+        diagnostic?: Diagnostic;
+      }
 
-    export interface Opportunity {
-      type: 'opportunity';
-      overallSavingsMs: number;
-      overallSavingsBytes?: number;
-      headings: OpportunityColumnHeading[];
-      items: OpportunityItem[];
-    }
+      /**
+       * A details type that is not rendered in the final report; usually used
+       * for including diagnostic information in the LHR. Can contain anything.
+       */
+      export interface Diagnostic {
+        type: 'diagnostic';
+        [p: string]: any;
+      }
 
-    export interface CriticalRequestChain {
-      type: 'criticalrequestchain';
-      longestChain: {
-        duration: number;
-        length: number;
-        transferSize: number;
-      };
-      chains: Audit.SimpleCriticalRequestNode;
-    }
+      /** Possible types of values found within table items. */
+      type ItemValueTypes = 'bytes' | 'code' | 'link' | 'ms' | 'node' | 'numeric' | 'text' | 'thumbnail' | 'timespanMs' | 'url';
 
-    // Contents of details below here
+      // TODO(bckenny): unify Table/Opportunity headings and items on next breaking change.
 
-    export interface OpportunityColumnHeading {
-      /** The name of the property within items being described. */
-      key: string;
-      /** Readable text label of the field. */
-      label: string;
-      /** The data format of the column of values being described. */
-      valueType: string;
-    }
-    
-    export interface OpportunityItem {
-      url: string;
-      wastedBytes?: number;
-      totalBytes?: number;
-      wastedMs?: number;
-      [p: string]: number | boolean | string | undefined;
-    }
+      export interface TableColumnHeading {
+        /** The name of the property within items being described. */
+        key: string;
+        /** Readable text label of the field. */
+        text: string;
+        /**
+         * The data format of the column of values being described. Usually
+         * those values will be primitives rendered as this type, but the values
+         * could also be objects with their own type to override this field.
+         */
+        itemType: ItemValueTypes;
 
+        displayUnit?: string;
+        granularity?: number;
+      }
+
+      export type TableItem = {
+        diagnostic?: Diagnostic;
+        [p: string]: string | number | boolean | undefined | Diagnostic | NodeValue | LinkValue | UrlValue | CodeValue;
+      }
+
+      export interface OpportunityColumnHeading {
+        /** The name of the property within items being described. */
+        key: string;
+        /** Readable text label of the field. */
+        label: string;
+        /**
+         * The data format of the column of values being described. Usually
+         * those values will be primitives rendered as this type, but the values
+         * could also be objects with their own type to override this field.
+         */
+        valueType: ItemValueTypes;
+
+        // NOTE: not used by opportunity details, but used in the renderer until table/opportunity unification.
+        displayUnit?: string;
+        granularity?: number;
+      }
+
+      export interface OpportunityItem {
+        url: string;
+        wastedBytes?: number;
+        totalBytes?: number;
+        wastedMs?: number;
+        diagnostic?: Diagnostic;
+        [p: string]: number | boolean | string | undefined | Diagnostic;
+      }
+
+      /**
+       * A value used within a details object, intended to be displayed as code,
+       * regardless of the controlling heading's valueType.
+       */
+      export interface CodeValue {
+        type: 'code';
+        value: string;
+      }
+
+      /**
+       * A value used within a details object, intended to be displayed as a
+       * link with text, regardless of the controlling heading's valueType.
+       */
+      export interface LinkValue {
+        type: 'link',
+        text: string;
+        url: string;
+      }
+
+      /**
+       * A value used within a details object, intended to be displayed an HTML
+       * node, regardless of the controlling heading's valueType.
+       */
+      export interface NodeValue {
+        type: 'node';
+        path?: string;
+        selector?: string;
+        snippet?: string;
+      }
+
+      /**
+       * A value used within a details object, intended to be displayed as a
+       * linkified URL, regardless of the controlling heading's valueType.
+       */
+      export interface UrlValue {
+        type: 'url';
+        value: string;
+      }
+    }
   }
 }
 
diff --git a/types/audit.d.ts b/types/audit.d.ts
index b57bb71a9..41cceacad 100644
--- a/types/audit.d.ts
+++ b/types/audit.d.ts
@@ -55,14 +55,6 @@ declare global {
       scoreDisplayMode?: Audit.ScoreDisplayMode;
     }
 
-    export interface Heading {
-      key: string;
-      itemType: string;
-      text: string;
-      displayUnit?: string;
-      granularity?: number;
-    }
-
     export interface ByteEfficiencyItem extends Audit.Details.OpportunityItem {
       url: string;
       wastedBytes: number;
@@ -70,51 +62,6 @@ declare global {
       wastedPercent?: number;
     }
 
-    // TODO: placeholder typedefs until Details are typed
-    export interface DetailsRendererDetailsSummary {
-      wastedMs?: number;
-      wastedBytes?: number;
-    }
-
-    export interface DetailsObject {
-      [x: string]: DetailsItem
-    }
-
-    // TODO: placeholder typedefs until Details are typed
-    export interface DetailsRendererDetailsJSON {
-      type: 'table';
-      headings: Array<Audit.Heading>;
-      items: Array<DetailsObject>;
-      summary?: DetailsRendererDetailsSummary;
-    }
-
-    export interface DetailsRendererCodeDetailJSON {
-      type: 'code',
-      value: string;
-    }
-
-    export type DetailsItem = string | number | DetailsRendererNodeDetailsJSON |
-      DetailsRendererLinkDetailsJSON | DetailsRendererCodeDetailJSON | undefined |
-      boolean | DetailsRendererUrlDetailsJSON | null;
-
-    export interface DetailsRendererNodeDetailsJSON {
-      type: 'node';
-      path?: string;
-      selector?: string;
-      snippet?: string;
-    }
-
-    export interface DetailsRendererLinkDetailsJSON {
-      type: 'link';
-      text: string;
-      url: string;
-    }
-
-    export interface DetailsRendererUrlDetailsJSON {
-      type: 'url';
-      value: string;
-    }
-
     // Type returned by Audit.audit(). Only rawValue is required.
     export interface Product {
       rawValue: boolean | number | null;
@@ -126,8 +73,7 @@ declare global {
       extendedInfo?: {[p: string]: any};
       /** Overrides scoreDisplayMode with notApplicable if set to true */
       notApplicable?: boolean;
-      // TODO(bckenny): define details
-      details?: object;
+      details?: Audit.Details;
     }
 
     /* Audit result returned in Lighthouse report. All audits offer a description and score of 0-1 */
@@ -155,7 +101,7 @@ declare global {
       /** A more detailed description that describes why the audit is important and links to Lighthouse documentation on the audit; markdown links supported. */
       description: string;
       // TODO(bckenny): define details
-      details?: any;
+      details?: Audit.Details;
     }
 
     export interface Results {
diff --git a/types/parse-cache-control/index.d.ts b/types/parse-cache-control/index.d.ts
index e78a485b6..5e8059fb5 100644
--- a/types/parse-cache-control/index.d.ts
+++ b/types/parse-cache-control/index.d.ts
@@ -8,7 +8,7 @@ declare module 'parse-cache-control' {
   // Follows the potential settings of cache-control, see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
   interface CacheHeaders {
     'max-age'?: number;
-    'must-validate'?: boolean;
+    'must-revalidate'?: boolean;
     'no-cache'?: boolean;
     'no-store'?: boolean;
     'private'?: boolean;


Test patch:
diff --git a/lighthouse-core/test/audits/byte-efficiency/uses-long-cache-ttl-test.js b/lighthouse-core/test/audits/byte-efficiency/uses-long-cache-ttl-test.js
index a4fa6ac43..aba0f68d0 100644
--- a/lighthouse-core/test/audits/byte-efficiency/uses-long-cache-ttl-test.js
+++ b/lighthouse-core/test/audits/byte-efficiency/uses-long-cache-ttl-test.js
@@ -190,7 +190,7 @@ describe('Cache headers audit', () => {
 
   it('ignores assets where policy implies they should not be cached long periods', () => {
     const networkRecords = [
-      networkRecord({headers: {'cache-control': 'must-validate'}}),
+      networkRecord({headers: {'cache-control': 'must-revalidate'}}),
       networkRecord({headers: {'cache-control': 'no-cache'}}),
       networkRecord({headers: {'cache-control': 'private'}}),
     ];
diff --git a/lighthouse-core/test/audits/dobetterweb/js-libraries-test.js b/lighthouse-core/test/audits/dobetterweb/js-libraries-test.js
index 65bb77641..9b3975c95 100644
--- a/lighthouse-core/test/audits/dobetterweb/js-libraries-test.js
+++ b/lighthouse-core/test/audits/dobetterweb/js-libraries-test.js
@@ -55,7 +55,7 @@ describe('Returns detected front-end JavaScript libraries', () => {
       {
         name: 'lib2',
         npm: 'lib2',
-        version: null,
+        version: undefined,
       },
     ];
     assert.equal(auditResult.rawValue, true);
diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json
index 3e2ee84cd..950cfeddd 100644
--- a/lighthouse-core/test/results/sample_v2.json
+++ b/lighthouse-core/test/results/sample_v2.json
@@ -478,6 +478,7 @@
       "rawValue": false,
       "explanation": "Failures: No manifest was fetched.",
       "details": {
+        "type": "diagnostic",
         "items": [
           {
             "failures": [
@@ -498,6 +499,7 @@
       "rawValue": false,
       "explanation": "Failures: No manifest was fetched.",
       "details": {
+        "type": "diagnostic",
         "items": [
           {
             "failures": [
@@ -518,6 +520,7 @@
       "rawValue": false,
       "explanation": "Failures: No manifest was fetched,\nNo `<meta name=\"theme-color\">` tag found.",
       "details": {
+        "type": "diagnostic",
         "items": [
           {
             "failures": [
@@ -804,6 +807,7 @@
       "scoreDisplayMode": "informative",
       "rawValue": 1,
       "details": {
+        "type": "diagnostic",
         "items": [
           {
             "numRequests": 18,
@@ -1373,6 +1377,7 @@
       "scoreDisplayMode": "informative",
       "rawValue": 4927.278,
       "details": {
+        "type": "diagnostic",
         "items": [
           {
             "firstContentfulPaint": 3969,
@@ -1564,12 +1569,15 @@
             }
           }
         ],
-        "impact": "serious",
-        "tags": [
-          "cat.color",
-          "wcag2aa",
-          "wcag143"
-        ]
+        "diagnostic": {
+          "type": "diagnostic",
+          "impact": "serious",
+          "tags": [
+            "cat.color",
+            "wcag2aa",
+            "wcag143"
+          ]
+        }
       }
     },
     "definition-list": {
@@ -1649,12 +1657,15 @@
             }
           }
         ],
-        "impact": "serious",
-        "tags": [
-          "cat.language",
-          "wcag2a",
-          "wcag311"
-        ]
+        "diagnostic": {
+          "type": "diagnostic",
+          "impact": "serious",
+          "tags": [
+            "cat.language",
+            "wcag2a",
+            "wcag311"
+          ]
+        }
       }
     },
     "html-lang-valid": {
@@ -1719,14 +1730,17 @@
             }
           }
         ],
-        "impact": "critical",
-        "tags": [
-          "cat.text-alternatives",
-          "wcag2a",
-          "wcag111",
-          "section508",
-          "section508.22.a"
-        ]
+        "diagnostic": {
+          "type": "diagnostic",
+          "impact": "critical",
+          "tags": [
+            "cat.text-alternatives",
+            "wcag2a",
+            "wcag111",
+            "section508",
+            "section508.22.a"
+          ]
+        }
       }
     },
     "input-image-alt": {
@@ -1782,15 +1796,18 @@
             }
           }
         ],
-        "impact": "critical",
-        "tags": [
-          "cat.forms",
-          "wcag2a",
-          "wcag332",
-          "wcag131",
-          "section508",
-          "section508.22.n"
-        ]
+        "diagnostic": {
+          "type": "diagnostic",
+          "impact": "critical",
+          "tags": [
+            "cat.forms",
+            "wcag2a",
+            "wcag332",
+            "wcag131",
+            "section508",
+            "section508.22.n"
+          ]
+        }
       }
     },
     "layout-table": {
@@ -1837,15 +1854,18 @@
             }
           }
         ],
-        "impact": "serious",
-        "tags": [
-          "cat.name-role-value",
-          "wcag2a",
-          "wcag412",
-          "wcag244",
-          "section508",
-          "section508.22.a"
-        ]
+        "diagnostic": {
+          "type": "diagnostic",
+          "impact": "serious",
+          "tags": [
+            "cat.name-role-value",
+            "wcag2a",
+            "wcag412",
+            "wcag244",
+            "section508",
+            "section508.22.a"
+          ]
+        }
       }
     },
     "list": {
@@ -1921,14 +1941,17 @@
             }
           }
         ],
-        "impact": "serious",
-        "tags": [
-          "cat.text-alternatives",
-          "wcag2a",
-          "wcag111",
-          "section508",
-          "section508.22.a"
-        ]
+        "diagnostic": {
+          "type": "diagnostic",
+          "impact": "serious",
+          "tags": [
+            "cat.text-alternatives",
+            "wcag2a",
+            "wcag111",
+            "section508",
+            "section508.22.a"
+          ]
+        }
       }
     },
     "tabindex": {
@@ -2108,7 +2131,6 @@
         "items": [
           {
             "url": "http://localhost:10200/zone.js",
-            "cacheControl": null,
             "cacheLifetimeMs": 0,
             "cacheHitProbability": 0,
             "totalBytes": 71654,
@@ -2116,7 +2138,6 @@
           },
           {
             "url": "http://localhost:10200/dobetterweb/lighthouse-480x318.jpg",
-            "cacheControl": null,
             "cacheLifetimeMs": 0,
             "cacheHitProbability": 0,
             "totalBytes": 24741,
@@ -2124,7 +2145,6 @@
           },
           {
             "url": "http://localhost:10200/dobetterweb/dbw_tester.js",
-            "cacheControl": null,
             "cacheLifetimeMs": 0,
             "cacheHitProbability": 0,
             "totalBytes": 1703,
@@ -2132,7 +2152,6 @@
           },
           {
             "url": "http://localhost:10200/dobetterweb/dbw_disabled.css?delay=200&isdisabled",
-            "cacheControl": null,
             "cacheLifetimeMs": 0,
             "cacheHitProbability": 0,
             "totalBytes": 1108,
@@ -2140,7 +2159,6 @@
           },
           {
             "url": "http://localhost:10200/dobetterweb/dbw_tester.css?delay=3000&async=true",
-            "cacheControl": null,
             "cacheLifetimeMs": 0,
             "cacheHitProbability": 0,
             "totalBytes": 821,
@@ -2148,7 +2166,6 @@
           },
           {
             "url": "http://localhost:10200/dobetterweb/dbw_tester.css?delay=100",
-            "cacheControl": null,
             "cacheLifetimeMs": 0,
             "cacheHitProbability": 0,
             "totalBytes": 821,
@@ -2156,7 +2173,6 @@
           },
           {
             "url": "http://localhost:10200/dobetterweb/dbw_tester.css?scriptActivated&delay=200",
-            "cacheControl": null,
             "cacheLifetimeMs": 0,
             "cacheHitProbability": 0,
             "totalBytes": 821,
@@ -2164,7 +2180,6 @@
           },
           {
             "url": "http://localhost:10200/dobetterweb/dbw_tester.css?delay=2200",
-            "cacheControl": null,
             "cacheLifetimeMs": 0,
             "cacheHitProbability": 0,
             "totalBytes": 821,
@@ -2172,7 +2187,6 @@
           },
           {
             "url": "http://localhost:10200/dobetterweb/dbw_tester.css?delay=2000&async=true",
-            "cacheControl": null,
             "cacheLifetimeMs": 0,
             "cacheHitProbability": 0,
             "totalBytes": 821,
@@ -2180,7 +2194,6 @@
           },
           {
             "url": "http://localhost:10200/dobetterweb/empty_module.js?delay=500",
-            "cacheControl": null,
             "cacheLifetimeMs": 0,
             "cacheHitProbability": 0,
             "totalBytes": 144,
@@ -2188,7 +2201,6 @@
           },
           {
             "url": "blob:http://localhost:10200/ae0eac03-ab9b-4a6a-b299-f5212153e277",
-            "cacheControl": null,
             "cacheLifetimeMs": 0,
             "cacheHitProbability": 0,
             "totalBytes": 0,