Repository: GoogleChrome/lighthouse
Pull request #5856 (2018-08-17 20:01:08)

core: remove last debugString references
Remaining cleanup from the debugString change, updates the gatherers to return explanation instead of debugString to align with our output in LHR.


Patch:
diff --git a/.cz-config.js b/.cz-config.js
index eb2ea425c..61108b3bb 100644
--- a/.cz-config.js
+++ b/.cz-config.js
@@ -13,6 +13,7 @@ module.exports = {
     {value: 'new_audit',  name: 'new_audit: A new audit'},
     {value: 'core',       name: 'core:      Driver, gather, (non-new) audits, LHR JSON, etc'},
     {value: 'tests',      name: 'tests:     Tests, smokehouse, etc'},
+    {value: 'i18n',       name: 'i18n:      Internationalization'},
     {value: 'docs',       name: 'docs:      Documentation'},
     {value: 'deps',       name: 'deps:      Dependency bumps only'},
     {value: 'report',     name: 'report:    Report, UI, renderers'},
diff --git a/lighthouse-cli/bin.js b/lighthouse-cli/bin.js
index d02844d89..4baa04b15 100644
--- a/lighthouse-cli/bin.js
+++ b/lighthouse-cli/bin.js
@@ -107,6 +107,7 @@ function run() {
       }
     })
     .then(_ => {
+      // @ts-ignore TODO(bckenny): Sentry type checking
       Sentry.init({
         url,
         flags: cliFlags,
diff --git a/lighthouse-core/audits/dobetterweb/dom-size.js b/lighthouse-core/audits/dobetterweb/dom-size.js
index de0872d36..38cb9e6e0 100644
--- a/lighthouse-core/audits/dobetterweb/dom-size.js
+++ b/lighthouse-core/audits/dobetterweb/dom-size.js
@@ -101,6 +101,7 @@ class DOMSize extends Audit {
       {key: 'width', itemType: 'text', text: str_(UIStrings.columnDOMWidth)},
     ];
 
+    /** @type {Array<Object<string, LH.Audit.DetailsItem>>} */
     const items = [
       {
         totalNodes: Util.formatNumber(stats.totalDOMNodes),
diff --git a/lighthouse-core/audits/mainthread-work-breakdown.js b/lighthouse-core/audits/mainthread-work-breakdown.js
index c5f6b1c07..4ec93e078 100644
--- a/lighthouse-core/audits/mainthread-work-breakdown.js
+++ b/lighthouse-core/audits/mainthread-work-breakdown.js
@@ -28,6 +28,8 @@ const UIStrings = {
 
 const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
 
+/** @typedef {import('../lib/task-groups.js').TaskGroupIds} TaskGroupIds */
+
 class MainThreadWorkBreakdown extends Audit {
   /**
    * @return {LH.Audit.Meta}
@@ -56,10 +58,10 @@ class MainThreadWorkBreakdown extends Audit {
 
   /**
    * @param {LH.Artifacts.TaskNode[]} tasks
-   * @return {Map<string, number>}
+   * @return {Map<TaskGroupIds, number>}
    */
   static getExecutionTimingsByGroup(tasks) {
-    /** @type {Map<string, number>} */
+    /** @type {Map<TaskGroupIds, number>} */
     const result = new Map();
 
     for (const task of tasks) {
@@ -86,6 +88,7 @@ class MainThreadWorkBreakdown extends Audit {
     const executionTimings = MainThreadWorkBreakdown.getExecutionTimingsByGroup(tasks);
 
     let totalExecutionTime = 0;
+    /** @type {Record<string, number>} */
     const categoryTotals = {};
     const results = Array.from(executionTimings).map(([groupId, rawDuration]) => {
       const duration = rawDuration * multiplier;
diff --git a/lighthouse-core/audits/user-timings.js b/lighthouse-core/audits/user-timings.js
index 6429bfd7c..d3015f13d 100644
--- a/lighthouse-core/audits/user-timings.js
+++ b/lighthouse-core/audits/user-timings.js
@@ -56,6 +56,7 @@ class UserTimings extends Audit {
   static filterTrace(tabTrace) {
     /** @type {Array<MarkEvent|MeasureEvent>} */
     const userTimings = [];
+    /** @type {Record<string, number>} */
     const measuresStartTimes = {};
 
     // Get all blink.user_timing events
diff --git a/lighthouse-core/audits/webapp-install-banner.js b/lighthouse-core/audits/webapp-install-banner.js
index be4f7a114..b747ed6bd 100644
--- a/lighthouse-core/audits/webapp-install-banner.js
+++ b/lighthouse-core/audits/webapp-install-banner.js
@@ -104,14 +104,13 @@ class WebappInstallBanner extends MultiCheckAudit {
 
     if (!hasOfflineStartUrl) {
       failures.push('Service worker does not successfully serve the manifest\'s start_url');
-      // TODO(phulce): align gatherer `debugString` with `explanation`
-      if (artifacts.StartUrl.debugString) {
-        failures.push(artifacts.StartUrl.debugString);
+      if (artifacts.StartUrl.explanation) {
+        failures.push(artifacts.StartUrl.explanation);
       }
     }
 
-    if (artifacts.StartUrl.debugString) {
-      warnings.push(artifacts.StartUrl.debugString);
+    if (artifacts.StartUrl.explanation) {
+      warnings.push(artifacts.StartUrl.explanation);
     }
 
     return {failures, warnings};
diff --git a/lighthouse-core/config/default-config.js b/lighthouse-core/config/default-config.js
index f582044cf..00e74f212 100644
--- a/lighthouse-core/config/default-config.js
+++ b/lighthouse-core/config/default-config.js
@@ -35,7 +35,8 @@ const UIStrings = {
 
 const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
 
-module.exports = {
+/** @type {LH.Config.Json} */
+const defaultConfig = {
   settings: constants.defaultSettings,
   passes: [{
     passName: 'defaultPass',
@@ -440,6 +441,8 @@ module.exports = {
   },
 };
 
+module.exports = defaultConfig;
+
 // Use `defineProperty` so that the strings are accesible from original but ignored when we copy it
 Object.defineProperty(module.exports, 'UIStrings', {
   enumerable: false,
diff --git a/lighthouse-core/config/full-config.js b/lighthouse-core/config/full-config.js
index 5d430473d..89ba2f57e 100644
--- a/lighthouse-core/config/full-config.js
+++ b/lighthouse-core/config/full-config.js
@@ -5,7 +5,8 @@
  */
 'use strict';
 
-module.exports = {
+/** @type {LH.Config.Json} */
+const fullConfig = {
   extends: 'lighthouse:default',
   settings: {},
   passes: [
@@ -19,6 +20,7 @@ module.exports = {
   audits: [
     'byte-efficiency/unused-javascript',
   ],
+  // @ts-ignore TODO(bckenny): type extended Config where e.g. category.title isn't required
   categories: {
     'performance': {
       auditRefs: [
@@ -27,3 +29,5 @@ module.exports = {
     },
   },
 };
+
+module.exports = fullConfig;
diff --git a/lighthouse-core/config/mixed-content-config.js b/lighthouse-core/config/mixed-content-config.js
index 86341e4e5..89526804b 100644
--- a/lighthouse-core/config/mixed-content-config.js
+++ b/lighthouse-core/config/mixed-content-config.js
@@ -5,7 +5,8 @@
  */
 'use strict';
 
-module.exports = {
+/** @type {LH.Config.Json} */
+const mixedContentConfig = {
   // This performs two passes:
   // (1) Gather the default resources requested by the page, and
   // (2) Re-load page but attempt to upgrade each request to HTTPS.
@@ -35,3 +36,5 @@ module.exports = {
     },
   },
 };
+
+module.exports = mixedContentConfig;
diff --git a/lighthouse-core/config/perf-config.js b/lighthouse-core/config/perf-config.js
index a00e14459..a28d383d9 100644
--- a/lighthouse-core/config/perf-config.js
+++ b/lighthouse-core/config/perf-config.js
@@ -5,10 +5,13 @@
  */
 'use strict';
 
-module.exports = {
+/** @type {LH.Config.Json} */
+const perfConfig = {
   extends: 'lighthouse:default',
   settings: {
     throttlingMethod: 'devtools',
     onlyCategories: ['performance'],
   },
 };
+
+module.exports = perfConfig;
diff --git a/lighthouse-core/config/plots-config.js b/lighthouse-core/config/plots-config.js
index 29eb53534..6f8598bda 100644
--- a/lighthouse-core/config/plots-config.js
+++ b/lighthouse-core/config/plots-config.js
@@ -5,7 +5,8 @@
  */
 'use strict';
 
-module.exports = {
+/** @type {LH.Config.Json} */
+const plotsConfig = {
   extends: 'lighthouse:default',
   settings: {
     onlyAudits: [
@@ -17,3 +18,5 @@ module.exports = {
     ],
   },
 };
+
+module.exports = plotsConfig;
diff --git a/lighthouse-core/gather/computed/metrics/lantern-metric.js b/lighthouse-core/gather/computed/metrics/lantern-metric.js
index 43a15c2b0..425ce6d06 100644
--- a/lighthouse-core/gather/computed/metrics/lantern-metric.js
+++ b/lighthouse-core/gather/computed/metrics/lantern-metric.js
@@ -85,6 +85,7 @@ class LanternMetricArtifact extends ComputedArtifact {
     const optimisticGraph = this.getOptimisticGraph(graph, traceOfTab);
     const pessimisticGraph = this.getPessimisticGraph(graph, traceOfTab);
 
+    /** @type {{flexibleOrdering?: boolean, label?: string}} */
     let simulateOptions = {label: `optimistic${metricName}`};
     const optimisticSimulation = simulator.simulate(optimisticGraph, simulateOptions);
 
diff --git a/lighthouse-core/gather/computed/metrics/metric.js b/lighthouse-core/gather/computed/metrics/metric.js
index 2c73c644e..13e616112 100644
--- a/lighthouse-core/gather/computed/metrics/metric.js
+++ b/lighthouse-core/gather/computed/metrics/metric.js
@@ -6,6 +6,7 @@
 'use strict';
 
 const ComputedArtifact = require('../computed-artifact');
+const TracingProcessor = require('../../../lib/traces/tracing-processor');
 
 /**
  * @fileOverview Encapsulates logic for choosing the correct metric computation method based on the
@@ -58,6 +59,8 @@ class ComputedMetric extends ComputedArtifact {
       traceOfTab: await computedArtifacts.requestTraceOfTab(trace),
     }, data);
 
+    TracingProcessor.assertHasToplevelEvents(augmentedData.traceOfTab.mainThreadEvents);
+
     switch (settings.throttlingMethod) {
       case 'simulate':
         return this.computeSimulatedMetric(augmentedData, computedArtifacts);
diff --git a/lighthouse-core/gather/computed/page-dependency-graph.js b/lighthouse-core/gather/computed/page-dependency-graph.js
index 0f991be11..95be2a63b 100644
--- a/lighthouse-core/gather/computed/page-dependency-graph.js
+++ b/lighthouse-core/gather/computed/page-dependency-graph.js
@@ -83,6 +83,8 @@ class PageDependencyGraphArtifact extends ComputedArtifact {
     const nodes = [];
     let i = 0;
 
+    TracingProcessor.assertHasToplevelEvents(traceOfTab.mainThreadEvents);
+
     const minimumEvtDur = MINIMUM_TASK_DURATION_OF_INTEREST * 1000;
     while (i < traceOfTab.mainThreadEvents.length) {
       const evt = traceOfTab.mainThreadEvents[i];
diff --git a/lighthouse-core/gather/gatherers/manifest.js b/lighthouse-core/gather/gatherers/manifest.js
index 0cdbf4fc8..ff4268aff 100644
--- a/lighthouse-core/gather/gatherers/manifest.js
+++ b/lighthouse-core/gather/gatherers/manifest.js
@@ -19,7 +19,7 @@ const BOM_FIRSTCHAR = 65279;
 class Manifest extends Gatherer {
   /**
    * Returns the parsed manifest or null if the page had no manifest. If the manifest
-   * was unparseable as JSON, manifest.value will be undefined and manifest.debugString
+   * was unparseable as JSON, manifest.value will be undefined and manifest.warning
    * will have the reason. See manifest-parser.js for more information.
    * @param {LH.Gatherer.PassContext} passContext
    * @return {Promise<LH.Artifacts['Manifest']>}
diff --git a/lighthouse-core/gather/gatherers/start-url.js b/lighthouse-core/gather/gatherers/start-url.js
index c859b23a4..9d7c59ec7 100644
--- a/lighthouse-core/gather/gatherers/start-url.js
+++ b/lighthouse-core/gather/gatherers/start-url.js
@@ -24,23 +24,23 @@ class StartUrl extends Gatherer {
       .then(manifest => {
         const startUrlInfo = this._readManifestStartUrl(manifest);
         if (startUrlInfo.isReadFailure) {
-          return {statusCode: -1, debugString: startUrlInfo.reason};
+          return {statusCode: -1, explanation: startUrlInfo.reason};
         }
 
         return this._attemptManifestFetch(passContext.driver, startUrlInfo.startUrl);
       }).catch(() => {
-        return {statusCode: -1, debugString: 'Unable to fetch start URL via service worker'};
+        return {statusCode: -1, explanation: 'Unable to fetch start URL via service worker'};
       });
   }
 
   /**
    * Read the parsed manifest and return failure reasons or the startUrl
-   * @param {?{value?: {start_url: {value?: string, debugString?: string}}, debugString?: string}} manifest
+   * @param {?{value?: {start_url: {value?: string, warning?: string}}, warning?: string}} manifest
    * @return {{isReadFailure: true, reason: string}|{isReadFailure: false, startUrl: string}}
    */
   _readManifestStartUrl(manifest) {
     if (!manifest || !manifest.value) {
-      const detailedMsg = manifest && manifest.debugString;
+      const detailedMsg = manifest && manifest.warning;
 
       if (detailedMsg) {
         return {isReadFailure: true, reason: `Error fetching web app manifest: ${detailedMsg}`};
@@ -50,12 +50,12 @@ class StartUrl extends Gatherer {
     }
 
     // Even if the start URL had an error, the browser will still supply a fallback URL.
-    // Therefore, we only set the debugString here and continue with the fetch.
-    if (manifest.value.start_url.debugString) {
-      return {isReadFailure: true, reason: manifest.value.start_url.debugString};
+    // Therefore, we only set the warning here and continue with the fetch.
+    if (manifest.value.start_url.warning) {
+      return {isReadFailure: true, reason: manifest.value.start_url.warning};
     }
 
-    // @ts-ignore - TODO(bckenny): should actually be testing value above, not debugString
+    // @ts-ignore - TODO(bckenny): should actually be testing value above, not warning
     return {isReadFailure: false, startUrl: manifest.value.start_url.value};
   }
 
@@ -64,13 +64,13 @@ class StartUrl extends Gatherer {
    * Resolves when we have a matched network request
    * @param {Driver} driver
    * @param {string} startUrl
-   * @return {Promise<{statusCode: number, debugString: string}>}
+   * @return {Promise<{statusCode: number, explanation: string}>}
    */
   _attemptManifestFetch(driver, startUrl) {
     // Wait up to 3s to get a matched network request from the fetch() to work
     const timeoutPromise = new Promise(resolve =>
       setTimeout(
-        () => resolve({statusCode: -1, debugString: 'Timed out waiting for fetched start_url'}),
+        () => resolve({statusCode: -1, explanation: 'Timed out waiting for fetched start_url'}),
         3000
       )
     );
@@ -88,7 +88,7 @@ class StartUrl extends Gatherer {
         if (!response.fromServiceWorker) {
           return resolve({
             statusCode: -1,
-            debugString: 'Unable to fetch start URL via service worker',
+            explanation: 'Unable to fetch start URL via service worker',
           });
         }
         // Successful SW-served fetch of the start_URL
diff --git a/lighthouse-core/lib/dependency-graph/simulator/network-analyzer.js b/lighthouse-core/lib/dependency-graph/simulator/network-analyzer.js
index 2eaac3dd7..c2ad615fa 100644
--- a/lighthouse-core/lib/dependency-graph/simulator/network-analyzer.js
+++ b/lighthouse-core/lib/dependency-graph/simulator/network-analyzer.js
@@ -11,8 +11,11 @@ const NetworkRequest = require('../../network-request');
 // Assume that 40% of TTFB was server response time by default for static assets
 const DEFAULT_SERVER_RESPONSE_PERCENTAGE = 0.4;
 
-// For certain resource types, server response time takes up a greater percentage of TTFB (dynamic
-// assets like HTML documents, XHR/API calls, etc)
+/**
+ * For certain resource types, server response time takes up a greater percentage of TTFB (dynamic
+ * assets like HTML documents, XHR/API calls, etc)
+ * @type {Partial<Record<LH.Crdp.Page.ResourceType, number>>}
+ */
 const SERVER_RESPONSE_PERCENTAGE_OF_TTFB = {
   Document: 0.9,
   XHR: 0.9,
@@ -73,9 +76,11 @@ class NetworkAnalyzer {
     return summaryByKey;
   }
 
+  /** @typedef {{record: LH.Artifacts.NetworkRequest, timing: LH.Crdp.Network.ResourceTiming, connectionReused?: boolean}} RequestInfo */
+
   /**
    * @param {LH.Artifacts.NetworkRequest[]} records
-   * @param {function(any):any} iteratee
+   * @param {(e: RequestInfo) => number | number[] | undefined} iteratee
    * @return {Map<string, number[]>}
    */
   static _estimateValueByOrigin(records, iteratee) {
@@ -190,6 +195,7 @@ class NetworkAnalyzer {
   static _estimateRTTByOriginViaHeadersEndTiming(records) {
     return NetworkAnalyzer._estimateValueByOrigin(records, ({record, timing, connectionReused}) => {
       if (!Number.isFinite(timing.receiveHeadersEnd) || timing.receiveHeadersEnd < 0) return;
+      if (!record.resourceType) return;
 
       const serverResponseTimePercentage = SERVER_RESPONSE_PERCENTAGE_OF_TTFB[record.resourceType]
         || DEFAULT_SERVER_RESPONSE_PERCENTAGE;
diff --git a/lighthouse-core/lib/i18n.js b/lighthouse-core/lib/i18n.js
index 05512a2f3..274137464 100644
--- a/lighthouse-core/lib/i18n.js
+++ b/lighthouse-core/lib/i18n.js
@@ -175,6 +175,7 @@ function _formatPathAsString(pathInLHR) {
  */
 function getRendererFormattedStrings(locale) {
   const icuMessageIds = Object.keys(LOCALES[locale]).filter(f => f.includes('core/report/html/'));
+  /** @type {LH.I18NRendererStrings} */
   const strings = {};
   for (const icuMessageId of icuMessageIds) {
     const [filename, varName] = icuMessageId.split(' | ');
@@ -190,6 +191,7 @@ function getRendererFormattedStrings(locale) {
  * @param {Record<string, string>} fileStrings
  */
 function createMessageInstanceIdFn(filename, fileStrings) {
+  /** @type {Record<string, string>} */
   const mergedStrings = {...UIStrings, ...fileStrings};
 
   /** @param {string} icuMessage @param {*} [values] */
@@ -284,6 +286,7 @@ function replaceIcuMessageInstanceIds(lhr, locale) {
     }
   }
 
+  /** @type {LH.I18NMessages} */
   const icuMessagePaths = {};
   replaceInObject(lhr, icuMessagePaths);
   return icuMessagePaths;
diff --git a/lighthouse-core/lib/locales/index.js b/lighthouse-core/lib/locales/index.js
index aa4baed52..26c4cc7ce 100644
--- a/lighthouse-core/lib/locales/index.js
+++ b/lighthouse-core/lib/locales/index.js
@@ -5,6 +5,13 @@
  */
 'use strict';
 
+/** @fileoverview
+ * Define message file to be used for a given locale. A few aliases are defined below.
+ *
+ * Google locale inheritance rules: https://goto.google.com/ccssm
+ * CLDR language aliases: https://www.unicode.org/cldr/charts/latest/supplemental/aliases.html
+ */
+
 /** @typedef {Record<string, {message: string}>} LocaleMessages */
 
 // The keys within this const must exactly match the LH.Locale type in externs.d.ts
@@ -48,6 +55,7 @@ const locales = {
   'iw': require('./he.json'), // Alias of 'he'
   'ja': require('./ja.json'),
   'ko': require('./ko.json'),
+  'ln': require('./fr.json'), // Alias of 'fr'
   'lt': require('./lt.json'),
   'lv': require('./lv.json'),
   'mo': require('./ro.json'), // Alias of 'ro'
diff --git a/lighthouse-core/lib/manifest-parser.js b/lighthouse-core/lib/manifest-parser.js
index 909781828..17b208af8 100644
--- a/lighthouse-core/lib/manifest-parser.js
+++ b/lighthouse-core/lib/manifest-parser.js
@@ -37,13 +37,13 @@ const ALLOWED_ORIENTATION_VALUES = [
  */
 function parseString(raw, trim) {
   let value;
-  let debugString;
+  let warning;
 
   if (typeof raw === 'string') {
     value = trim ? raw.trim() : raw;
   } else {
     if (raw !== undefined) {
-      debugString = 'ERROR: expected a string.';
+      warning = 'ERROR: expected a string.';
     }
     value = undefined;
   }
@@ -51,7 +51,7 @@ function parseString(raw, trim) {
   return {
     raw,
     value,
-    debugString,
+    warning,
   };
 }
 
@@ -70,7 +70,7 @@ function parseColor(raw) {
   const validatedColor = validateColor(color.raw);
   if (!validatedColor) {
     color.value = undefined;
-    color.debugString = 'ERROR: color parsing failed.';
+    color.warning = 'ERROR: color parsing failed.';
   }
 
   return color;
@@ -117,7 +117,7 @@ function parseStartUrl(jsonInput, manifestUrl, documentUrl) {
     return {
       raw,
       value: documentUrl,
-      debugString: 'ERROR: start_url string empty',
+      warning: 'ERROR: start_url string empty',
     };
   }
   const parsedAsString = parseString(raw);
@@ -135,7 +135,7 @@ function parseStartUrl(jsonInput, manifestUrl, documentUrl) {
     return {
       raw,
       value: documentUrl,
-      debugString: 'ERROR: invalid start_url relative to ${manifestUrl}',
+      warning: 'ERROR: invalid start_url relative to ${manifestUrl}',
     };
   }
 
@@ -144,7 +144,7 @@ function parseStartUrl(jsonInput, manifestUrl, documentUrl) {
     return {
       raw,
       value: documentUrl,
-      debugString: 'ERROR: start_url must be same-origin as document',
+      warning: 'ERROR: start_url must be same-origin as document',
     };
   }
 
@@ -165,7 +165,7 @@ function parseDisplay(jsonInput) {
     return {
       raw: jsonInput,
       value: DEFAULT_DISPLAY_MODE,
-      debugString: parsedString.debugString,
+      warning: parsedString.warning,
     };
   }
 
@@ -174,7 +174,7 @@ function parseDisplay(jsonInput) {
     return {
       raw: jsonInput,
       value: DEFAULT_DISPLAY_MODE,
-      debugString: 'ERROR: \'display\' has invalid value ' + displayValue +
+      warning: 'ERROR: \'display\' has invalid value ' + displayValue +
         `. will fall back to ${DEFAULT_DISPLAY_MODE}.`,
     };
   }
@@ -182,7 +182,7 @@ function parseDisplay(jsonInput) {
   return {
     raw: jsonInput,
     value: displayValue,
-    debugString: undefined,
+    warning: undefined,
   };
 }
 
@@ -195,7 +195,7 @@ function parseOrientation(jsonInput) {
   if (orientation.value &&
       !ALLOWED_ORIENTATION_VALUES.includes(orientation.value.toLowerCase())) {
     orientation.value = undefined;
-    orientation.debugString = 'ERROR: \'orientation\' has an invalid value, will be ignored.';
+    orientation.warning = 'ERROR: \'orientation\' has an invalid value, will be ignored.';
   }
 
   return orientation;
@@ -223,13 +223,13 @@ function parseIcon(raw, manifestUrl) {
     raw: raw.density,
     value: 1,
     /** @type {string|undefined} */
-    debugString: undefined,
+    warning: undefined,
   };
   if (density.raw !== undefined) {
     density.value = parseFloat(density.raw);
     if (isNaN(density.value) || !isFinite(density.value) || density.value <= 0) {
       density.value = 1;
-      density.debugString = 'ERROR: icon density cannot be NaN, +∞, or less than or equal to +0.';
+      density.warning = 'ERROR: icon density cannot be NaN, +∞, or less than or equal to +0.';
     }
   }
 
@@ -242,7 +242,7 @@ function parseIcon(raw, manifestUrl) {
     sizes = {
       raw: raw.sizes,
       value: set.size > 0 ? Array.from(set) : undefined,
-      debugString: undefined,
+      warning: undefined,
     };
   } else {
     sizes = {...parsedSizes, value: undefined};
@@ -256,7 +256,7 @@ function parseIcon(raw, manifestUrl) {
       density,
       sizes,
     },
-    debugString: undefined,
+    warning: undefined,
   };
 }
 
@@ -272,7 +272,7 @@ function parseIcons(jsonInput, manifestUrl) {
       raw,
       /** @type {Array<ReturnType<typeof parseIcon>>} */
       value: [],
-      debugString: undefined,
+      warning: undefined,
     };
   }
 
@@ -281,7 +281,7 @@ function parseIcons(jsonInput, manifestUrl) {
       raw,
       /** @type {Array<ReturnType<typeof parseIcon>>} */
       value: [],
-      debugString: 'ERROR: \'icons\' expected to be an array but is not.',
+      warning: 'ERROR: \'icons\' expected to be an array but is not.',
     };
   }
 
@@ -298,7 +298,7 @@ function parseIcons(jsonInput, manifestUrl) {
   return {
     raw,
     value,
-    debugString: undefined,
+    warning: undefined,
   };
 }
 
@@ -317,7 +317,7 @@ function parseApplication(raw) {
       appUrl.value = new URL(appUrl.value).href;
     } catch (e) {
       appUrl.value = undefined;
-      appUrl.debugString = 'ERROR: invalid application URL ${raw.url}';
+      appUrl.warning = 'ERROR: invalid application URL ${raw.url}';
     }
   }
 
@@ -328,7 +328,7 @@ function parseApplication(raw) {
       id,
       url: appUrl,
     },
-    debugString: undefined,
+    warning: undefined,
   };
 }
 
@@ -342,7 +342,7 @@ function parseRelatedApplications(jsonInput) {
     return {
       raw,
       value: undefined,
-      debugString: undefined,
+      warning: undefined,
     };
   }
 
@@ -350,7 +350,7 @@ function parseRelatedApplications(jsonInput) {
     return {
       raw,
       value: undefined,
-      debugString: 'ERROR: \'related_applications\' expected to be an array but is not.',
+      warning: 'ERROR: \'related_applications\' expected to be an array but is not.',
     };
   }
 
@@ -364,7 +364,7 @@ function parseRelatedApplications(jsonInput) {
   return {
     raw,
     value,
-    debugString: undefined,
+    warning: undefined,
   };
 }
 
@@ -374,13 +374,13 @@ function parseRelatedApplications(jsonInput) {
 function parsePreferRelatedApplications(jsonInput) {
   const raw = jsonInput.prefer_related_applications;
   let value;
-  let debugString;
+  let warning;
 
   if (typeof raw === 'boolean') {
     value = raw;
   } else {
     if (raw !== undefined) {
-      debugString = 'ERROR: \'prefer_related_applications\' expected to be a boolean.';
+      warning = 'ERROR: \'prefer_related_applications\' expected to be a boolean.';
     }
     value = undefined;
   }
@@ -388,7 +388,7 @@ function parsePreferRelatedApplications(jsonInput) {
   return {
     raw,
     value,
-    debugString,
+    warning,
   };
 }
 
@@ -425,7 +425,7 @@ function parse(string, manifestUrl, documentUrl) {
     return {
       raw: string,
       value: undefined,
-      debugString: 'ERROR: file isn\'t valid JSON: ' + e,
+      warning: 'ERROR: file isn\'t valid JSON: ' + e,
     };
   }
 
@@ -447,7 +447,7 @@ function parse(string, manifestUrl, documentUrl) {
   return {
     raw: string,
     value: manifest,
-    debugString: undefined,
+    warning: undefined,
   };
 }
 
diff --git a/lighthouse-core/lib/task-groups.js b/lighthouse-core/lib/task-groups.js
index d61af424d..07d03fd0a 100644
--- a/lighthouse-core/lib/task-groups.js
+++ b/lighthouse-core/lib/task-groups.js
@@ -5,9 +5,11 @@
  */
 'use strict';
 
+/** @typedef {'parseHTML'|'styleLayout'|'paintCompositeRender'|'scriptParseCompile'|'scriptEvaluation'|'garbageCollection'|'other'} TaskGroupIds */
+
 /**
  * @typedef TaskGroup
- * @property {string} id
+ * @property {TaskGroupIds} id
  * @property {string} label
  * @property {string[]} traceEventNames
  */
@@ -16,15 +18,16 @@
  * Make sure the traceEventNames keep up with the ones in DevTools
  * @see https://cs.chromium.org/chromium/src/third_party/blink/renderer/devtools/front_end/timeline_model/TimelineModel.js?type=cs&q=TimelineModel.TimelineModel.RecordType+%3D&g=0&l=1156
  * @see https://cs.chromium.org/chromium/src/third_party/blink/renderer/devtools/front_end/timeline/TimelineUIUtils.js?type=cs&q=_initEventStyles+-f:out+f:devtools&sq=package:chromium&g=0&l=39
+ * @type {{[P in TaskGroupIds]: {id: P, label: string, traceEventNames: Array<string>}}}
  */
 const taskGroups = {
   parseHTML: {
-    id: '',
+    id: 'parseHTML',
     label: 'Parse HTML & CSS',
     traceEventNames: ['ParseHTML', 'ParseAuthorStyleSheet'],
   },
   styleLayout: {
-    id: '',
+    id: 'styleLayout',
     label: 'Style & Layout',
     traceEventNames: [
       'ScheduleStyleRecalculation',
@@ -35,7 +38,7 @@ const taskGroups = {
     ],
   },
   paintCompositeRender: {
-    id: '',
+    id: 'paintCompositeRender',
     label: 'Rendering',
     traceEventNames: [
       'Animation',
@@ -55,12 +58,12 @@ const taskGroups = {
     ],
   },
   scriptParseCompile: {
-    id: '',
+    id: 'scriptParseCompile',
     label: 'Script Parsing & Compilation',
     traceEventNames: ['v8.compile', 'v8.compileModule', 'v8.parseOnBackground'],
   },
   scriptEvaluation: {
-    id: '',
+    id: 'scriptEvaluation',
     label: 'Script Evaluation',
     traceEventNames: [
       'EventDispatch',
@@ -75,7 +78,7 @@ const taskGroups = {
     ],
   },
   garbageCollection: {
-    id: '',
+    id: 'garbageCollection',
     label: 'Garbage Collection',
     traceEventNames: [
       'GCEvent',
@@ -87,7 +90,7 @@ const taskGroups = {
     ],
   },
   other: {
-    id: '',
+    id: 'other',
     label: 'Other',
     traceEventNames: [
       'MessageLoop::RunTask',
@@ -99,8 +102,7 @@ const taskGroups = {
 
 /** @type {Object<string, TaskGroup>} */
 const taskNameToGroup = {};
-for (const [groupId, group] of Object.entries(taskGroups)) {
-  group.id = groupId;
+for (const group of Object.values(taskGroups)) {
   for (const traceEventName of group.traceEventNames) {
     taskNameToGroup[traceEventName] = group;
   }
diff --git a/lighthouse-core/lib/traces/tracing-processor.js b/lighthouse-core/lib/traces/tracing-processor.js
index 6df2f3d27..05ff24d57 100644
--- a/lighthouse-core/lib/traces/tracing-processor.js
+++ b/lighthouse-core/lib/traces/tracing-processor.js
@@ -17,6 +17,20 @@ const SCHEDULABLE_TASK_TITLE_ALT2 = 'ThreadControllerImpl::RunTask';
 const LHError = require('../errors');
 
 class TraceProcessor {
+  /**
+   * There should *always* be at least one top level event, having 0 typically means something is
+   * drastically wrong with the trace and we should just give up early and loudly.
+   *
+   * @param {LH.TraceEvent[]} events
+   */
+  static assertHasToplevelEvents(events) {
+    const hasToplevelTask = events.some(TraceProcessor.isScheduleableTask);
+    if (!hasToplevelTask) {
+      throw new Error('Could not find any top level events');
+    }
+  }
+
+
   /**
    * Calculate duration at specified percentiles for given population of
    * durations.
@@ -175,12 +189,6 @@ class TraceProcessor {
       });
     }
 
-    // There should *always* be at least one top level event, having 0 typically means something is
-    // drastically wrong with the trace and would should just give up early and loudly.
-    if (!topLevelEvents.length) {
-      throw new Error('Could not find any top level events');
-    }
-
     return topLevelEvents;
   }
 
diff --git a/lighthouse-core/report/html/renderer/psi.js b/lighthouse-core/report/html/renderer/psi.js
index 1d81ef08e..93d81503f 100644
--- a/lighthouse-core/report/html/renderer/psi.js
+++ b/lighthouse-core/report/html/renderer/psi.js
@@ -37,6 +37,9 @@ function prepareLabData(LHResultJsonString, document) {
   const lhResult = /** @type {LH.Result} */ JSON.parse(LHResultJsonString);
   const dom = new DOM(document);
 
+  // Assume fresh styles needed on every call, so mark all template styles as unused.
+  dom.resetTemplates();
+
   const reportLHR = Util.prepareReportResult(lhResult);
   const perfCategory = reportLHR.reportCategories.find(cat => cat.id === 'performance');
   if (!perfCategory) throw new Error(`No performance category. Can't make lab data section`);
diff --git a/lighthouse-core/report/html/renderer/report-ui-features.js b/lighthouse-core/report/html/renderer/report-ui-features.js
index c3ea5bcb0..97d590c59 100644
--- a/lighthouse-core/report/html/renderer/report-ui-features.js
+++ b/lighthouse-core/report/html/renderer/report-ui-features.js
@@ -90,7 +90,7 @@ class ReportUIFeatures {
     this._setupHeaderAnimation();
     this._resetUIState();
     this._document.addEventListener('keydown', this.printShortCutDetect);
-    // @ts-ignore - tsc thinks document can't listen for `copy`
+    // @ts-ignore - TODO(bckenny): tsc thinks document can't listen for `copy`. Remove ignore in 3.1.
     this._document.addEventListener('copy', this.onCopy);
   }
 
@@ -447,6 +447,7 @@ class ReportUIFeatures {
    */
   getReportHtml() {
     this._resetUIState();
+    // @ts-ignore - technically documentElement can be null, but that's dumb - https://dom.spec.whatwg.org/#document-element
     return this._document.documentElement.outerHTML;
   }
 
diff --git a/lighthouse-core/report/html/renderer/util.js b/lighthouse-core/report/html/renderer/util.js
index b5c3ad0c3..b059a2c75 100644
--- a/lighthouse-core/report/html/renderer/util.js
+++ b/lighthouse-core/report/html/renderer/util.js
@@ -223,6 +223,7 @@ class Util {
    * @return {string}
    */
   static formatDateTime(date) {
+    /** @type {Intl.DateTimeFormatOptions} */
     const options = {
       month: 'short', day: 'numeric', year: 'numeric',
       hour: 'numeric', minute: 'numeric', timeZoneName: 'short',
@@ -444,6 +445,10 @@ class Util {
  */
 Util.numberDateLocale = 'en';
 
+/**
+ * Report-renderer-specific strings.
+ * @type {LH.I18NRendererStrings}
+ */
 Util.UIStrings = {
   /** Disclaimer shown to users below the metric values (First Contentful Paint, Time to Interactive, etc) to warn them that the numbers they see will likely change slightly the next time they run Lighthouse. */
   varianceDisclaimer: 'Values are estimated and may vary.',
diff --git a/lighthouse-core/runner.js b/lighthouse-core/runner.js
index c0eeb0d2b..117f115ed 100644
--- a/lighthouse-core/runner.js
+++ b/lighthouse-core/runner.js
@@ -368,6 +368,7 @@ class Runner {
       const ArtifactClass = require('./gather/computed/' + filename);
       const artifact = new ArtifactClass(computedArtifacts);
       // define the request* function that will be exposed on `artifacts`
+      // @ts-ignore - doesn't have an index signature, so can't be set dynamically.
       computedArtifacts['request' + artifact.name] = artifact.request.bind(artifact);
     });
 
diff --git a/lighthouse-core/scoring.js b/lighthouse-core/scoring.js
index 1768c782b..ddbab9fdb 100644
--- a/lighthouse-core/scoring.js
+++ b/lighthouse-core/scoring.js
@@ -50,6 +50,7 @@ class ReportScoring {
    * @return {Object<string, LH.Result.Category>}
    */
   static scoreAllCategories(configCategories, resultsByAuditId) {
+    /** @type {Record<string, LH.Result.Category>} */
     const scoredCategories = {};
 
     for (const [categoryId, configCategory] of Object.entries(configCategories)) {
diff --git a/lighthouse-core/scripts/i18n/assert-strings-collected.sh b/lighthouse-core/scripts/i18n/assert-strings-collected.sh
index c5d6a9ae6..df9a2e90d 100755
--- a/lighthouse-core/scripts/i18n/assert-strings-collected.sh
+++ b/lighthouse-core/scripts/i18n/assert-strings-collected.sh
@@ -30,7 +30,7 @@ cp "$collectedstringsPath" "$currentstringsPath"
 
 colorText "Collecting strings..." "$purple"
 set -x
-node "$lhroot_path/lighthouse-core/scripts/i18n/collect-strings.js"
+node "$lhroot_path/lighthouse-core/scripts/i18n/collect-strings.js" || exit 1
 set +x
 
 cp "$collectedstringsPath" "$freshstringsPath"
diff --git a/lighthouse-core/scripts/i18n/collect-strings.js b/lighthouse-core/scripts/i18n/collect-strings.js
index f67274823..4a5fc225e 100644
--- a/lighthouse-core/scripts/i18n/collect-strings.js
+++ b/lighthouse-core/scripts/i18n/collect-strings.js
@@ -103,6 +103,7 @@ function collectAllStringsInDir(dir, strings = {}) {
  */
 function writeStringsToLocaleFormat(locale, strings) {
   const fullPath = path.join(LH_ROOT, `lighthouse-core/lib/locales/${locale}.json`);
+  /** @type {Record<string, ICUMessageDefn>} */
   const output = {};
   const sortedEntries = Object.entries(strings).sort(([keyA], [keyB]) => keyA.localeCompare(keyB));
   for (const [key, defn] of sortedEntries) {
diff --git a/lighthouse-extension/app/src/lighthouse-ext-background.js b/lighthouse-extension/app/src/lighthouse-ext-background.js
index 4b8b46146..59d11c125 100644
--- a/lighthouse-extension/app/src/lighthouse-ext-background.js
+++ b/lighthouse-extension/app/src/lighthouse-ext-background.js
@@ -122,7 +122,9 @@ function createReportPageAsBlob(reportHtml) {
  */
 function saveSettings(settings) {
   const storage = {
+    /** @type {Record<string, boolean>} */
     [STORAGE_KEY]: {},
+    /** @type {Record<string, boolean>} */
     [SETTINGS_KEY]: {},
   };
 
@@ -149,6 +151,7 @@ function loadSettings() {
     chrome.storage.local.get([STORAGE_KEY, SETTINGS_KEY], result => {
       // Start with list of all default categories set to true so list is
       // always up to date.
+      /** @type {Record<string, boolean>} */
       const defaultCategories = {};
       background.getDefaultCategories().forEach(category => {
         defaultCategories[category.id] = true;
diff --git a/lighthouse-extension/app/src/popup.js b/lighthouse-extension/app/src/popup.js
index e644c9f7a..ae8816709 100644
--- a/lighthouse-extension/app/src/popup.js
+++ b/lighthouse-extension/app/src/popup.js
@@ -154,11 +154,14 @@ async function onGenerateReportButtonClick(background, settings) {
   }
   isRunning = true;
 
+  // resetting status message
+  const statusMsg = find('.status__msg');
+  statusMsg.textContent = 'Starting...';
+
   showRunningSubpage();
 
   const feedbackEl = find('.feedback');
   feedbackEl.textContent = '';
-
   const {selectedCategories, useDevTools} = settings;
   /** @type {LH.Flags} */
   const flags = {throttlingMethod: useDevTools ? 'devtools' : 'simulate'};
@@ -185,7 +188,7 @@ async function onGenerateReportButtonClick(background, settings) {
     feedbackEl.textContent = message;
 
     if (includeReportLink) {
-      feedbackEl.className = 'feedback-error';
+      feedbackEl.className = 'feedback feedback-error';
       feedbackEl.appendChild(buildReportErrorLink(err));
     }
 
diff --git a/lighthouse-extension/app/styles/lighthouse.css b/lighthouse-extension/app/styles/lighthouse.css
index 415165a91..bf3cef568 100644
--- a/lighthouse-extension/app/styles/lighthouse.css
+++ b/lighthouse-extension/app/styles/lighthouse.css
@@ -188,20 +188,14 @@ html, body {
   position: absolute;
   color: #fff;
   bottom: 10px;
-  left: 16px;
+  left: 14px; /* 16px - 2px of shadow from the options button */
   width: 180px;
   line-height: 1.45;
 }
 
 .feedback-error {
-  background-color: var(--lh-blue-color);
-  position: absolute;
-  color: #fff;
-  bottom: 10px;
-  left: 16px;
-  width: 180px;
-  line-height: 1.45;
   font-size: .9em;
+  min-height: 38px;
 }
 
 /* 1. scrollbar when extension is too small */
diff --git a/typings/artifacts.d.ts b/typings/artifacts.d.ts
index 9f3103892..5920558d9 100644
--- a/typings/artifacts.d.ts
+++ b/typings/artifacts.d.ts
@@ -103,8 +103,8 @@ declare global {
       Scripts: Record<string, string>;
       /** Version information for all ServiceWorkers active after the first page load. */
       ServiceWorker: {versions: Crdp.ServiceWorker.ServiceWorkerVersion[]};
-      /** The status of an offline fetch of the page's start_url. -1 and a debugString if missing or there was an error. */
-      StartUrl: {statusCode: number, debugString?: string};
+      /** The status of an offline fetch of the page's start_url. -1 and a explanation if missing or there was an error. */
+      StartUrl: {statusCode: number, explanation?: string};
       /** Information on <script> and <link> tags blocking first paint. */
       TagsBlockingFirstPaint: Artifacts.TagBlockingFirstPaint[];
       /** The value of the <meta name="theme=color">'s content attribute, or null. */
diff --git a/typings/config.d.ts b/typings/config.d.ts
index 9cb6b692d..1866a0955 100644
--- a/typings/config.d.ts
+++ b/typings/config.d.ts
@@ -52,13 +52,14 @@ declare global {
 
       export interface CategoryJson {
         title: string;
-        description: string;
         auditRefs: AuditRefJson[];
+        description?: string;
+        manualDescription?: string;
       }
 
       export interface GroupJson {
         title: string;
-        description: string;
+        description?: string;
       }
 
       export type AuditJson = {


Test patch:
diff --git a/lighthouse-core/test/audits/webapp-install-banner-test.js b/lighthouse-core/test/audits/webapp-install-banner-test.js
index 6043dd569..48b3ce501 100644
--- a/lighthouse-core/test/audits/webapp-install-banner-test.js
+++ b/lighthouse-core/test/audits/webapp-install-banner-test.js
@@ -152,7 +152,7 @@ describe('PWA: webapp install banner audit', () => {
 
   it('includes warning from start_url', () => {
     const artifacts = generateMockArtifacts();
-    artifacts.StartUrl = {statusCode: 200, debugString: 'Warning!'};
+    artifacts.StartUrl = {statusCode: 200, explanation: 'Warning!'};
 
     return WebappInstallBannerAudit.audit(artifacts).then(result => {
       assert.strictEqual(result.rawValue, true);
diff --git a/lighthouse-core/test/gather/computed/metrics/first-meaningful-paint-test.js b/lighthouse-core/test/gather/computed/metrics/first-meaningful-paint-test.js
index 51accdf4b..278a1bf29 100644
--- a/lighthouse-core/test/gather/computed/metrics/first-meaningful-paint-test.js
+++ b/lighthouse-core/test/gather/computed/metrics/first-meaningful-paint-test.js
@@ -26,6 +26,15 @@ describe('Metrics: FMP', () => {
   let trace;
   let devtoolsLog;
 
+  function addEmptyTask() {
+    const mainThreadEvt = trace.traceEvents.find(e => e.name === 'TracingStartedInPage');
+    trace.traceEvents.push({
+      ...mainThreadEvt,
+      cat: 'toplevel',
+      name: 'TaskQueueManager::ProcessTaskFromWorkQueue',
+    });
+  }
+
   beforeEach(() => {
     artifacts = Runner.instantiateComputedArtifacts();
     settings = {throttlingMethod: 'provided'};
@@ -60,6 +69,7 @@ describe('Metrics: FMP', () => {
 
   it('handles cases when there was a tracingStartedInPage after navStart', async () => {
     trace = lateTracingStartedTrace;
+    addEmptyTask();
     const result = await artifacts.requestFirstMeaningfulPaint({trace, devtoolsLog, settings});
     assert.equal(Math.round(result.timing), 530);
     assert.equal(result.timestamp, 29344070867);
@@ -67,6 +77,7 @@ describe('Metrics: FMP', () => {
 
   it('handles cases when there was a tracingStartedInPage after navStart #2', async () => {
     trace = badNavStartTrace;
+    addEmptyTask();
     const result = await artifacts.requestFirstMeaningfulPaint({trace, devtoolsLog, settings});
     assert.equal(Math.round(result.timing), 632);
     assert.equal(result.timestamp, 8886056891);
@@ -74,6 +85,7 @@ describe('Metrics: FMP', () => {
 
   it('handles cases when it appears before FCP', async () => {
     trace = preactTrace;
+    addEmptyTask();
     const result = await artifacts.requestFirstMeaningfulPaint({trace, devtoolsLog, settings});
     assert.equal(Math.round(result.timing), 878);
     assert.equal(result.timestamp, 1805797262960);
@@ -81,6 +93,7 @@ describe('Metrics: FMP', () => {
 
   it('handles cases when no FMP exists', async () => {
     trace = noFMPtrace;
+    addEmptyTask();
     const result = await artifacts.requestFirstMeaningfulPaint({trace, devtoolsLog, settings});
     assert.equal(Math.round(result.timing), 4461);
     assert.equal(result.timestamp, 2146740268666);
@@ -88,6 +101,7 @@ describe('Metrics: FMP', () => {
 
   it('handles cases when no FCP exists', async () => {
     trace = noFCPtrace;
+    addEmptyTask();
     const result = await artifacts.requestFirstMeaningfulPaint({trace, devtoolsLog, settings});
     assert.equal(Math.round(result.timing), 482);
     assert.equal(result.timestamp, 2149509604903);
diff --git a/lighthouse-core/test/gather/computed/page-dependency-graph-test.js b/lighthouse-core/test/gather/computed/page-dependency-graph-test.js
index b7686fda2..1cbc26f46 100644
--- a/lighthouse-core/test/gather/computed/page-dependency-graph-test.js
+++ b/lighthouse-core/test/gather/computed/page-dependency-graph-test.js
@@ -27,6 +27,8 @@ function createRequest(
   return {requestId, url, startTime, endTime, initiator, resourceType};
 }
 
+const TOPLEVEL_TASK_NAME = 'TaskQueueManager::ProcessTaskFromWorkQueue';
+
 /* eslint-env jest */
 describe('PageDependencyGraph computed artifact:', () => {
   let computedArtifacts;
@@ -34,7 +36,7 @@ describe('PageDependencyGraph computed artifact:', () => {
 
   function addTaskEvents(startTs, duration, evts) {
     const mainEvent = {
-      name: 'TaskQueueManager::ProcessTaskFromWorkQueue',
+      name: TOPLEVEL_TASK_NAME,
       tid: 1,
       ts: startTs * 1000,
       dur: duration * 1000,
@@ -149,6 +151,8 @@ describe('PageDependencyGraph computed artifact:', () => {
       const request4 = createRequest(4, '4', 10, {url: '2'});
       const networkRecords = [request1, request2, request3, request4];
 
+      addTaskEvents(0, 0, []);
+
       const graph = PageDependencyGraph.createGraph(traceOfTab, networkRecords);
       const nodes = [];
       graph.traverse(node => nodes.push(node));
@@ -202,6 +206,8 @@ describe('PageDependencyGraph computed artifact:', () => {
       const request4 = createRequest(4, '4', 10, {url: '2'});
       const networkRecords = [request1, request2, request3, request4];
 
+      addTaskEvents(0, 0, []);
+
       const graph = PageDependencyGraph.createGraph(traceOfTab, networkRecords);
       const nodes = [];
       graph.traverse(node => nodes.push(node));
@@ -261,6 +267,8 @@ describe('PageDependencyGraph computed artifact:', () => {
       const request2 = createRequest(2, '2', 5);
       const networkRecords = [request1, request2];
 
+      addTaskEvents(0, 0, []);
+
       const graph = PageDependencyGraph.createGraph(traceOfTab, networkRecords);
       const nodes = [];
       graph.traverse(node => nodes.push(node));
diff --git a/lighthouse-core/test/gather/gatherers/manifest-test.js b/lighthouse-core/test/gather/gatherers/manifest-test.js
index 3fb709ad9..a22813ff9 100644
--- a/lighthouse-core/test/gather/gatherers/manifest-test.js
+++ b/lighthouse-core/test/gather/gatherers/manifest-test.js
@@ -122,7 +122,7 @@ describe('Manifest gatherer', () => {
       url: EXAMPLE_DOC_URL,
     }).then(artifact => {
       assert.ok(typeof artifact.value === 'object');
-      assert.strictEqual(artifact.debugString, undefined);
+      assert.strictEqual(artifact.explanation, undefined);
     });
   });
 });
diff --git a/lighthouse-core/test/gather/gatherers/start-url-test.js b/lighthouse-core/test/gather/gatherers/start-url-test.js
index 18ae8871b..6924a3033 100644
--- a/lighthouse-core/test/gather/gatherers/start-url-test.js
+++ b/lighthouse-core/test/gather/gatherers/start-url-test.js
@@ -77,11 +77,11 @@ describe('Start-url gatherer', () => {
       startUrlGathererWithResponseNotFromSW.afterPass(optionsWithResponseNotFromSW),
     ]).then(([artifact, artifactWithQueryString, artifactWithResponseNotFromSW]) => {
       assert.equal(artifact.statusCode, -1);
-      assert.ok(artifact.debugString, 'did not set debug string');
+      assert.ok(artifact.explanation, 'did not set debug string');
       assert.equal(artifactWithQueryString.statusCode, -1);
-      assert.ok(artifactWithQueryString.debugString, 'did not set debug string');
+      assert.ok(artifactWithQueryString.explanation, 'did not set debug string');
       assert.equal(artifactWithResponseNotFromSW.statusCode, -1);
-      assert.equal(artifactWithResponseNotFromSW.debugString,
+      assert.equal(artifactWithResponseNotFromSW.explanation,
           'Unable to fetch start URL via service worker');
     });
   });
@@ -107,7 +107,7 @@ describe('Start-url gatherer', () => {
     });
   });
 
-  it('returns a debugString when manifest cannot be found', () => {
+  it('returns a explanation when manifest cannot be found', () => {
     const driver = Object.assign({}, mockDriver);
     const startUrlGatherer = new StartUrlGatherer();
     const options = {
@@ -119,12 +119,12 @@ describe('Start-url gatherer', () => {
 
     return startUrlGatherer.afterPass(options, tracingData)
       .then(artifact => {
-        assert.equal(artifact.debugString,
+        assert.equal(artifact.explanation,
           `No usable web app manifest found on page`);
       });
   });
 
-  it('returns a debugString when manifest cannot be parsed', () => {
+  it('returns a explanation when manifest cannot be parsed', () => {
     const driver = Object.assign({}, mockDriver);
     const startUrlGatherer = new StartUrlGatherer();
     const options = {
@@ -139,13 +139,13 @@ describe('Start-url gatherer', () => {
 
     return startUrlGatherer.afterPass(options, tracingData)
       .then(artifact => {
-        assert.equal(artifact.debugString,
+        assert.equal(artifact.explanation,
           `Error fetching web app manifest: ERROR: file isn't valid JSON: ` +
           `SyntaxError: Unexpected token h in JSON at position 1`);
       });
   });
 
-  it('returns a debugString when start_url cannot be found', () => {
+  it('returns a explanation when start_url cannot be found', () => {
     const startUrlGatherer = new StartUrlGatherer();
     const options = {
       url: 'https://ifixit-pwa.appspot.com/',
@@ -154,7 +154,7 @@ describe('Start-url gatherer', () => {
 
     return startUrlGatherer.afterPass(options, tracingData)
       .then(artifact => {
-        assert.equal(artifact.debugString, 'ERROR: start_url string empty');
+        assert.equal(artifact.explanation, 'ERROR: start_url string empty');
       });
   });
 
@@ -167,7 +167,7 @@ describe('Start-url gatherer', () => {
 
     return startUrlGatherer.afterPass(options, tracingData)
       .then(artifact => {
-        assert.equal(artifact.debugString, 'ERROR: start_url must be same-origin as document');
+        assert.equal(artifact.explanation, 'ERROR: start_url must be same-origin as document');
       });
   });
 });
diff --git a/lighthouse-core/test/lib/dependency-graph/simulator/network-analyzer-test.js b/lighthouse-core/test/lib/dependency-graph/simulator/network-analyzer-test.js
index 2bdae4f4e..db6444f71 100644
--- a/lighthouse-core/test/lib/dependency-graph/simulator/network-analyzer-test.js
+++ b/lighthouse-core/test/lib/dependency-graph/simulator/network-analyzer-test.js
@@ -170,7 +170,7 @@ describe('DependencyGraph/Simulator/NetworkAnalyzer', () => {
 
     it('should infer from TTFB when available', () => {
       const timing = {receiveHeadersEnd: 1000};
-      const record = createRecord({startTime: 0, endTime: 1, timing});
+      const record = createRecord({startTime: 0, endTime: 1, timing, resourceType: 'Other'});
       const result = NetworkAnalyzer.estimateRTTByOrigin([record], {
         coarseEstimateMultiplier: 1,
       });
diff --git a/lighthouse-core/test/lib/manifest-parser-test.js b/lighthouse-core/test/lib/manifest-parser-test.js
index 9ea6001af..5a0b37c0b 100644
--- a/lighthouse-core/test/lib/manifest-parser-test.js
+++ b/lighthouse-core/test/lib/manifest-parser-test.js
@@ -27,12 +27,12 @@ function noUrlManifestParser(manifestSrc) {
 describe('Manifest Parser', function() {
   it('should not parse empty string input', function() {
     const parsedManifest = noUrlManifestParser('');
-    assert.ok(parsedManifest.debugString);
+    assert.ok(parsedManifest.warning);
   });
 
   it('accepts empty dictionary', function() {
     const parsedManifest = noUrlManifestParser('{}');
-    assert(!parsedManifest.debugString);
+    assert(!parsedManifest.warning);
     assert.equal(parsedManifest.value.name.value, undefined);
     assert.equal(parsedManifest.value.short_name.value, undefined);
     assert.equal(parsedManifest.value.start_url.value, EXAMPLE_DOC_URL);
@@ -52,38 +52,38 @@ describe('Manifest Parser', function() {
     it('gives an empty array and an error for erroneous icons entry', () => {
       const parsedManifest = manifestParser('{"icons": {"16": "img/icons/icon16.png"}}',
           EXAMPLE_MANIFEST_URL, EXAMPLE_DOC_URL);
-      assert.ok(!parsedManifest.debugString);
+      assert.ok(!parsedManifest.warning);
       const icons = parsedManifest.value.icons;
       assert.ok(Array.isArray(icons.value));
       assert.equal(icons.value.length, 0);
-      assert.ok(icons.debugString);
+      assert.ok(icons.warning);
     });
 
     it('gives an empty array and no error for missing icons entry', () => {
       const parsedManifest = manifestParser('{}',
           EXAMPLE_MANIFEST_URL, EXAMPLE_DOC_URL);
-      assert.ok(!parsedManifest.debugString);
+      assert.ok(!parsedManifest.warning);
       const icons = parsedManifest.value.icons;
       assert.ok(Array.isArray(icons.value));
       assert.equal(icons.value.length, 0);
-      assert.ok(!icons.debugString);
+      assert.ok(!icons.warning);
     });
 
     it('parses basic string', function() {
       const parsedManifest = manifestParser('{"icons": [{"src": "192.png", "sizes": "192x192"}]}',
           EXAMPLE_MANIFEST_URL, EXAMPLE_DOC_URL);
-      assert(!parsedManifest.debugString);
+      assert(!parsedManifest.warning);
       const icons = parsedManifest.value.icons;
-      assert(!icons.debugString);
+      assert(!icons.warning);
       const icon192 = icons.value[0];
-      assert(!icon192.value.sizes.debugString);
+      assert(!icon192.value.sizes.warning);
       assert.equal(icons.value.length, 1);
     });
 
     it('finds four icons in the stub manifest', function() {
       const parsedManifest = manifestParser(JSON.stringify(manifestStub), EXAMPLE_MANIFEST_URL,
           EXAMPLE_DOC_URL);
-      assert(!parsedManifest.debugString);
+      assert(!parsedManifest.warning);
       assert.equal(parsedManifest.value.icons.value.length, 4);
     });
 
@@ -139,23 +139,23 @@ describe('Manifest Parser', function() {
   describe('name parsing', function() {
     it('parses basic string', function() {
       const parsedManifest = noUrlManifestParser('{"name":"foo"}');
-      assert(!parsedManifest.debugString);
+      assert(!parsedManifest.warning);
       assert.equal(parsedManifest.value.name.value, 'foo');
     });
 
     it('trims whitespaces', function() {
       const parsedManifest = noUrlManifestParser('{"name":" foo "}');
-      assert(!parsedManifest.debugString);
+      assert(!parsedManifest.warning);
       assert.equal(parsedManifest.value.name.value, 'foo');
     });
 
     it('doesn\'t parse non-string', function() {
       let parsedManifest = noUrlManifestParser('{"name": {} }');
-      assert(!parsedManifest.debugString);
+      assert(!parsedManifest.warning);
       assert.equal(parsedManifest.value.name.value, undefined);
 
       parsedManifest = noUrlManifestParser('{"name": 42 }');
-      assert(!parsedManifest.debugString);
+      assert(!parsedManifest.warning);
       assert.equal(parsedManifest.value.name.value, undefined);
     });
   });
@@ -163,23 +163,23 @@ describe('Manifest Parser', function() {
   describe('short_name parsing', function() {
     it('parses basic string', function() {
       const parsedManifest = noUrlManifestParser('{"short_name":"foo"}');
-      assert(!parsedManifest.debugString);
+      assert(!parsedManifest.warning);
       assert.equal(parsedManifest.value.short_name.value, 'foo');
     });
 
     it('trims whitespaces', function() {
       const parsedManifest = noUrlManifestParser('{"short_name":" foo "}');
-      assert(!parsedManifest.debugString);
+      assert(!parsedManifest.warning);
       assert.equal(parsedManifest.value.short_name.value, 'foo');
     });
 
     it('doesn\'t parse non-string', function() {
       let parsedManifest = noUrlManifestParser('{"short_name": {} }');
-      assert(!parsedManifest.debugString);
+      assert(!parsedManifest.warning);
       assert.equal(parsedManifest.value.short_name.value, undefined);
 
       parsedManifest = noUrlManifestParser('{"short_name": 42 }');
-      assert(!parsedManifest.debugString);
+      assert(!parsedManifest.warning);
       assert.equal(parsedManifest.value.short_name.value, undefined);
     });
   });
@@ -199,7 +199,7 @@ describe('Manifest Parser', function() {
 
       const parsedManifest = manifestParser(manifestSrc, manifestUrl, docUrl);
       const parsedUrl = parsedManifest.value.start_url;
-      assert.ok(parsedUrl.debugString);
+      assert.ok(parsedUrl.warning);
       assert.equal(parsedUrl.value, docUrl);
     });
 
@@ -212,7 +212,7 @@ describe('Manifest Parser', function() {
 
       const parsedManifest = manifestParser(manifestSrc, manifestUrl, docUrl);
       const parsedUrl = parsedManifest.value.start_url;
-      assert.ok(parsedUrl.debugString);
+      assert.ok(parsedUrl.warning);
       assert.equal(parsedUrl.value, docUrl);
     });
 
@@ -225,7 +225,7 @@ describe('Manifest Parser', function() {
 
       const parsedManifest = manifestParser(manifestSrc, manifestUrl, docUrl);
       const parsedUrl = parsedManifest.value.start_url;
-      assert.ok(parsedUrl.debugString);
+      assert.ok(parsedUrl.warning);
       assert.equal(parsedUrl.value, docUrl);
     });
 
@@ -236,7 +236,7 @@ describe('Manifest Parser', function() {
 
       const parsedManifest = manifestParser(manifestSrc, manifestUrl, docUrl);
       const parsedUrl = parsedManifest.value.start_url;
-      assert.ok(!parsedUrl.debugString);
+      assert.ok(!parsedUrl.warning);
       assert.equal(parsedUrl.value, docUrl);
     });
 
@@ -251,7 +251,7 @@ describe('Manifest Parser', function() {
 
       const parsedManifest = manifestParser(manifestSrc, manifestUrl, docUrl);
       const parsedUrl = parsedManifest.value.start_url;
-      assert.ok(parsedUrl.debugString);
+      assert.ok(parsedUrl.warning);
       assert.equal(parsedUrl.value, docUrl);
     });
 
@@ -265,7 +265,7 @@ describe('Manifest Parser', function() {
 
       const parsedManifest = manifestParser(manifestSrc, manifestUrl, docUrl);
       const parsedUrl = parsedManifest.value.start_url;
-      assert.ok(parsedUrl.debugString);
+      assert.ok(parsedUrl.warning);
       assert.equal(parsedUrl.value, docUrl);
     });
 
@@ -278,7 +278,7 @@ describe('Manifest Parser', function() {
 
       const parsedManifest = manifestParser(manifestSrc, manifestUrl, docUrl);
       const parsedUrl = parsedManifest.value.start_url;
-      assert.ok(parsedUrl.debugString);
+      assert.ok(parsedUrl.warning);
       assert.equal(parsedUrl.value, docUrl);
     });
 
@@ -291,7 +291,7 @@ describe('Manifest Parser', function() {
 
       const parsedManifest = manifestParser(manifestSrc, manifestUrl, docUrl);
       const parsedUrl = parsedManifest.value.start_url;
-      assert.ok(parsedUrl.debugString);
+      assert.ok(parsedUrl.warning);
       assert.equal(parsedUrl.value, docUrl);
     });
 
@@ -304,7 +304,7 @@ describe('Manifest Parser', function() {
 
       const parsedManifest = manifestParser(manifestSrc, manifestUrl, docUrl);
       const parsedUrl = parsedManifest.value.start_url;
-      assert.ok(!parsedUrl.debugString);
+      assert.ok(!parsedUrl.warning);
       assert.equal(parsedUrl.value, 'https://example.com/');
     });
 
@@ -318,7 +318,7 @@ describe('Manifest Parser', function() {
 
       const parsedManifest = manifestParser(manifestSrc, manifestUrl, docUrl);
       const parsedUrl = parsedManifest.value.start_url;
-      assert.ok(!parsedUrl.debugString);
+      assert.ok(!parsedUrl.warning);
       assert.equal(parsedUrl.value, 'https://example.com/start_point.html');
     });
     /* eslint-enable camelcase */
@@ -331,21 +331,21 @@ describe('Manifest Parser', function() {
     it('falls back to \'browser\' and issues a warning for an invalid value', () => {
       const parsedManifest = noUrlManifestParser('{"display": {} }');
       const display = parsedManifest.value.display;
-      assert.ok(display.debugString);
+      assert.ok(display.warning);
       assert.equal(display.value, 'browser');
     });
 
     it('falls back to \'browser\' and issues a warning for an invalid value', () => {
       const parsedManifest = noUrlManifestParser('{"display": 5 }');
       const display = parsedManifest.value.display;
-      assert.ok(display.debugString);
+      assert.ok(display.warning);
       assert.equal(display.value, 'browser');
     });
 
     it('falls back to \'browser\' and issues no warning when undefined', () => {
       const parsedManifest = noUrlManifestParser('{}');
       const display = parsedManifest.value.display;
-      assert.ok(!display.debugString);
+      assert.ok(!display.warning);
       assert.equal(display.value, 'browser');
       assert.equal(display.rawValue, undefined);
     });
@@ -354,7 +354,7 @@ describe('Manifest Parser', function() {
       const displayValue = ' fullscreen     ';
       const parsedManifest = noUrlManifestParser(`{"display": "${displayValue}" }`);
       const display = parsedManifest.value.display;
-      assert.ok(!display.debugString);
+      assert.ok(!display.warning);
       assert.equal(display.value, 'fullscreen');
     });
 
@@ -362,42 +362,42 @@ describe('Manifest Parser', function() {
       const displayValue = 'fUlLScrEEn';
       const parsedManifest = noUrlManifestParser(`{"display": "${displayValue}" }`);
       const display = parsedManifest.value.display;
-      assert.ok(!display.debugString);
+      assert.ok(!display.warning);
       assert.equal(display.value, 'fullscreen');
     });
 
     it('falls back to \'browser\' and issues a warning when a non-existent mode', () => {
       const parsedManifest = noUrlManifestParser('{"display": "fullestscreen" }');
       const display = parsedManifest.value.display;
-      assert.ok(display.debugString);
+      assert.ok(display.warning);
       assert.equal(display.value, 'browser');
     });
 
     it('correctly parses `fullscreen` display mode', () => {
       const parsedManifest = noUrlManifestParser('{"display": "fullscreen" }');
       const display = parsedManifest.value.display;
-      assert.ok(!display.debugString);
+      assert.ok(!display.warning);
       assert.equal(display.value, 'fullscreen');
     });
 
     it('correctly parses `standalone` display mode', () => {
       const parsedManifest = noUrlManifestParser('{"display": "standalone" }');
       const display = parsedManifest.value.display;
-      assert.ok(!display.debugString);
+      assert.ok(!display.warning);
       assert.equal(display.value, 'standalone');
     });
 
     it('correctly parses `minimal-ui` display mode', () => {
       const parsedManifest = noUrlManifestParser('{"display": "minimal-ui" }');
       const display = parsedManifest.value.display;
-      assert.ok(!display.debugString);
+      assert.ok(!display.warning);
       assert.equal(display.value, 'minimal-ui');
     });
 
     it('correctly parses `browser` display mode', () => {
       const parsedManifest = noUrlManifestParser('{"display": "browser" }');
       const display = parsedManifest.value.display;
-      assert.ok(!display.debugString);
+      assert.ok(!display.warning);
       assert.equal(display.value, 'browser');
     });
   });
diff --git a/lighthouse-core/test/report/html/renderer/performance-category-renderer-test.js b/lighthouse-core/test/report/html/renderer/performance-category-renderer-test.js
index a9004146f..8650cbf77 100644
--- a/lighthouse-core/test/report/html/renderer/performance-category-renderer-test.js
+++ b/lighthouse-core/test/report/html/renderer/performance-category-renderer-test.js
@@ -167,7 +167,7 @@ describe('PerfCategoryRenderer', () => {
         group: 'load-opportunities',
         result: {
           error: true, score: 0,
-          rawValue: 100, debugString: 'Yikes!!', title: 'Bug #2',
+          rawValue: 100, explanation: 'Yikes!!', title: 'Bug #2',
         },
       };
       const wastedMs = renderer._getWastedMs(auditWithDebug);
diff --git a/lighthouse-extension/test/extension-test.js b/lighthouse-extension/test/extension-test.js
index 1b794692c..f25ef29e7 100644
--- a/lighthouse-extension/test/extension-test.js
+++ b/lighthouse-extension/test/extension-test.js
@@ -154,21 +154,20 @@ describe('Lighthouse chrome extension', function() {
   });
 
   it('should not have any audit errors', async () => {
-    // TODO(phulce): rework these to look at the tooltips
-    function getDebugStrings(elems, selectors) {
+    function getErrors(elems, selectors) {
       return elems.map(el => {
         const audit = el.closest(selectors.audits);
         const auditTitle = audit && audit.querySelector(selectors.titles);
         return {
-          debugString: el.textContent,
+          explanation: el.textContent,
           title: auditTitle ? auditTitle.textContent : 'Audit title unvailable',
         };
       });
     }
 
-    const errorSelectors = '.lh-audit-explanation, .tooltip-error';
-    const auditErrors = await extensionPage.$$eval(errorSelectors, getDebugStrings, selectors);
-    const errors = auditErrors.filter(item => item.debugString.includes('Audit error:'));
+    const errorSelectors = '.lh-audit-explanation, .tooltip--error';
+    const auditErrors = await extensionPage.$$eval(errorSelectors, getErrors, selectors);
+    const errors = auditErrors.filter(item => item.explanation.includes('Audit error:'));
     assert.deepStrictEqual(errors, [], 'Audit errors found within the report');
   });
 
diff --git a/lighthouse-viewer/test/viewer-test-pptr.js b/lighthouse-viewer/test/viewer-test-pptr.js
index 8546e91a9..1a42831bf 100644
--- a/lighthouse-viewer/test/viewer-test-pptr.js
+++ b/lighthouse-viewer/test/viewer-test-pptr.js
@@ -119,23 +119,22 @@ describe('Lighthouse Viewer', function() {
   });
 
   it('should not have any unexpected audit errors', async () => {
-    // TODO(phulce): rework these to look at the tooltips
-    function getDebugStrings(elems, selectors) {
+    function getErrors(elems, selectors) {
       return elems.map(el => {
         const audit = el.closest(selectors.audits);
         const auditTitle = audit && audit.querySelector(selectors.titles);
         return {
-          debugString: el.textContent,
+          explanation: el.textContent,
           title: auditTitle ? auditTitle.textContent : 'Audit title unvailable',
         };
       });
     }
 
-    const errorSelectors = '.lh-audit-explanation, .tooltip-error';
-    const auditErrors = await viewerPage.$$eval(errorSelectors, getDebugStrings, selectors);
-    const errors = auditErrors.filter(item => item.debugString.includes('Audit error:'));
+    const errorSelectors = '.lh-audit-explanation, .tooltip--error';
+    const auditErrors = await viewerPage.$$eval(errorSelectors, getErrors, selectors);
+    const errors = auditErrors.filter(item => item.explanation.includes('Audit error:'));
     const unexpectedErrrors = errors.filter(item => {
-      return !item.debugString.includes('Required RobotsTxt gatherer did not run');
+      return !item.explanation.includes('Required RobotsTxt gatherer did not run');
     });
     assert.deepStrictEqual(unexpectedErrrors, [], 'Audit errors found within the report');
   });