Repository: GoogleChrome/lighthouse
Pull request #6767 (2018-12-10 22:09:01)

core(displayvalue): change displayValue to be string only
<!-- Thank you for submitting a pull request! -->

**Summary**
Convert displayValue to be a `string` value only instead of `[string, <string | number> ]` for consistency.  Also converted the only 2 audits that still use the old displayValue array format.
- font-size
- load-fast-enough-for-pwa

**Related Issues/PRs**
fixes: #6200 



Patch:
diff --git a/.github/ISSUE_TEMPLATE/Bug_report.md b/.github/ISSUE_TEMPLATE/Bug_report.md
index 8124528f7..ea0f5dc98 100644
--- a/.github/ISSUE_TEMPLATE/Bug_report.md
+++ b/.github/ISSUE_TEMPLATE/Bug_report.md
@@ -6,6 +6,8 @@ about: Report something working incorrectly
 
 <!-- Before creating an issue please make sure you are using the latest version and have checked for duplicate issues. -->
 
+<!-- Before creating an Accessibility issue please test that it is reproducible upstream with axe (https://www.deque.com/axe/) first and file the issue there if necessary. -->
+
 #### Provide the steps to reproduce
 1. Run LH on <affected url>
 
diff --git a/docs/configuration.md b/docs/configuration.md
index d3bd38659..d54eb3aeb 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -207,5 +207,5 @@ The best examples are the ones Lighthouse uses itself! There are several referen
 * [lighthouse-core/config/default-config.js](https://github.com/GoogleChrome/lighthouse/blob/master/lighthouse-core/config/default-config.js)
 * [lighthouse-core/config/perf-config.js](https://github.com/GoogleChrome/lighthouse/blob/master/lighthouse-core/config/perf-config.js)
 * [docs/recipes/custom-audit/custom-config.js](https://github.com/GoogleChrome/lighthouse/blob/master/docs/recipes/custom-audit/custom-config.js)
-* [pwmetrics](https://github.com/paulirish/pwmetrics/blob/master/lib/lh-config.ts)
+* [pwmetrics](https://github.com/paulirish/pwmetrics/blob/v4.1.1/lib/perf-config.ts)
 
diff --git a/lighthouse-core/audits/font-display.js b/lighthouse-core/audits/font-display.js
index c0ae9b09c..ae985bae2 100644
--- a/lighthouse-core/audits/font-display.js
+++ b/lighthouse-core/audits/font-display.js
@@ -84,7 +84,8 @@ class FontDisplay extends Audit {
         // Convert the relative CSS URL to an absolute URL and add it to the passing set
         for (const relativeURL of relativeURLs) {
           try {
-            const absoluteURL = new URL(relativeURL, artifacts.URL.finalUrl);
+            const relativeRoot = stylesheet.header.sourceURL || artifacts.URL.finalUrl;
+            const absoluteURL = new URL(relativeURL, relativeRoot);
             passingURLs.add(absoluteURL.href);
           } catch (err) {
             Sentry.captureException(err, {tags: {audit: this.meta.id}});
diff --git a/lighthouse-core/audits/load-fast-enough-for-pwa.js b/lighthouse-core/audits/load-fast-enough-for-pwa.js
index dc16cb47a..fe9898181 100644
--- a/lighthouse-core/audits/load-fast-enough-for-pwa.js
+++ b/lighthouse-core/audits/load-fast-enough-for-pwa.js
@@ -15,14 +15,28 @@ const isDeepEqual = require('lodash.isequal');
 const Audit = require('./audit');
 const mobileThrottling = require('../config/constants').throttling.mobileSlow4G;
 const Interactive = require('../computed/metrics/interactive.js');
-
-const displayValueText = `Interactive at %d\xa0s`;
-const displayValueTextWithOverride = `Interactive on simulated mobile network at %d\xa0s`;
+const i18n = require('../lib/i18n/i18n.js');
 
 // Maximum TTI to be considered "fast" for PWA baseline checklist
 //   https://developers.google.com/web/progressive-web-apps/checklist
 const MAXIMUM_TTI = 10 * 1000;
 
+const UIStrings = {
+  /** Imperative title of a Lighthouse audit that tells the user that their page has loaded fast enough to be considered a Progressive Web App. This is displayed in a list of audit titles that Lighthouse generates. */
+  title: 'Page load is fast enough on mobile networks',
+  /** Imperative title of a Lighthouse audit that tells the user that their page has loaded fast enough to be considered a Progressive Web App. This imperative title is shown to users when the web page has loaded too slowly to be considered a Progressive Web App. */
+  failureTitle: 'Page load is not fast enough on mobile networks',
+  /** Description of a Lighthouse audit that tells the user *why* they need to load fast enough on mobile networks. This is displayed after a user expands the section to see more. No character length limits. 'Learn More' becomes link text to additional documentation. */
+  description: 'A fast page load over a cellular network ensures a good mobile user experience. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/fast-3g).',
+  /** [ICU Syntax] Label for the audit identifying the time it took for the page to become interactive. */
+  displayValueText: 'Interactive at {timeInMs, number, seconds}\xa0s',
+  /** [ICU Syntax] Label for the audit identifying the time it took for the page to become interactive on a mobile network. */
+  displayValueTextWithOverride: 'Interactive on simulated mobile network at ' +
+  '{timeInMs, number, seconds}\xa0s',
+};
+
+const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
+
 class LoadFastEnough4Pwa extends Audit {
   /**
    * @return {LH.Audit.Meta}
@@ -30,11 +44,9 @@ class LoadFastEnough4Pwa extends Audit {
   static get meta() {
     return {
       id: 'load-fast-enough-for-pwa',
-      title: 'Page load is fast enough on mobile networks',
-      failureTitle: 'Page load is not fast enough on mobile networks',
-      description:
-        'A fast page load over a cellular network ensures a good mobile user experience. ' +
-        '[Learn more](https://developers.google.com/web/tools/lighthouse/audits/fast-3g).',
+      title: str_(UIStrings.title),
+      failureTitle: str_(UIStrings.failureTitle),
+      description: str_(UIStrings.description),
       requiredArtifacts: ['traces', 'devtoolsLogs'],
     };
   }
@@ -57,7 +69,8 @@ class LoadFastEnough4Pwa extends Audit {
     const override = context.settings.throttlingMethod === 'provided' ||
       !isDeepEqual(context.settings.throttling, mobileThrottling);
 
-    const displayValueFinal = override ? displayValueTextWithOverride : displayValueText;
+    const displayValueTemplate = override ?
+      UIStrings.displayValueTextWithOverride : UIStrings.displayValueText;
 
     const settings = override ? Object.assign({}, context.settings, settingOverrides) :
       context.settings;
@@ -72,7 +85,7 @@ class LoadFastEnough4Pwa extends Audit {
     /** @type {string|undefined} */
     let explanation;
     if (!score) {
-      displayValue = [displayValueFinal, tti.timing / 1000];
+      displayValue = str_(displayValueTemplate, {timeInMs: tti.timing});
       explanation = 'Your page loads too slowly and is not interactive within 10 seconds. ' +
         'Look at the opportunities and diagnostics in the "Performance" section to learn how to ' +
         'improve.';
@@ -88,3 +101,4 @@ class LoadFastEnough4Pwa extends Audit {
 }
 
 module.exports = LoadFastEnough4Pwa;
+module.exports.UIStrings = UIStrings;
diff --git a/lighthouse-core/audits/seo/font-size.js b/lighthouse-core/audits/seo/font-size.js
index 9a08eea1e..352ffed80 100644
--- a/lighthouse-core/audits/seo/font-size.js
+++ b/lighthouse-core/audits/seo/font-size.js
@@ -8,10 +8,24 @@
 /** @typedef {LH.Artifacts.FontSize['analyzedFailingNodesData'][0]} FailingNodeData */
 
 const URL = require('../../lib/url-shim');
+const i18n = require('../../lib/i18n/i18n.js');
 const Audit = require('../audit');
 const ViewportAudit = require('../viewport');
 const MINIMAL_PERCENTAGE_OF_LEGIBLE_TEXT = 60;
 
+const UIStrings = {
+  /** Imperative title of a Lighthouse audit that tells the user that they should use font sizes that are easily read by the user. This is displayed in a list of audit titles that Lighthouse generates. */
+  title: 'Document uses legible font sizes',
+  /** Imperative title of a Lighthouse audit that tells the user that they should use font sizes that are easily read by the user. This imperative title is shown to users when there is a font that is too small to be read by the user. */
+  failureTitle: 'Document doesn\'t use legible font sizes',
+  /** Description of a Lighthouse audit that tells the user *why* they need to use a larger font size. This is displayed after a user expands the section to see more. No character length limits. 'Learn More' becomes link text to additional documentation. */
+  description: 'Font sizes less than 12px are too small to be legible and require mobile visitors to “pinch to zoom” in order to read. Strive to have >60% of page text ≥12px. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/font-sizes).',
+  /** [ICU Syntax] Label for the audit identifying font sizes that are too small. */
+  displayValue: '{decimalProportion, number, extendedPercent} legible text',
+};
+
+const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
+
 /**
  * @param {Array<FailingNodeData>} fontSizeArtifact
  * @returns {Array<FailingNodeData>}
@@ -182,11 +196,9 @@ class FontSize extends Audit {
   static get meta() {
     return {
       id: 'font-size',
-      title: 'Document uses legible font sizes',
-      failureTitle: 'Document doesn\'t use legible font sizes',
-      description: 'Font sizes less than 12px are too small to be legible and require mobile ' +
-      'visitors to “pinch to zoom” in order to read. Strive to have >60% of page text ≥12px. ' +
-      '[Learn more](https://developers.google.com/web/tools/lighthouse/audits/font-sizes).',
+      title: str_(UIStrings.title),
+      failureTitle: str_(UIStrings.failureTitle),
+      description: str_(UIStrings.description),
       requiredArtifacts: ['FontSize', 'URL', 'Viewport'],
     };
   }
@@ -265,8 +277,9 @@ class FontSize extends Audit {
       });
     }
 
+    const decimalProportion = (percentageOfPassingText / 100);
     /** @type {LH.Audit.DisplayValue} */
-    const displayValue = ['%.1d% legible text', percentageOfPassingText];
+    const displayValue = str_(UIStrings.displayValue, {decimalProportion});
     const details = Audit.makeTableDetails(headings, tableData);
     const passed = percentageOfPassingText >= MINIMAL_PERCENTAGE_OF_LEGIBLE_TEXT;
 
@@ -294,3 +307,4 @@ class FontSize extends Audit {
 }
 
 module.exports = FontSize;
+module.exports.UIStrings = UIStrings;
diff --git a/lighthouse-core/audits/uses-rel-preconnect.js b/lighthouse-core/audits/uses-rel-preconnect.js
index f17d1ba58..0f09b12d9 100644
--- a/lighthouse-core/audits/uses-rel-preconnect.js
+++ b/lighthouse-core/audits/uses-rel-preconnect.js
@@ -8,6 +8,7 @@
 
 const Audit = require('./audit');
 const UnusedBytes = require('./byte-efficiency/byte-efficiency-audit');
+const URL = require('../lib/url-shim.js');
 const i18n = require('../lib/i18n/i18n.js');
 const NetworkRecords = require('../computed/network-records.js');
 const MainResource = require('../computed/main-resource.js');
@@ -28,6 +29,9 @@ const UIStrings = {
   description:
     'Consider adding preconnect or dns-prefetch resource hints to establish early ' +
     `connections to important third-party origins. [Learn more](https://developers.google.com/web/fundamentals/performance/resource-prioritization#preconnect).`,
+  /** A warning message that is shown when the user tried to follow the advice of the audit, but it's not working as expected. Forgetting to set the `crossorigin` HTML attribute, or setting it to an incorrect value, on the link is a common mistake when adding preconnect links. */
+  crossoriginWarning: 'A preconnect <link> was found for "{securityOrigin}" but was not used ' +
+    'by the browser. Check that you are using the `crossorigin` attribute properly.',
 };
 
 const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
@@ -41,7 +45,7 @@ class UsesRelPreconnectAudit extends Audit {
       id: 'uses-rel-preconnect',
       title: str_(UIStrings.title),
       description: str_(UIStrings.description),
-      requiredArtifacts: ['devtoolsLogs', 'URL'],
+      requiredArtifacts: ['devtoolsLogs', 'URL', 'LinkElements'],
       scoreDisplayMode: Audit.SCORING_MODES.NUMERIC,
     };
   }
@@ -85,13 +89,14 @@ class UsesRelPreconnectAudit extends Audit {
    */
   static async audit(artifacts, context) {
     const devtoolsLog = artifacts.devtoolsLogs[UsesRelPreconnectAudit.DEFAULT_PASS];
-    const URL = artifacts.URL;
     const settings = context.settings;
     let maxWasted = 0;
+    /** @type {string[]} */
+    const warnings = [];
 
     const [networkRecords, mainResource, loadSimulator] = await Promise.all([
       NetworkRecords.request(devtoolsLog, context),
-      MainResource.request({devtoolsLog, URL}, context),
+      MainResource.request({devtoolsLog, URL: artifacts.URL}, context),
       LoadSimulator.request({devtoolsLog, settings}, context),
     ]);
 
@@ -124,6 +129,9 @@ class UsesRelPreconnectAudit extends Audit {
         origins.set(securityOrigin, records);
       });
 
+    const preconnectLinks = artifacts.LinkElements.filter(el => el.rel === 'preconnect');
+    const preconnectOrigins = new Set(preconnectLinks.map(link => URL.getOrigin(link.href || '')));
+
     /** @type {Array<{url: string, wastedMs: number}>}*/
     let results = [];
     origins.forEach(records => {
@@ -154,6 +162,12 @@ class UsesRelPreconnectAudit extends Audit {
       const wastedMs = Math.min(connectionTime, timeBetweenMainResourceAndDnsStart);
       if (wastedMs < IGNORE_THRESHOLD_IN_MS) return;
 
+      if (preconnectOrigins.has(securityOrigin)) {
+        // Add a warning for any origin the user tried to preconnect to but failed
+        warnings.push(str_(UIStrings.crossoriginWarning, {securityOrigin}));
+        return;
+      }
+
       maxWasted = Math.max(wastedMs, maxWasted);
       results.push({
         url: securityOrigin,
@@ -181,6 +195,7 @@ class UsesRelPreconnectAudit extends Audit {
       extendedInfo: {
         value: results,
       },
+      warnings,
       details,
     };
   }
diff --git a/lighthouse-core/computed/page-dependency-graph.js b/lighthouse-core/computed/page-dependency-graph.js
index 44e1fdf42..e575940c8 100644
--- a/lighthouse-core/computed/page-dependency-graph.js
+++ b/lighthouse-core/computed/page-dependency-graph.js
@@ -269,8 +269,11 @@ class PageDependencyGraph {
     const networkNodeOutput = PageDependencyGraph.getNetworkNodeOutput(networkRecords);
     const cpuNodes = PageDependencyGraph.getCPUNodes(traceOfTab);
 
-    const rootRequest = networkRecords.reduce((min, r) => (min.startTime < r.startTime ? min : r));
+    // The root request is the earliest network request, using position in networkRecords array to break ties.
+    const rootRequest = networkRecords.reduce((min, r) => (r.startTime < min.startTime ? r : min));
     const rootNode = networkNodeOutput.idToNodeMap.get(rootRequest.requestId);
+    // The main document request is the earliest network request *of type document*.
+    // This will be different from the root request when there are server redirects.
     const mainDocumentRequest = NetworkAnalyzer.findMainDocument(networkRecords);
     const mainDocumentNode = networkNodeOutput.idToNodeMap.get(mainDocumentRequest.requestId);
 
@@ -279,6 +282,12 @@ class PageDependencyGraph {
       throw new Error(`${rootNode ? 'mainDocument' : 'root'}Node not found.`);
     }
 
+    if (mainDocumentNode !== rootNode &&
+        (!mainDocumentNode.record.redirects ||
+        !mainDocumentNode.record.redirects.includes(rootNode.record))) {
+      throw new Error('Root node was not in redirect chain of mainDocument');
+    }
+
     PageDependencyGraph.linkNetworkNodes(rootNode, networkNodeOutput);
     PageDependencyGraph.linkCPUNodes(rootNode, networkNodeOutput, cpuNodes);
     mainDocumentNode.setIsMainDocument(true);
diff --git a/lighthouse-core/config/default-config.js b/lighthouse-core/config/default-config.js
index 645dfd2dd..c8144c90c 100644
--- a/lighthouse-core/config/default-config.js
+++ b/lighthouse-core/config/default-config.js
@@ -94,6 +94,7 @@ const defaultConfig = {
       'chrome-console-messages',
       'image-usage',
       'accessibility',
+      'link-elements',
       'dobetterweb/anchors-with-no-rel-noopener',
       'dobetterweb/appcache',
       'dobetterweb/doctype',
diff --git a/lighthouse-core/gather/driver.js b/lighthouse-core/gather/driver.js
index 6b92db6db..aae4a4339 100644
--- a/lighthouse-core/gather/driver.js
+++ b/lighthouse-core/gather/driver.js
@@ -630,25 +630,23 @@ class Driver {
     /**
      * @param {Driver} driver
      * @param {() => void} resolve
+     * @return {Promise<void>}
      */
-    function checkForQuiet(driver, resolve) {
+    async function checkForQuiet(driver, resolve) {
+      if (cancelled) return;
+      const timeSinceLongTask = await driver.evaluateAsync(checkForQuietExpression);
       if (cancelled) return;
 
-      return driver.evaluateAsync(checkForQuietExpression)
-        .then(timeSinceLongTask => {
-          if (cancelled) return;
-
-          if (typeof timeSinceLongTask === 'number') {
-            if (timeSinceLongTask >= waitForCPUQuiet) {
-              log.verbose('Driver', `CPU has been idle for ${timeSinceLongTask} ms`);
-              resolve();
-            } else {
-              log.verbose('Driver', `CPU has been idle for ${timeSinceLongTask} ms`);
-              const timeToWait = waitForCPUQuiet - timeSinceLongTask;
-              lastTimeout = setTimeout(() => checkForQuiet(driver, resolve), timeToWait);
-            }
-          }
-        });
+      if (typeof timeSinceLongTask === 'number') {
+        if (timeSinceLongTask >= waitForCPUQuiet) {
+          log.verbose('Driver', `CPU has been idle for ${timeSinceLongTask} ms`);
+          resolve();
+        } else {
+          log.verbose('Driver', `CPU has been idle for ${timeSinceLongTask} ms`);
+          const timeToWait = waitForCPUQuiet - timeSinceLongTask;
+          lastTimeout = setTimeout(() => checkForQuiet(driver, resolve), timeToWait);
+        }
+      }
     }
 
     /** @type {(() => void)} */
@@ -656,7 +654,7 @@ class Driver {
       throw new Error('_waitForCPUIdle.cancel() called before it was defined');
     };
     const promise = new Promise((resolve, reject) => {
-      checkForQuiet(this, resolve);
+      checkForQuiet(this, resolve).catch(reject);
       cancel = () => {
         cancelled = true;
         if (lastTimeout) clearTimeout(lastTimeout);
@@ -892,19 +890,14 @@ class Driver {
     await this._beginNetworkStatusMonitoring(url);
     await this._clearIsolatedContextId();
 
-    // These can 'race' and that's OK.
-    // We don't want to wait for Page.navigate's resolution, as it can now
-    // happen _after_ onload: https://crbug.com/768961
-    this.sendCommand('Page.enable');
-    this.sendCommand('Emulation.setScriptExecutionDisabled', {value: disableJS});
+    await this.sendCommand('Page.enable');
+    await this.sendCommand('Emulation.setScriptExecutionDisabled', {value: disableJS});
     // No timeout needed for Page.navigate. See #6413.
-    this._innerSendCommand('Page.navigate', {url});
+    const waitforPageNavigateCmd = this._innerSendCommand('Page.navigate', {url});
 
     if (waitForNavigated) {
       await this._waitForFrameNavigated();
-    }
-
-    if (waitForLoad) {
+    } else if (waitForLoad) {
       const passConfig = /** @type {Partial<LH.Config.Pass>} */ (passContext.passConfig || {});
       let {pauseAfterLoadMs, networkQuietThresholdMs, cpuQuietThresholdMs} = passConfig;
       let maxWaitMs = passContext.settings && passContext.settings.maxWaitForLoad;
@@ -920,6 +913,9 @@ class Driver {
           maxWaitMs);
     }
 
+    // Bring `Page.navigate` errors back into the promise chain. See #6739.
+    await waitforPageNavigateCmd;
+
     return this._endNetworkStatusMonitoring();
   }
 
diff --git a/lighthouse-core/gather/gatherers/link-elements.js b/lighthouse-core/gather/gatherers/link-elements.js
new file mode 100644
index 000000000..4a74e3abe
--- /dev/null
+++ b/lighthouse-core/gather/gatherers/link-elements.js
@@ -0,0 +1,37 @@
+/**
+ * @license Copyright 2018 Google Inc. All Rights Reserved.
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
+ */
+'use strict';
+
+const Gatherer = require('./gatherer.js');
+const getElementsInDocumentString = require('../../lib/page-functions.js').getElementsInDocumentString; // eslint-disable-line max-len
+
+class LinkElements extends Gatherer {
+  /**
+   * @param {LH.Gatherer.PassContext} passContext
+   * @return {Promise<LH.Artifacts['LinkElements']>}
+   */
+  async afterPass(passContext) {
+    const driver = passContext.driver;
+
+    // TODO(phulce): merge this with the main resource header values too
+    // We'll use evaluateAsync because the `node.getAttribute` method doesn't actually normalize
+    // the values like access from JavaScript does.
+    return driver.evaluateAsync(`(() => {
+      ${getElementsInDocumentString};
+
+      return getElementsInDocument('link').map(link => {
+        return {
+          rel: link.rel,
+          href: link.href,
+          as: link.as,
+          crossOrigin: link.crossOrigin,
+        };
+      });
+    })()`, {useIsolation: true});
+  }
+}
+
+module.exports = LinkElements;
diff --git a/lighthouse-core/lib/dependency-graph/base-node.js b/lighthouse-core/lib/dependency-graph/base-node.js
index 91b23142b..308ea66ef 100644
--- a/lighthouse-core/lib/dependency-graph/base-node.js
+++ b/lighthouse-core/lib/dependency-graph/base-node.js
@@ -209,7 +209,7 @@ class BaseNode {
       }
     });
 
-    if (!idToNodeMap.has(this.id)) throw new Error(`Cloned graph missing node ${this.id}`);
+    if (!idToNodeMap.has(this.id)) throw new Error('Cloned graph missing node');
     return idToNodeMap.get(this.id);
   }
 
diff --git a/lighthouse-core/lib/dependency-graph/simulator/network-analyzer.js b/lighthouse-core/lib/dependency-graph/simulator/network-analyzer.js
index 6b45bbebf..ef2187df5 100644
--- a/lighthouse-core/lib/dependency-graph/simulator/network-analyzer.js
+++ b/lighthouse-core/lib/dependency-graph/simulator/network-analyzer.js
@@ -451,7 +451,8 @@ class NetworkAnalyzer {
     // TODO(phulce): handle more edge cases like client redirects, or plumb through finalUrl
     const documentRequests = records.filter(record => record.resourceType ===
         NetworkRequest.TYPES.Document);
-    return documentRequests.sort((a, b) => a.startTime - b.startTime)[0];
+    // The main document is the earliest document request, using position in networkRecords array to break ties.
+    return documentRequests.reduce((min, r) => (r.startTime < min.startTime ? r : min));
   }
 }
 
diff --git a/lighthouse-core/lib/i18n/en-US.json b/lighthouse-core/lib/i18n/en-US.json
index e35e009b1..c1e1a37f8 100644
--- a/lighthouse-core/lib/i18n/en-US.json
+++ b/lighthouse-core/lib/i18n/en-US.json
@@ -635,6 +635,26 @@
     "message": "All text remains visible during webfont loads",
     "description": "Title of a diagnostic audit that provides detail on if all the text on a webpage was visible while the page was loading its webfonts. This descriptive title is shown to users when the amount is acceptable and no user action is required."
   },
+  "lighthouse-core/audits/load-fast-enough-for-pwa.js | description": {
+    "message": "A fast page load over a cellular network ensures a good mobile user experience. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/fast-3g).",
+    "description": "Description of a Lighthouse audit that tells the user *why* they need to load fast enough on mobile networks. This is displayed after a user expands the section to see more. No character length limits. 'Learn More' becomes link text to additional documentation."
+  },
+  "lighthouse-core/audits/load-fast-enough-for-pwa.js | displayValueText": {
+    "message": "Interactive at {timeInMs, number, seconds} s",
+    "description": "[ICU Syntax] Label for the audit identifying the time it took for the page to become interactive."
+  },
+  "lighthouse-core/audits/load-fast-enough-for-pwa.js | displayValueTextWithOverride": {
+    "message": "Interactive on simulated mobile network at {timeInMs, number, seconds} s",
+    "description": "[ICU Syntax] Label for the audit identifying the time it took for the page to become interactive on a mobile network."
+  },
+  "lighthouse-core/audits/load-fast-enough-for-pwa.js | failureTitle": {
+    "message": "Page load is not fast enough on mobile networks",
+    "description": "Imperative title of a Lighthouse audit that tells the user that their page has loaded fast enough to be considered a Progressive Web App. This imperative title is shown to users when the web page has loaded too slowly to be considered a Progressive Web App."
+  },
+  "lighthouse-core/audits/load-fast-enough-for-pwa.js | title": {
+    "message": "Page load is fast enough on mobile networks",
+    "description": "Imperative title of a Lighthouse audit that tells the user that their page has loaded fast enough to be considered a Progressive Web App. This is displayed in a list of audit titles that Lighthouse generates."
+  },
   "lighthouse-core/audits/mainthread-work-breakdown.js | columnCategory": {
     "message": "Category",
     "description": "Label for the Main Thread Category column in data tables, rows will have a main thread Category and main thread Task Name."
@@ -707,6 +727,22 @@
     "message": "Avoid multiple page redirects",
     "description": "Imperative title of a Lighthouse audit that tells the user to eliminate the redirects taken through multiple URLs to load the page. This is shown in a list of audits that Lighthouse generates."
   },
+  "lighthouse-core/audits/seo/font-size.js | description": {
+    "message": "Font sizes less than 12px are too small to be legible and require mobile visitors to “pinch to zoom” in order to read. Strive to have >60% of page text ≥12px. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/font-sizes).",
+    "description": "Description of a Lighthouse audit that tells the user *why* they need to use a larger font size. This is displayed after a user expands the section to see more. No character length limits. 'Learn More' becomes link text to additional documentation."
+  },
+  "lighthouse-core/audits/seo/font-size.js | displayValue": {
+    "message": "{decimalProportion, number, extendedPercent} legible text",
+    "description": "[ICU Syntax] Label for the audit identifying font sizes that are too small."
+  },
+  "lighthouse-core/audits/seo/font-size.js | failureTitle": {
+    "message": "Document doesn't use legible font sizes",
+    "description": "Imperative title of a Lighthouse audit that tells the user that they should use font sizes that are easily read by the user. This imperative title is shown to users when there is a font that is too small to be read by the user."
+  },
+  "lighthouse-core/audits/seo/font-size.js | title": {
+    "message": "Document uses legible font sizes",
+    "description": "Imperative title of a Lighthouse audit that tells the user that they should use font sizes that are easily read by the user. This is displayed in a list of audit titles that Lighthouse generates."
+  },
   "lighthouse-core/audits/time-to-first-byte.js | description": {
     "message": "Time To First Byte identifies the time at which your server sends a response. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/ttfb).",
     "description": "Description of a Lighthouse audit that tells the user *why* they should reduce the amount of time it takes their server to start responding to requests. This is displayed after a user expands the section to see more. No character length limits. 'Learn More' becomes link text to additional documentation."
@@ -751,6 +787,10 @@
     "message": "User Timing marks and measures",
     "description": "Descriptive title of a diagnostic audit that provides details on any timestamps generated by the page. User Timing refers to the 'User Timing API', which enables a website to record specific times as 'marks', or spans of time as 'measures'."
   },
+  "lighthouse-core/audits/uses-rel-preconnect.js | crossoriginWarning": {
+    "message": "A preconnect <link> was found for \"{securityOrigin}\" but was not used by the browser. Check that you are using the `crossorigin` attribute properly.",
+    "description": "A warning message that is shown when the user tried to follow the advice of the audit, but it's not working as expected. Forgetting to set the `crossorigin` HTML attribute, or setting it to an incorrect value, on the link is a common mistake when adding preconnect links."
+  },
   "lighthouse-core/audits/uses-rel-preconnect.js | description": {
     "message": "Consider adding preconnect or dns-prefetch resource hints to establish early connections to important third-party origins. [Learn more](https://developers.google.com/web/fundamentals/performance/resource-prioritization#preconnect).",
     "description": "Description of a Lighthouse audit that tells the user how to connect early to third-party domains that will be used to load page resources. This is displayed after a user expands the section to see more. No character length limits. 'Learn More' becomes link text to additional documentation."
@@ -924,11 +964,11 @@
     "description": "Used to show the duration in seconds that something lasted. The {timeInMs} placeholder will be replaced with the time duration, shown in seconds (e.g. 5.2 s)"
   },
   "lighthouse-core/lib/lh-error.js | badTraceRecording": {
-    "message": "Something went wrong with recording the trace over your page load. Please run Lighthouse again.",
+    "message": "Something went wrong with recording the trace over your page load. Please run Lighthouse again. ({errorCode})",
     "description": "Error message explaining that the network trace was not able to be recorded for the Lighthouse run."
   },
   "lighthouse-core/lib/lh-error.js | didntCollectScreenshots": {
-    "message": "Chrome didn't collect any screenshots during the page load. Please make sure there is content visible on the page, and then try re-running Lighthouse.",
+    "message": "Chrome didn't collect any screenshots during the page load. Please make sure there is content visible on the page, and then try re-running Lighthouse. ({errorCode})",
     "description": "Error message explaining that the Lighthouse run was not able to collect screenshots through Chrome."
   },
   "lighthouse-core/lib/lh-error.js | dnsFailure": {
@@ -960,7 +1000,7 @@
     "description": "Error message explaining that Lighthouse could not load the requested URL and the steps that might be taken to fix the unreliability."
   },
   "lighthouse-core/lib/lh-error.js | pageLoadTookTooLong": {
-    "message": "Your page took too long to load. Please follow the opportunities in the report to reduce your page load time, and then try re-running Lighthouse.",
+    "message": "Your page took too long to load. Please follow the opportunities in the report to reduce your page load time, and then try re-running Lighthouse. ({errorCode})",
     "description": "Error message explaining that the page loaded too slowly to perform a Lighthouse run."
   },
   "lighthouse-core/lib/lh-error.js | protocolTimeout": {
diff --git a/lighthouse-core/lib/i18n/i18n.js b/lighthouse-core/lib/i18n/i18n.js
index 89c198a38..e77bc4e50 100644
--- a/lighthouse-core/lib/i18n/i18n.js
+++ b/lighthouse-core/lib/i18n/i18n.js
@@ -71,6 +71,11 @@ const formats = {
       minimumFractionDigits: 1,
       maximumFractionDigits: 1,
     },
+    extendedPercent: {
+      // Force allow up to two digits after decimal place in percentages. (Intl.NumberFormat options)
+      maximumFractionDigits: 2,
+      style: 'percent',
+    },
   },
 };
 
diff --git a/lighthouse-core/lib/lh-error.js b/lighthouse-core/lib/lh-error.js
index c83015e3f..df1b3f406 100644
--- a/lighthouse-core/lib/lh-error.js
+++ b/lighthouse-core/lib/lh-error.js
@@ -10,11 +10,11 @@ const i18n = require('./i18n/i18n.js');
 /* eslint-disable max-len */
 const UIStrings = {
   /** Error message explaining that the Lighthouse run was not able to collect screenshots through Chrome.*/
-  didntCollectScreenshots: `Chrome didn't collect any screenshots during the page load. Please make sure there is content visible on the page, and then try re-running Lighthouse.`,
+  didntCollectScreenshots: `Chrome didn't collect any screenshots during the page load. Please make sure there is content visible on the page, and then try re-running Lighthouse. ({errorCode})`,
   /** Error message explaining that the network trace was not able to be recorded for the Lighthouse run. */
-  badTraceRecording: 'Something went wrong with recording the trace over your page load. Please run Lighthouse again.',
+  badTraceRecording: 'Something went wrong with recording the trace over your page load. Please run Lighthouse again. ({errorCode})',
   /** Error message explaining that the page loaded too slowly to perform a Lighthouse run.  */
-  pageLoadTookTooLong: 'Your page took too long to load. Please follow the opportunities in the report to reduce your page load time, and then try re-running Lighthouse.',
+  pageLoadTookTooLong: 'Your page took too long to load. Please follow the opportunities in the report to reduce your page load time, and then try re-running Lighthouse. ({errorCode})',
   /** Error message explaining that Lighthouse could not load the requested URL and the steps that might be taken to fix the unreliability. */
   pageLoadFailed: 'Lighthouse was unable to reliably load the page you requested. Make sure you are testing the correct URL and that the server is properly responding to all requests.',
   /** Error message explaining that Lighthouse could not load the requested URL and the steps that might be taken to fix the unreliability. */
@@ -57,23 +57,14 @@ class LighthouseError extends Error {
     super(errorDefinition.code);
     this.name = 'LHError';
     this.code = errorDefinition.code;
-    this.friendlyMessage = str_(errorDefinition.message, properties);
+    // Insert the i18n reference with errorCode and all additional ICU replacement properties.
+    this.friendlyMessage = str_(errorDefinition.message, {errorCode: this.code, ...properties});
     this.lhrRuntimeError = !!errorDefinition.lhrRuntimeError;
     if (properties) Object.assign(this, properties);
 
     Error.captureStackTrace(this, LighthouseError);
   }
 
-  /**
-   * @param {LighthouseError} err
-   * @return {LighthouseError}
-   */
-  static fromLighthouseError(err) {
-    const {code, friendlyMessage: message, ...rest} = err;
-    // Note: {...rest} convinces tsc 3.1 that it's assignable to a Record.
-    return new LighthouseError({code, message}, {...rest});
-  }
-
   /**
    * @param {string} method
    * @param {{message: string, data?: string|undefined}} protocolError
diff --git a/lighthouse-core/lib/network-request.js b/lighthouse-core/lib/network-request.js
index dd157c868..883a851df 100644
--- a/lighthouse-core/lib/network-request.js
+++ b/lighthouse-core/lib/network-request.js
@@ -272,6 +272,9 @@ module.exports = class NetworkRequest {
    * @param {LH.Crdp.Network.ResourceTiming} timing
    */
   _recomputeTimesWithResourceTiming(timing) {
+    // Don't recompute times if the data is invalid. RequestTime should always be a thread timestamp.
+    // If we don't have receiveHeadersEnd, we really don't have more accurate data.
+    if (timing.requestTime === 0 || timing.receiveHeadersEnd === -1) return;
     // Take startTime and responseReceivedTime from timing data for better accuracy.
     // Timing's requestTime is a baseline in seconds, rest of the numbers there are ticks in millis.
     this.startTime = timing.requestTime;
diff --git a/lighthouse-core/runner.js b/lighthouse-core/runner.js
index a77a5a8a6..db7c06575 100644
--- a/lighthouse-core/runner.js
+++ b/lighthouse-core/runner.js
@@ -321,9 +321,7 @@ class Runner {
 
       Sentry.captureException(err, {tags: {audit: audit.meta.id}, level: 'error'});
       // Errors become error audit result.
-      const errorMessage = err.friendlyMessage ?
-        `${err.friendlyMessage} (${err.message})` :
-        `Audit error: ${err.message}`;
+      const errorMessage = err.friendlyMessage ? err.friendlyMessage : err.message;
       auditResult = Audit.generateErrorAuditResult(audit, errorMessage);
     }
 
diff --git a/proto/sample_v2_round_trip.json b/proto/sample_v2_round_trip.json
index 3b0b1fd72..e8249ace0 100644
--- a/proto/sample_v2_round_trip.json
+++ b/proto/sample_v2_round_trip.json
@@ -720,7 +720,7 @@
                 ], 
                 "type": "table"
             }, 
-            "displayValue": "%.1d% legible text | 100", 
+            "displayValue": "100% legible text", 
             "id": "font-size", 
             "score": 1.0, 
             "scoreDisplayMode": "binary", 
@@ -969,7 +969,7 @@
         }, 
         "is-crawlable": {
             "description": "Search engines are unable to include your pages in search results if they don't have permission to crawl them. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/indexing).", 
-            "errorMessage": "Audit error: Required RobotsTxt gatherer did not run.", 
+            "errorMessage": "Required RobotsTxt gatherer did not run.", 
             "id": "is-crawlable", 
             "score": null, 
             "scoreDisplayMode": "error", 
@@ -1821,7 +1821,7 @@
         }, 
         "robots-txt": {
             "description": "If your robots.txt file is malformed, crawlers may not be able to understand how you want your website to be crawled or indexed.", 
-            "errorMessage": "Audit error: Required RobotsTxt gatherer did not run.", 
+            "errorMessage": "Required RobotsTxt gatherer did not run.", 
             "id": "robots-txt", 
             "score": null, 
             "scoreDisplayMode": "error", 
@@ -2409,7 +2409,8 @@
             "id": "uses-rel-preconnect", 
             "score": 1.0, 
             "scoreDisplayMode": "numeric", 
-            "title": "Preconnect to required origins"
+            "title": "Preconnect to required origins", 
+            "warnings": []
         }, 
         "uses-rel-preload": {
             "description": "Consider using <link rel=preload> to prioritize fetching resources that are currently requested later in page load. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/preload).", 
diff --git a/readme.md b/readme.md
index 21604b8b5..c9882b27f 100644
--- a/readme.md
+++ b/readme.md
@@ -14,12 +14,16 @@ Lighthouse is integrated directly into the Chrome Developer Tools, under the "Au
 
 ## Using the Chrome extension
 
+The Chrome extension was available prior to Lighthouse being available in Chrome Developer Tools, and offers similar functionality.
+
 **Installation**: [install the extension](https://chrome.google.com/webstore/detail/lighthouse/blipmdconlkpinefehnmjammfjpmpbjk) from the Chrome Web Store.
 
 **Run it**: follow the [extension quick-start guide](https://developers.google.com/web/tools/lighthouse/#extension).
 
 ## Using the Node CLI
 
+The Node CLI provides the most flexibility in how Lighthouse runs can be configured and reported. Users who want more advanced usage, or want to run Lighthouse in an automated fashion should use the Node CLI.
+
 _Lighthouse requires Node 8 LTS (8.9) or later._
 
 **Installation**:
diff --git a/types/artifacts.d.ts b/types/artifacts.d.ts
index d37048300..d4ccf5024 100644
--- a/types/artifacts.d.ts
+++ b/types/artifacts.d.ts
@@ -69,6 +69,8 @@ declare global {
       DOMStats: Artifacts.DOMStats;
       /** Relevant attributes and child properties of all <object>s, <embed>s and <applet>s in the page. */
       EmbeddedContent: Artifacts.EmbeddedContentInfo[];
+      /** All the link elements on the page. */
+      LinkElements: Artifacts.LinkElement[];
       /** Information for font faces used in the page. */
       Fonts: Artifacts.Font[];
       /** Information on poorly sized font usage and the text affected by it. */
@@ -169,6 +171,14 @@ declare global {
         params: {name: string; value: string}[];
       }
 
+      /** @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link#Attributes */
+      export interface LinkElement {
+        rel: string
+        href: string
+        as: string
+        crossOrigin: 'anonymous'|'use-credentials'|null
+      }
+
       export interface Font {
         display: string;
         family: string;
diff --git a/types/audit.d.ts b/types/audit.d.ts
index f2596f444..ec5527c58 100644
--- a/types/audit.d.ts
+++ b/types/audit.d.ts
@@ -38,11 +38,7 @@ declare global {
 
     export type ScoreDisplayMode = Audit.ScoreDisplayModes[keyof Audit.ScoreDisplayModes];
 
-    interface DisplayValueArray extends Array<string|number> {
-      0: string;
-    }
-
-    export type DisplayValue = string | DisplayValueArray;
+    export type DisplayValue = string;
 
     export interface Meta {
       /** The string identifier of the audit, in kebab case. */


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 691971d6a..937a7bf3a 100644
--- a/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
+++ b/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
@@ -1063,6 +1063,9 @@ Object {
         Object {
           "path": "accessibility",
         },
+        Object {
+          "path": "link-elements",
+        },
         Object {
           "path": "dobetterweb/anchors-with-no-rel-noopener",
         },
diff --git a/lighthouse-cli/test/fixtures/perf/preload_tester.js b/lighthouse-cli/test/fixtures/perf/preload_tester.js
index 68e1803e4..5ac0b60e7 100644
--- a/lighthouse-cli/test/fixtures/perf/preload_tester.js
+++ b/lighthouse-cli/test/fixtures/perf/preload_tester.js
@@ -6,7 +6,16 @@
 
 /* eslint-disable */
 
-document.write('<script src="level-2.js?delay=500"></script>')
+document.write('<script src="level-2.js?delay=500"></script>');
 
-// load another origin
-fetch('http://localhost:10503/preload.html');
+// delay our preconnect-candidates so that they're not assumed to be working already
+setTimeout(() => {
+  // load another origin
+  fetch('http://localhost:10503/preload.html');
+
+  // load another origin in a way where the `crossorigin` attribute matters
+  const link = document.createElement('link');
+  link.rel = 'stylesheet';
+  link.href = 'https://fonts.googleapis.com/css?family=Lobster%20Two';
+  document.head.appendChild(link);
+}, 300);
diff --git a/lighthouse-cli/test/fixtures/preload.html b/lighthouse-cli/test/fixtures/preload.html
index 3fe5ff339..2d05c12d6 100644
--- a/lighthouse-cli/test/fixtures/preload.html
+++ b/lighthouse-cli/test/fixtures/preload.html
@@ -1,10 +1,15 @@
 <html>
   <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
   <link rel="stylesheet" href="/perf/preload_style.css" />
+  <!-- WARN(preconnect): This should be no crossorigin -->
+  <link rel="preconnect" href="https://fonts.googleapis.com" crossorigin="anonymous" />
+  <!-- PASS(preconnect): This uses crossorigin correctly -->
+  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
   <body>
     There was nothing special about this site,
     nothing was left to be seen,
     not even a kite.
+    <span style="font-family: 'Lobster Two'">Lobster Two!</span>
     <script src="/perf/preload_tester.js"></script>
   </body>
 </html>
diff --git a/lighthouse-cli/test/smokehouse/perf/expectations.js b/lighthouse-cli/test/smokehouse/perf/expectations.js
index bdfdc45a0..809587f28 100644
--- a/lighthouse-cli/test/smokehouse/perf/expectations.js
+++ b/lighthouse-cli/test/smokehouse/perf/expectations.js
@@ -47,6 +47,10 @@ module.exports = [
       },
       'uses-rel-preconnect': {
         score: '<1',
+        warnings: {
+          0: /fonts.googleapis/,
+          length: 1,
+        },
         details: {
           items: {
             length: 1,
diff --git a/lighthouse-core/test/audits/font-display-test.js b/lighthouse-core/test/audits/font-display-test.js
index 67bf9b7d2..6a9dadd0d 100644
--- a/lighthouse-core/test/audits/font-display-test.js
+++ b/lighthouse-core/test/audits/font-display-test.js
@@ -17,7 +17,7 @@ describe('Performance: Font Display audit', () => {
   let context;
 
   beforeEach(() => {
-    stylesheet = {content: ''};
+    stylesheet = {content: '', header: {}};
     context = {computedCache: new Map()};
   });
 
@@ -75,6 +75,62 @@ describe('Performance: Font Display audit', () => {
     assert.deepEqual(result.details.items, items);
   });
 
+  it('resolves URLs relative to stylesheet URL when available', async () => {
+    stylesheet.header.sourceURL = 'https://cdn.example.com/foo/bar/file.css';
+    stylesheet.content = `
+      @font-face {
+        font-display: 'block';
+        /* try with " and same directory */
+        src: url("./font-a.woff");
+      }
+
+      @font-face {
+        font-display: 'block';
+        /* try with " and site origin */
+        src: url("https://example.com/foo/bar/document-font.woff");
+      }
+
+      @font-face {
+        font-display: 'fallback';
+        /* try up a directory with ' */
+        src: url('../font-b.woff');
+      }
+
+      @font-face {
+        font-display: 'optional';
+        /* try no path with no quotes ' */
+        src: url(font.woff);
+      }
+    `;
+
+    networkRecords = [
+      {
+        url: 'https://example.com/foo/bar/document-font.woff',
+        endTime: 3, startTime: 1,
+        resourceType: 'Font',
+      },
+      {
+        url: 'https://cdn.example.com/foo/bar/font-a.woff',
+        endTime: 3, startTime: 1,
+        resourceType: 'Font',
+      },
+      {
+        url: 'https://cdn.example.com/foo/font-b.woff',
+        endTime: 5, startTime: 1,
+        resourceType: 'Font',
+      },
+      {
+        url: 'https://cdn.example.com/foo/bar/font.woff',
+        endTime: 2, startTime: 1,
+        resourceType: 'Font',
+      },
+    ];
+
+    const result = await FontDisplayAudit.audit(getArtifacts(), context);
+    assert.strictEqual(result.rawValue, true);
+    assert.deepEqual(result.details.items, []);
+  });
+
   it('passes when all fonts have a correct font-display rule', async () => {
     stylesheet.content = `
       @font-face {
diff --git a/lighthouse-core/test/audits/load-fast-enough-for-pwa-test.js b/lighthouse-core/test/audits/load-fast-enough-for-pwa-test.js
index 939ef8756..90d2eceab 100644
--- a/lighthouse-core/test/audits/load-fast-enough-for-pwa-test.js
+++ b/lighthouse-core/test/audits/load-fast-enough-for-pwa-test.js
@@ -51,7 +51,7 @@ describe('PWA: load-fast-enough-for-pwa audit', () => {
     return FastPWAAudit.audit(artifacts, {settings, computedCache: new Map()}).then(result => {
       assert.equal(result.score, false, 'not failing a long TTI value');
       assert.equal(result.rawValue, 15000);
-      assert.deepEqual(result.displayValue, ['Interactive at %d\xa0s', 15]);
+      expect(result.displayValue).toBeDisplayString('Interactive at 15.0\xa0s');
       assert.ok(result.explanation);
     });
   });
@@ -108,7 +108,8 @@ describe('PWA: load-fast-enough-for-pwa audit', () => {
 
     const settings = {throttlingMethod: 'provided', throttling: {rttMs: 40, throughput: 100000}};
     const result = await FastPWAAudit.audit(artifacts, {settings, computedCache: new Map()});
-    expect(result.displayValue).toContain('Interactive on simulated mobile network at %d\xa0s');
+    expect(result.displayValue)
+      .toBeDisplayString('Interactive on simulated mobile network at 24.9\xa0s');
     expect(result.rawValue).toBeGreaterThan(10000);
   });
 });
diff --git a/lighthouse-core/test/audits/seo/font-size-test.js b/lighthouse-core/test/audits/seo/font-size-test.js
index 6f819bffd..947eb7cb5 100644
--- a/lighthouse-core/test/audits/seo/font-size-test.js
+++ b/lighthouse-core/test/audits/seo/font-size-test.js
@@ -45,6 +45,7 @@ describe('SEO: Font size audit', () => {
     const auditResult = FontSizeAudit.audit(artifacts);
     assert.equal(auditResult.rawValue, false);
     assert.ok(auditResult.explanation.includes('41%'));
+    expect(auditResult.displayValue).toBeDisplayString('59% legible text');
   });
 
   it('passes when there is no text', () => {
@@ -83,6 +84,7 @@ describe('SEO: Font size audit', () => {
     };
     const auditResult = FontSizeAudit.audit(artifacts);
     assert.equal(auditResult.rawValue, true);
+    expect(auditResult.displayValue).toBeDisplayString('90% legible text');
   });
 
   it('groups entries with same source, sorts them by coverage', () => {
@@ -122,6 +124,7 @@ describe('SEO: Font size audit', () => {
     assert.equal(auditResult.rawValue, false);
     assert.equal(auditResult.details.items.length, 2);
     assert.equal(auditResult.details.items[0].coverage, '57.14%');
+    expect(auditResult.displayValue).toBeDisplayString('0% legible text');
   });
 
   it('adds a category for failing text that wasn\'t analyzed', () => {
@@ -143,6 +146,7 @@ describe('SEO: Font size audit', () => {
     assert.equal(auditResult.details.items.length, 3);
     assert.equal(auditResult.details.items[1].source, 'Add\'l illegible text');
     assert.equal(auditResult.details.items[1].coverage, '40.00%');
+    expect(auditResult.displayValue).toBeDisplayString('50% legible text');
   });
 
   it('informs user if audit haven\'t covered all text on the page', () => {
@@ -162,5 +166,44 @@ describe('SEO: Font size audit', () => {
     const auditResult = FontSizeAudit.audit(artifacts);
     assert.equal(auditResult.rawValue, false);
     assert.ok(auditResult.explanation.includes('50%'));
+    expect(auditResult.displayValue).toBeDisplayString('0% legible text');
+  });
+
+  it('maintains 2 trailing decimal places', () => {
+    const artifacts = {
+      URL,
+      Viewport: validViewport,
+      FontSize: {
+        totalTextLength: 323,
+        visitedTextLength: 323,
+        failingTextLength: 33,
+        analyzedFailingTextLength: 33,
+        analyzedFailingNodesData: [
+          {textLength: 11, fontSize: 10, node: {nodeId: 1, localName: 'p', attributes: []}},
+          {textLength: 22, fontSize: 11, node: {nodeId: 2, localName: 'p', attributes: []}},
+        ],
+      },
+    };
+    const auditResult = FontSizeAudit.audit(artifacts);
+    expect(auditResult.displayValue).toBeDisplayString('89.78% legible text');
+  });
+
+  it('maintains 2 trailing decimal places with only 1 leading digit', () => {
+    const artifacts = {
+      URL,
+      Viewport: validViewport,
+      FontSize: {
+        totalTextLength: 323,
+        visitedTextLength: 323,
+        failingTextLength: 315,
+        analyzedFailingTextLength: 315,
+        analyzedFailingNodesData: [
+          {textLength: 311, fontSize: 10, node: {nodeId: 1, localName: 'p', attributes: []}},
+          {textLength: 4, fontSize: 11, node: {nodeId: 2, localName: 'p', attributes: []}},
+        ],
+      },
+    };
+    const auditResult = FontSizeAudit.audit(artifacts);
+    expect(auditResult.displayValue).toBeDisplayString('2.48% legible text');
   });
 });
diff --git a/lighthouse-core/test/audits/uses-rel-preconnect-test.js b/lighthouse-core/test/audits/uses-rel-preconnect-test.js
index 2ab2d27f0..957e52508 100644
--- a/lighthouse-core/test/audits/uses-rel-preconnect-test.js
+++ b/lighthouse-core/test/audits/uses-rel-preconnect-test.js
@@ -30,6 +30,7 @@ describe('Performance: uses-rel-preconnect audit', () => {
       },
     ];
     const artifacts = {
+      LinkElements: [],
       devtoolsLogs: {[UsesRelPreconnect.DEFAULT_PASS]: networkRecordsToDevtoolsLog(networkRecords)},
       URL: {finalUrl: mainResource.url},
     };
@@ -54,6 +55,7 @@ describe('Performance: uses-rel-preconnect audit', () => {
       },
     ];
     const artifacts = {
+      LinkElements: [],
       devtoolsLogs: {[UsesRelPreconnect.DEFAULT_PASS]: networkRecordsToDevtoolsLog(networkRecords)},
       URL: {finalUrl: mainResource.url},
     };
@@ -75,6 +77,7 @@ describe('Performance: uses-rel-preconnect audit', () => {
       },
     ];
     const artifacts = {
+      LinkElements: [],
       devtoolsLogs: {[UsesRelPreconnect.DEFAULT_PASS]: networkRecordsToDevtoolsLog(networkRecords)},
       URL: {finalUrl: mainResource.url},
     };
@@ -102,6 +105,7 @@ describe('Performance: uses-rel-preconnect audit', () => {
       },
     ];
     const artifacts = {
+      LinkElements: [],
       devtoolsLogs: {[UsesRelPreconnect.DEFAULT_PASS]: networkRecordsToDevtoolsLog(networkRecords)},
       URL: {finalUrl: mainResource.url},
     };
@@ -124,6 +128,7 @@ describe('Performance: uses-rel-preconnect audit', () => {
       },
     ];
     const artifacts = {
+      LinkElements: [],
       devtoolsLogs: {[UsesRelPreconnect.DEFAULT_PASS]: networkRecordsToDevtoolsLog(networkRecords)},
       URL: {finalUrl: mainResource.url},
     };
@@ -135,6 +140,34 @@ describe('Performance: uses-rel-preconnect audit', () => {
     assert.equal(details.items.length, 0);
   });
 
+  it(`warns when origin has preconnect directive but not used`, async () => {
+    const networkRecords = [
+      mainResource,
+      {
+        url: 'https://cdn.example.com/request',
+        initiator: {},
+        startTime: 2,
+        timing: {
+          dnsStart: 100,
+          connectStart: 150,
+          connectEnd: 300,
+          receiveHeadersEnd: 2.3,
+        },
+      },
+    ];
+    const artifacts = {
+      LinkElements: [{rel: 'preconnect', href: 'https://cdn.example.com/'}],
+      devtoolsLogs: {[UsesRelPreconnect.DEFAULT_PASS]: networkRecordsToDevtoolsLog(networkRecords)},
+      URL: {finalUrl: mainResource.url},
+    };
+
+    const context = {settings: {}, computedCache: new Map()};
+    const {score, warnings} = await UsesRelPreconnect.audit(artifacts, context);
+    expect(score).toBe(1);
+    expect(warnings).toHaveLength(1);
+    expect(warnings[0]).toBeDisplayString(/cdn.example.com.*not used/);
+  });
+
   it(`should only list an origin once`, async () => {
     const networkRecords = [
       mainResource,
@@ -162,6 +195,7 @@ describe('Performance: uses-rel-preconnect audit', () => {
       },
     ];
     const artifacts = {
+      LinkElements: [],
       devtoolsLogs: {[UsesRelPreconnect.DEFAULT_PASS]: networkRecordsToDevtoolsLog(networkRecords)},
       URL: {finalUrl: mainResource.url},
     };
@@ -202,6 +236,7 @@ describe('Performance: uses-rel-preconnect audit', () => {
       },
     ];
     const artifacts = {
+      LinkElements: [],
       devtoolsLogs: {[UsesRelPreconnect.DEFAULT_PASS]: networkRecordsToDevtoolsLog(networkRecords)},
       URL: {finalUrl: mainResource.url},
     };
diff --git a/lighthouse-core/test/computed/page-dependency-graph-test.js b/lighthouse-core/test/computed/page-dependency-graph-test.js
index c0cda7d0d..814378405 100644
--- a/lighthouse-core/test/computed/page-dependency-graph-test.js
+++ b/lighthouse-core/test/computed/page-dependency-graph-test.js
@@ -261,9 +261,12 @@ describe('PageDependencyGraph computed artifact:', () => {
     });
 
     it('should set isMainDocument on first document request', () => {
-      const request1 = createRequest(1, '1', 0, null, NetworkRequest.TYPES.Image);
-      const request2 = createRequest(2, '2', 5);
-      const networkRecords = [request1, request2];
+      const request1 = createRequest(1, '1', 0, null, NetworkRequest.TYPES.Other);
+      const request2 = createRequest(2, '2', 5, null, NetworkRequest.TYPES.Document);
+      // Add in another unrelated + early request to make sure we pick the correct chain
+      const request3 = createRequest(3, '3', 0, null, NetworkRequest.TYPES.Other);
+      request2.redirects = [request1];
+      const networkRecords = [request1, request2, request3];
 
       addTaskEvents(0, 0, []);
 
@@ -271,9 +274,22 @@ describe('PageDependencyGraph computed artifact:', () => {
       const nodes = [];
       graph.traverse(node => nodes.push(node));
 
-      assert.equal(nodes.length, 2);
+      assert.equal(nodes.length, 3);
+      assert.equal(nodes[0].id, 1);
       assert.equal(nodes[0].isMainDocument(), false);
       assert.equal(nodes[1].isMainDocument(), true);
+      assert.equal(nodes[2].isMainDocument(), false);
+    });
+
+    it('should throw when root node is not related to main document', () => {
+      const request1 = createRequest(1, '1', 0, null, NetworkRequest.TYPES.Other);
+      const request2 = createRequest(2, '2', 5, null, NetworkRequest.TYPES.Document);
+      const networkRecords = [request1, request2];
+
+      addTaskEvents(0, 0, []);
+
+      const fn = () => PageDependencyGraph.createGraph(traceOfTab, networkRecords);
+      expect(fn).toThrow(/root node.*document/i);
     });
   });
 });
diff --git a/lighthouse-core/test/config/default-config-test.js b/lighthouse-core/test/config/default-config-test.js
index 6d33a51e5..3ea427d3f 100644
--- a/lighthouse-core/test/config/default-config-test.js
+++ b/lighthouse-core/test/config/default-config-test.js
@@ -30,7 +30,7 @@ describe('Default Config', () => {
 
     // And that they have the correct shape.
     opportunityResults.forEach(auditResult => {
-      assert.strictEqual(auditResult.details.type, 'opportunity');
+      expect(auditResult).toMatchObject({details: {type: 'opportunity'}});
       assert.ok(!auditResult.errorMessage, `${auditResult.id}: ${auditResult.errorMessage}`);
       assert.ok(auditResult.details.overallSavingsMs !== undefined,
           `${auditResult.id} has an undefined overallSavingsMs`);
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 4b42606cb..af091f527 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
@@ -374,5 +374,16 @@ describe('DependencyGraph/Simulator/NetworkAnalyzer', () => {
       const mainDocument = NetworkAnalyzer.findMainDocument(records);
       assert.equal(mainDocument.url, 'https://pwa.rocks/');
     });
+
+    it('should break ties using position in array', async () => {
+      const records = [
+        {url: 'http://example.com', resourceType: 'Other'},
+        {url: 'https://example.com', resourceType: 'Other'},
+        {url: 'https://www.example.com', resourceType: 'Document', startTime: 0},
+        {url: 'https://www.iframe.com', resourceType: 'Document', startTime: 0},
+      ];
+      const mainDocument = NetworkAnalyzer.findMainDocument(records);
+      assert.equal(mainDocument.url, 'https://www.example.com');
+    });
   });
 });
diff --git a/lighthouse-core/test/lib/i18n/i18n-test.js b/lighthouse-core/test/lib/i18n/i18n-test.js
index edd90fcf1..e4ec68eef 100644
--- a/lighthouse-core/test/lib/i18n/i18n-test.js
+++ b/lighthouse-core/test/lib/i18n/i18n-test.js
@@ -76,4 +76,47 @@ describe('i18n', () => {
       expect(i18n.lookupLocale('jk-Latn-DE-1996-a-ext-x-phonebk-i-klingon')).toEqual('en');
     });
   });
+
+  describe('Message values are properly formatted', () => {
+    // Message strings won't be in locale files, so will fall back to values given here.
+    const UIStrings = {
+      helloWorld: 'Hello World',
+      helloBytesWorld: 'Hello {in, number, bytes} World',
+      helloMsWorld: 'Hello {in, number, milliseconds} World',
+      helloSecWorld: 'Hello {in, number, seconds} World',
+      helloTimeInMsWorld: 'Hello {timeInMs, number, seconds} World',
+      helloPercentWorld: 'Hello {in, number, extendedPercent} World',
+    };
+    const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
+
+    it('formats a basic message', () => {
+      const helloStr = str_(UIStrings.helloWorld);
+      expect(helloStr).toBeDisplayString('Hello World');
+    });
+
+    it('formats a message with bytes', () => {
+      const helloBytesStr = str_(UIStrings.helloBytesWorld, {in: 1875});
+      expect(helloBytesStr).toBeDisplayString('Hello 2 World');
+    });
+
+    it('formats a message with milliseconds', () => {
+      const helloMsStr = str_(UIStrings.helloMsWorld, {in: 432});
+      expect(helloMsStr).toBeDisplayString('Hello 430 World');
+    });
+
+    it('formats a message with seconds', () => {
+      const helloSecStr = str_(UIStrings.helloSecWorld, {in: 753});
+      expect(helloSecStr).toBeDisplayString('Hello 753.0 World');
+    });
+
+    it('formats a message with seconds timeInMs', () => {
+      const helloTimeInMsStr = str_(UIStrings.helloTimeInMsWorld, {timeInMs: 753543});
+      expect(helloTimeInMsStr).toBeDisplayString('Hello 753.5 World');
+    });
+
+    it('formats a message with extended percent', () => {
+      const helloPercentStr = str_(UIStrings.helloPercentWorld, {in: 0.43078});
+      expect(helloPercentStr).toBeDisplayString('Hello 43.08% World');
+    });
+  });
 });
diff --git a/lighthouse-core/test/lib/network-recorder-test.js b/lighthouse-core/test/lib/network-recorder-test.js
index 0a409ef3d..0d839bd22 100644
--- a/lighthouse-core/test/lib/network-recorder-test.js
+++ b/lighthouse-core/test/lib/network-recorder-test.js
@@ -6,6 +6,7 @@
 'use strict';
 
 const NetworkRecorder = require('../../lib/network-recorder');
+const networkRecordsToDevtoolsLog = require('../network-records-to-devtools-log.js');
 const assert = require('assert');
 const devtoolsLogItems = require('../fixtures/artifacts/perflog/defaultPass.devtoolslog.json');
 const redirectsDevtoolsLog = require('../fixtures/wikipedia-redirect.devtoolslog.json');
@@ -45,7 +46,7 @@ describe('network recorder', function() {
     assert.equal(mainDocument.resourceType, 'Document');
   });
 
-  it('recordsFromLogs ignores invalid records', function() {
+  it('recordsFromLogs ignores records with an invalid URL', function() {
     const logs = [
       { // valid request
         'method': 'Network.requestWillBeSent',
@@ -55,6 +56,7 @@ describe('network recorder', function() {
           'loaderId': '1',
           'documentURL': 'https://www.example.com',
           'request': {
+            // This URL is valid
             'url': 'https://www.example.com',
             'method': 'GET',
             'headers': {
@@ -80,6 +82,7 @@ describe('network recorder', function() {
           'loaderId': '2',
           'documentURL': 'https://www.example.com',
           'request': {
+            // This URL is invalid
             'url': 'https:',
             'method': 'GET',
             'headers': {
@@ -105,6 +108,15 @@ describe('network recorder', function() {
     assert.equal(records.length, 1);
   });
 
+  it('should ignore invalid `timing` data', () => {
+    const inputRecords = [{url: 'http://example.com', startTime: 1, endTime: 2}];
+    const devtoolsLogs = networkRecordsToDevtoolsLog(inputRecords);
+    const responseReceived = devtoolsLogs.find(item => item.method === 'Network.responseReceived');
+    responseReceived.params.response.timing = {requestTime: 0, receiveHeadersEnd: -1};
+    const records = NetworkRecorder.recordsFromLogs(devtoolsLogs);
+    expect(records).toMatchObject([{url: 'http://example.com', startTime: 1, endTime: 2}]);
+  });
+
   describe('#findNetworkQuietPeriods', () => {
     function record(data) {
       const url = data.url || 'https://example.com';
diff --git a/lighthouse-core/test/results/artifacts/artifacts.json b/lighthouse-core/test/results/artifacts/artifacts.json
index e9d1efa7d..3e7fc8677 100644
--- a/lighthouse-core/test/results/artifacts/artifacts.json
+++ b/lighthouse-core/test/results/artifacts/artifacts.json
@@ -9,6 +9,7 @@
     "finalUrl": "http://localhost:10200/dobetterweb/dbw_tester.html"
   },
   "Timing": [],
+  "LinkElements": [],
   "Scripts": {
     "75994.10": "/**\n * @license Copyright 2016 Google Inc. All Rights Reserved.\n * Licensed under the Apache License, Version 2.0 (the \"License\"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0\n * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an \"AS IS\" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.\n */\n\n/*eslint-disable*/\n\n(function() {\n\nconst params = new URLSearchParams(location.search);\n\nif (location.search === '' || params.has('dateNow')) {\n  // FAIL - Date.now() usage in another file.\n  const d = Date.now();\n}\n\nif (location.search === '' || params.has('mutationEvents')) {\n  // FAIL - MutationEvent usage in another file.\n  document.addEventListener('DOMNodeInserted', function(e) {\n    console.log('DOMNodeInserted');\n  });\n}\n\nif (location.search === '' || params.has('passiveEvents')) {\n  // FAIL - non-passive listener usage in another file.\n  document.addEventListener('wheel', e => {\n    console.log('wheel: arrow function');\n  });\n}\n\nif (location.search === '' || params.has('deprecations')) {\n  const div = document.createElement('div');\n  div.createShadowRoot();\n  // FAIL(errors-in-console) - multiple shadow v0 roots.\n  // TODO: disabled until m64 is stable (when moved from deprecation warning to error)\n  // div.createShadowRoot();\n}\n\n})();\n",
     "75994.21": "/**\n* @license\n* Copyright Google Inc. All Rights Reserved.\n*\n* Use of this source code is governed by an MIT-style license that can be\n* found in the LICENSE file at https://angular.io/license\n*/\n(function (global, factory) {\n    typeof exports === 'object' && typeof module !== 'undefined' ? factory() :\n    typeof define === 'function' && define.amd ? define(factory) :\n    (factory());\n}(this, (function () { 'use strict';\n\n/**\n * @license\n * Copyright Google Inc. All Rights Reserved.\n *\n * Use of this source code is governed by an MIT-style license that can be\n * found in the LICENSE file at https://angular.io/license\n */\n\n\nvar Zone$1 = (function (global) {\n    if (global['Zone']) {\n        throw new Error('Zone already loaded.');\n    }\n    var Zone = (function () {\n        function Zone(parent, zoneSpec) {\n            this._properties = null;\n            this._parent = parent;\n            this._name = zoneSpec ? zoneSpec.name || 'unnamed' : '<root>';\n            this._properties = zoneSpec && zoneSpec.properties || {};\n            this._zoneDelegate =\n                new ZoneDelegate(this, this._parent && this._parent._zoneDelegate, zoneSpec);\n        }\n        Zone.assertZonePatched = function () {\n            if (global.Promise !== ZoneAwarePromise) {\n                throw new Error('Zone.js has detected that ZoneAwarePromise `(window|global).Promise` ' +\n                    'has been overwritten.\\n' +\n                    'Most likely cause is that a Promise polyfill has been loaded ' +\n                    'after Zone.js (Polyfilling Promise api is not necessary when zone.js is loaded. ' +\n                    'If you must load one, do so before loading zone.js.)');\n            }\n        };\n        Object.defineProperty(Zone, \"current\", {\n            get: function () {\n                return _currentZoneFrame.zone;\n            },\n            enumerable: true,\n            configurable: true\n        });\n        \n        Object.defineProperty(Zone, \"currentTask\", {\n            get: function () {\n                return _currentTask;\n            },\n            enumerable: true,\n            configurable: true\n        });\n        \n        Object.defineProperty(Zone.prototype, \"parent\", {\n            get: function () {\n                return this._parent;\n            },\n            enumerable: true,\n            configurable: true\n        });\n        \n        Object.defineProperty(Zone.prototype, \"name\", {\n            get: function () {\n                return this._name;\n            },\n            enumerable: true,\n            configurable: true\n        });\n        \n        Zone.prototype.get = function (key) {\n            var zone = this.getZoneWith(key);\n            if (zone)\n                return zone._properties[key];\n        };\n        Zone.prototype.getZoneWith = function (key) {\n            var current = this;\n            while (current) {\n                if (current._properties.hasOwnProperty(key)) {\n                    return current;\n                }\n                current = current._parent;\n            }\n            return null;\n        };\n        Zone.prototype.fork = function (zoneSpec) {\n            if (!zoneSpec)\n                throw new Error('ZoneSpec required!');\n            return this._zoneDelegate.fork(this, zoneSpec);\n        };\n        Zone.prototype.wrap = function (callback, source) {\n            if (typeof callback !== 'function') {\n                throw new Error('Expecting function got: ' + callback);\n            }\n            var _callback = this._zoneDelegate.intercept(this, callback, source);\n            var zone = this;\n            return function () {\n                return zone.runGuarded(_callback, this, arguments, source);\n            };\n        };\n        Zone.prototype.run = function (callback, applyThis, applyArgs, source) {\n            if (applyThis === void 0) { applyThis = null; }\n            if (applyArgs === void 0) { applyArgs = null; }\n            if (source === void 0) { source = null; }\n            _currentZoneFrame = new ZoneFrame(_currentZoneFrame, this);\n            try {\n                return this._zoneDelegate.invoke(this, callback, applyThis, applyArgs, source);\n            }\n            finally {\n                _currentZoneFrame = _currentZoneFrame.parent;\n            }\n        };\n        Zone.prototype.runGuarded = function (callback, applyThis, applyArgs, source) {\n            if (applyThis === void 0) { applyThis = null; }\n            if (applyArgs === void 0) { applyArgs = null; }\n            if (source === void 0) { source = null; }\n            _currentZoneFrame = new ZoneFrame(_currentZoneFrame, this);\n            try {\n                try {\n                    return this._zoneDelegate.invoke(this, callback, applyThis, applyArgs, source);\n                }\n                catch (error) {\n                    if (this._zoneDelegate.handleError(this, error)) {\n                        throw error;\n                    }\n                }\n            }\n            finally {\n                _currentZoneFrame = _currentZoneFrame.parent;\n            }\n        };\n        Zone.prototype.runTask = function (task, applyThis, applyArgs) {\n            task.runCount++;\n            if (task.zone != this)\n                throw new Error('A task can only be run in the zone which created it! (Creation: ' + task.zone.name +\n                    '; Execution: ' + this.name + ')');\n            var previousTask = _currentTask;\n            _currentTask = task;\n            _currentZoneFrame = new ZoneFrame(_currentZoneFrame, this);\n            try {\n                if (task.type == 'macroTask' && task.data && !task.data.isPeriodic) {\n                    task.cancelFn = null;\n                }\n                try {\n                    return this._zoneDelegate.invokeTask(this, task, applyThis, applyArgs);\n                }\n                catch (error) {\n                    if (this._zoneDelegate.handleError(this, error)) {\n                        throw error;\n                    }\n                }\n            }\n            finally {\n                _currentZoneFrame = _currentZoneFrame.parent;\n                _currentTask = previousTask;\n            }\n        };\n        Zone.prototype.scheduleMicroTask = function (source, callback, data, customSchedule) {\n            return this._zoneDelegate.scheduleTask(this, new ZoneTask('microTask', this, source, callback, data, customSchedule, null));\n        };\n        Zone.prototype.scheduleMacroTask = function (source, callback, data, customSchedule, customCancel) {\n            return this._zoneDelegate.scheduleTask(this, new ZoneTask('macroTask', this, source, callback, data, customSchedule, customCancel));\n        };\n        Zone.prototype.scheduleEventTask = function (source, callback, data, customSchedule, customCancel) {\n            return this._zoneDelegate.scheduleTask(this, new ZoneTask('eventTask', this, source, callback, data, customSchedule, customCancel));\n        };\n        Zone.prototype.cancelTask = function (task) {\n            var value = this._zoneDelegate.cancelTask(this, task);\n            task.runCount = -1;\n            task.cancelFn = null;\n            return value;\n        };\n        Zone.__symbol__ = __symbol__;\n        return Zone;\n    }());\n    \n    var ZoneDelegate = (function () {\n        function ZoneDelegate(zone, parentDelegate, zoneSpec) {\n            this._taskCounts = { microTask: 0, macroTask: 0, eventTask: 0 };\n            this.zone = zone;\n            this._parentDelegate = parentDelegate;\n            this._forkZS = zoneSpec && (zoneSpec && zoneSpec.onFork ? zoneSpec : parentDelegate._forkZS);\n            this._forkDlgt = zoneSpec && (zoneSpec.onFork ? parentDelegate : parentDelegate._forkDlgt);\n            this._forkCurrZone = zoneSpec && (zoneSpec.onFork ? this.zone : parentDelegate.zone);\n            this._interceptZS =\n                zoneSpec && (zoneSpec.onIntercept ? zoneSpec : parentDelegate._interceptZS);\n            this._interceptDlgt =\n                zoneSpec && (zoneSpec.onIntercept ? parentDelegate : parentDelegate._interceptDlgt);\n            this._interceptCurrZone =\n                zoneSpec && (zoneSpec.onIntercept ? this.zone : parentDelegate.zone);\n            this._invokeZS = zoneSpec && (zoneSpec.onInvoke ? zoneSpec : parentDelegate._invokeZS);\n            this._invokeDlgt =\n                zoneSpec && (zoneSpec.onInvoke ? parentDelegate : parentDelegate._invokeDlgt);\n            this._invokeCurrZone = zoneSpec && (zoneSpec.onInvoke ? this.zone : parentDelegate.zone);\n            this._handleErrorZS =\n                zoneSpec && (zoneSpec.onHandleError ? zoneSpec : parentDelegate._handleErrorZS);\n            this._handleErrorDlgt =\n                zoneSpec && (zoneSpec.onHandleError ? parentDelegate : parentDelegate._handleErrorDlgt);\n            this._handleErrorCurrZone =\n                zoneSpec && (zoneSpec.onHandleError ? this.zone : parentDelegate.zone);\n            this._scheduleTaskZS =\n                zoneSpec && (zoneSpec.onScheduleTask ? zoneSpec : parentDelegate._scheduleTaskZS);\n            this._scheduleTaskDlgt =\n                zoneSpec && (zoneSpec.onScheduleTask ? parentDelegate : parentDelegate._scheduleTaskDlgt);\n            this._scheduleTaskCurrZone =\n                zoneSpec && (zoneSpec.onScheduleTask ? this.zone : parentDelegate.zone);\n            this._invokeTaskZS =\n                zoneSpec && (zoneSpec.onInvokeTask ? zoneSpec : parentDelegate._invokeTaskZS);\n            this._invokeTaskDlgt =\n                zoneSpec && (zoneSpec.onInvokeTask ? parentDelegate : parentDelegate._invokeTaskDlgt);\n            this._invokeTaskCurrZone =\n                zoneSpec && (zoneSpec.onInvokeTask ? this.zone : parentDelegate.zone);\n            this._cancelTaskZS =\n                zoneSpec && (zoneSpec.onCancelTask ? zoneSpec : parentDelegate._cancelTaskZS);\n            this._cancelTaskDlgt =\n                zoneSpec && (zoneSpec.onCancelTask ? parentDelegate : parentDelegate._cancelTaskDlgt);\n            this._cancelTaskCurrZone =\n                zoneSpec && (zoneSpec.onCancelTask ? this.zone : parentDelegate.zone);\n            this._hasTaskZS = zoneSpec && (zoneSpec.onHasTask ? zoneSpec : parentDelegate._hasTaskZS);\n            this._hasTaskDlgt =\n                zoneSpec && (zoneSpec.onHasTask ? parentDelegate : parentDelegate._hasTaskDlgt);\n            this._hasTaskCurrZone = zoneSpec && (zoneSpec.onHasTask ? this.zone : parentDelegate.zone);\n        }\n        ZoneDelegate.prototype.fork = function (targetZone, zoneSpec) {\n            return this._forkZS ? this._forkZS.onFork(this._forkDlgt, this.zone, targetZone, zoneSpec) :\n                new Zone(targetZone, zoneSpec);\n        };\n        ZoneDelegate.prototype.intercept = function (targetZone, callback, source) {\n            return this._interceptZS ?\n                this._interceptZS.onIntercept(this._interceptDlgt, this._interceptCurrZone, targetZone, callback, source) :\n                callback;\n        };\n        ZoneDelegate.prototype.invoke = function (targetZone, callback, applyThis, applyArgs, source) {\n            return this._invokeZS ?\n                this._invokeZS.onInvoke(this._invokeDlgt, this._invokeCurrZone, targetZone, callback, applyThis, applyArgs, source) :\n                callback.apply(applyThis, applyArgs);\n        };\n        ZoneDelegate.prototype.handleError = function (targetZone, error) {\n            return this._handleErrorZS ?\n                this._handleErrorZS.onHandleError(this._handleErrorDlgt, this._handleErrorCurrZone, targetZone, error) :\n                true;\n        };\n        ZoneDelegate.prototype.scheduleTask = function (targetZone, task) {\n            try {\n                if (this._scheduleTaskZS) {\n                    return this._scheduleTaskZS.onScheduleTask(this._scheduleTaskDlgt, this._scheduleTaskCurrZone, targetZone, task);\n                }\n                else if (task.scheduleFn) {\n                    task.scheduleFn(task);\n                }\n                else if (task.type == 'microTask') {\n                    scheduleMicroTask(task);\n                }\n                else {\n                    throw new Error('Task is missing scheduleFn.');\n                }\n                return task;\n            }\n            finally {\n                if (targetZone == this.zone) {\n                    this._updateTaskCount(task.type, 1);\n                }\n            }\n        };\n        ZoneDelegate.prototype.invokeTask = function (targetZone, task, applyThis, applyArgs) {\n            try {\n                return this._invokeTaskZS ?\n                    this._invokeTaskZS.onInvokeTask(this._invokeTaskDlgt, this._invokeTaskCurrZone, targetZone, task, applyThis, applyArgs) :\n                    task.callback.apply(applyThis, applyArgs);\n            }\n            finally {\n                if (targetZone == this.zone && (task.type != 'eventTask') &&\n                    !(task.data && task.data.isPeriodic)) {\n                    this._updateTaskCount(task.type, -1);\n                }\n            }\n        };\n        ZoneDelegate.prototype.cancelTask = function (targetZone, task) {\n            var value;\n            if (this._cancelTaskZS) {\n                value = this._cancelTaskZS.onCancelTask(this._cancelTaskDlgt, this._cancelTaskCurrZone, targetZone, task);\n            }\n            else if (!task.cancelFn) {\n                throw new Error('Task does not support cancellation, or is already canceled.');\n            }\n            else {\n                value = task.cancelFn(task);\n            }\n            if (targetZone == this.zone) {\n                // this should not be in the finally block, because exceptions assume not canceled.\n                this._updateTaskCount(task.type, -1);\n            }\n            return value;\n        };\n        ZoneDelegate.prototype.hasTask = function (targetZone, isEmpty) {\n            return this._hasTaskZS &&\n                this._hasTaskZS.onHasTask(this._hasTaskDlgt, this._hasTaskCurrZone, targetZone, isEmpty);\n        };\n        ZoneDelegate.prototype._updateTaskCount = function (type, count) {\n            var counts = this._taskCounts;\n            var prev = counts[type];\n            var next = counts[type] = prev + count;\n            if (next < 0) {\n                throw new Error('More tasks executed then were scheduled.');\n            }\n            if (prev == 0 || next == 0) {\n                var isEmpty = {\n                    microTask: counts.microTask > 0,\n                    macroTask: counts.macroTask > 0,\n                    eventTask: counts.eventTask > 0,\n                    change: type\n                };\n                try {\n                    this.hasTask(this.zone, isEmpty);\n                }\n                finally {\n                    if (this._parentDelegate) {\n                        this._parentDelegate._updateTaskCount(type, count);\n                    }\n                }\n            }\n        };\n        return ZoneDelegate;\n    }());\n    var ZoneTask = (function () {\n        function ZoneTask(type, zone, source, callback, options, scheduleFn, cancelFn) {\n            this.runCount = 0;\n            this.type = type;\n            this.zone = zone;\n            this.source = source;\n            this.data = options;\n            this.scheduleFn = scheduleFn;\n            this.cancelFn = cancelFn;\n            this.callback = callback;\n            var self = this;\n            this.invoke = function () {\n                _numberOfNestedTaskFrames++;\n                try {\n                    return zone.runTask(self, this, arguments);\n                }\n                finally {\n                    if (_numberOfNestedTaskFrames == 1) {\n                        drainMicroTaskQueue();\n                    }\n                    _numberOfNestedTaskFrames--;\n                }\n            };\n        }\n        ZoneTask.prototype.toString = function () {\n            if (this.data && typeof this.data.handleId !== 'undefined') {\n                return this.data.handleId;\n            }\n            else {\n                return Object.prototype.toString.call(this);\n            }\n        };\n        return ZoneTask;\n    }());\n    var ZoneFrame = (function () {\n        function ZoneFrame(parent, zone) {\n            this.parent = parent;\n            this.zone = zone;\n        }\n        return ZoneFrame;\n    }());\n    function __symbol__(name) {\n        return '__zone_symbol__' + name;\n    }\n    \n    var symbolSetTimeout = __symbol__('setTimeout');\n    var symbolPromise = __symbol__('Promise');\n    var symbolThen = __symbol__('then');\n    var _currentZoneFrame = new ZoneFrame(null, new Zone(null, null));\n    var _currentTask = null;\n    var _microTaskQueue = [];\n    var _isDrainingMicrotaskQueue = false;\n    var _uncaughtPromiseErrors = [];\n    var _numberOfNestedTaskFrames = 0;\n    function scheduleQueueDrain() {\n        // if we are not running in any task, and there has not been anything scheduled\n        // we must bootstrap the initial task creation by manually scheduling the drain\n        if (_numberOfNestedTaskFrames === 0 && _microTaskQueue.length === 0) {\n            // We are not running in Task, so we need to kickstart the microtask queue.\n            if (global[symbolPromise]) {\n                global[symbolPromise].resolve(0)[symbolThen](drainMicroTaskQueue);\n            }\n            else {\n                global[symbolSetTimeout](drainMicroTaskQueue, 0);\n            }\n        }\n    }\n    function scheduleMicroTask(task) {\n        scheduleQueueDrain();\n        _microTaskQueue.push(task);\n    }\n    function consoleError(e) {\n        var rejection = e && e.rejection;\n        if (rejection) {\n            console.error('Unhandled Promise rejection:', rejection instanceof Error ? rejection.message : rejection, '; Zone:', e.zone.name, '; Task:', e.task && e.task.source, '; Value:', rejection, rejection instanceof Error ? rejection.stack : undefined);\n        }\n        console.error(e);\n    }\n    function drainMicroTaskQueue() {\n        if (!_isDrainingMicrotaskQueue) {\n            _isDrainingMicrotaskQueue = true;\n            while (_microTaskQueue.length) {\n                var queue = _microTaskQueue;\n                _microTaskQueue = [];\n                for (var i = 0; i < queue.length; i++) {\n                    var task = queue[i];\n                    try {\n                        task.zone.runTask(task, null, null);\n                    }\n                    catch (e) {\n                        consoleError(e);\n                    }\n                }\n            }\n            while (_uncaughtPromiseErrors.length) {\n                var _loop_1 = function() {\n                    var uncaughtPromiseError = _uncaughtPromiseErrors.shift();\n                    try {\n                        uncaughtPromiseError.zone.runGuarded(function () {\n                            throw uncaughtPromiseError;\n                        });\n                    }\n                    catch (e) {\n                        consoleError(e);\n                    }\n                };\n                while (_uncaughtPromiseErrors.length) {\n                    _loop_1();\n                }\n            }\n            _isDrainingMicrotaskQueue = false;\n        }\n    }\n    function isThenable(value) {\n        return value && value.then;\n    }\n    function forwardResolution(value) {\n        return value;\n    }\n    function forwardRejection(rejection) {\n        return ZoneAwarePromise.reject(rejection);\n    }\n    var symbolState = __symbol__('state');\n    var symbolValue = __symbol__('value');\n    var source = 'Promise.then';\n    var UNRESOLVED = null;\n    var RESOLVED = true;\n    var REJECTED = false;\n    var REJECTED_NO_CATCH = 0;\n    function makeResolver(promise, state) {\n        return function (v) {\n            resolvePromise(promise, state, v);\n            // Do not return value or you will break the Promise spec.\n        };\n    }\n    function resolvePromise(promise, state, value) {\n        if (promise[symbolState] === UNRESOLVED) {\n            if (value instanceof ZoneAwarePromise && value.hasOwnProperty(symbolState) &&\n                value.hasOwnProperty(symbolValue) && value[symbolState] !== UNRESOLVED) {\n                clearRejectedNoCatch(value);\n                resolvePromise(promise, value[symbolState], value[symbolValue]);\n            }\n            else if (isThenable(value)) {\n                value.then(makeResolver(promise, state), makeResolver(promise, false));\n            }\n            else {\n                promise[symbolState] = state;\n                var queue = promise[symbolValue];\n                promise[symbolValue] = value;\n                for (var i = 0; i < queue.length;) {\n                    scheduleResolveOrReject(promise, queue[i++], queue[i++], queue[i++], queue[i++]);\n                }\n                if (queue.length == 0 && state == REJECTED) {\n                    promise[symbolState] = REJECTED_NO_CATCH;\n                    try {\n                        throw new Error('Uncaught (in promise): ' + value +\n                            (value && value.stack ? '\\n' + value.stack : ''));\n                    }\n                    catch (e) {\n                        var error_1 = e;\n                        error_1.rejection = value;\n                        error_1.promise = promise;\n                        error_1.zone = Zone.current;\n                        error_1.task = Zone.currentTask;\n                        _uncaughtPromiseErrors.push(error_1);\n                        scheduleQueueDrain();\n                    }\n                }\n            }\n        }\n        // Resolving an already resolved promise is a noop.\n        return promise;\n    }\n    function clearRejectedNoCatch(promise) {\n        if (promise[symbolState] === REJECTED_NO_CATCH) {\n            promise[symbolState] = REJECTED;\n            for (var i = 0; i < _uncaughtPromiseErrors.length; i++) {\n                if (promise === _uncaughtPromiseErrors[i].promise) {\n                    _uncaughtPromiseErrors.splice(i, 1);\n                    break;\n                }\n            }\n        }\n    }\n    function scheduleResolveOrReject(promise, zone, chainPromise, onFulfilled, onRejected) {\n        clearRejectedNoCatch(promise);\n        var delegate = promise[symbolState] ? onFulfilled || forwardResolution : onRejected || forwardRejection;\n        zone.scheduleMicroTask(source, function () {\n            try {\n                resolvePromise(chainPromise, true, zone.run(delegate, null, [promise[symbolValue]]));\n            }\n            catch (error) {\n                resolvePromise(chainPromise, false, error);\n            }\n        });\n    }\n    var ZoneAwarePromise = (function () {\n        function ZoneAwarePromise(executor) {\n            var promise = this;\n            if (!(promise instanceof ZoneAwarePromise)) {\n                throw new Error('Must be an instanceof Promise.');\n            }\n            promise[symbolState] = UNRESOLVED;\n            promise[symbolValue] = []; // queue;\n            try {\n                executor && executor(makeResolver(promise, RESOLVED), makeResolver(promise, REJECTED));\n            }\n            catch (e) {\n                resolvePromise(promise, false, e);\n            }\n        }\n        ZoneAwarePromise.resolve = function (value) {\n            return resolvePromise(new this(null), RESOLVED, value);\n        };\n        ZoneAwarePromise.reject = function (error) {\n            return resolvePromise(new this(null), REJECTED, error);\n        };\n        ZoneAwarePromise.race = function (values) {\n            var resolve;\n            var reject;\n            var promise = new this(function (res, rej) {\n                _a = [res, rej], resolve = _a[0], reject = _a[1];\n                var _a;\n            });\n            function onResolve(value) {\n                promise && (promise = null || resolve(value));\n            }\n            function onReject(error) {\n                promise && (promise = null || reject(error));\n            }\n            for (var _i = 0, values_1 = values; _i < values_1.length; _i++) {\n                var value = values_1[_i];\n                if (!isThenable(value)) {\n                    value = this.resolve(value);\n                }\n                value.then(onResolve, onReject);\n            }\n            return promise;\n        };\n        ZoneAwarePromise.all = function (values) {\n            var resolve;\n            var reject;\n            var promise = new this(function (res, rej) {\n                resolve = res;\n                reject = rej;\n            });\n            var count = 0;\n            var resolvedValues = [];\n            for (var _i = 0, values_2 = values; _i < values_2.length; _i++) {\n                var value = values_2[_i];\n                if (!isThenable(value)) {\n                    value = this.resolve(value);\n                }\n                value.then((function (index) { return function (value) {\n                    resolvedValues[index] = value;\n                    count--;\n                    if (!count) {\n                        resolve(resolvedValues);\n                    }\n                }; })(count), reject);\n                count++;\n            }\n            if (!count)\n                resolve(resolvedValues);\n            return promise;\n        };\n        ZoneAwarePromise.prototype.then = function (onFulfilled, onRejected) {\n            var chainPromise = new this.constructor(null);\n            var zone = Zone.current;\n            if (this[symbolState] == UNRESOLVED) {\n                this[symbolValue].push(zone, chainPromise, onFulfilled, onRejected);\n            }\n            else {\n                scheduleResolveOrReject(this, zone, chainPromise, onFulfilled, onRejected);\n            }\n            return chainPromise;\n        };\n        ZoneAwarePromise.prototype.catch = function (onRejected) {\n            return this.then(null, onRejected);\n        };\n        return ZoneAwarePromise;\n    }());\n    // Protect against aggressive optimizers dropping seemingly unused properties.\n    // E.g. Closure Compiler in advanced mode.\n    ZoneAwarePromise['resolve'] = ZoneAwarePromise.resolve;\n    ZoneAwarePromise['reject'] = ZoneAwarePromise.reject;\n    ZoneAwarePromise['race'] = ZoneAwarePromise.race;\n    ZoneAwarePromise['all'] = ZoneAwarePromise.all;\n    var NativePromise = global[__symbol__('Promise')] = global['Promise'];\n    global['Promise'] = ZoneAwarePromise;\n    function patchThen(NativePromise) {\n        var NativePromiseProtototype = NativePromise.prototype;\n        var NativePromiseThen = NativePromiseProtototype[__symbol__('then')] =\n            NativePromiseProtototype.then;\n        NativePromiseProtototype.then = function (onResolve, onReject) {\n            var nativePromise = this;\n            return new ZoneAwarePromise(function (resolve, reject) {\n                NativePromiseThen.call(nativePromise, resolve, reject);\n            })\n                .then(onResolve, onReject);\n        };\n    }\n    if (NativePromise) {\n        patchThen(NativePromise);\n        if (typeof global['fetch'] !== 'undefined') {\n            var fetchPromise = void 0;\n            try {\n                // In MS Edge this throws\n                fetchPromise = global['fetch']();\n            }\n            catch (e) {\n                // In Chrome this throws instead.\n                fetchPromise = global['fetch']('about:blank');\n            }\n            // ignore output to prevent error;\n            fetchPromise.then(function () { return null; }, function () { return null; });\n            if (fetchPromise.constructor != NativePromise &&\n                fetchPromise.constructor != ZoneAwarePromise) {\n                patchThen(fetchPromise.constructor);\n            }\n        }\n    }\n    // This is not part of public API, but it is usefull for tests, so we expose it.\n    Promise[Zone.__symbol__('uncaughtPromiseErrors')] = _uncaughtPromiseErrors;\n    /*\n     * This code patches Error so that:\n     *   - It ignores un-needed stack frames.\n     *   - It Shows the associated Zone for reach frame.\n     */\n    var FrameType;\n    (function (FrameType) {\n        /// Skip this frame when printing out stack\n        FrameType[FrameType[\"blackList\"] = 0] = \"blackList\";\n        /// This frame marks zone transition\n        FrameType[FrameType[\"transition\"] = 1] = \"transition\";\n    })(FrameType || (FrameType = {}));\n    var NativeError = global[__symbol__('Error')] = global.Error;\n    // Store the frames which should be removed from the stack frames\n    var blackListedStackFrames = {};\n    // We must find the frame where Error was created, otherwise we assume we don't understand stack\n    var zoneAwareFrame;\n    global.Error = ZoneAwareError;\n    // How should the stack frames be parsed.\n    var frameParserStrategy = null;\n    var stackRewrite = 'stackRewrite';\n    /**\n     * This is ZoneAwareError which processes the stack frame and cleans up extra frames as well as\n     * adds zone information to it.\n     */\n    function ZoneAwareError() {\n        // Create an Error.\n        var error = NativeError.apply(this, arguments);\n        this.message = error.message;\n        // Save original stack trace\n        this.originalStack = error.stack;\n        // Process the stack trace and rewrite the frames.\n        if (ZoneAwareError[stackRewrite] && this.originalStack) {\n            var frames_1 = this.originalStack.split('\\n');\n            var zoneFrame = _currentZoneFrame;\n            var i = 0;\n            // Find the first frame\n            while (frames_1[i] !== zoneAwareFrame && i < frames_1.length) {\n                i++;\n            }\n            for (; i < frames_1.length && zoneFrame; i++) {\n                var frame = frames_1[i];\n                if (frame.trim()) {\n                    var frameType = blackListedStackFrames.hasOwnProperty(frame) && blackListedStackFrames[frame];\n                    if (frameType === FrameType.blackList) {\n                        frames_1.splice(i, 1);\n                        i--;\n                    }\n                    else if (frameType === FrameType.transition) {\n                        if (zoneFrame.parent) {\n                            // This is the special frame where zone changed. Print and process it accordingly\n                            frames_1[i] += \" [\" + zoneFrame.parent.zone.name + \" => \" + zoneFrame.zone.name + \"]\";\n                            zoneFrame = zoneFrame.parent;\n                        }\n                        else {\n                            zoneFrame = null;\n                        }\n                    }\n                    else {\n                        frames_1[i] += \" [\" + zoneFrame.zone.name + \"]\";\n                    }\n                }\n            }\n            this.stack = this.zoneAwareStack = frames_1.join('\\n');\n        }\n    }\n    \n    // Copy the prototype so that instanceof operator works as expected\n    ZoneAwareError.prototype = Object.create(NativeError.prototype);\n    ZoneAwareError[Zone.__symbol__('blacklistedStackFrames')] = blackListedStackFrames;\n    ZoneAwareError[stackRewrite] = false;\n    if (NativeError.hasOwnProperty('stackTraceLimit')) {\n        // Extend default stack limit as we will be removing few frames.\n        NativeError.stackTraceLimit = Math.max(NativeError.stackTraceLimit, 15);\n        // make sure that ZoneAwareError has the same property which forwards to NativeError.\n        Object.defineProperty(ZoneAwareError, 'stackTraceLimit', {\n            get: function () {\n                return NativeError.stackTraceLimit;\n            },\n            set: function (value) {\n                return NativeError.stackTraceLimit = value;\n            }\n        });\n    }\n    if (NativeError.hasOwnProperty('captureStackTrace')) {\n        Object.defineProperty(ZoneAwareError, 'captureStackTrace', {\n            value: function (targetObject, constructorOpt) {\n                NativeError.captureStackTrace(targetObject, constructorOpt);\n            }\n        });\n    }\n    Object.defineProperty(ZoneAwareError, 'prepareStackTrace', {\n        get: function () {\n            return NativeError.prepareStackTrace;\n        },\n        set: function (value) {\n            return NativeError.prepareStackTrace = value;\n        }\n    });\n    // Now we need to populet the `blacklistedStackFrames` as well as find the\n    // Now we need to populet the `blacklistedStackFrames` as well as find the\n    // run/runGuraded/runTask frames. This is done by creating a detect zone and then threading\n    // the execution through all of the above methods so that we can look at the stack trace and\n    // find the frames of interest.\n    var detectZone = Zone.current.fork({\n        name: 'detect',\n        onInvoke: function (parentZoneDelegate, currentZone, targetZone, delegate, applyThis, applyArgs, source) {\n            // Here only so that it will show up in the stack frame so that it can be black listed.\n            return parentZoneDelegate.invoke(targetZone, delegate, applyThis, applyArgs, source);\n        },\n        onHandleError: function (parentZD, current, target, error) {\n            if (error.originalStack && Error === ZoneAwareError) {\n                var frames_2 = error.originalStack.split(/\\n/);\n                var runFrame = false, runGuardedFrame = false, runTaskFrame = false;\n                while (frames_2.length) {\n                    var frame = frames_2.shift();\n                    // On safari it is possible to have stack frame with no line number.\n                    // This check makes sure that we don't filter frames on name only (must have\n                    // linenumber)\n                    if (/:\\d+:\\d+/.test(frame)) {\n                        // Get rid of the path so that we don't accidintely find function name in path.\n                        // In chrome the seperator is `(` and `@` in FF and safari\n                        // Chrome: at Zone.run (zone.js:100)\n                        // Chrome: at Zone.run (http://localhost:9876/base/build/lib/zone.js:100:24)\n                        // FireFox: Zone.prototype.run@http://localhost:9876/base/build/lib/zone.js:101:24\n                        // Safari: run@http://localhost:9876/base/build/lib/zone.js:101:24\n                        var fnName = frame.split('(')[0].split('@')[0];\n                        var frameType = FrameType.transition;\n                        if (fnName.indexOf('ZoneAwareError') !== -1) {\n                            zoneAwareFrame = frame;\n                        }\n                        if (fnName.indexOf('runGuarded') !== -1) {\n                            runGuardedFrame = true;\n                        }\n                        else if (fnName.indexOf('runTask') !== -1) {\n                            runTaskFrame = true;\n                        }\n                        else if (fnName.indexOf('run') !== -1) {\n                            runFrame = true;\n                        }\n                        else {\n                            frameType = FrameType.blackList;\n                        }\n                        blackListedStackFrames[frame] = frameType;\n                        // Once we find all of the frames we can stop looking.\n                        if (runFrame && runGuardedFrame && runTaskFrame) {\n                            ZoneAwareError[stackRewrite] = true;\n                            break;\n                        }\n                    }\n                }\n            }\n            return false;\n        }\n    });\n    // carefully constructor a stack frame which contains all of the frames of interest which\n    // need to be detected and blacklisted.\n    var detectRunFn = function () {\n        detectZone.run(function () {\n            detectZone.runGuarded(function () {\n                throw new Error('blacklistStackFrames');\n            });\n        });\n    };\n    // Cause the error to extract the stack frames.\n    detectZone.runTask(detectZone.scheduleMacroTask('detect', detectRunFn, null, function () { return null; }, null));\n    return global['Zone'] = Zone;\n})(typeof window === 'object' && window || typeof self === 'object' && self || global);\n\n/**\n * @license\n * Copyright Google Inc. All Rights Reserved.\n *\n * Use of this source code is governed by an MIT-style license that can be\n * found in the LICENSE file at https://angular.io/license\n */\nvar zoneSymbol = function (n) { return (\"__zone_symbol__\" + n); };\nvar _global$1 = typeof window === 'object' && window || typeof self === 'object' && self || global;\nfunction bindArguments(args, source) {\n    for (var i = args.length - 1; i >= 0; i--) {\n        if (typeof args[i] === 'function') {\n            args[i] = Zone.current.wrap(args[i], source + '_' + i);\n        }\n    }\n    return args;\n}\nfunction patchPrototype(prototype, fnNames) {\n    var source = prototype.constructor['name'];\n    var _loop_1 = function(i) {\n        var name_1 = fnNames[i];\n        var delegate = prototype[name_1];\n        if (delegate) {\n            prototype[name_1] = (function (delegate) {\n                return function () {\n                    return delegate.apply(this, bindArguments(arguments, source + '.' + name_1));\n                };\n            })(delegate);\n        }\n    };\n    for (var i = 0; i < fnNames.length; i++) {\n        _loop_1(i);\n    }\n}\nvar isWebWorker = (typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope);\nvar isNode = (!('nw' in _global$1) && typeof process !== 'undefined' &&\n    {}.toString.call(process) === '[object process]');\nvar isBrowser = !isNode && !isWebWorker && !!(typeof window !== 'undefined' && window['HTMLElement']);\nfunction patchProperty(obj, prop) {\n    var desc = Object.getOwnPropertyDescriptor(obj, prop) || { enumerable: true, configurable: true };\n    var originalDesc = Object.getOwnPropertyDescriptor(obj, 'original' + prop);\n    if (!originalDesc && desc.get) {\n        Object.defineProperty(obj, 'original' + prop, { enumerable: false, configurable: true, get: desc.get });\n    }\n    // A property descriptor cannot have getter/setter and be writable\n    // deleting the writable and value properties avoids this error:\n    //\n    // TypeError: property descriptors must not specify a value or be writable when a\n    // getter or setter has been specified\n    delete desc.writable;\n    delete desc.value;\n    // substr(2) cuz 'onclick' -> 'click', etc\n    var eventName = prop.substr(2);\n    var _prop = '_' + prop;\n    desc.set = function (fn) {\n        if (this[_prop]) {\n            this.removeEventListener(eventName, this[_prop]);\n        }\n        if (typeof fn === 'function') {\n            var wrapFn = function (event) {\n                var result;\n                result = fn.apply(this, arguments);\n                if (result != undefined && !result)\n                    event.preventDefault();\n            };\n            this[_prop] = wrapFn;\n            this.addEventListener(eventName, wrapFn, false);\n        }\n        else {\n            this[_prop] = null;\n        }\n    };\n    // The getter would return undefined for unassigned properties but the default value of an\n    // unassigned property is null\n    desc.get = function () {\n        var r = this[_prop] || null;\n        // result will be null when use inline event attribute,\n        // such as <button onclick=\"func();\">OK</button>\n        // because the onclick function is internal raw uncompiled handler\n        // the onclick will be evaluated when first time event was triggered or\n        // the property is accessed, https://github.com/angular/zone.js/issues/525\n        // so we should use original native get to retrive the handler\n        if (r === null) {\n            var oriDesc = Object.getOwnPropertyDescriptor(obj, 'original' + prop);\n            if (oriDesc && oriDesc.get) {\n                r = oriDesc.get.apply(this, arguments);\n                if (r) {\n                    desc.set.apply(this, [r]);\n                    this.removeAttribute(prop);\n                }\n            }\n        }\n        return this[_prop] || null;\n    };\n    Object.defineProperty(obj, prop, desc);\n}\n\nfunction patchOnProperties(obj, properties) {\n    var onProperties = [];\n    for (var prop in obj) {\n        if (prop.substr(0, 2) == 'on') {\n            onProperties.push(prop);\n        }\n    }\n    for (var j = 0; j < onProperties.length; j++) {\n        patchProperty(obj, onProperties[j]);\n    }\n    if (properties) {\n        for (var i = 0; i < properties.length; i++) {\n            patchProperty(obj, 'on' + properties[i]);\n        }\n    }\n}\n\nvar EVENT_TASKS = zoneSymbol('eventTasks');\n// For EventTarget\nvar ADD_EVENT_LISTENER = 'addEventListener';\nvar REMOVE_EVENT_LISTENER = 'removeEventListener';\nfunction findExistingRegisteredTask(target, handler, name, capture, remove) {\n    var eventTasks = target[EVENT_TASKS];\n    if (eventTasks) {\n        for (var i = 0; i < eventTasks.length; i++) {\n            var eventTask = eventTasks[i];\n            var data = eventTask.data;\n            var listener = data.handler;\n            if ((data.handler === handler || listener.listener === handler) &&\n                data.useCapturing === capture && data.eventName === name) {\n                if (remove) {\n                    eventTasks.splice(i, 1);\n                }\n                return eventTask;\n            }\n        }\n    }\n    return null;\n}\nfunction findAllExistingRegisteredTasks(target, name, capture, remove) {\n    var eventTasks = target[EVENT_TASKS];\n    if (eventTasks) {\n        var result = [];\n        for (var i = eventTasks.length - 1; i >= 0; i--) {\n            var eventTask = eventTasks[i];\n            var data = eventTask.data;\n            if (data.eventName === name && data.useCapturing === capture) {\n                result.push(eventTask);\n                if (remove) {\n                    eventTasks.splice(i, 1);\n                }\n            }\n        }\n        return result;\n    }\n    return null;\n}\nfunction attachRegisteredEvent(target, eventTask, isPrepend) {\n    var eventTasks = target[EVENT_TASKS];\n    if (!eventTasks) {\n        eventTasks = target[EVENT_TASKS] = [];\n    }\n    if (isPrepend) {\n        eventTasks.unshift(eventTask);\n    }\n    else {\n        eventTasks.push(eventTask);\n    }\n}\nvar defaultListenerMetaCreator = function (self, args) {\n    return {\n        useCapturing: args[2],\n        eventName: args[0],\n        handler: args[1],\n        target: self || _global$1,\n        name: args[0],\n        invokeAddFunc: function (addFnSymbol, delegate) {\n            if (delegate && delegate.invoke) {\n                return this.target[addFnSymbol](this.eventName, delegate.invoke, this.useCapturing);\n            }\n            else {\n                return this.target[addFnSymbol](this.eventName, delegate, this.useCapturing);\n            }\n        },\n        invokeRemoveFunc: function (removeFnSymbol, delegate) {\n            if (delegate && delegate.invoke) {\n                return this.target[removeFnSymbol](this.eventName, delegate.invoke, this.useCapturing);\n            }\n            else {\n                return this.target[removeFnSymbol](this.eventName, delegate, this.useCapturing);\n            }\n        }\n    };\n};\nfunction makeZoneAwareAddListener(addFnName, removeFnName, useCapturingParam, allowDuplicates, isPrepend, metaCreator) {\n    if (useCapturingParam === void 0) { useCapturingParam = true; }\n    if (allowDuplicates === void 0) { allowDuplicates = false; }\n    if (isPrepend === void 0) { isPrepend = false; }\n    if (metaCreator === void 0) { metaCreator = defaultListenerMetaCreator; }\n    var addFnSymbol = zoneSymbol(addFnName);\n    var removeFnSymbol = zoneSymbol(removeFnName);\n    var defaultUseCapturing = useCapturingParam ? false : undefined;\n    function scheduleEventListener(eventTask) {\n        var meta = eventTask.data;\n        attachRegisteredEvent(meta.target, eventTask, isPrepend);\n        return meta.invokeAddFunc(addFnSymbol, eventTask);\n    }\n    function cancelEventListener(eventTask) {\n        var meta = eventTask.data;\n        findExistingRegisteredTask(meta.target, eventTask.invoke, meta.eventName, meta.useCapturing, true);\n        return meta.invokeRemoveFunc(removeFnSymbol, eventTask);\n    }\n    return function zoneAwareAddListener(self, args) {\n        var data = metaCreator(self, args);\n        data.useCapturing = data.useCapturing || defaultUseCapturing;\n        // - Inside a Web Worker, `this` is undefined, the context is `global`\n        // - When `addEventListener` is called on the global context in strict mode, `this` is undefined\n        // see https://github.com/angular/zone.js/issues/190\n        var delegate = null;\n        if (typeof data.handler == 'function') {\n            delegate = data.handler;\n        }\n        else if (data.handler && data.handler.handleEvent) {\n            delegate = function (event) { return data.handler.handleEvent(event); };\n        }\n        var validZoneHandler = false;\n        try {\n            // In cross site contexts (such as WebDriver frameworks like Selenium),\n            // accessing the handler object here will cause an exception to be thrown which\n            // will fail tests prematurely.\n            validZoneHandler = data.handler && data.handler.toString() === '[object FunctionWrapper]';\n        }\n        catch (e) {\n            // Returning nothing here is fine, because objects in a cross-site context are unusable\n            return;\n        }\n        // Ignore special listeners of IE11 & Edge dev tools, see\n        // https://github.com/angular/zone.js/issues/150\n        if (!delegate || validZoneHandler) {\n            return data.invokeAddFunc(addFnSymbol, data.handler);\n        }\n        if (!allowDuplicates) {\n            var eventTask = findExistingRegisteredTask(data.target, data.handler, data.eventName, data.useCapturing, false);\n            if (eventTask) {\n                // we already registered, so this will have noop.\n                return data.invokeAddFunc(addFnSymbol, eventTask);\n            }\n        }\n        var zone = Zone.current;\n        var source = data.target.constructor['name'] + '.' + addFnName + ':' + data.eventName;\n        zone.scheduleEventTask(source, delegate, data, scheduleEventListener, cancelEventListener);\n    };\n}\nfunction makeZoneAwareRemoveListener(fnName, useCapturingParam, metaCreator) {\n    if (useCapturingParam === void 0) { useCapturingParam = true; }\n    if (metaCreator === void 0) { metaCreator = defaultListenerMetaCreator; }\n    var symbol = zoneSymbol(fnName);\n    var defaultUseCapturing = useCapturingParam ? false : undefined;\n    return function zoneAwareRemoveListener(self, args) {\n        var data = metaCreator(self, args);\n        data.useCapturing = data.useCapturing || defaultUseCapturing;\n        // - Inside a Web Worker, `this` is undefined, the context is `global`\n        // - When `addEventListener` is called on the global context in strict mode, `this` is undefined\n        // see https://github.com/angular/zone.js/issues/190\n        var eventTask = findExistingRegisteredTask(data.target, data.handler, data.eventName, data.useCapturing, true);\n        if (eventTask) {\n            eventTask.zone.cancelTask(eventTask);\n        }\n        else {\n            data.invokeRemoveFunc(symbol, data.handler);\n        }\n    };\n}\n\n\nvar zoneAwareAddEventListener = makeZoneAwareAddListener(ADD_EVENT_LISTENER, REMOVE_EVENT_LISTENER);\nvar zoneAwareRemoveEventListener = makeZoneAwareRemoveListener(REMOVE_EVENT_LISTENER);\nfunction patchEventTargetMethods(obj, addFnName, removeFnName, metaCreator) {\n    if (addFnName === void 0) { addFnName = ADD_EVENT_LISTENER; }\n    if (removeFnName === void 0) { removeFnName = REMOVE_EVENT_LISTENER; }\n    if (metaCreator === void 0) { metaCreator = defaultListenerMetaCreator; }\n    if (obj && obj[addFnName]) {\n        patchMethod(obj, addFnName, function () { return makeZoneAwareAddListener(addFnName, removeFnName, true, false, false, metaCreator); });\n        patchMethod(obj, removeFnName, function () { return makeZoneAwareRemoveListener(removeFnName, true, metaCreator); });\n        return true;\n    }\n    else {\n        return false;\n    }\n}\nvar originalInstanceKey = zoneSymbol('originalInstance');\n// wrap some native API on `window`\nfunction patchClass(className) {\n    var OriginalClass = _global$1[className];\n    if (!OriginalClass)\n        return;\n    _global$1[className] = function () {\n        var a = bindArguments(arguments, className);\n        switch (a.length) {\n            case 0:\n                this[originalInstanceKey] = new OriginalClass();\n                break;\n            case 1:\n                this[originalInstanceKey] = new OriginalClass(a[0]);\n                break;\n            case 2:\n                this[originalInstanceKey] = new OriginalClass(a[0], a[1]);\n                break;\n            case 3:\n                this[originalInstanceKey] = new OriginalClass(a[0], a[1], a[2]);\n                break;\n            case 4:\n                this[originalInstanceKey] = new OriginalClass(a[0], a[1], a[2], a[3]);\n                break;\n            default:\n                throw new Error('Arg list too long.');\n        }\n    };\n    var instance = new OriginalClass(function () { });\n    var prop;\n    for (prop in instance) {\n        // https://bugs.webkit.org/show_bug.cgi?id=44721\n        if (className === 'XMLHttpRequest' && prop === 'responseBlob')\n            continue;\n        (function (prop) {\n            if (typeof instance[prop] === 'function') {\n                _global$1[className].prototype[prop] = function () {\n                    return this[originalInstanceKey][prop].apply(this[originalInstanceKey], arguments);\n                };\n            }\n            else {\n                Object.defineProperty(_global$1[className].prototype, prop, {\n                    set: function (fn) {\n                        if (typeof fn === 'function') {\n                            this[originalInstanceKey][prop] = Zone.current.wrap(fn, className + '.' + prop);\n                        }\n                        else {\n                            this[originalInstanceKey][prop] = fn;\n                        }\n                    },\n                    get: function () {\n                        return this[originalInstanceKey][prop];\n                    }\n                });\n            }\n        }(prop));\n    }\n    for (prop in OriginalClass) {\n        if (prop !== 'prototype' && OriginalClass.hasOwnProperty(prop)) {\n            _global$1[className][prop] = OriginalClass[prop];\n        }\n    }\n}\n\nfunction createNamedFn(name, delegate) {\n    try {\n        return (Function('f', \"return function \" + name + \"(){return f(this, arguments)}\"))(delegate);\n    }\n    catch (e) {\n        // if we fail, we must be CSP, just return delegate.\n        return function () {\n            return delegate(this, arguments);\n        };\n    }\n}\nfunction patchMethod(target, name, patchFn) {\n    var proto = target;\n    while (proto && Object.getOwnPropertyNames(proto).indexOf(name) === -1) {\n        proto = Object.getPrototypeOf(proto);\n    }\n    if (!proto && target[name]) {\n        // somehow we did not find it, but we can see it. This happens on IE for Window properties.\n        proto = target;\n    }\n    var delegateName = zoneSymbol(name);\n    var delegate;\n    if (proto && !(delegate = proto[delegateName])) {\n        delegate = proto[delegateName] = proto[name];\n        proto[name] = createNamedFn(name, patchFn(delegate, delegateName, name));\n    }\n    return delegate;\n}\n\n/**\n * @license\n * Copyright Google Inc. All Rights Reserved.\n *\n * Use of this source code is governed by an MIT-style license that can be\n * found in the LICENSE file at https://angular.io/license\n */\nfunction patchTimer(window, setName, cancelName, nameSuffix) {\n    var setNative = null;\n    var clearNative = null;\n    setName += nameSuffix;\n    cancelName += nameSuffix;\n    var tasksByHandleId = {};\n    function scheduleTask(task) {\n        var data = task.data;\n        data.args[0] = function () {\n            task.invoke.apply(this, arguments);\n            delete tasksByHandleId[data.handleId];\n        };\n        data.handleId = setNative.apply(window, data.args);\n        tasksByHandleId[data.handleId] = task;\n        return task;\n    }\n    function clearTask(task) {\n        delete tasksByHandleId[task.data.handleId];\n        return clearNative(task.data.handleId);\n    }\n    setNative =\n        patchMethod(window, setName, function (delegate) { return function (self, args) {\n            if (typeof args[0] === 'function') {\n                var zone = Zone.current;\n                var options = {\n                    handleId: null,\n                    isPeriodic: nameSuffix === 'Interval',\n                    delay: (nameSuffix === 'Timeout' || nameSuffix === 'Interval') ? args[1] || 0 : null,\n                    args: args\n                };\n                var task = zone.scheduleMacroTask(setName, args[0], options, scheduleTask, clearTask);\n                if (!task) {\n                    return task;\n                }\n                // Node.js must additionally support the ref and unref functions.\n                var handle = task.data.handleId;\n                if (handle.ref && handle.unref) {\n                    task.ref = handle.ref.bind(handle);\n                    task.unref = handle.unref.bind(handle);\n                }\n                return task;\n            }\n            else {\n                // cause an error by calling it directly.\n                return delegate.apply(window, args);\n            }\n        }; });\n    clearNative =\n        patchMethod(window, cancelName, function (delegate) { return function (self, args) {\n            var task = typeof args[0] === 'number' ? tasksByHandleId[args[0]] : args[0];\n            if (task && typeof task.type === 'string') {\n                if (task.cancelFn && task.data.isPeriodic || task.runCount === 0) {\n                    // Do not cancel already canceled functions\n                    task.zone.cancelTask(task);\n                }\n            }\n            else {\n                // cause an error by calling it directly.\n                delegate.apply(window, args);\n            }\n        }; });\n}\n\n/**\n * @license\n * Copyright Google Inc. All Rights Reserved.\n *\n * Use of this source code is governed by an MIT-style license that can be\n * found in the LICENSE file at https://angular.io/license\n */\n/*\n * This is necessary for Chrome and Chrome mobile, to enable\n * things like redefining `createdCallback` on an element.\n */\nvar _defineProperty = Object[zoneSymbol('defineProperty')] = Object.defineProperty;\nvar _getOwnPropertyDescriptor = Object[zoneSymbol('getOwnPropertyDescriptor')] =\n    Object.getOwnPropertyDescriptor;\nvar _create = Object.create;\nvar unconfigurablesKey = zoneSymbol('unconfigurables');\nfunction propertyPatch() {\n    Object.defineProperty = function (obj, prop, desc) {\n        if (isUnconfigurable(obj, prop)) {\n            throw new TypeError('Cannot assign to read only property \\'' + prop + '\\' of ' + obj);\n        }\n        var originalConfigurableFlag = desc.configurable;\n        if (prop !== 'prototype') {\n            desc = rewriteDescriptor(obj, prop, desc);\n        }\n        return _tryDefineProperty(obj, prop, desc, originalConfigurableFlag);\n    };\n    Object.defineProperties = function (obj, props) {\n        Object.keys(props).forEach(function (prop) {\n            Object.defineProperty(obj, prop, props[prop]);\n        });\n        return obj;\n    };\n    Object.create = function (obj, proto) {\n        if (typeof proto === 'object' && !Object.isFrozen(proto)) {\n            Object.keys(proto).forEach(function (prop) {\n                proto[prop] = rewriteDescriptor(obj, prop, proto[prop]);\n            });\n        }\n        return _create(obj, proto);\n    };\n    Object.getOwnPropertyDescriptor = function (obj, prop) {\n        var desc = _getOwnPropertyDescriptor(obj, prop);\n        if (isUnconfigurable(obj, prop)) {\n            desc.configurable = false;\n        }\n        return desc;\n    };\n}\n\nfunction _redefineProperty(obj, prop, desc) {\n    var originalConfigurableFlag = desc.configurable;\n    desc = rewriteDescriptor(obj, prop, desc);\n    return _tryDefineProperty(obj, prop, desc, originalConfigurableFlag);\n}\n\nfunction isUnconfigurable(obj, prop) {\n    return obj && obj[unconfigurablesKey] && obj[unconfigurablesKey][prop];\n}\nfunction rewriteDescriptor(obj, prop, desc) {\n    desc.configurable = true;\n    if (!desc.configurable) {\n        if (!obj[unconfigurablesKey]) {\n            _defineProperty(obj, unconfigurablesKey, { writable: true, value: {} });\n        }\n        obj[unconfigurablesKey][prop] = true;\n    }\n    return desc;\n}\nfunction _tryDefineProperty(obj, prop, desc, originalConfigurableFlag) {\n    try {\n        return _defineProperty(obj, prop, desc);\n    }\n    catch (e) {\n        if (desc.configurable) {\n            // In case of errors, when the configurable flag was likely set by rewriteDescriptor(), let's\n            // retry with the original flag value\n            if (typeof originalConfigurableFlag == 'undefined') {\n                delete desc.configurable;\n            }\n            else {\n                desc.configurable = originalConfigurableFlag;\n            }\n            try {\n                return _defineProperty(obj, prop, desc);\n            }\n            catch (e) {\n                var descJson = null;\n                try {\n                    descJson = JSON.stringify(desc);\n                }\n                catch (e) {\n                    descJson = descJson.toString();\n                }\n                console.log(\"Attempting to configure '\" + prop + \"' with descriptor '\" + descJson + \"' on object '\" + obj + \"' and got error, giving up: \" + e);\n            }\n        }\n        else {\n            throw e;\n        }\n    }\n}\n\n/**\n * @license\n * Copyright Google Inc. All Rights Reserved.\n *\n * Use of this source code is governed by an MIT-style license that can be\n * found in the LICENSE file at https://angular.io/license\n */\nvar WTF_ISSUE_555 = 'Anchor,Area,Audio,BR,Base,BaseFont,Body,Button,Canvas,Content,DList,Directory,Div,Embed,FieldSet,Font,Form,Frame,FrameSet,HR,Head,Heading,Html,IFrame,Image,Input,Keygen,LI,Label,Legend,Link,Map,Marquee,Media,Menu,Meta,Meter,Mod,OList,Object,OptGroup,Option,Output,Paragraph,Pre,Progress,Quote,Script,Select,Source,Span,Style,TableCaption,TableCell,TableCol,Table,TableRow,TableSection,TextArea,Title,Track,UList,Unknown,Video';\nvar NO_EVENT_TARGET = 'ApplicationCache,EventSource,FileReader,InputMethodContext,MediaController,MessagePort,Node,Performance,SVGElementInstance,SharedWorker,TextTrack,TextTrackCue,TextTrackList,WebKitNamedFlow,Window,Worker,WorkerGlobalScope,XMLHttpRequest,XMLHttpRequestEventTarget,XMLHttpRequestUpload,IDBRequest,IDBOpenDBRequest,IDBDatabase,IDBTransaction,IDBCursor,DBIndex,WebSocket'\n    .split(',');\nvar EVENT_TARGET = 'EventTarget';\nfunction eventTargetPatch(_global) {\n    var apis = [];\n    var isWtf = _global['wtf'];\n    if (isWtf) {\n        // Workaround for: https://github.com/google/tracing-framework/issues/555\n        apis = WTF_ISSUE_555.split(',').map(function (v) { return 'HTML' + v + 'Element'; }).concat(NO_EVENT_TARGET);\n    }\n    else if (_global[EVENT_TARGET]) {\n        apis.push(EVENT_TARGET);\n    }\n    else {\n        // Note: EventTarget is not available in all browsers,\n        // if it's not available, we instead patch the APIs in the IDL that inherit from EventTarget\n        apis = NO_EVENT_TARGET;\n    }\n    for (var i = 0; i < apis.length; i++) {\n        var type = _global[apis[i]];\n        patchEventTargetMethods(type && type.prototype);\n    }\n}\n\n/**\n * @license\n * Copyright Google Inc. All Rights Reserved.\n *\n * Use of this source code is governed by an MIT-style license that can be\n * found in the LICENSE file at https://angular.io/license\n */\n// we have to patch the instance since the proto is non-configurable\nfunction apply(_global) {\n    var WS = _global.WebSocket;\n    // On Safari window.EventTarget doesn't exist so need to patch WS add/removeEventListener\n    // On older Chrome, no need since EventTarget was already patched\n    if (!_global.EventTarget) {\n        patchEventTargetMethods(WS.prototype);\n    }\n    _global.WebSocket = function (a, b) {\n        var socket = arguments.length > 1 ? new WS(a, b) : new WS(a);\n        var proxySocket;\n        // Safari 7.0 has non-configurable own 'onmessage' and friends properties on the socket instance\n        var onmessageDesc = Object.getOwnPropertyDescriptor(socket, 'onmessage');\n        if (onmessageDesc && onmessageDesc.configurable === false) {\n            proxySocket = Object.create(socket);\n            ['addEventListener', 'removeEventListener', 'send', 'close'].forEach(function (propName) {\n                proxySocket[propName] = function () {\n                    return socket[propName].apply(socket, arguments);\n                };\n            });\n        }\n        else {\n            // we can patch the real socket\n            proxySocket = socket;\n        }\n        patchOnProperties(proxySocket, ['close', 'error', 'message', 'open']);\n        return proxySocket;\n    };\n    for (var prop in WS) {\n        _global.WebSocket[prop] = WS[prop];\n    }\n}\n\n/**\n * @license\n * Copyright Google Inc. All Rights Reserved.\n *\n * Use of this source code is governed by an MIT-style license that can be\n * found in the LICENSE file at https://angular.io/license\n */\nvar eventNames = 'copy cut paste abort blur focus canplay canplaythrough change click contextmenu dblclick drag dragend dragenter dragleave dragover dragstart drop durationchange emptied ended input invalid keydown keypress keyup load loadeddata loadedmetadata loadstart message mousedown mouseenter mouseleave mousemove mouseout mouseover mouseup pause play playing progress ratechange reset scroll seeked seeking select show stalled submit suspend timeupdate volumechange waiting mozfullscreenchange mozfullscreenerror mozpointerlockchange mozpointerlockerror error webglcontextrestored webglcontextlost webglcontextcreationerror'\n    .split(' ');\nfunction propertyDescriptorPatch(_global) {\n    if (isNode) {\n        return;\n    }\n    var supportsWebSocket = typeof WebSocket !== 'undefined';\n    if (canPatchViaPropertyDescriptor()) {\n        // for browsers that we can patch the descriptor:  Chrome & Firefox\n        if (isBrowser) {\n            patchOnProperties(HTMLElement.prototype, eventNames);\n        }\n        patchOnProperties(XMLHttpRequest.prototype, null);\n        if (typeof IDBIndex !== 'undefined') {\n            patchOnProperties(IDBIndex.prototype, null);\n            patchOnProperties(IDBRequest.prototype, null);\n            patchOnProperties(IDBOpenDBRequest.prototype, null);\n            patchOnProperties(IDBDatabase.prototype, null);\n            patchOnProperties(IDBTransaction.prototype, null);\n            patchOnProperties(IDBCursor.prototype, null);\n        }\n        if (supportsWebSocket) {\n            patchOnProperties(WebSocket.prototype, null);\n        }\n    }\n    else {\n        // Safari, Android browsers (Jelly Bean)\n        patchViaCapturingAllTheEvents();\n        patchClass('XMLHttpRequest');\n        if (supportsWebSocket) {\n            apply(_global);\n        }\n    }\n}\nfunction canPatchViaPropertyDescriptor() {\n    if (isBrowser && !Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'onclick') &&\n        typeof Element !== 'undefined') {\n        // WebKit https://bugs.webkit.org/show_bug.cgi?id=134364\n        // IDL interface attributes are not configurable\n        var desc = Object.getOwnPropertyDescriptor(Element.prototype, 'onclick');\n        if (desc && !desc.configurable)\n            return false;\n    }\n    Object.defineProperty(XMLHttpRequest.prototype, 'onreadystatechange', {\n        get: function () {\n            return true;\n        }\n    });\n    var req = new XMLHttpRequest();\n    var result = !!req.onreadystatechange;\n    Object.defineProperty(XMLHttpRequest.prototype, 'onreadystatechange', {});\n    return result;\n}\n\nvar unboundKey = zoneSymbol('unbound');\n// Whenever any eventListener fires, we check the eventListener target and all parents\n// for `onwhatever` properties and replace them with zone-bound functions\n// - Chrome (for now)\nfunction patchViaCapturingAllTheEvents() {\n    var _loop_1 = function(i) {\n        var property = eventNames[i];\n        var onproperty = 'on' + property;\n        self.addEventListener(property, function (event) {\n            var elt = event.target, bound, source;\n            if (elt) {\n                source = elt.constructor['name'] + '.' + onproperty;\n            }\n            else {\n                source = 'unknown.' + onproperty;\n            }\n            while (elt) {\n                if (elt[onproperty] && !elt[onproperty][unboundKey]) {\n                    bound = Zone.current.wrap(elt[onproperty], source);\n                    bound[unboundKey] = elt[onproperty];\n                    elt[onproperty] = bound;\n                }\n                elt = elt.parentElement;\n            }\n        }, true);\n    };\n    for (var i = 0; i < eventNames.length; i++) {\n        _loop_1(i);\n    }\n    \n}\n\n/**\n * @license\n * Copyright Google Inc. All Rights Reserved.\n *\n * Use of this source code is governed by an MIT-style license that can be\n * found in the LICENSE file at https://angular.io/license\n */\nfunction registerElementPatch(_global) {\n    if (!isBrowser || !('registerElement' in _global.document)) {\n        return;\n    }\n    var _registerElement = document.registerElement;\n    var callbacks = ['createdCallback', 'attachedCallback', 'detachedCallback', 'attributeChangedCallback'];\n    document.registerElement = function (name, opts) {\n        if (opts && opts.prototype) {\n            callbacks.forEach(function (callback) {\n                var source = 'Document.registerElement::' + callback;\n                if (opts.prototype.hasOwnProperty(callback)) {\n                    var descriptor = Object.getOwnPropertyDescriptor(opts.prototype, callback);\n                    if (descriptor && descriptor.value) {\n                        descriptor.value = Zone.current.wrap(descriptor.value, source);\n                        _redefineProperty(opts.prototype, callback, descriptor);\n                    }\n                    else {\n                        opts.prototype[callback] = Zone.current.wrap(opts.prototype[callback], source);\n                    }\n                }\n                else if (opts.prototype[callback]) {\n                    opts.prototype[callback] = Zone.current.wrap(opts.prototype[callback], source);\n                }\n            });\n        }\n        return _registerElement.apply(document, [name, opts]);\n    };\n}\n\n/**\n * @license\n * Copyright Google Inc. All Rights Reserved.\n *\n * Use of this source code is governed by an MIT-style license that can be\n * found in the LICENSE file at https://angular.io/license\n */\nvar set = 'set';\nvar clear = 'clear';\nvar blockingMethods = ['alert', 'prompt', 'confirm'];\nvar _global = typeof window === 'object' && window || typeof self === 'object' && self || global;\npatchTimer(_global, set, clear, 'Timeout');\npatchTimer(_global, set, clear, 'Interval');\npatchTimer(_global, set, clear, 'Immediate');\npatchTimer(_global, 'request', 'cancel', 'AnimationFrame');\npatchTimer(_global, 'mozRequest', 'mozCancel', 'AnimationFrame');\npatchTimer(_global, 'webkitRequest', 'webkitCancel', 'AnimationFrame');\nfor (var i = 0; i < blockingMethods.length; i++) {\n    var name = blockingMethods[i];\n    patchMethod(_global, name, function (delegate, symbol, name) {\n        return function (s, args) {\n            return Zone.current.run(delegate, _global, args, name);\n        };\n    });\n}\neventTargetPatch(_global);\npropertyDescriptorPatch(_global);\npatchClass('MutationObserver');\npatchClass('WebKitMutationObserver');\npatchClass('FileReader');\npropertyPatch();\nregisterElementPatch(_global);\n// Treat XMLHTTPRequest as a macrotask.\npatchXHR(_global);\nvar XHR_TASK = zoneSymbol('xhrTask');\nvar XHR_SYNC = zoneSymbol('xhrSync');\nvar XHR_LISTENER = zoneSymbol('xhrListener');\nvar XHR_SCHEDULED = zoneSymbol('xhrScheduled');\nfunction patchXHR(window) {\n    function findPendingTask(target) {\n        var pendingTask = target[XHR_TASK];\n        return pendingTask;\n    }\n    function scheduleTask(task) {\n        self[XHR_SCHEDULED] = false;\n        var data = task.data;\n        // remove existing event listener\n        var listener = data.target[XHR_LISTENER];\n        if (listener) {\n            data.target.removeEventListener('readystatechange', listener);\n        }\n        var newListener = data.target[XHR_LISTENER] = function () {\n            if (data.target.readyState === data.target.DONE) {\n                if (!data.aborted && self[XHR_SCHEDULED]) {\n                    task.invoke();\n                }\n            }\n        };\n        data.target.addEventListener('readystatechange', newListener);\n        var storedTask = data.target[XHR_TASK];\n        if (!storedTask) {\n            data.target[XHR_TASK] = task;\n        }\n        sendNative.apply(data.target, data.args);\n        self[XHR_SCHEDULED] = true;\n        return task;\n    }\n    function placeholderCallback() { }\n    function clearTask(task) {\n        var data = task.data;\n        // Note - ideally, we would call data.target.removeEventListener here, but it's too late\n        // to prevent it from firing. So instead, we store info for the event listener.\n        data.aborted = true;\n        return abortNative.apply(data.target, data.args);\n    }\n    var openNative = patchMethod(window.XMLHttpRequest.prototype, 'open', function () { return function (self, args) {\n        self[XHR_SYNC] = args[2] == false;\n        return openNative.apply(self, args);\n    }; });\n    var sendNative = patchMethod(window.XMLHttpRequest.prototype, 'send', function () { return function (self, args) {\n        var zone = Zone.current;\n        if (self[XHR_SYNC]) {\n            // if the XHR is sync there is no task to schedule, just execute the code.\n            return sendNative.apply(self, args);\n        }\n        else {\n            var options = { target: self, isPeriodic: false, delay: null, args: args, aborted: false };\n            return zone.scheduleMacroTask('XMLHttpRequest.send', placeholderCallback, options, scheduleTask, clearTask);\n        }\n    }; });\n    var abortNative = patchMethod(window.XMLHttpRequest.prototype, 'abort', function (delegate) { return function (self, args) {\n        var task = findPendingTask(self);\n        if (task && typeof task.type == 'string') {\n            // If the XHR has already completed, do nothing.\n            if (task.cancelFn == null) {\n                return;\n            }\n            task.zone.cancelTask(task);\n        }\n        // Otherwise, we are trying to abort an XHR which has not yet been sent, so there is no task\n        // to cancel. Do nothing.\n    }; });\n}\n/// GEO_LOCATION\nif (_global['navigator'] && _global['navigator'].geolocation) {\n    patchPrototype(_global['navigator'].geolocation, ['getCurrentPosition', 'watchPosition']);\n}\n\n/**\n * @license\n * Copyright Google Inc. All Rights Reserved.\n *\n * Use of this source code is governed by an MIT-style license that can be\n * found in the LICENSE file at https://angular.io/license\n */\n\n})));\n",
diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json
index cb09a0e23..168f799e8 100644
--- a/lighthouse-core/test/results/sample_v2.json
+++ b/lighthouse-core/test/results/sample_v2.json
@@ -766,6 +766,7 @@
       "scoreDisplayMode": "numeric",
       "rawValue": 0,
       "displayValue": "",
+      "warnings": [],
       "details": {
         "type": "opportunity",
         "headings": [],
@@ -2609,10 +2610,7 @@
       "score": 1,
       "scoreDisplayMode": "binary",
       "rawValue": true,
-      "displayValue": [
-        "%.1d% legible text",
-        100
-      ],
+      "displayValue": "100% legible text",
       "details": {
         "type": "table",
         "headings": [
@@ -2668,7 +2666,7 @@
       "score": null,
       "scoreDisplayMode": "error",
       "rawValue": null,
-      "errorMessage": "Audit error: Required RobotsTxt gatherer did not run."
+      "errorMessage": "Required RobotsTxt gatherer did not run."
     },
     "robots-txt": {
       "id": "robots-txt",
@@ -2677,7 +2675,7 @@
       "score": null,
       "scoreDisplayMode": "error",
       "rawValue": null,
-      "errorMessage": "Audit error: Required RobotsTxt gatherer did not run."
+      "errorMessage": "Required RobotsTxt gatherer did not run."
     },
     "hreflang": {
       "id": "hreflang",
@@ -4398,6 +4396,12 @@
       "lighthouse-core/audits/metrics/first-meaningful-paint.js | description": [
         "audits[first-meaningful-paint].description"
       ],
+      "lighthouse-core/audits/load-fast-enough-for-pwa.js | title": [
+        "audits[load-fast-enough-for-pwa].title"
+      ],
+      "lighthouse-core/audits/load-fast-enough-for-pwa.js | description": [
+        "audits[load-fast-enough-for-pwa].description"
+      ],
       "lighthouse-core/audits/metrics/speed-index.js | title": [
         "audits[speed-index].title"
       ],
@@ -4908,6 +4912,20 @@
       "lighthouse-core/audits/dobetterweb/dom-size.js | statisticDOMWidth": [
         "audits[dom-size].details.items[2].statistic"
       ],
+      "lighthouse-core/audits/seo/font-size.js | title": [
+        "audits[font-size].title"
+      ],
+      "lighthouse-core/audits/seo/font-size.js | description": [
+        "audits[font-size].description"
+      ],
+      "lighthouse-core/audits/seo/font-size.js | displayValue": [
+        {
+          "values": {
+            "decimalProportion": 1
+          },
+          "path": "audits[font-size].displayValue"
+        }
+      ],
       "lighthouse-core/config/default-config.js | performanceCategoryTitle": [
         "categories.performance.title"
       ],
diff --git a/lighthouse-core/test/runner-test.js b/lighthouse-core/test/runner-test.js
index d3683f316..8774274e2 100644
--- a/lighthouse-core/test/runner-test.js
+++ b/lighthouse-core/test/runner-test.js
@@ -606,7 +606,8 @@ describe('Runner', () => {
     assert.ok(lhr.audits['test-audit'].errorMessage.includes(NO_FCP.code));
     // And it bubbled up to the runtimeError.
     assert.strictEqual(lhr.runtimeError.code, NO_FCP.code);
-    assert.ok(lhr.runtimeError.message.includes(NO_FCP.message));
+    expect(lhr.runtimeError.message)
+      .toBeDisplayString(/Something .*\(NO_FCP\)/);
   });
 
   it('localized errors thrown from driver', async () => {