Repository: GoogleChrome/lighthouse
Pull request #9413 (2019-07-18 23:30:09)

tests(font-size): assert on source
cont. #9400


Patch:
diff --git a/docs/architecture.md b/docs/architecture.md
index cf67e925d..7b519f96a 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -16,7 +16,7 @@ _Some incomplete notes_
 ### Audit/Report terminology
 * **Category** - Roll-up collection of audits and audit groups into a user-facing section of the report (eg. `Best Practices`). Applies weighting and overall scoring to the section. Examples: PWA, Accessibility, Best Practices.
 * **Audit title** - Short user-visible title for the successful audit. eg. “All image elements have `[alt]` attributes.”
-* **Audit failureTitle** - Short user-visible title for a failing  audit. eg. “Some image elements do not have `[alt]` attributes.”
+* **Audit failureTitle** - Short user-visible title for a failing audit. eg. “Some image elements do not have `[alt]` attributes.”
 * **Audit description** - Explanation of why the user should care about the audit. Not necessarily how to fix it, unless there is no external link that explains it. ([See description guidelines](CONTRIBUTING.md#description-guidelines)). eg. “Informative elements should aim for short, descriptive alternate text. Decorative elements can be ignored with an empty alt attribute. [Learn more].”
 
 ## Protocol
diff --git a/docs/understanding-results.md b/docs/understanding-results.md
index 587f83e5d..7d795f455 100644
--- a/docs/understanding-results.md
+++ b/docs/understanding-results.md
@@ -12,7 +12,7 @@ For an always up-to-date definition of the LHR, take a look [at our typedefs](ht
 
 | Name | Description |
 | - | - |
-| lighthouseVersion | The version of Lighthouse with which this result were generated. |
+| lighthouseVersion | The version of Lighthouse with which this result was generated. |
 | fetchTime | The ISO-8601 timestamp of when the result was generated. |
 | userAgent | The user agent string of the version of Chrome that was used by Lighthouse. |
 | requestedUrl | The URL that was supplied to Lighthouse and initially navigated to. |
diff --git a/docs/variability.md b/docs/variability.md
index 6be848435..dce77de3b 100644
--- a/docs/variability.md
+++ b/docs/variability.md
@@ -30,7 +30,7 @@ Local networks have inherent variability from packet loss, variable traffic prio
 
 ### 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.
+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 masks these effects with network throttling. _Simulated_ throttling mitigates these effects by replaying network activity on its own.
 
 ### Web Server Variability
 
diff --git a/lighthouse-core/config/default-config.js b/lighthouse-core/config/default-config.js
index 93ab96ddf..76c749273 100644
--- a/lighthouse-core/config/default-config.js
+++ b/lighthouse-core/config/default-config.js
@@ -21,8 +21,8 @@ const UIStrings = {
   metricGroupTitle: 'Metrics',
   /** Title of the opportunity section of the Performance category. Within this section are audits with imperative titles that suggest actions the user can take to improve the loading performance of their web page. 'Suggestion'/'Optimization'/'Recommendation' are reasonable synonyms for 'opportunity' in this case. */
   loadOpportunitiesGroupTitle: 'Opportunities',
-  /** Description of the opportunity section of the Performance category. 'Optimizations' could also be 'recommendations' or 'suggestions'. Within this section are audits with imperative titles that suggest actions the user can take to improve the loading performance of their web page. */
-  loadOpportunitiesGroupDescription: 'These optimizations can speed up your page load.',
+  /** Description of the opportunity section of the Performance category. 'Suggestions' could also be 'recommendations'. Within this section are audits with imperative titles that suggest actions the user can take to improve the loading performance of their web page. */
+  loadOpportunitiesGroupDescription: 'These suggestions can help your page load faster. They don\'t [directly affect](https://github.com/GoogleChrome/lighthouse/blob/d2ec9ffbb21de9ad1a0f86ed24575eda32c796f0/docs/scoring.md#how-are-the-scores-weighted) the Performance score.',
   /** Title of an opportunity sub-section of the Performance category. Within this section are audits with imperative titles that suggest actions the user can take to improve the time of the first initial render of the webpage. */
   firstPaintImprovementsGroupTitle: 'First Paint Improvements',
   /** Description of an opportunity sub-section of the Performance category. Within this section are audits with imperative titles that suggest actions the user can take to improve the time of the first initial render of the webpage. */
@@ -33,8 +33,8 @@ const UIStrings = {
   overallImprovementsGroupDescription: 'Enhance the overall loading experience, so the page is responsive and ready to use as soon as possible. Key metrics: Time to Interactive, Speed Index',
   /** Title of the diagnostics section of the Performance category. Within this section are audits with non-imperative titles that provide more detail on the page's page load performance characteristics. Whereas the 'Opportunities' suggest an action along with expected time savings, diagnostics do not. Within this section, the user may read the details and deduce additional actions they could take. */
   diagnosticsGroupTitle: 'Diagnostics',
-  /** Description of the diagnostics section of the Performance category. Within this section are audits with non-imperative titles that provide more detail on the page's page load performance characteristics. Whereas the 'Opportunities' suggest an action along with expected time savings, diagnostics do not. Within this section, the user may read the details and deduce additional actions they could take. */
-  diagnosticsGroupDescription: 'More information about the performance of your application.',
+  /** Description of the diagnostics section of the Performance category. Within this section are audits with non-imperative titles that provide more detail on a web page's load performance characteristics. Within this section, the user may read the details and deduce additional actions they could take to improve performance. */
+  diagnosticsGroupDescription: 'More information about the performance of your application. These numbers don\'t [directly affect](https://github.com/GoogleChrome/lighthouse/blob/d2ec9ffbb21de9ad1a0f86ed24575eda32c796f0/docs/scoring.md#how-are-the-scores-weighted) the Performance score.',
   /** Title of the Accessibility category of audits. This section contains audits focused on making web content accessible to all users. Also used as a label of a score gauge; try to limit to 20 characters. */
   a11yCategoryTitle: 'Accessibility',
   /** Description of the Accessibility category. This is displayed at the top of a list of audits focused on making web content accessible to all users. No character length limits. 'improve the accessibility of your web app' becomes link text to additional documentation. */
diff --git a/lighthouse-core/lib/asset-saver.js b/lighthouse-core/lib/asset-saver.js
index b5325c877..8ff4c65ba 100644
--- a/lighthouse-core/lib/asset-saver.js
+++ b/lighthouse-core/lib/asset-saver.js
@@ -16,6 +16,7 @@ const rimraf = require('rimraf');
 const mkdirp = require('mkdirp');
 const NetworkAnalysisComputed = require('../computed/network-analysis.js');
 const LoadSimulatorComputed = require('../computed/load-simulator.js');
+const LHError = require('../lib/lh-error.js');
 
 const artifactsFilename = 'artifacts.json';
 const traceSuffix = '.trace.json';
@@ -42,9 +43,10 @@ function loadArtifacts(basePath) {
     throw new Error('No saved artifacts found at ' + basePath);
   }
 
-  // load artifacts.json
+  // load artifacts.json using a reviver to deserialize any LHErrors in artifacts.
+  const artifactsStr = fs.readFileSync(path.join(basePath, artifactsFilename), 'utf8');
   /** @type {LH.Artifacts} */
-  const artifacts = JSON.parse(fs.readFileSync(path.join(basePath, artifactsFilename), 'utf8'));
+  const artifacts = JSON.parse(artifactsStr, LHError.parseReviver);
 
   const filenames = fs.readdirSync(basePath);
 
@@ -73,6 +75,21 @@ function loadArtifacts(basePath) {
   return artifacts;
 }
 
+/**
+ * A replacer function for JSON.stingify of the artifacts. Used to serialize objects that
+ * JSON won't normally handle.
+ * @param {string} key
+ * @param {any} value
+ */
+function stringifyReplacer(key, value) {
+  // Currently only handle LHError and other Error types.
+  if (value instanceof Error) {
+    return LHError.stringifyReplacer(value);
+  }
+
+  return value;
+}
+
 /**
  * Save artifacts object mostly to single file located at basePath/artifacts.log.
  * Also save the traces & devtoolsLogs to their own files
@@ -100,8 +117,8 @@ async function saveArtifacts(artifacts, basePath) {
     fs.writeFileSync(`${basePath}/${passName}${devtoolsLogSuffix}`, log, 'utf8');
   }
 
-  // save everything else
-  const restArtifactsString = JSON.stringify(restArtifacts, null, 2);
+  // save everything else, using a replacer to serialize LHErrors in the artifacts.
+  const restArtifactsString = JSON.stringify(restArtifacts, stringifyReplacer, 2);
   fs.writeFileSync(`${basePath}/${artifactsFilename}`, restArtifactsString, 'utf8');
   log.log('Artifacts saved to disk in folder:', basePath);
   log.timeEnd(status);
diff --git a/lighthouse-core/lib/i18n/en-US.json b/lighthouse-core/lib/i18n/en-US.json
index b0dd8ba42..c9c19ed8d 100644
--- a/lighthouse-core/lib/i18n/en-US.json
+++ b/lighthouse-core/lib/i18n/en-US.json
@@ -1476,8 +1476,8 @@
     "description": "Title of the Budgets section of the Performance Category. 'Budgets' refers to a budget (like a financial budget), but applied to the amount of resources on a page, rather than money."
   },
   "lighthouse-core/config/default-config.js | diagnosticsGroupDescription": {
-    "message": "More information about the performance of your application.",
-    "description": "Description of the diagnostics section of the Performance category. Within this section are audits with non-imperative titles that provide more detail on the page's page load performance characteristics. Whereas the 'Opportunities' suggest an action along with expected time savings, diagnostics do not. Within this section, the user may read the details and deduce additional actions they could take."
+    "message": "More information about the performance of your application. These numbers don't [directly affect](https://github.com/GoogleChrome/lighthouse/blob/d2ec9ffbb21de9ad1a0f86ed24575eda32c796f0/docs/scoring.md#how-are-the-scores-weighted) the Performance score.",
+    "description": "Description of the diagnostics section of the Performance category. Within this section are audits with non-imperative titles that provide more detail on a web page's load performance characteristics. Within this section, the user may read the details and deduce additional actions they could take to improve performance."
   },
   "lighthouse-core/config/default-config.js | diagnosticsGroupTitle": {
     "message": "Diagnostics",
@@ -1492,8 +1492,8 @@
     "description": "Title of an opportunity sub-section of the Performance category. Within this section are audits with imperative titles that suggest actions the user can take to improve the time of the first initial render of the webpage."
   },
   "lighthouse-core/config/default-config.js | loadOpportunitiesGroupDescription": {
-    "message": "These optimizations can speed up your page load.",
-    "description": "Description of the opportunity section of the Performance category. 'Optimizations' could also be 'recommendations' or 'suggestions'. Within this section are audits with imperative titles that suggest actions the user can take to improve the loading performance of their web page."
+    "message": "These suggestions can help your page load faster. They don't [directly affect](https://github.com/GoogleChrome/lighthouse/blob/d2ec9ffbb21de9ad1a0f86ed24575eda32c796f0/docs/scoring.md#how-are-the-scores-weighted) the Performance score.",
+    "description": "Description of the opportunity section of the Performance category. 'Suggestions' could also be 'recommendations'. Within this section are audits with imperative titles that suggest actions the user can take to improve the loading performance of their web page."
   },
   "lighthouse-core/config/default-config.js | loadOpportunitiesGroupTitle": {
     "message": "Opportunities",
@@ -1788,7 +1788,7 @@
     "description": "Label shown preceding any important warnings that may have invalidated the entire report. For example, if the user has Chrome extensions installed, they may add enough performance overhead that Lighthouse's performance metrics are unreliable. If shown, this will be displayed at the top of the report UI."
   },
   "lighthouse-core/report/html/renderer/util.js | varianceDisclaimer": {
-    "message": "Values are estimated and may vary.",
+    "message": "Values are estimated and may vary. The performance score is [based only on these metrics](https://github.com/GoogleChrome/lighthouse/blob/d2ec9ffbb21de9ad1a0f86ed24575eda32c796f0/docs/scoring.md#how-are-the-scores-weighted).",
     "description": "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."
   },
   "lighthouse-core/report/html/renderer/util.js | warningAuditsGroupTitle": {
diff --git a/lighthouse-core/lib/i18n/locales/en-XL.json b/lighthouse-core/lib/i18n/locales/en-XL.json
index 7759bc4ca..41d675b7e 100644
--- a/lighthouse-core/lib/i18n/locales/en-XL.json
+++ b/lighthouse-core/lib/i18n/locales/en-XL.json
@@ -1107,7 +1107,7 @@
     "message": "B̂úd̂ǵêt́ŝ"
   },
   "lighthouse-core/config/default-config.js | diagnosticsGroupDescription": {
-    "message": "M̂ór̂é îńf̂ór̂ḿât́îón̂ áb̂óût́ t̂h́ê ṕêŕf̂ór̂ḿâńĉé ôf́ ŷóûŕ âṕp̂ĺîćât́îón̂."
+    "message": "M̂ór̂é îńf̂ór̂ḿât́îón̂ áb̂óût́ t̂h́ê ṕêŕf̂ór̂ḿâńĉé ôf́ ŷóûŕ âṕp̂ĺîćât́îón̂. T́ĥéŝé n̂úm̂b́êŕŝ d́ôń't̂ [d́îŕêćt̂ĺŷ áf̂f́êćt̂](h́t̂t́p̂ś://ĝít̂h́ûb́.ĉóm̂/ǴôóĝĺêĆĥŕôḿê/ĺîǵĥt́ĥóûśê/b́l̂ób̂/d́2êć9f̂f́b̂b́21d̂é9âd́1â0f́86êd́24575êd́â32ć796f̂0/d́ôćŝ/śĉór̂ín̂ǵ.m̂d́#ĥóŵ-ár̂é-t̂h́ê-śĉór̂éŝ-ẃêíĝh́t̂éd̂) t́ĥé P̂ér̂f́ôŕm̂án̂ćê śĉór̂é."
   },
   "lighthouse-core/config/default-config.js | diagnosticsGroupTitle": {
     "message": "D̂íâǵn̂óŝt́îćŝ"
@@ -1119,7 +1119,7 @@
     "message": "F̂ír̂śt̂ Ṕâín̂t́ Îḿp̂ŕôv́êḿêńt̂ś"
   },
   "lighthouse-core/config/default-config.js | loadOpportunitiesGroupDescription": {
-    "message": "T̂h́êśê óp̂t́îḿîźât́îón̂ś ĉán̂ śp̂éêd́ ûṕ ŷóûŕ p̂áĝé l̂óâd́."
+    "message": "T̂h́êśê śûǵĝéŝt́îón̂ś ĉán̂ h́êĺp̂ ýôúr̂ ṕâǵê ĺôád̂ f́âśt̂ér̂. T́ĥéŷ d́ôń't̂ [d́îŕêćt̂ĺŷ áf̂f́êćt̂](h́t̂t́p̂ś://ĝít̂h́ûb́.ĉóm̂/ǴôóĝĺêĆĥŕôḿê/ĺîǵĥt́ĥóûśê/b́l̂ób̂/d́2êć9f̂f́b̂b́21d̂é9âd́1â0f́86êd́24575êd́â32ć796f̂0/d́ôćŝ/śĉór̂ín̂ǵ.m̂d́#ĥóŵ-ár̂é-t̂h́ê-śĉór̂éŝ-ẃêíĝh́t̂éd̂) t́ĥé P̂ér̂f́ôŕm̂án̂ćê śĉór̂é."
   },
   "lighthouse-core/config/default-config.js | loadOpportunitiesGroupTitle": {
     "message": "Ôṕp̂ór̂t́ûńît́îéŝ"
@@ -1341,7 +1341,7 @@
     "message": "T̂h́êŕê ẃêŕê íŝśûéŝ áf̂f́êćt̂ín̂ǵ t̂h́îś r̂ún̂ óf̂ Ĺîǵĥt́ĥóûśê:"
   },
   "lighthouse-core/report/html/renderer/util.js | varianceDisclaimer": {
-    "message": "V̂ál̂úêś âŕê éŝt́îḿât́êd́ âńd̂ ḿâý v̂ár̂ý."
+    "message": "V̂ál̂úêś âŕê éŝt́îḿât́êd́ âńd̂ ḿâý v̂ár̂ý. T̂h́ê ṕêŕf̂ór̂ḿâńĉé ŝćôŕê íŝ [b́âśêd́ ôńl̂ý ôń t̂h́êśê ḿêt́r̂íĉś](ĥt́t̂ṕŝ://ǵît́ĥúb̂.ćôḿ/Ĝóôǵl̂éĈh́r̂óm̂é/l̂íĝh́t̂h́ôúŝé/b̂ĺôb́/d̂2éĉ9f́f̂b́b̂21d́ê9ád̂1á0f̂86éd̂24575éd̂á32ĉ796f́0/d̂óĉś/ŝćôŕîńĝ.ḿd̂#h́ôẃ-âŕê-t́ĥé-ŝćôŕêś-ŵéîǵĥt́êd́)."
   },
   "lighthouse-core/report/html/renderer/util.js | warningAuditsGroupTitle": {
     "message": "P̂áŝśêd́ âúd̂ít̂ś b̂út̂ ẃît́ĥ ẃâŕn̂ín̂ǵŝ"
diff --git a/lighthouse-core/lib/lh-error.js b/lighthouse-core/lib/lh-error.js
index 63804c1ac..7c71b1778 100644
--- a/lighthouse-core/lib/lh-error.js
+++ b/lighthouse-core/lib/lh-error.js
@@ -56,10 +56,17 @@ const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
  * @property {boolean} [lhrRuntimeError] True if it should appear in the top-level LHR.runtimeError property.
  */
 
+const LHERROR_SENTINEL = '__LighthouseErrorSentinel';
+const ERROR_SENTINEL = '__ErrorSentinel';
+/**
+ * @typedef {{sentinel: '__LighthouseErrorSentinel', code: string, stack?: string, [p: string]: string|undefined}} SerializedLighthouseError
+ * @typedef {{sentinel: '__ErrorSentinel', message: string, code?: string, stack?: string}} SerializedBaseError
+ */
+
 class LighthouseError extends Error {
   /**
    * @param {LighthouseErrorDefinition} errorDefinition
-   * @param {Record<string, string|boolean|undefined>=} properties
+   * @param {Record<string, string|undefined>=} properties
    */
   constructor(errorDefinition, properties) {
     super(errorDefinition.code);
@@ -96,6 +103,77 @@ class LighthouseError extends Error {
     const error = new Error(`Protocol error ${errMsg}`);
     return Object.assign(error, {protocolMethod: method, protocolError: protocolError.message});
   }
+
+  /**
+   * A JSON.stringify replacer to serialize LHErrors and (as a fallback) Errors.
+   * Returns a simplified version of the error object that can be reconstituted
+   * as a copy of the original error at parse time.
+   * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#The_replacer_parameter
+   * @param {Error|LighthouseError} err
+   * @return {SerializedBaseError|SerializedLighthouseError}
+   */
+  static stringifyReplacer(err) {
+    if (err instanceof LighthouseError) {
+      // Remove class props so that remaining values were what was passed in as `properties`.
+      // eslint-disable-next-line no-unused-vars
+      const {name, code, message, friendlyMessage, lhrRuntimeError, stack, ...properties} = err;
+
+      return {
+        sentinel: LHERROR_SENTINEL,
+        code,
+        stack,
+        ...properties,
+      };
+    }
+
+    // Unexpected errors won't be LHErrors, but we want them serialized as well.
+    if (err instanceof Error) {
+      const {message, stack} = err;
+      // @ts-ignore - code can be helpful for e.g. node errors, so preserve it if it's present.
+      const code = err.code;
+      return {
+        sentinel: ERROR_SENTINEL,
+        message,
+        code,
+        stack,
+      };
+    }
+
+    throw new Error('Invalid value for LHError stringification');
+  }
+
+  /**
+   * A JSON.parse reviver. If any value passed in is a serialized Error or
+   * LHError, the error is recreated as the original object. Otherwise, the
+   * value is passed through unchanged.
+   * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse#Using_the_reviver_parameter
+   * @param {string} key
+   * @param {any} possibleError
+   * @return {any}
+   */
+  static parseReviver(key, possibleError) {
+    if (typeof possibleError === 'object' && possibleError !== null) {
+      if (possibleError.sentinel === LHERROR_SENTINEL) {
+        // Include sentinel in destructuring so it doesn't end up in `properties`.
+        // eslint-disable-next-line no-unused-vars
+        const {sentinel, code, stack, ...properties} = /** @type {SerializedLighthouseError} */ (possibleError);
+        const errorDefinition = LighthouseError.errors[/** @type {keyof typeof ERRORS} */ (code)];
+        const lhError = new LighthouseError(errorDefinition, properties);
+        lhError.stack = stack;
+
+        return lhError;
+      }
+
+      if (possibleError.sentinel === ERROR_SENTINEL) {
+        const {message, code, stack} = /** @type {SerializedBaseError} */ (possibleError);
+        const error = new Error(message);
+        Object.assign(error, {code, stack});
+        return error;
+      }
+    }
+
+    return possibleError;
+  }
 }
 
 const ERRORS = {
@@ -227,7 +305,7 @@ const ERRORS = {
   },
 
   /* Protocol timeout failures
-   * Requires an additional `icuProtocolMethod` field for translation.
+   * Requires an additional `protocolMethod` field for translation.
    */
   PROTOCOL_TIMEOUT: {
     code: 'PROTOCOL_TIMEOUT',
diff --git a/lighthouse-core/report/html/renderer/performance-category-renderer.js b/lighthouse-core/report/html/renderer/performance-category-renderer.js
index 1193bb3a8..52c0975f0 100644
--- a/lighthouse-core/report/html/renderer/performance-category-renderer.js
+++ b/lighthouse-core/report/html/renderer/performance-category-renderer.js
@@ -148,7 +148,8 @@ class PerformanceCategoryRenderer extends CategoryRenderer {
     // 'Values are estimated and may vary' is used as the category description for PSI
     if (environment !== 'PSI') {
       const estValuesEl = this.dom.createChildOf(metricAuditsEl, 'div', 'lh-metrics__disclaimer');
-      estValuesEl.textContent = Util.UIStrings.varianceDisclaimer;
+      const disclaimerEl = this.dom.convertMarkdownLinkSnippets(Util.UIStrings.varianceDisclaimer);
+      estValuesEl.appendChild(disclaimerEl);
     }
 
     metricAuditsEl.classList.add('lh-audit-group--metrics');
diff --git a/lighthouse-core/report/html/renderer/util.js b/lighthouse-core/report/html/renderer/util.js
index f29b62933..b47602a57 100644
--- a/lighthouse-core/report/html/renderer/util.js
+++ b/lighthouse-core/report/html/renderer/util.js
@@ -557,7 +557,7 @@ Util.numberDateLocale = 'en';
  */
 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.',
+  varianceDisclaimer: 'Values are estimated and may vary. The performance score is [based only on these metrics](https://github.com/GoogleChrome/lighthouse/blob/d2ec9ffbb21de9ad1a0f86ed24575eda32c796f0/docs/scoring.md#how-are-the-scores-weighted).',
   /** Column heading label for the listing of opportunity audits. Each audit title represents an opportunity. There are only 2 columns, so no strict character limit.  */
   opportunityResourceColumnLabel: 'Opportunity',
   /** Column heading label for the estimated page load savings of opportunity audits. Estimated Savings is the total amount of time (in seconds) that Lighthouse computed could be reduced from the total page load time, if the suggested action is taken. There are only 2 columns, so no strict character limit. */
diff --git a/lighthouse-core/report/html/report-styles.css b/lighthouse-core/report/html/report-styles.css
index be06994c3..5e3d387ed 100644
--- a/lighthouse-core/report/html/report-styles.css
+++ b/lighthouse-core/report/html/report-styles.css
@@ -45,6 +45,7 @@
   --color-gray-50: #FAFAFA;
   --color-gray-500: #9E9E9E;
   --color-gray-600: #757575;
+  --color-gray-700: #616161;
   --color-gray-800: #424242;
   --color-gray-900: #212121;
   --color-gray: #000000;
@@ -97,6 +98,7 @@
   --header-line-height: 24px;
   --highlighter-background-color: var(--report-text-color);
   --icon-square-size: calc(var(--score-icon-size) * 0.88);
+  --image-preview-size: 48px;
   --metric-toggle-lines-fill: #7F7F7F;
   --metrics-toggle-background-color: var(--color-gray-200);
   --plugin-badge-background-color: var(--color-white);
@@ -208,6 +210,7 @@
     --gauge-percentage-font-size: 26px;
     --gauge-wrapper-width: 112px;
     --header-padding: 16px 0 16px 0;
+    --image-preview-size: 24px;
     --plugin-icon-size: 75%;
     --pwa-icon-margin: 0 7px 0 -3px;
     --report-font-size: 14px;
@@ -591,6 +594,9 @@
   color: var(--color-gray-600);
   margin: var(--section-padding-vertical) 0;
 }
+.lh-metrics__disclaimer a {
+  color: var(--color-gray-700);
+}
 
 .lh-metric__description {
   display: none;
@@ -1300,7 +1306,6 @@
 }
 
 .lh-table {
-  --image-preview-size: 24px;
   border-collapse: collapse;
   /* Can't assign padding to table, so shorten the width instead. */
   width: calc(100% - var(--audit-description-padding-left));
@@ -1347,8 +1352,10 @@
 }
 
 
-.lh-table-column--thumbnail {
-  width: calc(var(--image-preview-size) * 2);
+
+.lh-table .lh-table-column--thumbnail {
+  width: var(--image-preview-size);
+  padding: 0;
 }
 
 .lh-table-column--url {
@@ -1377,9 +1384,10 @@
 }
 
 .lh-thumbnail {
-  height: var(--image-preview-size);
+  object-fit: cover;
   width: var(--image-preview-size);
-  object-fit: contain;
+  height: var(--image-preview-size);
+  display: block;
 }
 
 .lh-text__url > a {
diff --git a/lighthouse-core/report/html/templates.html b/lighthouse-core/report/html/templates.html
index 26687d46d..a418bf9f1 100644
--- a/lighthouse-core/report/html/templates.html
+++ b/lighthouse-core/report/html/templates.html
@@ -45,7 +45,7 @@ limitations under the License.
 <!-- Lighthouse category header -->
 <template id="tmpl-lh-category-header">
   <div class="lh-category-header">
-    <div class="lh-score__gauge"></div>
+    <div class="lh-score__gauge" role="heading" aria-level="2"></div>
     <div class="lh-category-header__description"></div>
   </div>
 </template>
diff --git a/lighthouse-core/scripts/build-report-for-autodeployment.js b/lighthouse-core/scripts/build-report-for-autodeployment.js
index 1fd9c2904..ea1260a86 100644
--- a/lighthouse-core/scripts/build-report-for-autodeployment.js
+++ b/lighthouse-core/scripts/build-report-for-autodeployment.js
@@ -19,7 +19,7 @@ const {defaultSettings} = require('../config/constants.js');
 const lighthouse = require('../index.js');
 const lhr = /** @type {LH.Result} */ (require('../../lighthouse-core/test/results/sample_v2.json'));
 
-const DIST = path.join(__dirname, `../../dist`);
+const DIST = path.join(__dirname, `../../dist/now`);
 
 (async function() {
   addPluginCategory(lhr);
diff --git a/now.json b/now.json
index 02b220f9d..9e04006d2 100644
--- a/now.json
+++ b/now.json
@@ -3,7 +3,8 @@
   "builds": [
     {
       "src": "package.json",
-      "use": "@now/static-build"
+      "use": "@now/static-build",
+      "config": {"distDir": "dist/now"}
     }
   ],
   "github": {
diff --git a/proto/sample_v2_round_trip.json b/proto/sample_v2_round_trip.json
index 8e2f7b1af..4ad9bc15d 100644
--- a/proto/sample_v2_round_trip.json
+++ b/proto/sample_v2_round_trip.json
@@ -3914,11 +3914,11 @@
             "title": "Budgets"
         },
         "diagnostics": {
-            "description": "More information about the performance of your application.",
+            "description": "More information about the performance of your application. These numbers don't [directly affect](https://github.com/GoogleChrome/lighthouse/blob/d2ec9ffbb21de9ad1a0f86ed24575eda32c796f0/docs/scoring.md#how-are-the-scores-weighted) the Performance score.",
             "title": "Diagnostics"
         },
         "load-opportunities": {
-            "description": "These optimizations can speed up your page load.",
+            "description": "These suggestions can help your page load faster. They don't [directly affect](https://github.com/GoogleChrome/lighthouse/blob/d2ec9ffbb21de9ad1a0f86ed24575eda32c796f0/docs/scoring.md#how-are-the-scores-weighted) the Performance score.",
             "title": "Opportunities"
         },
         "metrics": {
@@ -3976,7 +3976,7 @@
             "snippetExpandButtonLabel": "Expand snippet",
             "thirdPartyResourcesLabel": "Show 3rd-party resources",
             "toplevelWarningsMessage": "There were issues affecting this run of Lighthouse:",
-            "varianceDisclaimer": "Values are estimated and may vary.",
+            "varianceDisclaimer": "Values are estimated and may vary. The performance score is [based only on these metrics](https://github.com/GoogleChrome/lighthouse/blob/d2ec9ffbb21de9ad1a0f86ed24575eda32c796f0/docs/scoring.md#how-are-the-scores-weighted).",
             "warningAuditsGroupTitle": "Passed audits but with warnings",
             "warningHeader": "Warnings: "
         }


Test patch:
diff --git a/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap b/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
index 06c58615e..2daeb8136 100644
--- a/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
+++ b/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
@@ -1079,11 +1079,11 @@ Object {
       "title": "Budgets",
     },
     "diagnostics": Object {
-      "description": "More information about the performance of your application.",
+      "description": "More information about the performance of your application. These numbers don't [directly affect](https://github.com/GoogleChrome/lighthouse/blob/d2ec9ffbb21de9ad1a0f86ed24575eda32c796f0/docs/scoring.md#how-are-the-scores-weighted) the Performance score.",
       "title": "Diagnostics",
     },
     "load-opportunities": Object {
-      "description": "These optimizations can speed up your page load.",
+      "description": "These suggestions can help your page load faster. They don't [directly affect](https://github.com/GoogleChrome/lighthouse/blob/d2ec9ffbb21de9ad1a0f86ed24575eda32c796f0/docs/scoring.md#how-are-the-scores-weighted) the Performance score.",
       "title": "Opportunities",
     },
     "metrics": Object {
@@ -1326,11 +1326,11 @@ Object {
       "title": "Budgets",
     },
     "diagnostics": Object {
-      "description": "More information about the performance of your application.",
+      "description": "More information about the performance of your application. These numbers don't [directly affect](https://github.com/GoogleChrome/lighthouse/blob/d2ec9ffbb21de9ad1a0f86ed24575eda32c796f0/docs/scoring.md#how-are-the-scores-weighted) the Performance score.",
       "title": "Diagnostics",
     },
     "load-opportunities": Object {
-      "description": "These optimizations can speed up your page load.",
+      "description": "These suggestions can help your page load faster. They don't [directly affect](https://github.com/GoogleChrome/lighthouse/blob/d2ec9ffbb21de9ad1a0f86ed24575eda32c796f0/docs/scoring.md#how-are-the-scores-weighted) the Performance score.",
       "title": "Opportunities",
     },
     "metrics": Object {
diff --git a/lighthouse-core/test/audits/dobetterweb/no-vulnerable-libraries-test.js b/lighthouse-core/test/audits/dobetterweb/no-vulnerable-libraries-test.js
index ef65ff78c..33f1c60fe 100644
--- a/lighthouse-core/test/audits/dobetterweb/no-vulnerable-libraries-test.js
+++ b/lighthouse-core/test/audits/dobetterweb/no-vulnerable-libraries-test.js
@@ -8,6 +8,7 @@
 const NoVulnerableLibrariesAudit =
   require('../../../audits/dobetterweb/no-vulnerable-libraries.js');
 const assert = require('assert');
+const semver = require('semver');
 
 /* eslint-env jest */
 describe('Avoids front-end JavaScript libraries with known vulnerabilities', () => {
@@ -99,3 +100,55 @@ describe('Avoids front-end JavaScript libraries with known vulnerabilities', ()
     assert.equal(auditResult.score, 1);
   });
 });
+
+describe('Snyk database', () => {
+  // https://github.com/npm/node-semver/issues/166#issuecomment-245990039
+  function hasUpperBound(rangeString) {
+    const range = new semver.Range(rangeString);
+    if (!range) return false;
+
+    // For every subset ...
+    for (const subset of range.set) {
+    // Upperbound exists if...
+
+    // < or <= is in one of the subset's clauses (= gets normalized to >= and <).
+      if (subset.some(comparator => comparator.operator && comparator.operator.match(/^</))) {
+        continue;
+      }
+
+      // Subset has a prerelease tag (operator will be empty string).
+      if (subset.length === 1 && subset[0].operator === '') {
+        continue;
+      }
+
+      // No upperbound for this subset.
+      return false;
+    }
+
+    return true;
+  }
+
+  it('hasUpperBound works as intended', () => {
+    assert.equal(hasUpperBound('<1.12.2'), true);
+    assert.equal(hasUpperBound('=1.12.2'), true);
+    assert.equal(hasUpperBound('>=1.12.3 <2.2.2'), true);
+    assert.equal(hasUpperBound('>=2.2.3 <3.0.0'), true);
+    assert.equal(hasUpperBound('>=3.0.0 <3.10.1 || =3.10.2'), true);
+
+    assert.equal(hasUpperBound('>1.12.2'), false);
+    assert.equal(hasUpperBound('>=1.12.2'), false);
+    assert.equal(hasUpperBound('*'), false);
+  });
+
+  it('every snyk vulnerability has an upper bound', () => {
+    for (const vulns of Object.values(NoVulnerableLibrariesAudit.snykDB.npm)) {
+      for (const vuln of vulns) {
+        for (const semver of vuln.semver.vulnerable) {
+          if (!hasUpperBound(semver)) {
+            assert.fail(`invalid semver: ${semver}. Must contain an upper bound`);
+          }
+        }
+      }
+    }
+  });
+});
diff --git a/lighthouse-core/test/audits/seo/font-size-test.js b/lighthouse-core/test/audits/seo/font-size-test.js
index 83138f049..ceb715c2d 100644
--- a/lighthouse-core/test/audits/seo/font-size-test.js
+++ b/lighthouse-core/test/audits/seo/font-size-test.js
@@ -238,10 +238,10 @@ describe('SEO: Font size audit', () => {
     expect(auditResult.notApplicable).toBe(true);
   });
 
-  describe('attributes source location', () => {
+  describe('attributes source of style', () => {
     async function runFontSizeAuditWithSingleFailingStyle(style, nodeProperties) {
       const artifacts = {
-        URL: {finalUrl: 'http://www.example.com'},
+        URL,
         MetaElements: makeMetaElements(validViewport),
         FontSize: {
           analyzedFailingNodesData: [
@@ -264,10 +264,13 @@ describe('SEO: Font size audit', () => {
         attributes: ['class', 'my-p'],
       });
 
-      expect(auditResult.details.items[0].selector).toMatchObject({
-        type: 'node',
-        selector: '#my-parent',
-        snippet: '<p class="my-p">',
+      expect(auditResult.details.items[0]).toMatchObject({
+        source: URL.finalUrl,
+        selector: {
+          type: 'node',
+          selector: '#my-parent',
+          snippet: '<p class="my-p">',
+        },
       });
     });
 
@@ -280,10 +283,13 @@ describe('SEO: Font size audit', () => {
         attributes: ['size', '10px'],
       });
 
-      expect(auditResult.details.items[0].selector).toMatchObject({
-        type: 'node',
-        selector: '#my-parent',
-        snippet: '<font size="10px">',
+      expect(auditResult.details.items[0]).toMatchObject({
+        source: URL.finalUrl,
+        selector: {
+          type: 'node',
+          selector: '#my-parent',
+          snippet: '<font size="10px">',
+        },
       });
     });
   });
diff --git a/lighthouse-core/test/lib/asset-saver-test.js b/lighthouse-core/test/lib/asset-saver-test.js
index c37a8e360..bce063e58 100644
--- a/lighthouse-core/test/lib/asset-saver-test.js
+++ b/lighthouse-core/test/lib/asset-saver-test.js
@@ -9,6 +9,8 @@ const assetSaver = require('../../lib/asset-saver.js');
 const Metrics = require('../../lib/traces/pwmetrics-events.js');
 const assert = require('assert');
 const fs = require('fs');
+const rimraf = require('rimraf');
+const LHError = require('../../lib/lh-error.js');
 
 const traceEvents = require('../fixtures/traces/progressive-app.json');
 const dbwTrace = require('../results/artifacts/defaultPass.trace.json');
@@ -167,6 +169,68 @@ describe('asset-saver helper', () => {
     });
   });
 
+  describe('JSON serialization', () => {
+    const outputPath = __dirname + '/json-serialization-test-data/';
+
+    afterEach(() => {
+      rimraf.sync(outputPath);
+    });
+
+    it('round trips saved artifacts', async () => {
+      const artifactsPath = __dirname + '/../results/artifacts/';
+      const originalArtifacts = await assetSaver.loadArtifacts(artifactsPath);
+
+      await assetSaver.saveArtifacts(originalArtifacts, outputPath);
+      const roundTripArtifacts = await assetSaver.loadArtifacts(outputPath);
+      expect(roundTripArtifacts).toStrictEqual(originalArtifacts);
+    });
+
+    it('round trips artifacts with an Error member', async () => {
+      const error = new Error('Connection refused by server');
+      // test code to make sure e.g. Node errors get serialized well.
+      error.code = 'ECONNREFUSED';
+
+      const artifacts = {
+        traces: {},
+        devtoolsLogs: {},
+        ViewportDimensions: error,
+      };
+
+      await assetSaver.saveArtifacts(artifacts, outputPath);
+      const roundTripArtifacts = await assetSaver.loadArtifacts(outputPath);
+      expect(roundTripArtifacts).toStrictEqual(artifacts);
+
+      expect(roundTripArtifacts.ViewportDimensions).toBeInstanceOf(Error);
+      expect(roundTripArtifacts.ViewportDimensions.code).toEqual('ECONNREFUSED');
+      expect(roundTripArtifacts.ViewportDimensions.stack).toMatch(
+        /^Error: Connection refused by server.*test[\\/]lib[\\/]asset-saver-test\.js/s);
+    });
+
+    it('round trips artifacts with an LHError member', async () => {
+      // Use an LHError that has an ICU replacement.
+      const protocolMethod = 'Page.getFastness';
+      const lhError = new LHError(LHError.errors.PROTOCOL_TIMEOUT, {protocolMethod});
+
+      const artifacts = {
+        traces: {},
+        devtoolsLogs: {},
+        ScriptElements: lhError,
+      };
+
+      await assetSaver.saveArtifacts(artifacts, outputPath);
+      const roundTripArtifacts = await assetSaver.loadArtifacts(outputPath);
+      expect(roundTripArtifacts).toStrictEqual(artifacts);
+
+      expect(roundTripArtifacts.ScriptElements).toBeInstanceOf(LHError);
+      expect(roundTripArtifacts.ScriptElements.code).toEqual('PROTOCOL_TIMEOUT');
+      expect(roundTripArtifacts.ScriptElements.protocolMethod).toEqual(protocolMethod);
+      expect(roundTripArtifacts.ScriptElements.stack).toMatch(
+          /^LHError: PROTOCOL_TIMEOUT.*test[\\/]lib[\\/]asset-saver-test\.js/s);
+      expect(roundTripArtifacts.ScriptElements.friendlyMessage)
+        .toBeDisplayString(/\(Method: Page\.getFastness\)/);
+    });
+  });
+
   describe('saveLanternNetworkData', () => {
     const outputFilename = 'test-lantern-network-data.json';
 
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 eec7b0744..7078d1e6c 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
@@ -81,6 +81,18 @@ describe('PerfCategoryRenderer', () => {
     assert.equal(timelineElements.length + nontimelineElements.length, metricAudits.length);
   });
 
+  it('renders the metrics variance disclaimer as markdown', () => {
+    const categoryDOM = renderer.render(category, sampleResults.categoryGroups);
+    const disclaimerEl =
+        categoryDOM.querySelector('.lh-audit-group--metrics > .lh-metrics__disclaimer');
+
+    assert.ok(disclaimerEl.textContent.includes('Values are estimated'));
+    const disclamerLink = disclaimerEl.querySelector('a');
+    assert.ok(disclamerLink, 'disclaimer contains coverted markdown link');
+    const disclamerUrl = new URL(disclamerLink.href);
+    assert.strictEqual(disclamerUrl.hostname, 'github.com');
+  });
+
   it('renders the failing performance opportunities', () => {
     const categoryDOM = renderer.render(category, sampleResults.categoryGroups);
 
diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json
index 1ec3c3047..082ce4041 100644
--- a/lighthouse-core/test/results/sample_v2.json
+++ b/lighthouse-core/test/results/sample_v2.json
@@ -4047,7 +4047,7 @@
     },
     "load-opportunities": {
       "title": "Opportunities",
-      "description": "These optimizations can speed up your page load."
+      "description": "These suggestions can help your page load faster. They don't [directly affect](https://github.com/GoogleChrome/lighthouse/blob/d2ec9ffbb21de9ad1a0f86ed24575eda32c796f0/docs/scoring.md#how-are-the-scores-weighted) the Performance score."
     },
     "budgets": {
       "title": "Budgets",
@@ -4055,7 +4055,7 @@
     },
     "diagnostics": {
       "title": "Diagnostics",
-      "description": "More information about the performance of your application."
+      "description": "More information about the performance of your application. These numbers don't [directly affect](https://github.com/GoogleChrome/lighthouse/blob/d2ec9ffbb21de9ad1a0f86ed24575eda32c796f0/docs/scoring.md#how-are-the-scores-weighted) the Performance score."
     },
     "pwa-fast-reliable": {
       "title": "Fast and reliable"
@@ -5112,7 +5112,7 @@
       "snippetExpandButtonLabel": "Expand snippet",
       "thirdPartyResourcesLabel": "Show 3rd-party resources",
       "toplevelWarningsMessage": "There were issues affecting this run of Lighthouse:",
-      "varianceDisclaimer": "Values are estimated and may vary.",
+      "varianceDisclaimer": "Values are estimated and may vary. The performance score is [based only on these metrics](https://github.com/GoogleChrome/lighthouse/blob/d2ec9ffbb21de9ad1a0f86ed24575eda32c796f0/docs/scoring.md#how-are-the-scores-weighted).",
       "warningAuditsGroupTitle": "Passed audits but with warnings",
       "warningHeader": "Warnings: "
     },
diff --git a/lighthouse-core/test/runner-test.js b/lighthouse-core/test/runner-test.js
index ccf4bf339..99c41c861 100644
--- a/lighthouse-core/test/runner-test.js
+++ b/lighthouse-core/test/runner-test.js
@@ -330,32 +330,40 @@ describe('Runner', () => {
       });
     });
 
-    // TODO: need to support save/load of artifact errors.
-    // See https://github.com/GoogleChrome/lighthouse/issues/4984
-    it.skip('outputs an error audit result when required artifact was an Error', () => {
-      const errorMessage = 'blurst of times';
-      const artifactError = new Error(errorMessage);
+    it('outputs an error audit result when required artifact was an Error', async () => {
+      // Start with empty-artifacts.
+      const baseArtifacts = assetSaver.loadArtifacts(__dirname +
+          '/fixtures/artifacts/empty-artifacts/');
 
-      const url = 'https://example.com';
+      // Add error and save artifacts using assetSaver to serialize Error object.
+      const errorMessage = 'blurst of times';
+      const artifacts = {
+        ...baseArtifacts,
+        ViewportDimensions: new Error(errorMessage),
+        TestedAsMobileDevice: true,
+      };
+      const artifactsPath = '.tmp/test_artifacts';
+      const resolvedPath = path.resolve(process.cwd(), artifactsPath);
+      await assetSaver.saveArtifacts(artifacts, resolvedPath);
+
+      // Load artifacts via auditMode.
       const config = new Config({
+        settings: {
+          auditMode: resolvedPath,
+        },
         audits: [
+          // requires ViewportDimensions and TestedAsMobileDevice artifacts
           'content-width',
         ],
-
-        artifacts: {
-          // Error objects don't make it through the Config constructor due to
-          // JSON.stringify/parse step, so populate with test error below.
-          ViewportDimensions: null,
-        },
       });
-      config.artifacts.ViewportDimensions = artifactError;
 
-      return Runner.run({}, {url, config}).then(results => {
-        const auditResult = results.lhr.audits['content-width'];
-        assert.strictEqual(auditResult.score, null);
-        assert.strictEqual(auditResult.scoreDisplayMode, 'error');
-        assert.ok(auditResult.errorMessage.includes(errorMessage));
-      });
+      const results = await Runner.run({}, {config});
+      const auditResult = results.lhr.audits['content-width'];
+      assert.strictEqual(auditResult.score, null);
+      assert.strictEqual(auditResult.scoreDisplayMode, 'error');
+      assert.ok(auditResult.errorMessage.includes(errorMessage));
+
+      rimraf.sync(resolvedPath);
     });
 
     it('only passes the required artifacts to the audit', async () => {