Repository: GoogleChrome/lighthouse
Pull request #7228 (2019-02-12 20:47:24)

core(seo): add rel=canonical test for auditing a domain root
covers an extra case that @brendankenny and i discussed while reviewing https://github.com/GoogleChrome/lighthouse/pull/6860/files#diff-14d8658f7122f6404900585e27a2466cR33


Patch:
diff --git a/.travis.yml b/.travis.yml
index bfd347b23..b42b9cebf 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -8,6 +8,8 @@ matrix:
     - node_js: "8"
     - node_js: "10"
       if: head_branch IS blank AND branch = master
+    - node_js: "11"
+      if: head_branch IS blank AND branch = master
 dist: trusty
 cache:
   yarn: true
diff --git a/lighthouse-core/audits/byte-efficiency/unused-css-rules.js b/lighthouse-core/audits/byte-efficiency/unused-css-rules.js
index 475d8657a..c917155b0 100644
--- a/lighthouse-core/audits/byte-efficiency/unused-css-rules.js
+++ b/lighthouse-core/audits/byte-efficiency/unused-css-rules.js
@@ -10,10 +10,10 @@ const i18n = require('../../lib/i18n/i18n.js');
 
 const UIStrings = {
   /** Imperative title of a Lighthouse audit that tells the user to remove content from their CSS that isn’t needed immediately and instead load that content at a later time. This is displayed in a list of audit titles that Lighthouse generates. */
-  title: 'Defer unused CSS',
+  title: 'Remove unused CSS',
   /** Description of a Lighthouse audit that tells the user *why* they should defer loading any content in CSS that isn’t needed at page load. This is displayed after a user expands the section to see more. No word length limits. 'Learn More' becomes link text to additional documentation. */
-  description: 'Remove unused rules from stylesheets to reduce unnecessary ' +
-    'bytes consumed by network activity. ' +
+  description: 'Remove dead rules from stylesheets and defer the loading of CSS not used for ' +
+    'above-the-fold content to reduce unnecessary bytes consumed by network activity. ' +
     '[Learn more](https://developers.google.com/web/tools/lighthouse/audits/unused-css).',
 };
 
diff --git a/lighthouse-core/audits/seo/canonical.js b/lighthouse-core/audits/seo/canonical.js
index b0032aed2..afd777345 100644
--- a/lighthouse-core/audits/seo/canonical.js
+++ b/lighthouse-core/audits/seo/canonical.js
@@ -10,6 +10,32 @@ const LinkHeader = require('http-link-header');
 const URL = require('../../lib/url-shim');
 const MainResource = require('../../computed/main-resource.js');
 const LINK_HEADER = 'link';
+const i18n = require('../../lib/i18n/i18n.js');
+
+const UIStrings = {
+  /** Title of a Lighthouse audit that provides detail on a page's rel=canonical link. This descriptive title is shown to users when the rel=canonical link is valid. "rel=canonical" is an HTML attribute and value and so should not be translated. */
+  title: 'Document has a valid `rel=canonical`',
+  /** Title of a Lighthouse audit that provides detail on a page's rel=canonical link. This descriptive title is shown to users when the rel=canonical link is invalid and should be fixed. "rel=canonical" is an HTML attribute and value and so should not be translated. */
+  failureTitle: 'Document does not have a valid `rel=canonical`',
+  /** Description of a Lighthouse audit that tells the user *why* they need to have a valid rel=canonical link. 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: 'Canonical links suggest which URL to show in search results. ' +
+    '[Learn more](https://developers.google.com/web/tools/lighthouse/audits/canonical).',
+  /** [ICU Syntax] Explanatory message stating that there was a failure in an audit caused by multiple URLs conflicting with each other. "urlList" will be replaced by a list of URLs (e.g. https://example.com, https://example2.com, etc ). */
+  explanationConflict: 'Multiple conflicting URLs ({urlList})',
+  /** [ICU Syntax] Explanatory message stating that there was a failure in an audit caused by a URL being invalid. "url" will be replaced by the invalid URL (e.g. https://example.com). */
+  explanationInvalid: 'Invalid URL ({url})',
+  /** [ICU Syntax] Explanatory message stating that there was a failure in an audit caused by a URL being relative instead of absolute. "url" will be replaced by the invalid URL (e.g. https://example.com). */
+  explanationRelative: 'Relative URL ({url})',
+  /** [ICU Syntax] Explanatory message stating that there was a failure in an audit caused by a URL pointing to a different hreflang than the current context. "url" will be replaced by the invalid URL (e.g. https://example.com). 'hreflang' is an HTML attribute and should not be translated. */
+  explanationPointsElsewhere: 'Points to another `hreflang` location ({url})',
+  /** [ICU Syntax] Explanatory message stating that there was a failure in an audit caused by a URL pointing to a different domain. "url" will be replaced by the invalid URL (e.g. https://example.com). */
+  explanationDifferentDomain: 'Points to a different domain ({url})',
+  /** Explanatory message stating that the page's canonical URL was pointing to the domain's root URL, which is a common mistake. "points" refers to the action of the 'rel=canonical' referencing another link. "root" refers to the starting/home page of the website. "domain" refers to the registered domain name of the website. */
+  explanationRoot: 'Points to the domain\'s root URL (the homepage), ' +
+    'instead of an equivalent page of content',
+};
+
+const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
 
 /**
  * @param {string} headerValue
@@ -62,10 +88,9 @@ class Canonical extends Audit {
   static get meta() {
     return {
       id: 'canonical',
-      title: 'Document has a valid `rel=canonical`',
-      failureTitle: 'Document does not have a valid `rel=canonical`',
-      description: 'Canonical links suggest which URL to show in search results. ' +
-        '[Learn more](https://developers.google.com/web/tools/lighthouse/audits/canonical).',
+      title: str_(UIStrings.title),
+      failureTitle: str_(UIStrings.failureTitle),
+      description: str_(UIStrings.description),
       requiredArtifacts: ['Canonical', 'Hreflang', 'URL'],
     };
   }
@@ -118,7 +143,7 @@ class Canonical extends Audit {
         if (canonicals.length > 1) {
           return {
             rawValue: false,
-            explanation: `Multiple conflicting URLs (${canonicals.join(', ')})`,
+            explanation: str_(UIStrings.explanationConflict, {urlList: canonicals.join(', ')}),
           };
         }
 
@@ -127,14 +152,14 @@ class Canonical extends Audit {
         if (!isValidRelativeOrAbsoluteURL(canonical)) {
           return {
             rawValue: false,
-            explanation: `Invalid URL (${canonical})`,
+            explanation: str_(UIStrings.explanationInvalid, {url: canonical}),
           };
         }
 
         if (!URL.isValid(canonical)) {
           return {
             rawValue: false,
-            explanation: `Relative URL (${canonical})`,
+            explanation: str_(UIStrings.explanationRelative, {url: canonical}),
           };
         }
 
@@ -145,7 +170,7 @@ class Canonical extends Audit {
           baseURL.href !== canonicalURL.href) {
           return {
             rawValue: false,
-            explanation: `Points to another hreflang location (${baseURL.href})`,
+            explanation: str_(UIStrings.explanationPointsElsewhere, {url: baseURL.href}),
           };
         }
 
@@ -154,7 +179,7 @@ class Canonical extends Audit {
         if (getPrimaryDomain(canonicalURL) !== getPrimaryDomain(baseURL)) {
           return {
             rawValue: false,
-            explanation: `Points to a different domain (${canonicalURL})`,
+            explanation: str_(UIStrings.explanationDifferentDomain, {url: canonicalURL}),
           };
         }
 
@@ -163,7 +188,7 @@ class Canonical extends Audit {
           canonicalURL.pathname === '/' && baseURL.pathname !== '/') {
           return {
             rawValue: false,
-            explanation: 'Points to a root of the same origin',
+            explanation: str_(UIStrings.explanationRoot),
           };
         }
 
@@ -175,3 +200,4 @@ class Canonical extends Audit {
 }
 
 module.exports = Canonical;
+module.exports.UIStrings = UIStrings;
diff --git a/lighthouse-core/audits/seo/font-size.js b/lighthouse-core/audits/seo/font-size.js
index bd569d01e..3473a502f 100644
--- a/lighthouse-core/audits/seo/font-size.js
+++ b/lighthouse-core/audits/seo/font-size.js
@@ -14,14 +14,22 @@ 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 of a Lighthouse audit that provides detail on the font sizes used on the page. This descriptive title is shown to users when the fonts used on the page are large enough to be considered legible. */
   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. */
+  /** Title of a Lighthouse audit that provides detail on the font sizes used on the page. This descriptive title is shown to users when there is a font that may be too small to be read by users. */
   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',
+  /** Explanatory message stating that there was a failure in an audit caused by a missing page viewport meta tag configuration. "viewport" and "meta" are HTML terms and should not be translated. */
+  explanationViewport: 'Text is illegible because there\'s no viewport meta tag optimized ' +
+    'for mobile screens.',
+  /** Explanatory message stating that there was a failure in an audit caused by a certain percentage of the text on the page being too small. "decimalProportion" will be replaced by a percentage between 0 and 100%. */
+  explanation: '{decimalProportion, number, extendedPercent} of text is too small.',
+  /** Explanatory message stating that there was a failure in an audit caused by a certain percentage of the text on the page being too small, based on a sample size of text that was less than 100% of the text on the page. "decimalProportion" will be replaced by a percentage between 0 and 100%. */
+  explanationWithDisclaimer: '{decimalProportion, number, extendedPercent} of text is too ' +
+    'small (based on {decimalProportionVisited, number, extendedPercent} sample).',
 };
 
 const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
@@ -212,8 +220,7 @@ class FontSize extends Audit {
     if (!hasViewportSet) {
       return {
         rawValue: false,
-        explanation:
-          'Text is illegible because there\'s no viewport meta tag optimized for mobile screens',
+        explanation: str_(UIStrings.explanationViewport),
       };
     }
 
@@ -287,16 +294,20 @@ class FontSize extends Audit {
 
     let explanation;
     if (!passed) {
-      const percentageOfFailingText = parseFloat((100 - percentageOfPassingText).toFixed(2));
-      let disclaimer = '';
+      const percentageOfFailingText = (100 - percentageOfPassingText) / 100;
 
       // if we were unable to visit all text nodes we should disclose that information
       if (visitedTextLength < totalTextLength) {
-        const percentageOfVisitedText = visitedTextLength / totalTextLength * 100;
-        disclaimer = ` (based on ${percentageOfVisitedText.toFixed()}% sample)`;
+        const percentageOfVisitedText = (visitedTextLength / totalTextLength);
+        explanation = str_(UIStrings.explanationWithDisclaimer,
+          {
+            decimalProportion: percentageOfFailingText,
+            decimalProportionVisited: percentageOfVisitedText,
+          });
+      } else {
+        explanation = str_(UIStrings.explanation,
+          {decimalProportion: percentageOfFailingText});
       }
-
-      explanation = `${percentageOfFailingText}% of text is too small${disclaimer}.`;
     }
 
     return {
diff --git a/lighthouse-core/audits/seo/hreflang.js b/lighthouse-core/audits/seo/hreflang.js
index b7749725f..851fa1452 100644
--- a/lighthouse-core/audits/seo/hreflang.js
+++ b/lighthouse-core/audits/seo/hreflang.js
@@ -11,6 +11,20 @@ const MainResource = require('../../computed/main-resource.js');
 const VALID_LANGS = importValidLangs();
 const LINK_HEADER = 'link';
 const NO_LANGUAGE = 'x-default';
+const i18n = require('../../lib/i18n/i18n.js');
+
+const UIStrings = {
+  /** Title of a Lighthouse audit that provides detail on the `hreflang` attribute on a page. This descriptive title is shown when the page's `hreflang` attribute is configured correctly. "hreflang" is an HTML attribute and should not be translated. */
+  title: 'Document has a valid `hreflang`',
+  /** Title of a Lighthouse audit that provides detail on the `hreflang` attribute on a page. This descriptive title is shown when the page's `hreflang` attribute is not valid and needs to be fixed. "hreflang" is an HTML attribute and should not be translated. */
+  failureTitle: 'Document doesn\'t have a valid `hreflang`',
+  /** Description of a Lighthouse audit that tells the user *why* they need to have an hreflang link on their page. This is displayed after a user expands the section to see more. No character length limits. 'Learn More' becomes link text to additional documentation. "hreflang" is an HTML attribute and should not be translated. */
+  description: 'hreflang links tell search engines what version of a page they should ' +
+    'list in search results for a given language or region. [Learn more]' +
+    '(https://developers.google.com/web/tools/lighthouse/audits/hreflang).',
+};
+
+const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
 
 /**
  * Import list of valid languages from axe core without including whole axe-core package
@@ -65,11 +79,9 @@ class Hreflang extends Audit {
   static get meta() {
     return {
       id: 'hreflang',
-      title: 'Document has a valid `hreflang`',
-      failureTitle: 'Document doesn\'t have a valid `hreflang`',
-      description: 'hreflang links tell search engines what version of a page they should ' +
-        'list in search results for a given language or region. [Learn more]' +
-        '(https://developers.google.com/web/tools/lighthouse/audits/hreflang).',
+      title: str_(UIStrings.title),
+      failureTitle: str_(UIStrings.failureTitle),
+      description: str_(UIStrings.description),
       requiredArtifacts: ['Hreflang', 'URL'],
     };
   }
@@ -120,3 +132,4 @@ class Hreflang extends Audit {
 }
 
 module.exports = Hreflang;
+module.exports.UIStrings = UIStrings;
diff --git a/lighthouse-core/audits/seo/http-status-code.js b/lighthouse-core/audits/seo/http-status-code.js
index cf80dc525..0cbb4b4e3 100644
--- a/lighthouse-core/audits/seo/http-status-code.js
+++ b/lighthouse-core/audits/seo/http-status-code.js
@@ -9,6 +9,20 @@ const Audit = require('../audit');
 const MainResource = require('../../computed/main-resource.js');
 const HTTP_UNSUCCESSFUL_CODE_LOW = 400;
 const HTTP_UNSUCCESSFUL_CODE_HIGH = 599;
+const i18n = require('../../lib/i18n/i18n.js');
+
+const UIStrings = {
+  /** Title of a Lighthouse audit that provides detail on the HTTP status code a page responds with. This descriptive title is shown when the page has responded with a valid HTTP status code. */
+  title: 'Page has successful HTTP status code',
+  /** Descriptive title of a Lighthouse audit that provides detail on the HTTP status code a page responds with. This descriptive title is shown when the page responds to requests with an HTTP status code that indicates the request was unsuccessful. */
+  failureTitle: 'Page has unsuccessful HTTP status code',
+  /** Description of a Lighthouse audit that tells the user *why* they need to serve pages with a valid HTTP status code. 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: 'Pages with unsuccessful HTTP status codes may not be indexed properly. ' +
+  '[Learn more]' +
+  '(https://developers.google.com/web/tools/lighthouse/audits/successful-http-code).',
+};
+
+const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
 
 class HTTPStatusCode extends Audit {
   /**
@@ -17,11 +31,9 @@ class HTTPStatusCode extends Audit {
   static get meta() {
     return {
       id: 'http-status-code',
-      title: 'Page has successful HTTP status code',
-      failureTitle: 'Page has unsuccessful HTTP status code',
-      description: 'Pages with unsuccessful HTTP status codes may not be indexed properly. ' +
-      '[Learn more]' +
-      '(https://developers.google.com/web/tools/lighthouse/audits/successful-http-code).',
+      title: str_(UIStrings.title),
+      failureTitle: str_(UIStrings.failureTitle),
+      description: str_(UIStrings.description),
       requiredArtifacts: ['devtoolsLogs', 'URL'],
     };
   }
@@ -55,3 +67,4 @@ class HTTPStatusCode extends Audit {
 }
 
 module.exports = HTTPStatusCode;
+module.exports.UIStrings = UIStrings;
diff --git a/lighthouse-core/audits/seo/is-crawlable.js b/lighthouse-core/audits/seo/is-crawlable.js
index a61b6ff75..4f5ea1f08 100644
--- a/lighthouse-core/audits/seo/is-crawlable.js
+++ b/lighthouse-core/audits/seo/is-crawlable.js
@@ -15,6 +15,20 @@ const BLOCKLIST = new Set([
 ]);
 const ROBOTS_HEADER = 'x-robots-tag';
 const UNAVAILABLE_AFTER = 'unavailable_after';
+const i18n = require('../../lib/i18n/i18n.js');
+
+const UIStrings = {
+  /** Title of a Lighthouse audit that provides detail on if search-engine crawlers are blocked from indexing the page. This title is shown when the page is not blocked from indexing and can be crawled. */
+  title: 'Page isn’t blocked from indexing',
+  /** Title of a Lighthouse audit that provides detail on if search-engine crawlers are blocked from indexing the page. This title is shown when the page has been configured to block indexing and therefore cannot be indexed by search engines. */
+  failureTitle: 'Page is blocked from indexing',
+  /** Description of a Lighthouse audit that tells the user *why* allowing search-engine crawling of their page is beneficial. 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: '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).',
+};
+
+const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
 
 /**
  * Checks if given directive is a valid unavailable_after directive with a date in the past
@@ -64,11 +78,9 @@ class IsCrawlable extends Audit {
   static get meta() {
     return {
       id: 'is-crawlable',
-      title: 'Page isn’t blocked from indexing',
-      failureTitle: 'Page is blocked from indexing',
-      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).',
+      title: str_(UIStrings.title),
+      failureTitle: str_(UIStrings.failureTitle),
+      description: str_(UIStrings.description),
       requiredArtifacts: ['MetaElements', 'RobotsTxt', 'URL'],
     };
   }
@@ -135,3 +147,4 @@ class IsCrawlable extends Audit {
 }
 
 module.exports = IsCrawlable;
+module.exports.UIStrings = UIStrings;
diff --git a/lighthouse-core/audits/seo/link-text.js b/lighthouse-core/audits/seo/link-text.js
index 6c2cc93b2..3abd8a270 100644
--- a/lighthouse-core/audits/seo/link-text.js
+++ b/lighthouse-core/audits/seo/link-text.js
@@ -18,6 +18,24 @@ const BLOCKLIST = new Set([
   'more',
   'learn more',
 ]);
+const i18n = require('../../lib/i18n/i18n.js');
+
+const UIStrings = {
+  /** Title of a Lighthouse audit that tests if each link on a page contains a sufficient description of what a user will find when they click it. Generic, non-descriptive text like "click here" doesn't give an indication of what the link leads to. This descriptive title is shown when all links on the page have sufficient textual descriptions. */
+  title: 'Links have descriptive text',
+  /** Title of a Lighthouse audit that tests if each link on a page contains a sufficient description of what a user will find when they click it. Generic, non-descriptive text like "click here" doesn't give an indication of what the link leads to. This descriptive title is shown when one or more links on the page contain generic, non-descriptive text. */
+  failureTitle: 'Links do not have descriptive text',
+  /** Description of a Lighthouse audit that tells the user *why* they need to have descriptive text on the links in their page. 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: 'Descriptive link text helps search engines understand your content. ' +
+  '[Learn more](https://developers.google.com/web/tools/lighthouse/audits/descriptive-link-text).',
+  /** [ICU Syntax] Label for the audit identifying the number of links found. "link" here refers to the links in a web page to other web pages. */
+  displayValue: `{itemCount, plural,
+    =1 {1 link found}
+    other {# links found}
+    }`,
+};
+
+const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
 
 class LinkText extends Audit {
   /**
@@ -26,10 +44,9 @@ class LinkText extends Audit {
   static get meta() {
     return {
       id: 'link-text',
-      title: 'Links have descriptive text',
-      failureTitle: 'Links do not have descriptive text',
-      description: 'Descriptive link text helps search engines understand your content. ' +
-      '[Learn more](https://developers.google.com/web/tools/lighthouse/audits/descriptive-link-text).',
+      title: str_(UIStrings.title),
+      failureTitle: str_(UIStrings.failureTitle),
+      description: str_(UIStrings.description),
       requiredArtifacts: ['URL', 'AnchorElements'],
     };
   }
@@ -70,8 +87,7 @@ class LinkText extends Audit {
     let displayValue;
 
     if (failingLinks.length) {
-      displayValue = failingLinks.length > 1 ?
-        `${failingLinks.length} links found` : '1 link found';
+      displayValue = str_(UIStrings.displayValue, {itemCount: failingLinks.length});
     }
 
     return {
@@ -83,3 +99,4 @@ class LinkText extends Audit {
 }
 
 module.exports = LinkText;
+module.exports.UIStrings = UIStrings;
diff --git a/lighthouse-core/audits/seo/manual/mobile-friendly.js b/lighthouse-core/audits/seo/manual/mobile-friendly.js
deleted file mode 100644
index 402416a47..000000000
--- a/lighthouse-core/audits/seo/manual/mobile-friendly.js
+++ /dev/null
@@ -1,27 +0,0 @@
-/**
- * @license Copyright 2017 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 ManualAudit = require('../../manual/manual-audit');
-
-/**
- * @fileoverview Manual SEO audit to check if page is mobile friendly.
- */
-
-class MobileFriendly extends ManualAudit {
-  /**
-   * @return {LH.Audit.Meta}
-   */
-  static get meta() {
-    return Object.assign({
-      id: 'mobile-friendly',
-      description: 'Take the [Mobile-Friendly Test](https://search.google.com/test/mobile-friendly) to check for audits not covered by Lighthouse, like sizing tap targets appropriately. [Learn more](https://developers.google.com/search/mobile-sites/).',
-      title: 'Page is mobile friendly',
-    }, super.partialMeta);
-  }
-}
-
-module.exports = MobileFriendly;
diff --git a/lighthouse-core/audits/seo/manual/structured-data.js b/lighthouse-core/audits/seo/manual/structured-data.js
index e8386fc57..042f1b5cb 100644
--- a/lighthouse-core/audits/seo/manual/structured-data.js
+++ b/lighthouse-core/audits/seo/manual/structured-data.js
@@ -6,6 +6,16 @@
 'use strict';
 
 const ManualAudit = require('../../manual/manual-audit');
+const i18n = require('../../../lib/i18n/i18n.js');
+
+const UIStrings = {
+  /** Description of a Lighthouse audit that provides detail on the structured data in a page. "Structured data" is a standardized data format on a page that helps a search engine categorize and understand its contents. This description is displayed after a user expands the section to see more. No character length limits. 'Learn More' becomes link text to additional documentation. */
+  description: 'Run the [Structured Data Testing Tool](https://search.google.com/structured-data/testing-tool/) and the [Structured Data Linter](http://linter.structured-data.org/) to validate structured data. [Learn more](https://developers.google.com/search/docs/guides/mark-up-content).',
+  /** Title of a Lighthouse audit that prompts users to manually check their page for valid structured data. "Structured data" is a standardized data format on a page that helps a search engine categorize and understand its contents. */
+  title: 'Structured data is valid',
+};
+
+const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
 
 /**
  * @fileoverview Manual SEO audit to check if structured data on page is valid.
@@ -18,10 +28,11 @@ class StructuredData extends ManualAudit {
   static get meta() {
     return Object.assign({
       id: 'structured-data',
-      description: 'Run the [Structured Data Testing Tool](https://search.google.com/structured-data/testing-tool/) and the [Structured Data Linter](http://linter.structured-data.org/) to validate structured data. [Learn more](https://developers.google.com/search/docs/guides/mark-up-content).',
-      title: 'Structured data is valid',
+      description: str_(UIStrings.description),
+      title: str_(UIStrings.title),
     }, super.partialMeta);
   }
 }
 
 module.exports = StructuredData;
+module.exports.UIStrings = UIStrings;
diff --git a/lighthouse-core/audits/seo/meta-description.js b/lighthouse-core/audits/seo/meta-description.js
index cc9c93c55..5aa72b057 100644
--- a/lighthouse-core/audits/seo/meta-description.js
+++ b/lighthouse-core/audits/seo/meta-description.js
@@ -6,6 +6,22 @@
 'use strict';
 
 const Audit = require('../audit');
+const i18n = require('../../lib/i18n/i18n.js');
+
+const UIStrings = {
+  /** Title of a Lighthouse audit that provides detail on the web page's document meta description. This descriptive title is shown when the document has a meta description. "meta" should be left untranslated because it refers to an HTML element. */
+  title: 'Document has a meta description',
+  /** Title of a Lighthouse audit that provides detail on the web page's document meta description. This descriptive title is shown when the document does not have a meta description. "meta" should be left untranslated because it refers to an HTML element. */
+  failureTitle: 'Document does not have a meta description',
+  /** Description of a Lighthouse audit that tells the user *why* they need to have meta descriptions on their page. 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: 'Meta descriptions may be included in search results to concisely summarize ' +
+      'page content. ' +
+      '[Learn more](https://developers.google.com/web/tools/lighthouse/audits/description).',
+  /** Explanatory message stating that there was a failure in an audit caused by the page's meta description text being empty. */
+  explanation: 'Description text is empty.',
+};
+
+const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
 
 class Description extends Audit {
   /**
@@ -14,11 +30,9 @@ class Description extends Audit {
   static get meta() {
     return {
       id: 'meta-description',
-      title: 'Document has a meta description',
-      failureTitle: 'Document does not have a meta description',
-      description: 'Meta descriptions may be included in search results to concisely summarize ' +
-          'page content. ' +
-          '[Learn more](https://developers.google.com/web/tools/lighthouse/audits/description).',
+      title: str_(UIStrings.title),
+      failureTitle: str_(UIStrings.failureTitle),
+      description: str_(UIStrings.description),
       requiredArtifacts: ['MetaElements'],
     };
   }
@@ -39,7 +53,7 @@ class Description extends Audit {
     if (description.trim().length === 0) {
       return {
         rawValue: false,
-        explanation: 'Description text is empty.',
+        explanation: str_(UIStrings.explanation),
       };
     }
 
@@ -50,3 +64,4 @@ class Description extends Audit {
 }
 
 module.exports = Description;
+module.exports.UIStrings = UIStrings;
diff --git a/lighthouse-core/audits/seo/plugins.js b/lighthouse-core/audits/seo/plugins.js
index 18bc35a1f..2052941f8 100644
--- a/lighthouse-core/audits/seo/plugins.js
+++ b/lighthouse-core/audits/seo/plugins.js
@@ -31,6 +31,20 @@ const SOURCE_PARAMS = new Set([
   'source',
   'src',
 ]);
+const i18n = require('../../lib/i18n/i18n.js');
+
+const UIStrings = {
+  /** Title of a Lighthouse audit that provides detail on the browser plugins used by the page. This descriptive title is shown when there is no plugin content on the page that would restrict search indexing. */
+  title: 'Document avoids plugins',
+  /** Descriptive title of a Lighthouse audit that provides detail on the browser plugins used by the page. This title is shown when there is plugin content on the page. */
+  failureTitle: 'Document uses plugins',
+  /** Description of a Lighthouse audit that tells the user *why* they need to avoid using browser plugins in their content. 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: 'Search engines can\'t index plugin content, and ' +
+    'many devices restrict plugins or don\'t support them. ' +
+    '[Learn more](https://developers.google.com/web/tools/lighthouse/audits/plugins).',
+};
+
+const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
 
 /**
  * Verifies if given MIME type matches any known plugin MIME type
@@ -73,11 +87,9 @@ class Plugins extends Audit {
   static get meta() {
     return {
       id: 'plugins',
-      title: 'Document avoids plugins',
-      failureTitle: 'Document uses plugins',
-      description: 'Search engines can\'t index plugin content, and ' +
-        'many devices restrict plugins or don\'t support them. ' +
-        '[Learn more](https://developers.google.com/web/tools/lighthouse/audits/plugins).',
+      title: str_(UIStrings.title),
+      failureTitle: str_(UIStrings.failureTitle),
+      description: str_(UIStrings.description),
       requiredArtifacts: ['EmbeddedContent'],
     };
   }
@@ -155,3 +167,4 @@ class Plugins extends Audit {
 }
 
 module.exports = Plugins;
+module.exports.UIStrings = UIStrings;
diff --git a/lighthouse-core/audits/seo/robots-txt.js b/lighthouse-core/audits/seo/robots-txt.js
index cd13d7431..36ded8c1e 100644
--- a/lighthouse-core/audits/seo/robots-txt.js
+++ b/lighthouse-core/audits/seo/robots-txt.js
@@ -31,6 +31,28 @@ const DIRECTIVE_SAFELIST = new Set([
   'request-rate', 'visit-time', 'noindex', // not officially supported, but used in the wild
 ]);
 const SITEMAP_VALID_PROTOCOLS = new Set(['https:', 'http:', 'ftp:']);
+const i18n = require('../../lib/i18n/i18n.js');
+
+const UIStrings = {
+  /** Title of a Lighthouse audit that provides detail on the site's robots.txt file. Note: "robots.txt" is a canonical filename and should not be translated. This descriptive title is shown when the robots.txt file is present and configured correctly. */
+  title: 'robots.txt is valid',
+  /** Title of a Lighthouse audit that provides detail on the site's robots.txt file. Note: "robots.txt" is a canonical filename and should not be translated. This descriptive title is shown when the robots.txt file is misconfigured, which makes the page hard or impossible to scan via web crawler. */
+  failureTitle: 'robots.txt is not valid',
+  /** Description of a Lighthouse audit that tells the user *why* they need to have a valid robots.txt file. Note: "robots.txt" is a canonical filename and should not be translated. This is displayed after a user expands the section to see more. No character length limits. */
+  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.',
+  /** [ICU Syntax] Label for the audit identifying that the robots.txt request has returned a specific HTTP status code. Note: "robots.txt" is a canonical filename and should not be translated. "statusCode" will be replaced with a 3 digit integer which represents the status of the HTTP connectiong for this page. */
+  displayValueHttpBadCode: 'request for robots.txt returned HTTP status: {statusCode, number}',
+  /** [ICU Syntax] Label for the audit identifying the number of errors that occured while validating the robots.txt file. "itemCount" will be replaced by the integer count of errors encountered. */
+  displayValueValidationError: `{itemCount, plural,
+    =1 {1 error found}
+    other {# errors found}
+    }`,
+  /** Explanatory message stating that there was a failure in an audit caused by Lighthouse not being able to download the robots.txt file for the site.  Note: "robots.txt" is a canonical filename and should not be translated. */
+  explanation: 'Lighthouse was unable to download a robots.txt file',
+};
+
+const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
 
 /**
  * @param {string} directiveName
@@ -161,10 +183,9 @@ class RobotsTxt extends Audit {
   static get meta() {
     return {
       id: 'robots-txt',
-      title: 'robots.txt is valid',
-      failureTitle: 'robots.txt is not valid',
-      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.',
+      title: str_(UIStrings.title),
+      failureTitle: str_(UIStrings.failureTitle),
+      description: str_(UIStrings.description),
       requiredArtifacts: ['RobotsTxt'],
     };
   }
@@ -182,14 +203,14 @@ class RobotsTxt extends Audit {
     if (!status) {
       return {
         rawValue: false,
-        explanation: 'Lighthouse was unable to download your robots.txt file',
+        explanation: str_(UIStrings.explanation),
       };
     }
 
     if (status >= HTTP_SERVER_ERROR_CODE_LOW) {
       return {
         rawValue: false,
-        displayValue: `request for robots.txt returned HTTP${status}`,
+        displayValue: str_(UIStrings.displayValueHttpBadCode, {statusCode: status}),
       };
     } else if (status >= HTTP_CLIENT_ERROR_CODE_LOW || content === '') {
       return {
@@ -216,8 +237,8 @@ class RobotsTxt extends Audit {
     let displayValue;
 
     if (validationErrors.length) {
-      displayValue = validationErrors.length > 1 ?
-        `${validationErrors.length} errors found` : '1 error found';
+      displayValue =
+        str_(UIStrings.displayValueValidationError, {itemCount: validationErrors.length});
     }
 
     return {
@@ -229,3 +250,4 @@ class RobotsTxt extends Audit {
 }
 
 module.exports = RobotsTxt;
+module.exports.UIStrings = UIStrings;
diff --git a/lighthouse-core/audits/seo/tap-targets.js b/lighthouse-core/audits/seo/tap-targets.js
index 63bdb29c6..08b0c387d 100644
--- a/lighthouse-core/audits/seo/tap-targets.js
+++ b/lighthouse-core/audits/seo/tap-targets.js
@@ -25,7 +25,7 @@ const i18n = require('../../lib/i18n/i18n.js');
 const UIStrings = {
   /** Title of a Lighthouse audit that provides detail on whether tap targets (like buttons and links) on a page are big enough so they can easily be tapped on a mobile device. This descriptive title is shown when tap targets are easy to tap on. */
   title: 'Tap targets are sized appropriately',
-  /** Title of a Lighthouse audit that provides detail on whether tap targets (like buttons and links) on a page are big enough so they can easily be tapped on a mobile device. This descriptive title is shown when tap targets are not easy to tap on. */
+  /** Descriptive title of a Lighthouse audit that provides detail on whether tap targets (like buttons and links) on a page are big enough so they can easily be tapped on a mobile device. This descriptive title is shown when tap targets are not easy to tap on. */
   failureTitle: 'Tap targets are not sized appropriately',
   /** Description of a Lighthouse audit that tells the user why buttons and links need to be big enough and what 'big enough' means. 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: 'Interactive elements like buttons and links should be large enough (48x48px), and have enough space around them, to be easy enough to tap without overlapping onto other elements. [Learn more](https://developers.google.com/web/fundamentals/accessibility/accessible-styles#multi-device_responsive_design).',
diff --git a/lighthouse-core/config/default-config.js b/lighthouse-core/config/default-config.js
index 5a8370e57..ed83759fb 100644
--- a/lighthouse-core/config/default-config.js
+++ b/lighthouse-core/config/default-config.js
@@ -69,6 +69,14 @@ const UIStrings = {
   a11yMetaGroupTitle: 'Meta Tags Used Properly',
   /* Description of the meta tag section within the Accessibility category. Within this section are audits with descriptive titles that highlight if meta tags on the page have been used properly and if any important ones are missing. */
   a11yMetaGroupDescription: 'These are opportunities to improve the user experience of your site.',
+  /** Title of the Search Engine Optimization (SEO) category of audits. This is displayed at the top of a list of audits focused on topics related to optimizing a website for indexing by search engines. Also used as a label of a score gauge; try to limit to 20 characters. */
+  seoCategoryTitle: 'SEO',
+  /** Description of the Search Engine Optimization (SEO) category. This is displayed at the top of a list of audits focused on optimizing a website for indexing by search engines. No character length limits. 'Learn More' becomes link text to additional documentation. */
+  seoCategoryDescription: 'These checks ensure that your page is optimized for search engine results ranking. ' +
+  'There are additional factors Lighthouse does not check that may affect your search ranking. ' +
+  '[Learn more](https://support.google.com/webmasters/answer/35769).',
+  /** Description of the Search Engine Optimization (SEO) manual checks category, the additional validators must be run by hand in order to check all SEO best practices. This is displayed at the top of a list of manually run audits focused on optimizing a website for indexing by search engines. No character length limits. */
+  seoCategoryManualDescription: 'Run these additional validators on your site to check additional SEO best practices.',
   /** Title of the Fast and Reliable section of the web app category. Within this section are audits that check if the web site loaded quickly and can reliably load even if the internet connection is very slow or goes offline. */
   pwaFastReliableGroupTitle: 'Fast and reliable',
   /** Title of the Installable section of the web app category. Within this section are audits that check if Chrome supports installing the web site as an app on their device. */
@@ -256,7 +264,6 @@ const defaultConfig = {
     'seo/hreflang',
     'seo/plugins',
     'seo/canonical',
-    'seo/manual/mobile-friendly',
     'seo/manual/structured-data',
   ],
 
@@ -447,11 +454,9 @@ const defaultConfig = {
       ],
     },
     'seo': {
-      title: 'SEO',
-      description: 'These checks ensure that your page is optimized for search engine results ranking. ' +
-          'There are additional factors Lighthouse does not check that may affect your search ranking. ' +
-          '[Learn more](https://support.google.com/webmasters/answer/35769).',
-      manualDescription: 'Run these additional validators on your site to check additional SEO best practices.',
+      title: str_(UIStrings.seoCategoryTitle),
+      description: str_(UIStrings.seoCategoryDescription),
+      manualDescription: str_(UIStrings.seoCategoryManualDescription),
       auditRefs: [
         {id: 'viewport', weight: 1, group: 'seo-mobile'},
         {id: 'document-title', weight: 1, group: 'seo-content'},
@@ -466,7 +471,6 @@ const defaultConfig = {
         {id: 'plugins', weight: 1, group: 'seo-content'},
         {id: 'tap-targets', weight: 1, group: 'seo-mobile'},
         // Manual audits
-        {id: 'mobile-friendly', weight: 0},
         {id: 'structured-data', weight: 0},
       ],
     },
diff --git a/lighthouse-core/lib/i18n/en-US.json b/lighthouse-core/lib/i18n/en-US.json
index 2beef88eb..ce893677a 100644
--- a/lighthouse-core/lib/i18n/en-US.json
+++ b/lighthouse-core/lib/i18n/en-US.json
@@ -504,11 +504,11 @@
     "description": "Imperative title of a Lighthouse audit that tells the user to minify the page’s JS code to reduce file size. This is displayed in a list of audit titles that Lighthouse generates."
   },
   "lighthouse-core/audits/byte-efficiency/unused-css-rules.js | description": {
-    "message": "Remove unused rules from stylesheets to reduce unnecessary bytes consumed by network activity. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/unused-css).",
+    "message": "Remove dead rules from stylesheets and defer the loading of CSS not used for above-the-fold content to reduce unnecessary bytes consumed by network activity. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/unused-css).",
     "description": "Description of a Lighthouse audit that tells the user *why* they should defer loading any content in CSS that isn’t needed at page load. This is displayed after a user expands the section to see more. No word length limits. 'Learn More' becomes link text to additional documentation."
   },
   "lighthouse-core/audits/byte-efficiency/unused-css-rules.js | title": {
-    "message": "Defer unused CSS",
+    "message": "Remove unused CSS",
     "description": "Imperative title of a Lighthouse audit that tells the user to remove content from their CSS that isn’t needed immediately and instead load that content at a later time. This is displayed in a list of audit titles that Lighthouse generates."
   },
   "lighthouse-core/audits/byte-efficiency/unused-javascript.js | description": {
@@ -747,6 +747,42 @@
     "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/canonical.js | description": {
+    "message": "Canonical links suggest which URL to show in search results. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/canonical).",
+    "description": "Description of a Lighthouse audit that tells the user *why* they need to have a valid rel=canonical link. 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/canonical.js | explanationConflict": {
+    "message": "Multiple conflicting URLs ({urlList})",
+    "description": "[ICU Syntax] Explanatory message stating that there was a failure in an audit caused by multiple URLs conflicting with each other. \"urlList\" will be replaced by a list of URLs (e.g. https://example.com, https://example2.com, etc )."
+  },
+  "lighthouse-core/audits/seo/canonical.js | explanationDifferentDomain": {
+    "message": "Points to a different domain ({url})",
+    "description": "[ICU Syntax] Explanatory message stating that there was a failure in an audit caused by a URL pointing to a different domain. \"url\" will be replaced by the invalid URL (e.g. https://example.com)."
+  },
+  "lighthouse-core/audits/seo/canonical.js | explanationInvalid": {
+    "message": "Invalid URL ({url})",
+    "description": "[ICU Syntax] Explanatory message stating that there was a failure in an audit caused by a URL being invalid. \"url\" will be replaced by the invalid URL (e.g. https://example.com)."
+  },
+  "lighthouse-core/audits/seo/canonical.js | explanationPointsElsewhere": {
+    "message": "Points to another `hreflang` location ({url})",
+    "description": "[ICU Syntax] Explanatory message stating that there was a failure in an audit caused by a URL pointing to a different hreflang than the current context. \"url\" will be replaced by the invalid URL (e.g. https://example.com). 'hreflang' is an HTML attribute and should not be translated."
+  },
+  "lighthouse-core/audits/seo/canonical.js | explanationRelative": {
+    "message": "Relative URL ({url})",
+    "description": "[ICU Syntax] Explanatory message stating that there was a failure in an audit caused by a URL being relative instead of absolute. \"url\" will be replaced by the invalid URL (e.g. https://example.com)."
+  },
+  "lighthouse-core/audits/seo/canonical.js | explanationRoot": {
+    "message": "Points to the domain's root URL (the homepage), instead of an equivalent page of content",
+    "description": "Explanatory message stating that the page's canonical URL was pointing to the domain's root URL, which is a common mistake. \"points\" refers to the action of the 'rel=canonical' referencing another link. \"root\" refers to the starting/home page of the website. \"domain\" refers to the registered domain name of the website."
+  },
+  "lighthouse-core/audits/seo/canonical.js | failureTitle": {
+    "message": "Document does not have a valid `rel=canonical`",
+    "description": "Title of a Lighthouse audit that provides detail on a page's rel=canonical link. This descriptive title is shown to users when the rel=canonical link is invalid and should be fixed. \"rel=canonical\" is an HTML attribute and value and so should not be translated."
+  },
+  "lighthouse-core/audits/seo/canonical.js | title": {
+    "message": "Document has a valid `rel=canonical`",
+    "description": "Title of a Lighthouse audit that provides detail on a page's rel=canonical link. This descriptive title is shown to users when the rel=canonical link is valid. \"rel=canonical\" is an HTML attribute and value and so should not be translated."
+  },
   "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."
@@ -755,13 +791,137 @@
     "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 | explanation": {
+    "message": "{decimalProportion, number, extendedPercent} of text is too small.",
+    "description": "Explanatory message stating that there was a failure in an audit caused by a certain percentage of the text on the page being too small. \"decimalProportion\" will be replaced by a percentage between 0 and 100%."
+  },
+  "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
+    "message": "Text is illegible because there's no viewport meta tag optimized for mobile screens.",
+    "description": "Explanatory message stating that there was a failure in an audit caused by a missing page viewport meta tag configuration. \"viewport\" and \"meta\" are HTML terms and should not be translated."
+  },
+  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
+    "message": "{decimalProportion, number, extendedPercent} of text is too small (based on {decimalProportionVisited, number, extendedPercent} sample).",
+    "description": "Explanatory message stating that there was a failure in an audit caused by a certain percentage of the text on the page being too small, based on a sample size of text that was less than 100% of the text on the page. \"decimalProportion\" will be replaced by a percentage between 0 and 100%."
+  },
   "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."
+    "description": "Title of a Lighthouse audit that provides detail on the font sizes used on the page. This descriptive title is shown to users when there is a font that may be too small to be read by users."
   },
   "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."
+    "description": "Title of a Lighthouse audit that provides detail on the font sizes used on the page. This descriptive title is shown to users when the fonts used on the page are large enough to be considered legible."
+  },
+  "lighthouse-core/audits/seo/hreflang.js | description": {
+    "message": "hreflang links tell search engines what version of a page they should list in search results for a given language or region. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/hreflang).",
+    "description": "Description of a Lighthouse audit that tells the user *why* they need to have an hreflang link on their page. This is displayed after a user expands the section to see more. No character length limits. 'Learn More' becomes link text to additional documentation. \"hreflang\" is an HTML attribute and should not be translated."
+  },
+  "lighthouse-core/audits/seo/hreflang.js | failureTitle": {
+    "message": "Document doesn't have a valid `hreflang`",
+    "description": "Title of a Lighthouse audit that provides detail on the `hreflang` attribute on a page. This descriptive title is shown when the page's `hreflang` attribute is not valid and needs to be fixed. \"hreflang\" is an HTML attribute and should not be translated."
+  },
+  "lighthouse-core/audits/seo/hreflang.js | title": {
+    "message": "Document has a valid `hreflang`",
+    "description": "Title of a Lighthouse audit that provides detail on the `hreflang` attribute on a page. This descriptive title is shown when the page's `hreflang` attribute is configured correctly. \"hreflang\" is an HTML attribute and should not be translated."
+  },
+  "lighthouse-core/audits/seo/http-status-code.js | description": {
+    "message": "Pages with unsuccessful HTTP status codes may not be indexed properly. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/successful-http-code).",
+    "description": "Description of a Lighthouse audit that tells the user *why* they need to serve pages with a valid HTTP status code. 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/http-status-code.js | failureTitle": {
+    "message": "Page has unsuccessful HTTP status code",
+    "description": "Descriptive title of a Lighthouse audit that provides detail on the HTTP status code a page responds with. This descriptive title is shown when the page responds to requests with an HTTP status code that indicates the request was unsuccessful."
+  },
+  "lighthouse-core/audits/seo/http-status-code.js | title": {
+    "message": "Page has successful HTTP status code",
+    "description": "Title of a Lighthouse audit that provides detail on the HTTP status code a page responds with. This descriptive title is shown when the page has responded with a valid HTTP status code."
+  },
+  "lighthouse-core/audits/seo/is-crawlable.js | description": {
+    "message": "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).",
+    "description": "Description of a Lighthouse audit that tells the user *why* allowing search-engine crawling of their page is beneficial. 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/is-crawlable.js | failureTitle": {
+    "message": "Page is blocked from indexing",
+    "description": "Title of a Lighthouse audit that provides detail on if search-engine crawlers are blocked from indexing the page. This title is shown when the page has been configured to block indexing and therefore cannot be indexed by search engines."
+  },
+  "lighthouse-core/audits/seo/is-crawlable.js | title": {
+    "message": "Page isn’t blocked from indexing",
+    "description": "Title of a Lighthouse audit that provides detail on if search-engine crawlers are blocked from indexing the page. This title is shown when the page is not blocked from indexing and can be crawled."
+  },
+  "lighthouse-core/audits/seo/link-text.js | description": {
+    "message": "Descriptive link text helps search engines understand your content. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/descriptive-link-text).",
+    "description": "Description of a Lighthouse audit that tells the user *why* they need to have descriptive text on the links in their page. 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/link-text.js | displayValue": {
+    "message": "{itemCount, plural,\n    =1 {1 link found}\n    other {# links found}\n    }",
+    "description": "[ICU Syntax] Label for the audit identifying the number of links found. \"link\" here refers to the links in a web page to other web pages."
+  },
+  "lighthouse-core/audits/seo/link-text.js | failureTitle": {
+    "message": "Links do not have descriptive text",
+    "description": "Title of a Lighthouse audit that tests if each link on a page contains a sufficient description of what a user will find when they click it. Generic, non-descriptive text like \"click here\" doesn't give an indication of what the link leads to. This descriptive title is shown when one or more links on the page contain generic, non-descriptive text."
+  },
+  "lighthouse-core/audits/seo/link-text.js | title": {
+    "message": "Links have descriptive text",
+    "description": "Title of a Lighthouse audit that tests if each link on a page contains a sufficient description of what a user will find when they click it. Generic, non-descriptive text like \"click here\" doesn't give an indication of what the link leads to. This descriptive title is shown when all links on the page have sufficient textual descriptions."
+  },
+  "lighthouse-core/audits/seo/manual/structured-data.js | description": {
+    "message": "Run the [Structured Data Testing Tool](https://search.google.com/structured-data/testing-tool/) and the [Structured Data Linter](http://linter.structured-data.org/) to validate structured data. [Learn more](https://developers.google.com/search/docs/guides/mark-up-content).",
+    "description": "Description of a Lighthouse audit that provides detail on the structured data in a page. \"Structured data\" is a standardized data format on a page that helps a search engine categorize and understand its contents. This description 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/manual/structured-data.js | title": {
+    "message": "Structured data is valid",
+    "description": "Title of a Lighthouse audit that prompts users to manually check their page for valid structured data. \"Structured data\" is a standardized data format on a page that helps a search engine categorize and understand its contents."
+  },
+  "lighthouse-core/audits/seo/meta-description.js | description": {
+    "message": "Meta descriptions may be included in search results to concisely summarize page content. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/description).",
+    "description": "Description of a Lighthouse audit that tells the user *why* they need to have meta descriptions on their page. 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/meta-description.js | explanation": {
+    "message": "Description text is empty.",
+    "description": "Explanatory message stating that there was a failure in an audit caused by the page's meta description text being empty."
+  },
+  "lighthouse-core/audits/seo/meta-description.js | failureTitle": {
+    "message": "Document does not have a meta description",
+    "description": "Title of a Lighthouse audit that provides detail on the web page's document meta description. This descriptive title is shown when the document does not have a meta description. \"meta\" should be left untranslated because it refers to an HTML element."
+  },
+  "lighthouse-core/audits/seo/meta-description.js | title": {
+    "message": "Document has a meta description",
+    "description": "Title of a Lighthouse audit that provides detail on the web page's document meta description. This descriptive title is shown when the document has a meta description. \"meta\" should be left untranslated because it refers to an HTML element."
+  },
+  "lighthouse-core/audits/seo/plugins.js | description": {
+    "message": "Search engines can't index plugin content, and many devices restrict plugins or don't support them. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/plugins).",
+    "description": "Description of a Lighthouse audit that tells the user *why* they need to avoid using browser plugins in their content. 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/plugins.js | failureTitle": {
+    "message": "Document uses plugins",
+    "description": "Descriptive title of a Lighthouse audit that provides detail on the browser plugins used by the page. This title is shown when there is plugin content on the page."
+  },
+  "lighthouse-core/audits/seo/plugins.js | title": {
+    "message": "Document avoids plugins",
+    "description": "Title of a Lighthouse audit that provides detail on the browser plugins used by the page. This descriptive title is shown when there is no plugin content on the page that would restrict search indexing."
+  },
+  "lighthouse-core/audits/seo/robots-txt.js | description": {
+    "message": "If your robots.txt file is malformed, crawlers may not be able to understand how you want your website to be crawled or indexed.",
+    "description": "Description of a Lighthouse audit that tells the user *why* they need to have a valid robots.txt file. Note: \"robots.txt\" is a canonical filename and should not be translated. This is displayed after a user expands the section to see more. No character length limits."
+  },
+  "lighthouse-core/audits/seo/robots-txt.js | displayValueHttpBadCode": {
+    "message": "request for robots.txt returned HTTP status: {statusCode, number}",
+    "description": "[ICU Syntax] Label for the audit identifying that the robots.txt request has returned a specific HTTP status code. Note: \"robots.txt\" is a canonical filename and should not be translated. \"statusCode\" will be replaced with a 3 digit integer which represents the status of the HTTP connectiong for this page."
+  },
+  "lighthouse-core/audits/seo/robots-txt.js | displayValueValidationError": {
+    "message": "{itemCount, plural,\n    =1 {1 error found}\n    other {# errors found}\n    }",
+    "description": "[ICU Syntax] Label for the audit identifying the number of errors that occured while validating the robots.txt file. \"itemCount\" will be replaced by the integer count of errors encountered."
+  },
+  "lighthouse-core/audits/seo/robots-txt.js | explanation": {
+    "message": "Lighthouse was unable to download a robots.txt file",
+    "description": "Explanatory message stating that there was a failure in an audit caused by Lighthouse not being able to download the robots.txt file for the site.  Note: \"robots.txt\" is a canonical filename and should not be translated."
+  },
+  "lighthouse-core/audits/seo/robots-txt.js | failureTitle": {
+    "message": "robots.txt is not valid",
+    "description": "Title of a Lighthouse audit that provides detail on the site's robots.txt file. Note: \"robots.txt\" is a canonical filename and should not be translated. This descriptive title is shown when the robots.txt file is misconfigured, which makes the page hard or impossible to scan via web crawler."
+  },
+  "lighthouse-core/audits/seo/robots-txt.js | title": {
+    "message": "robots.txt is valid",
+    "description": "Title of a Lighthouse audit that provides detail on the site's robots.txt file. Note: \"robots.txt\" is a canonical filename and should not be translated. This descriptive title is shown when the robots.txt file is present and configured correctly."
   },
   "lighthouse-core/audits/seo/tap-targets.js | description": {
     "message": "Interactive elements like buttons and links should be large enough (48x48px), and have enough space around them, to be easy enough to tap without overlapping onto other elements. [Learn more](https://developers.google.com/web/fundamentals/accessibility/accessible-styles#multi-device_responsive_design).",
@@ -777,7 +937,7 @@
   },
   "lighthouse-core/audits/seo/tap-targets.js | failureTitle": {
     "message": "Tap targets are not sized appropriately",
-    "description": "Title of a Lighthouse audit that provides detail on whether tap targets (like buttons and links) on a page are big enough so they can easily be tapped on a mobile device. This descriptive title is shown when tap targets are not easy to tap on."
+    "description": "Descriptive title of a Lighthouse audit that provides detail on whether tap targets (like buttons and links) on a page are big enough so they can easily be tapped on a mobile device. This descriptive title is shown when tap targets are not easy to tap on."
   },
   "lighthouse-core/audits/seo/tap-targets.js | overlappingTargetHeader": {
     "message": "Overlapping Target",
@@ -991,6 +1151,18 @@
     "message": "PWA Optimized",
     "description": "Title of the \"PWA Optimized\" section of the web app category. Within this section are audits that check if the developer has taken advantage of features to make their web page more enjoyable and engaging for the user."
   },
+  "lighthouse-core/config/default-config.js | seoCategoryDescription": {
+    "message": "These checks ensure that your page is optimized for search engine results ranking. There are additional factors Lighthouse does not check that may affect your search ranking. [Learn more](https://support.google.com/webmasters/answer/35769).",
+    "description": "Description of the Search Engine Optimization (SEO) category. This is displayed at the top of a list of audits focused on optimizing a website for indexing by search engines. No character length limits. 'Learn More' becomes link text to additional documentation."
+  },
+  "lighthouse-core/config/default-config.js | seoCategoryManualDescription": {
+    "message": "Run these additional validators on your site to check additional SEO best practices.",
+    "description": "Description of the Search Engine Optimization (SEO) manual checks category, the additional validators must be run by hand in order to check all SEO best practices. This is displayed at the top of a list of manually run audits focused on optimizing a website for indexing by search engines. No character length limits."
+  },
+  "lighthouse-core/config/default-config.js | seoCategoryTitle": {
+    "message": "SEO",
+    "description": "Title of the Search Engine Optimization (SEO) category of audits. This is displayed at the top of a list of audits focused on topics related to optimizing a website for indexing by search engines. Also used as a label of a score gauge; try to limit to 20 characters."
+  },
   "lighthouse-core/lib/i18n/i18n.js | columnCacheTTL": {
     "message": "Cache TTL",
     "description": "Label for the TTL column in data tables, entries will be the time to live value of the cache header on a web resource"
diff --git a/proto/sample_v2_round_trip.json b/proto/sample_v2_round_trip.json
index 97a1c102e..ee6b196f0 100644
--- a/proto/sample_v2_round_trip.json
+++ b/proto/sample_v2_round_trip.json
@@ -1606,13 +1606,6 @@
             "scoreDisplayMode": "informative", 
             "title": "Metrics"
         }, 
-        "mobile-friendly": {
-            "description": "Take the [Mobile-Friendly Test](https://search.google.com/test/mobile-friendly) to check for audits not covered by Lighthouse, like sizing tap targets appropriately. [Learn more](https://developers.google.com/search/mobile-sites/).", 
-            "id": "mobile-friendly", 
-            "score": null, 
-            "scoreDisplayMode": "manual", 
-            "title": "Page is mobile friendly"
-        }, 
         "network-requests": {
             "description": "Lists the network requests that were made during page load.", 
             "details": {
@@ -2540,7 +2533,7 @@
             "warnings": []
         }, 
         "unused-css-rules": {
-            "description": "Remove unused rules from stylesheets to reduce unnecessary bytes consumed by network activity. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/unused-css).", 
+            "description": "Remove dead rules from stylesheets and defer the loading of CSS not used for above-the-fold content to reduce unnecessary bytes consumed by network activity. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/unused-css).", 
             "details": {
                 "headings": [], 
                 "items": [], 
@@ -2551,7 +2544,7 @@
             "id": "unused-css-rules", 
             "score": 1.0, 
             "scoreDisplayMode": "numeric", 
-            "title": "Defer unused CSS"
+            "title": "Remove unused CSS"
         }, 
         "use-landmarks": {
             "description": "Landmark elements (<main>, <nav>, etc.) are used to improve the keyboard navigation of the page for assistive technology. [Learn more](https://developers.google.com/web/fundamentals/accessibility/how-to-review#take_advantage_of_headings_and_landmarks).", 
@@ -3622,10 +3615,6 @@
                     "id": "tap-targets", 
                     "weight": 1.0
                 }, 
-                {
-                    "id": "mobile-friendly", 
-                    "weight": 0.0
-                }, 
                 {
                     "id": "structured-data", 
                     "weight": 0.0
@@ -4654,12 +4643,6 @@
                 "name": "lh:audit:canonical", 
                 "startTime": 0.0
             }, 
-            {
-                "duration": 100.0, 
-                "entryType": "measure", 
-                "name": "lh:audit:mobile-friendly", 
-                "startTime": 0.0
-            }, 
             {
                 "duration": 100.0, 
                 "entryType": "measure", 
diff --git a/third-party/snyk/snapshot.json b/third-party/snyk/snapshot.json
index 299aae90e..0c7924b62 100644
--- a/third-party/snyk/snapshot.json
+++ b/third-party/snyk/snapshot.json
@@ -52,7 +52,7 @@
       {"id":"npm:handlebars:20110425","severity":"medium","semver":{"vulnerable":["<=1.0.0-beta.3"]}}
     ],
     "highcharts":[
-      {"id":"npm:highcharts:20180225","severity":"low","semver":{"vulnerable":["<6.1.0"]}}
+      {"id":"npm:highcharts:20180225","severity":"high","semver":{"vulnerable":["<6.1.0"]}}
     ],
     "jquery":[
       {"id":"npm:jquery:20160529","severity":"low","semver":{"vulnerable":["=3.0.0-rc.1"]}},
@@ -81,7 +81,7 @@
     "moment":[
       {"id":"npm:moment:20170905","severity":"low","semver":{"vulnerable":["<2.19.3"]}},
       {"id":"npm:moment:20161019","severity":"medium","semver":{"vulnerable":["<2.15.2"]}},
-      {"id":"npm:moment:20160126","severity":"low","semver":{"vulnerable":["<=2.11.1"]}}
+      {"id":"npm:moment:20160126","severity":"medium","semver":{"vulnerable":["<=2.11.1"]}}
     ],
     "mustache":[
       {"id":"npm:mustache:20151207","severity":"medium","semver":{"vulnerable":["<2.2.1"]}},


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 40666ee60..1f34fbe16 100644
--- a/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
+++ b/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
@@ -369,9 +369,6 @@ Object {
     Object {
       "path": "seo/canonical",
     },
-    Object {
-      "path": "seo/manual/mobile-friendly",
-    },
     Object {
       "path": "seo/manual/structured-data",
     },
@@ -989,10 +986,6 @@ Object {
           "id": "tap-targets",
           "weight": 1,
         },
-        Object {
-          "id": "mobile-friendly",
-          "weight": 0,
-        },
         Object {
           "id": "structured-data",
           "weight": 0,
diff --git a/lighthouse-cli/test/smokehouse/seo/expectations.js b/lighthouse-cli/test/smokehouse/seo/expectations.js
index 06197e253..bc0176a62 100644
--- a/lighthouse-cli/test/smokehouse/seo/expectations.js
+++ b/lighthouse-cli/test/smokehouse/seo/expectations.js
@@ -100,7 +100,7 @@ module.exports = [
       'font-size': {
         rawValue: false,
         explanation:
-          'Text is illegible because there\'s no viewport meta tag optimized for mobile screens',
+          'Text is illegible because there\'s no viewport meta tag optimized for mobile screens.',
       },
       'link-text': {
         score: 0,
diff --git a/lighthouse-core/test/audits/seo/canonical-test.js b/lighthouse-core/test/audits/seo/canonical-test.js
index 484b60233..449268ec5 100644
--- a/lighthouse-core/test/audits/seo/canonical-test.js
+++ b/lighthouse-core/test/audits/seo/canonical-test.js
@@ -52,7 +52,8 @@ describe('SEO: Document has valid canonical link', () => {
     const context = {computedCache: new Map()};
     return CanonicalAudit.audit(artifacts, context).then(auditResult => {
       assert.equal(auditResult.rawValue, false);
-      assert.ok(auditResult.explanation.includes('Multiple'), auditResult.explanation);
+      expect(auditResult.explanation)
+        .toBeDisplayString('Multiple conflicting URLs (https://example.com, https://www.example.com)');
     });
   });
 
@@ -118,7 +119,8 @@ describe('SEO: Document has valid canonical link', () => {
     const context = {computedCache: new Map()};
     return CanonicalAudit.audit(artifacts, context).then(auditResult => {
       assert.equal(auditResult.rawValue, false);
-      assert.ok(auditResult.explanation.includes('hreflang'), auditResult.explanation);
+      expect(auditResult.explanation)
+        .toBeDisplayString('Points to another `hreflang` location (https://example.com/)');
     });
   });
 
@@ -139,10 +141,30 @@ describe('SEO: Document has valid canonical link', () => {
     const context = {computedCache: new Map()};
     return CanonicalAudit.audit(artifacts, context).then(auditResult => {
       assert.equal(auditResult.rawValue, false);
-      assert.ok(auditResult.explanation.includes('domain'), auditResult.explanation);
+      expect(auditResult.explanation)
+        .toBeDisplayString('Points to a different domain (https://example.com/)');
     });
   });
 
+  it('passes when canonical points to the root while current URL is also the root', async () => {
+    const finalUrl = 'https://example.com/';
+    const mainResource = {
+      url: finalUrl,
+      responseHeaders: [],
+    };
+    const devtoolsLog = networkRecordsToDevtoolsLog([mainResource]);
+    const artifacts = {
+      devtoolsLogs: {[CanonicalAudit.DEFAULT_PASS]: devtoolsLog},
+      URL: {finalUrl},
+      Canonical: ['https://example.com/'],
+      Hreflang: [],
+    };
+
+    const context = {computedCache: new Map()};
+    const auditResult = await CanonicalAudit.audit(artifacts, context);
+    assert.equal(auditResult.rawValue, true);
+  });
+
   it('fails when canonical points to the root while current URL is not the root', () => {
     const finalUrl = 'https://example.com/articles/cats-and-you';
     const mainResource = {
@@ -160,7 +182,8 @@ describe('SEO: Document has valid canonical link', () => {
     const context = {computedCache: new Map()};
     return CanonicalAudit.audit(artifacts, context).then(auditResult => {
       assert.equal(auditResult.rawValue, false);
-      assert.ok(auditResult.explanation.includes('root'), auditResult.explanation);
+      expect(auditResult.explanation).toBeDisplayString('Points to the domain\'s root URL (the ' +
+        'homepage), instead of an equivalent page of content');
     });
   });
 
diff --git a/lighthouse-core/test/audits/seo/font-size-test.js b/lighthouse-core/test/audits/seo/font-size-test.js
index f6513a045..ee6612d09 100644
--- a/lighthouse-core/test/audits/seo/font-size-test.js
+++ b/lighthouse-core/test/audits/seo/font-size-test.js
@@ -25,7 +25,9 @@ describe('SEO: Font size audit', () => {
 
     const auditResult = FontSizeAudit.audit(artifacts);
     assert.equal(auditResult.rawValue, false);
-    assert.ok(auditResult.explanation.includes('no viewport meta tag'));
+    expect(auditResult.explanation)
+      .toBeDisplayString('Text is illegible because there\'s ' +
+        'no viewport meta tag optimized for mobile screens.');
   });
 
   it('fails when less than 60% of text is legible', () => {
@@ -46,7 +48,7 @@ describe('SEO: Font size audit', () => {
 
     const auditResult = FontSizeAudit.audit(artifacts);
     assert.equal(auditResult.rawValue, false);
-    assert.ok(auditResult.explanation.includes('41%'));
+    expect(auditResult.explanation).toBeDisplayString('41% of text is too small.');
     expect(auditResult.displayValue).toBeDisplayString('59% legible text');
   });
 
@@ -167,7 +169,8 @@ describe('SEO: Font size audit', () => {
     };
     const auditResult = FontSizeAudit.audit(artifacts);
     assert.equal(auditResult.rawValue, false);
-    assert.ok(auditResult.explanation.includes('50%'));
+    expect(auditResult.explanation)
+      .toBeDisplayString('100% of text is too small (based on 50% sample).');
     expect(auditResult.displayValue).toBeDisplayString('0% legible text');
   });
 
diff --git a/lighthouse-core/test/audits/seo/meta-description-test.js b/lighthouse-core/test/audits/seo/meta-description-test.js
index e62c39d5c..715a5f029 100644
--- a/lighthouse-core/test/audits/seo/meta-description-test.js
+++ b/lighthouse-core/test/audits/seo/meta-description-test.js
@@ -25,7 +25,7 @@ describe('SEO: description audit', () => {
       MetaElements: makeMetaElements(''),
     });
     assert.equal(auditResult.rawValue, false);
-    assert.ok(auditResult.explanation.includes('empty'), auditResult.explanation);
+    expect(auditResult.explanation).toBeDisplayString('Description text is empty.');
   });
 
   it('fails when description consists only of whitespace', () => {
@@ -33,7 +33,7 @@ describe('SEO: description audit', () => {
       MetaElements: makeMetaElements('\t\xa0'),
     });
     assert.equal(auditResult.rawValue, false);
-    assert.ok(auditResult.explanation.includes('empty'), auditResult.explanation);
+    expect(auditResult.explanation).toBeDisplayString('Description text is empty.');
   });
 
   it('passes when a description text is provided', () => {
diff --git a/lighthouse-core/test/gather/driver-test.js b/lighthouse-core/test/gather/driver-test.js
index 9dc52c175..54d17fe2e 100644
--- a/lighthouse-core/test/gather/driver-test.js
+++ b/lighthouse-core/test/gather/driver-test.js
@@ -5,9 +5,6 @@
  */
 'use strict';
 
-let sendCommandParams = [];
-const sendCommandMockResponses = new Map();
-
 const Driver = require('../../gather/driver.js');
 const Connection = require('../../gather/connections/connection.js');
 const Element = require('../../lib/element.js');
@@ -30,37 +27,39 @@ function createOnceStub(events) {
   };
 }
 
-function createSWRegistration(id, url, isDeleted) {
-  return {
-    isDeleted: !!isDeleted,
-    registrationId: id,
-    scopeURL: url,
+/**
+ * Creates a jest mock function whose implementation consumes mocked protocol responses matching the
+ * requested command in the order they were mocked.
+ *
+ * It is decorated with two methods:
+ *    - `mockResponse` which pushes protocol message responses for consumption
+ *    - `findInvocation` which asserts that `sendCommand` was invoked with the given command and
+ *      returns the protocol message argument.
+ */
+function createMockSendCommandFn() {
+  const mockResponses = [];
+  const mockFn = jest.fn().mockImplementation(command => {
+    const indexOfResponse = mockResponses.findIndex(entry => entry.command === command);
+    if (indexOfResponse === -1) throw new Error(`${command} unimplemented`);
+    const {response} = mockResponses[indexOfResponse];
+    mockResponses.splice(indexOfResponse, 1);
+    return Promise.resolve(response);
+  });
+
+  mockFn.mockResponse = (command, response) => {
+    mockResponses.push({command, response});
+    return mockFn;
   };
-}
 
-function createActiveWorker(id, url, controlledClients, status = 'activated') {
-  return {
-    registrationId: id,
-    scriptURL: url,
-    controlledClients,
-    status,
+  mockFn.findInvocation = command => {
+    expect(mockFn).toHaveBeenCalledWith(command, expect.anything());
+    return mockFn.mock.calls.find(call => call[0] === command)[1];
   };
-}
 
-function createOnceMethodResponse(method, response) {
-  assert.equal(sendCommandMockResponses.has(method), false, 'stub response already defined');
-  sendCommandMockResponses.set(method, response);
+  return mockFn;
 }
 
-function sendCommandStub(command, params) {
-  sendCommandParams.push({command, params});
-
-  if (sendCommandMockResponses.has(command)) {
-    const mockResponse = sendCommandMockResponses.get(command);
-    sendCommandMockResponses.delete(command);
-    return Promise.resolve(mockResponse);
-  }
-
+function sendCommandOldStub(command, params) {
   switch (command) {
     case 'Browser.getVersion':
       return Promise.resolve(protocolGetVersionResponse);
@@ -83,9 +82,11 @@ function sendCommandStub(command, params) {
           value: {
             value: '123',
           },
-        }, {
-          name: 'novalue',
-        }],
+        },
+          {
+            name: 'novalue',
+          },
+        ],
       });
     case 'Page.getResourceTree':
       return Promise.resolve({frameTree: {frame: {id: 1}}});
@@ -116,97 +117,269 @@ function sendCommandStub(command, params) {
 
 /* eslint-env jest */
 
-let driverStub;
+let driver;
 let connectionStub;
+
 beforeEach(() => {
   connectionStub = new Connection();
-  connectionStub.sendCommand = sendCommandStub;
-  driverStub = new Driver(connectionStub);
+  connectionStub.sendCommand = sendCommandOldStub;
+  driver = new Driver(connectionStub);
 });
 
-describe('Browser Driver', () => {
-  beforeEach(() => {
-    sendCommandParams = [];
-    sendCommandMockResponses.clear();
-  });
-
+describe('.querySelector(All)', () => {
   it('returns null when DOM.querySelector finds no node', () => {
-    return driverStub.querySelector('invalid').then(value => {
+    return driver.querySelector('invalid').then(value => {
       assert.equal(value, null);
     });
   });
 
   it('returns element when DOM.querySelector finds node', () => {
-    return driverStub.querySelector('meta head').then(value => {
+    return driver.querySelector('meta head').then(value => {
       assert.equal(value instanceof Element, true);
     });
   });
 
   it('returns [] when DOM.querySelectorAll finds no node', () => {
-    return driverStub.querySelectorAll('invalid').then(value => {
+    return driver.querySelectorAll('invalid').then(value => {
       assert.deepEqual(value, []);
     });
   });
 
   it('returns element when DOM.querySelectorAll finds node', () => {
-    return driverStub.querySelectorAll('a').then(value => {
+    return driver.querySelectorAll('a').then(value => {
       assert.equal(value.length, 1);
       assert.equal(value[0] instanceof Element, true);
     });
   });
+});
 
+describe('.getObjectProperty', () => {
   it('returns value when getObjectProperty finds property name', () => {
-    return driverStub.getObjectProperty('test', 'test').then(value => {
+    return driver.getObjectProperty('test', 'test').then(value => {
       assert.deepEqual(value, 123);
     });
   });
 
   it('returns null when getObjectProperty finds no property name', () => {
-    return driverStub.getObjectProperty('invalid', 'invalid').then(value => {
+    return driver.getObjectProperty('invalid', 'invalid').then(value => {
       assert.deepEqual(value, null);
     });
   });
 
   it('returns null when getObjectProperty finds property name with no value', () => {
-    return driverStub.getObjectProperty('test', 'novalue').then(value => {
+    return driver.getObjectProperty('test', 'novalue').then(value => {
       assert.deepEqual(value, null);
     });
   });
+});
 
+describe('.getRequestContent', () => {
   it('throws if getRequestContent takes too long', () => {
-    return driverStub.getRequestContent('', MAX_WAIT_FOR_PROTOCOL).then(_ => {
-      assert.ok(false, 'long-running getRequestContent supposed to reject');
-    }, e => {
-      assert.equal(e.code, 'PROTOCOL_TIMEOUT');
-      expect(e.friendlyMessage).toBeDisplayString(
-        /^Waiting for DevTools.*Method: Network.getResponseBody/);
-    });
+    return driver.getRequestContent('', MAX_WAIT_FOR_PROTOCOL).then(
+      _ => {
+        assert.ok(false, 'long-running getRequestContent supposed to reject');
+      },
+      e => {
+        assert.equal(e.code, 'PROTOCOL_TIMEOUT');
+        expect(e.friendlyMessage).toBeDisplayString(
+          /^Waiting for DevTools.*Method: Network.getResponseBody/
+        );
+      }
+    );
   });
+});
 
-  it('evaluates an expression', () => {
-    return driverStub.evaluateAsync('120 + 3').then(value => {
-      assert.deepEqual(value, 123);
-      assert.equal(sendCommandParams[0].command, 'Runtime.evaluate');
+describe('.evaluateAsync', () => {
+  it('evaluates an expression', async () => {
+    connectionStub.sendCommand = createMockSendCommandFn()
+      .mockResponse('Runtime.evaluate', {result: {value: 2}});
+
+    const value = await driver.evaluateAsync('1 + 1');
+    expect(value).toEqual(2);
+    connectionStub.sendCommand.findInvocation('Runtime.evaluate');
+  });
+
+  it('evaluates an expression in isolation', async () => {
+    connectionStub.sendCommand = createMockSendCommandFn()
+      .mockResponse('Page.getResourceTree', {frameTree: {frame: {id: 1337}}})
+      .mockResponse('Page.createIsolatedWorld', {executionContextId: 1})
+      .mockResponse('Runtime.evaluate', {result: {value: 2}});
+
+    const value = await driver.evaluateAsync('1 + 1', {useIsolation: true});
+    expect(value).toEqual(2);
+
+    // Check that we used the correct frame when creating the isolated context
+    const createWorldArgs = connectionStub.sendCommand.findInvocation('Page.createIsolatedWorld');
+    expect(createWorldArgs).toMatchObject({frameId: 1337});
+
+    // Check that we used the isolated context when evaluating
+    const evaluateArgs = connectionStub.sendCommand.findInvocation('Runtime.evaluate');
+    expect(evaluateArgs).toMatchObject({contextId: 1});
+
+    // Make sure we cached the isolated context from last time
+    connectionStub.sendCommand = createMockSendCommandFn().mockResponse(
+      'Runtime.evaluate',
+      {result: {value: 2}}
+    );
+    await driver.evaluateAsync('1 + 1', {useIsolation: true});
+    expect(connectionStub.sendCommand).not.toHaveBeenCalledWith(
+      'Page.createIsolatedWorld',
+      expect.anything()
+    );
+  });
+});
+
+describe('.sendCommand', () => {
+  it('.sendCommand timesout when commands take too long', async () => {
+    class SlowConnection extends EventEmitter {
+      connect() {
+        return Promise.resolve();
+      }
+      disconnect() {
+        return Promise.resolve();
+      }
+      sendCommand() {
+        return new Promise(resolve => setTimeout(resolve, 15));
+      }
+    }
+    const slowConnection = new SlowConnection();
+    const driver = new Driver(slowConnection);
+    driver.setNextProtocolTimeout(25);
+    await driver.sendCommand('Page.enable');
+
+    driver.setNextProtocolTimeout(5);
+    try {
+      await driver.sendCommand('Page.disable');
+      assert.fail('expected driver.sendCommand to timeout');
+    } catch (err) {
+      assert.equal(err.code, 'PROTOCOL_TIMEOUT');
+    }
+  });
+});
+
+describe('.beginTrace', () => {
+  beforeEach(() => {
+    connectionStub.sendCommand = createMockSendCommandFn()
+      .mockResponse('Browser.getVersion', protocolGetVersionResponse)
+      .mockResponse('Page.enable', {})
+      .mockResponse('Tracing.start', {});
+  });
+
+  it('will request default traceCategories', async () => {
+    await driver.beginTrace();
+
+    const tracingStartArgs = connectionStub.sendCommand.findInvocation('Tracing.start');
+    expect(tracingStartArgs.categories).toContain('devtools.timeline');
+    expect(tracingStartArgs.categories).not.toContain('toplevel');
+    expect(tracingStartArgs.categories).toContain('disabled-by-default-lighthouse');
+  });
+
+  it('will use requested additionalTraceCategories', async () => {
+    await driver.beginTrace({additionalTraceCategories: 'loading,xtra_cat'});
+
+    const tracingStartArgs = connectionStub.sendCommand.findInvocation('Tracing.start');
+    expect(tracingStartArgs.categories).toContain('blink.user_timing');
+    expect(tracingStartArgs.categories).toContain('xtra_cat');
+    // Make sure it deduplicates categories too
+    expect(tracingStartArgs.categories).not.toMatch(/loading.*loading/);
+  });
+
+  it('will adjust traceCategories based on chrome version', async () => {
+    connectionStub.sendCommand = createMockSendCommandFn()
+      .mockResponse('Browser.getVersion', {product: 'Chrome/70.0.3577.0'})
+      .mockResponse('Page.enable', {})
+      .mockResponse('Tracing.start', {});
+
+    await driver.beginTrace();
+
+    const tracingStartArgs = connectionStub.sendCommand.findInvocation('Tracing.start');
+    // m70 doesn't have disabled-by-default-lighthouse, so 'toplevel' is used instead.
+    expect(tracingStartArgs.categories).toContain('toplevel');
+    expect(tracingStartArgs.categories).not.toContain('disabled-by-default-lighthouse');
+  });
+});
+
+describe('.setExtraHTTPHeaders', () => {
+  it('should Network.setExtraHTTPHeaders when there are extra-headers', async () => {
+    connectionStub.sendCommand = createMockSendCommandFn().mockResponse(
+      'Network.setExtraHTTPHeaders',
+      {}
+    );
+
+    await driver.setExtraHTTPHeaders({
+      'Cookie': 'monster',
+      'x-men': 'wolverine',
     });
+
+    expect(connectionStub.sendCommand).toHaveBeenCalledWith(
+      'Network.setExtraHTTPHeaders',
+      expect.anything()
+    );
   });
 
-  it('evaluates an expression in isolation', () => {
-    return driverStub.evaluateAsync('120 + 3', {useIsolation: true}).then(value => {
-      assert.deepEqual(value, 123);
+  it('should Network.setExtraHTTPHeaders when there are extra-headers', async () => {
+    connectionStub.sendCommand = createMockSendCommandFn();
+    await driver.setExtraHTTPHeaders();
+
+    expect(connectionStub.sendCommand).not.toHaveBeenCalled();
+  });
+});
 
-      assert.ok(sendCommandParams.length > 1, 'did not create execution context');
-      const evaluateCommand = sendCommandParams.find(data => data.command === 'Runtime.evaluate');
-      assert.equal(evaluateCommand.params.contextId, 1);
+describe('.getAppManifest', () => {
+  it('should return null when no manifest', async () => {
+    connectionStub.sendCommand = createMockSendCommandFn().mockResponse(
+      'Page.getAppManifest',
+      {data: undefined, url: '/manifest'}
+    );
+    const result = await driver.getAppManifest();
+    expect(result).toEqual(null);
+  });
 
-      // test repeat isolation evaluations
-      sendCommandParams = [];
-      return driverStub.evaluateAsync('120 + 3', {useIsolation: true});
-    }).then(value => {
-      assert.deepEqual(value, 123);
-      assert.ok(sendCommandParams.length === 1, 'created unnecessary 2nd execution context');
+  it('should return the manifest', async () => {
+    const manifest = {name: 'The App'};
+    connectionStub.sendCommand = createMockSendCommandFn().mockResponse(
+      'Page.getAppManifest',
+      {data: JSON.stringify(manifest), url: '/manifest'}
+    );
+    const result = await driver.getAppManifest();
+    expect(result).toEqual({data: JSON.stringify(manifest), url: '/manifest'});
+  });
+
+  it('should handle BOM-encoded manifest', async () => {
+    const fs = require('fs');
+    const manifestWithoutBOM = fs.readFileSync(__dirname + '/../fixtures/manifest.json').toString();
+    const manifestWithBOM = fs
+      .readFileSync(__dirname + '/../fixtures/manifest-bom.json')
+      .toString();
+
+    connectionStub.sendCommand = createMockSendCommandFn().mockResponse(
+      'Page.getAppManifest',
+      {data: manifestWithBOM, url: '/manifest'}
+    );
+    const result = await driver.getAppManifest();
+    expect(result).toEqual({data: manifestWithoutBOM, url: '/manifest'});
+  });
+});
+
+describe('.goOffline', () => {
+  it('should send offline emulation', async () => {
+    connectionStub.sendCommand = createMockSendCommandFn()
+      .mockResponse('Network.enable', {})
+      .mockResponse('Network.emulateNetworkConditions', {});
+
+    await driver.goOffline();
+    const emulateArgs = connectionStub.sendCommand
+      .findInvocation('Network.emulateNetworkConditions');
+    expect(emulateArgs).toEqual({
+      offline: true,
+      latency: 0,
+      downloadThroughput: 0,
+      uploadThroughput: 0,
     });
   });
+});
 
+describe('.gotoURL', () => {
   it('will track redirects through gotoURL load', () => {
     const delay = _ => new Promise(resolve => setTimeout(resolve));
 
@@ -254,135 +427,115 @@ describe('Browser Driver', () => {
     });
   });
 
-  it('.sendCommand timesout when commands take too long', async () => {
-    class SlowConnection extends EventEmitter {
-      connect() {
-        return Promise.resolve();
-      }
-      disconnect() {
-        return Promise.resolve();
-      }
-      sendCommand() {
-        return new Promise(resolve => setTimeout(resolve, 15));
-      }
-    }
-    const slowConnection = new SlowConnection();
-    const driver = new Driver(slowConnection);
-    driver.setNextProtocolTimeout(25);
-    await driver.sendCommand('Page.enable');
+  describe('when waitForNavigated', () => {});
 
-    driver.setNextProtocolTimeout(5);
-    try {
-      await driver.sendCommand('Page.disable');
-      assert.fail('expected driver.sendCommand to timeout');
-    } catch (err) {
-      assert.equal(err.code, 'PROTOCOL_TIMEOUT');
-    }
-  });
-
-  it('will request default traceCategories', () => {
-    return driverStub.beginTrace().then(() => {
-      const traceCmd = sendCommandParams.find(obj => obj.command === 'Tracing.start');
-      const categories = traceCmd.params.categories;
-      assert.ok(categories.includes('devtools.timeline'), 'contains devtools.timeline');
-    });
-  });
-
-  it('will use requested additionalTraceCategories', () => {
-    return driverStub.beginTrace({additionalTraceCategories: 'loading,xtra_cat'}).then(() => {
-      const traceCmd = sendCommandParams.find(obj => obj.command === 'Tracing.start');
-      const categories = traceCmd.params.categories;
-      assert.ok(categories.includes('blink.user_timing'), 'contains default categories');
-      assert.ok(categories.includes('xtra_cat'), 'contains added categories');
-      assert.ok(categories.indexOf('loading') === categories.lastIndexOf('loading'),
-          'de-dupes categories');
-    });
-  });
-
-  it('will adjust traceCategories based on chrome version', async () => {
-    // m70 doesn't have disabled-by-default-lighthouse, so 'toplevel' is used instead.
-    const m70ProtocolGetVersionResponse = Object.assign({}, protocolGetVersionResponse, {
-      product: 'Chrome/70.0.3577.0',
-      // eslint-disable-next-line max-len
-      userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3577.0 Safari/537.36',
-    });
-    createOnceMethodResponse('Browser.getVersion', m70ProtocolGetVersionResponse);
-
-    // eslint-disable-next-line max-len
-    await driverStub.beginTrace();
-    const traceCmd = sendCommandParams.find(obj => obj.command === 'Tracing.start');
-    const categories = traceCmd.params.categories;
-    assert.ok(categories.includes('toplevel'), 'contains old toplevel category');
-    assert.equal(categories.indexOf('disabled-by-default-lighthouse'), -1, 'excludes new cat');
-  });
-
-  it('should send the Network.setExtraHTTPHeaders command when there are extra-headers', () => {
-    return driverStub.setExtraHTTPHeaders({
-      'Cookie': 'monster',
-      'x-men': 'wolverine',
-    }).then(() => {
-      assert.equal(sendCommandParams[0].command, 'Network.setExtraHTTPHeaders');
-    });
-  });
+  describe('when waitForLoad', () => {
+    it('does not reject when page is secure', async () => {
+      const secureSecurityState = {
+        explanations: [],
+        securityState: 'secure',
+      };
+      driver.on = driver.once = createOnceStub({
+        'Security.securityStateChanged': secureSecurityState,
+        'Page.loadEventFired': {},
+        'Page.domContentEventFired': {},
+      });
 
-  it('should not send the Network.setExtraHTTPHeaders command when there no extra-headers', () => {
-    return driverStub.setExtraHTTPHeaders().then(() => {
-      assert.equal(sendCommandParams[0], undefined);
+      const startUrl = 'https://www.example.com';
+      const loadOptions = {
+        waitForLoad: true,
+        passContext: {
+          passConfig: {
+            networkQuietThresholdMs: 1,
+          },
+          settings: {
+            maxWaitForLoad: 1,
+          },
+        },
+      };
+      await driver.gotoURL(startUrl, loadOptions);
     });
-  });
 
-  describe('.getAppManifest', () => {
-    it('should return null when no manifest', async () => {
-      connectionStub.sendCommand = jest.fn()
-        .mockResolvedValueOnce({data: undefined, url: '/manifest'});
-      const result = await driverStub.getAppManifest();
-      expect(result).toEqual(null);
-    });
+    it('rejects when page is insecure', async () => {
+      const insecureSecurityState = {
+        explanations: [
+          {
+            description: 'reason 1.',
+            securityState: 'insecure',
+          },
+          {
+            description: 'blah.',
+            securityState: 'info',
+          },
+          {
+            description: 'reason 2.',
+            securityState: 'insecure',
+          },
+        ],
+        securityState: 'insecure',
+      };
+      driver.on = createOnceStub({
+        'Security.securityStateChanged': insecureSecurityState,
+      });
 
-    it('should return the manifest', async () => {
-      const manifest = {name: 'The App'};
-      connectionStub.sendCommand = jest.fn()
-        .mockResolvedValueOnce({data: JSON.stringify(manifest), url: '/manifest'});
-      const result = await driverStub.getAppManifest();
-      expect(result).toEqual({data: JSON.stringify(manifest), url: '/manifest'});
-    });
+      const startUrl = 'https://www.example.com';
+      const loadOptions = {
+        waitForLoad: true,
+        passContext: {
+          passConfig: {
+            networkQuietThresholdMs: 1,
+          },
+        },
+      };
 
-    it('should handle BOM-encoded manifest', async () => {
-      const fs = require('fs');
-      const manifestWithoutBOM = fs.readFileSync(__dirname + '/../fixtures/manifest.json')
-        .toString();
-      const manifestWithBOM = fs.readFileSync(__dirname + '/../fixtures/manifest-bom.json')
-        .toString();
-
-      connectionStub.sendCommand = jest.fn()
-        .mockResolvedValueOnce({data: manifestWithBOM, url: '/manifest'});
-      const result = await driverStub.getAppManifest();
-      expect(result).toEqual({data: manifestWithoutBOM, url: '/manifest'});
+      try {
+        await driver.gotoURL(startUrl, loadOptions);
+        assert.fail('security check should have rejected');
+      } catch (err) {
+        assert.equal(err.message, 'INSECURE_DOCUMENT_REQUEST');
+        assert.equal(err.code, 'INSECURE_DOCUMENT_REQUEST');
+        /* eslint-disable-next-line max-len */
+        expect(err.friendlyMessage).toBeDisplayString(
+          'The URL you have provided does not have valid security credentials. reason 1. reason 2.'
+        );
+      }
     });
   });
 });
 
-describe('Multiple tab check', () => {
-  beforeEach(() => {
-    sendCommandParams = [];
-    sendCommandMockResponses.clear();
-  });
+describe('.assertNoSameOriginServiceWorkerClients', () => {
+  function createSWRegistration(id, url, isDeleted) {
+    return {
+      isDeleted: !!isDeleted,
+      registrationId: id,
+      scopeURL: url,
+    };
+  }
+
+  function createActiveWorker(id, url, controlledClients, status = 'activated') {
+    return {
+      registrationId: id,
+      scriptURL: url,
+      controlledClients,
+      status,
+    };
+  }
 
   it('will pass if there are no current service workers', () => {
     const pageUrl = 'https://example.com/';
-    driverStub.once = createOnceStub({
+    driver.once = createOnceStub({
       'ServiceWorker.workerRegistrationUpdated': {
         registrations: [],
       },
     });
 
-    driverStub.on = createOnceStub({
+    driver.on = createOnceStub({
       'ServiceWorker.workerVersionUpdated': {
         versions: [],
       },
     });
 
-    return driverStub.assertNoSameOriginServiceWorkerClients(pageUrl);
+    return driver.assertNoSameOriginServiceWorkerClients(pageUrl);
   });
 
   it('will pass if there is an active service worker for a different origin', () => {
@@ -390,55 +543,48 @@ describe('Multiple tab check', () => {
     const secondUrl = 'https://example.edu';
     const swUrl = `${secondUrl}sw.js`;
 
-    const registrations = [
-      createSWRegistration(1, secondUrl),
-    ];
-    const versions = [
-      createActiveWorker(1, swUrl, ['uniqueId']),
-    ];
+    const registrations = [createSWRegistration(1, secondUrl)];
+    const versions = [createActiveWorker(1, swUrl, ['uniqueId'])];
 
-    driverStub.once = createOnceStub({
+    driver.once = createOnceStub({
       'ServiceWorker.workerRegistrationUpdated': {
         registrations,
       },
     });
 
-    driverStub.on = createOnceStub({
+    driver.on = createOnceStub({
       'ServiceWorker.workerVersionUpdated': {
         versions,
       },
     });
 
-    return driverStub.assertNoSameOriginServiceWorkerClients(pageUrl);
+    return driver.assertNoSameOriginServiceWorkerClients(pageUrl);
   });
 
   it('will fail if a service worker with a matching origin has a controlled client', () => {
     const pageUrl = 'https://example.com/';
     const swUrl = `${pageUrl}sw.js`;
-    const registrations = [
-      createSWRegistration(1, pageUrl),
-    ];
-    const versions = [
-      createActiveWorker(1, swUrl, ['uniqueId']),
-    ];
-
-    driverStub.once = createOnceStub({
+    const registrations = [createSWRegistration(1, pageUrl)];
+    const versions = [createActiveWorker(1, swUrl, ['uniqueId'])];
+
+    driver.once = createOnceStub({
       'ServiceWorker.workerRegistrationUpdated': {
         registrations,
       },
     });
 
-    driverStub.on = createOnceStub({
+    driver.on = createOnceStub({
       'ServiceWorker.workerVersionUpdated': {
         versions,
       },
     });
 
-    return driverStub.assertNoSameOriginServiceWorkerClients(pageUrl)
-      .then(_ => assert.ok(false),
-          err => {
-            assert.ok(err.message.toLowerCase().includes('multiple tabs'));
-          });
+    return driver.assertNoSameOriginServiceWorkerClients(pageUrl).then(
+      _ => assert.ok(false),
+      err => {
+        assert.ok(err.message.toLowerCase().includes('multiple tabs'));
+      }
+    );
   });
 
   it('will succeed if a service worker with a matching origin has no controlled clients', () => {
@@ -447,19 +593,19 @@ describe('Multiple tab check', () => {
     const registrations = [createSWRegistration(1, pageUrl)];
     const versions = [createActiveWorker(1, swUrl, [])];
 
-    driverStub.once = createOnceStub({
+    driver.once = createOnceStub({
       'ServiceWorker.workerRegistrationUpdated': {
         registrations,
       },
     });
 
-    driverStub.on = createOnceStub({
+    driver.on = createOnceStub({
       'ServiceWorker.workerVersionUpdated': {
         versions,
       },
     });
 
-    return driverStub.assertNoSameOriginServiceWorkerClients(pageUrl);
+    return driver.assertNoSameOriginServiceWorkerClients(pageUrl);
   });
 
   it('will wait for serviceworker to be activated', () => {
@@ -468,21 +614,19 @@ describe('Multiple tab check', () => {
     const registrations = [createSWRegistration(1, pageUrl)];
     const versions = [createActiveWorker(1, swUrl, [], 'installing')];
 
-    driverStub.once = createOnceStub({
+    driver.once = createOnceStub({
       'ServiceWorker.workerRegistrationUpdated': {
         registrations,
       },
     });
 
-    driverStub.on = (eventName, cb) => {
+    driver.on = (eventName, cb) => {
       if (eventName === 'ServiceWorker.workerVersionUpdated') {
         cb({versions});
 
         setTimeout(() => {
           cb({
-            versions: [
-              createActiveWorker(1, swUrl, [], 'activated'),
-            ],
+            versions: [createActiveWorker(1, swUrl, [], 'activated')],
           });
         }, 1000);
 
@@ -492,161 +636,79 @@ describe('Multiple tab check', () => {
       throw Error(`Stub not implemented: ${eventName}`);
     };
 
-    return driverStub.assertNoSameOriginServiceWorkerClients(pageUrl);
+    return driver.assertNoSameOriginServiceWorkerClients(pageUrl);
   });
+});
 
-  describe('.goOnline', () => {
-    it('re-establishes previous throttling settings', async () => {
-      await driverStub.goOnline({
-        passConfig: {useThrottling: true},
-        settings: {
-          throttlingMethod: 'devtools',
-          throttling: {
-            requestLatencyMs: 500,
-            downloadThroughputKbps: 1000,
-            uploadThroughputKbps: 1000,
-          },
-        },
-      });
-
-      const emulateCommand = sendCommandParams
-        .find(item => item.command === 'Network.emulateNetworkConditions');
-      assert.ok(emulateCommand, 'did not call emulate network');
-      assert.deepStrictEqual(emulateCommand.params, {
-        offline: false,
-        latency: 500,
-        downloadThroughput: 1000 * 1024 / 8,
-        uploadThroughput: 1000 * 1024 / 8,
-      });
-    });
+describe('.goOnline', () => {
+  beforeEach(() => {
+    connectionStub.sendCommand = createMockSendCommandFn()
+      .mockResponse('Network.enable', {})
+      .mockResponse('Emulation.setCPUThrottlingRate', {})
+      .mockResponse('Network.emulateNetworkConditions', {});
+  });
 
-    it('clears network emulation when throttling is not devtools', async () => {
-      await driverStub.goOnline({
-        passConfig: {useThrottling: true},
-        settings: {
-          throttlingMethod: 'provided',
+  it('re-establishes previous throttling settings', async () => {
+    await driver.goOnline({
+      passConfig: {useThrottling: true},
+      settings: {
+        throttlingMethod: 'devtools',
+        throttling: {
+          requestLatencyMs: 500,
+          downloadThroughputKbps: 1000,
+          uploadThroughputKbps: 1000,
         },
-      });
-
-      const emulateCommand = sendCommandParams
-        .find(item => item.command === 'Network.emulateNetworkConditions');
-      assert.ok(emulateCommand, 'did not call emulate network');
-      assert.deepStrictEqual(emulateCommand.params, {
-        offline: false,
-        latency: 0,
-        downloadThroughput: 0,
-        uploadThroughput: 0,
-      });
+      },
     });
 
-    it('clears network emulation when useThrottling is false', async () => {
-      await driverStub.goOnline({
-        passConfig: {useThrottling: false},
-        settings: {
-          throttlingMethod: 'devtools',
-          throttling: {
-            requestLatencyMs: 500,
-            downloadThroughputKbps: 1000,
-            uploadThroughputKbps: 1000,
-          },
-        },
-      });
-
-      const emulateCommand = sendCommandParams
-        .find(item => item.command === 'Network.emulateNetworkConditions');
-      assert.ok(emulateCommand, 'did not call emulate network');
-      assert.deepStrictEqual(emulateCommand.params, {
-        offline: false,
-        latency: 0,
-        downloadThroughput: 0,
-        uploadThroughput: 0,
-      });
+    const emulateArgs = connectionStub.sendCommand
+      .findInvocation('Network.emulateNetworkConditions');
+    expect(emulateArgs).toEqual({
+      offline: false,
+      latency: 500,
+      downloadThroughput: (1000 * 1024) / 8,
+      uploadThroughput: (1000 * 1024) / 8,
     });
   });
 
-  describe('.goOffline', () => {
-    it('should send offline emulation', async () => {
-      await driverStub.goOffline();
-      const emulateCommand = sendCommandParams
-        .find(item => item.command === 'Network.emulateNetworkConditions');
-      assert.ok(emulateCommand, 'did not call emulate network');
-      assert.deepStrictEqual(emulateCommand.params, {
-        offline: true,
-        latency: 0,
-        downloadThroughput: 0,
-        uploadThroughput: 0,
-      });
+  it('clears network emulation when throttling is not devtools', async () => {
+    await driver.goOnline({
+      passConfig: {useThrottling: true},
+      settings: {
+        throttlingMethod: 'provided',
+      },
     });
-  });
-
-  describe('Security check', () => {
-    it('does not reject when page is secure', async () => {
-      const secureSecurityState = {
-        explanations: [],
-        securityState: 'secure',
-      };
-      driverStub.on = driverStub.once = createOnceStub({
-        'Security.securityStateChanged': secureSecurityState,
-        'Page.loadEventFired': {},
-        'Page.domContentEventFired': {},
-      });
 
-      const startUrl = 'https://www.example.com';
-      const loadOptions = {
-        waitForLoad: true,
-        passContext: {
-          passConfig: {
-            networkQuietThresholdMs: 1,
-          },
-          settings: {
-            maxWaitForLoad: 1,
-          },
-        },
-      };
-      await driverStub.gotoURL(startUrl, loadOptions);
+    const emulateArgs = connectionStub.sendCommand
+      .findInvocation('Network.emulateNetworkConditions');
+    expect(emulateArgs).toEqual({
+      offline: false,
+      latency: 0,
+      downloadThroughput: 0,
+      uploadThroughput: 0,
     });
+  });
 
-    it('rejects when page is insecure', async () => {
-      const insecureSecurityState = {
-        explanations: [
-          {
-            description: 'reason 1.',
-            securityState: 'insecure',
-          },
-          {
-            description: 'blah.',
-            securityState: 'info',
-          },
-          {
-            description: 'reason 2.',
-            securityState: 'insecure',
-          },
-        ],
-        securityState: 'insecure',
-      };
-      driverStub.on = createOnceStub({
-        'Security.securityStateChanged': insecureSecurityState,
-      });
-
-      const startUrl = 'https://www.example.com';
-      const loadOptions = {
-        waitForLoad: true,
-        passContext: {
-          passConfig: {
-            networkQuietThresholdMs: 1,
-          },
+  it('clears network emulation when useThrottling is false', async () => {
+    await driver.goOnline({
+      passConfig: {useThrottling: false},
+      settings: {
+        throttlingMethod: 'devtools',
+        throttling: {
+          requestLatencyMs: 500,
+          downloadThroughputKbps: 1000,
+          uploadThroughputKbps: 1000,
         },
-      };
+      },
+    });
 
-      try {
-        await driverStub.gotoURL(startUrl, loadOptions);
-        assert.fail('security check should have rejected');
-      } catch (err) {
-        assert.equal(err.message, 'INSECURE_DOCUMENT_REQUEST');
-        assert.equal(err.code, 'INSECURE_DOCUMENT_REQUEST');
-        /* eslint-disable-next-line max-len */
-        expect(err.friendlyMessage).toBeDisplayString('The URL you have provided does not have valid security credentials. reason 1. reason 2.');
-      }
+    const emulateArgs = connectionStub.sendCommand
+      .findInvocation('Network.emulateNetworkConditions');
+    expect(emulateArgs).toEqual({
+      offline: false,
+      latency: 0,
+      downloadThroughput: 0,
+      uploadThroughput: 0,
     });
   });
 });
diff --git a/lighthouse-core/test/gather/gatherers/seo/font-size-test.js b/lighthouse-core/test/gather/gatherers/seo/font-size-test.js
index 58b09939f..6569b4f98 100644
--- a/lighthouse-core/test/gather/gatherers/seo/font-size-test.js
+++ b/lighthouse-core/test/gather/gatherers/seo/font-size-test.js
@@ -172,69 +172,63 @@ describe('Font size gatherer', () => {
 
     it('should identify inline styles', () => {
       const result = FontSizeGather.getEffectiveFontRule({inlineStyle});
-      expect(result).toMatchInlineSnapshot(`
-Object {
-  "cssProperties": Array [
-    Object {
-      "name": "font-size",
-      "value": "1em",
-    },
-  ],
-  "styleSheetId": 1,
-  "type": "Inline",
-}
-`);
+      expect(result).toEqual({
+        cssProperties: [
+          {
+            name: 'font-size',
+            value: '1em',
+          },
+        ],
+        styleSheetId: 1,
+        type: 'Inline',
+      });
     });
 
     it('should identify direct CSS rules', () => {
       const result = FontSizeGather.getEffectiveFontRule({matchedCSSRules});
-      expect(result).toMatchInlineSnapshot(`
-Object {
-  "cssProperties": Array [
-    Object {
-      "name": "font-size",
-      "value": "1em",
-    },
-  ],
-  "parentRule": Object {
-    "origin": "regular",
-    "selectors": Array [
-      Object {
-        "text": "html body *",
-      },
-      Object {
-        "text": "#main",
-      },
-    ],
-  },
-  "styleSheetId": 123,
-  "type": "Regular",
-}
-`);
+      expect(result).toEqual({
+        cssProperties: [
+          {
+            name: 'font-size',
+            value: '1em',
+          },
+        ],
+        parentRule: {
+          origin: 'regular',
+          selectors: [
+            {
+              text: 'html body *',
+            },
+            {
+              text: '#main',
+            },
+          ],
+        },
+        styleSheetId: 123,
+        type: 'Regular',
+      });
     });
 
     it('should identify inherited CSS rules', () => {
       const result = FontSizeGather.getEffectiveFontRule({inherited});
-      expect(result).toMatchInlineSnapshot(`
-Object {
-  "cssProperties": Array [
-    Object {
-      "name": "font-size",
-      "value": 12,
-    },
-  ],
-  "parentRule": Object {
-    "origin": "user-agent",
-    "selectors": Array [
-      Object {
-        "text": "body",
-      },
-    ],
-  },
-  "styleSheetId": undefined,
-  "type": "Regular",
-}
-`);
+      expect(result).toEqual({
+        cssProperties: [
+          {
+            name: 'font-size',
+            value: 12,
+          },
+        ],
+        parentRule: {
+          origin: 'user-agent',
+          selectors: [
+            {
+              text: 'body',
+            },
+          ],
+        },
+        styleSheetId: undefined,
+        type: 'Regular',
+      });
     });
 
     it('should respect precendence', () => {
diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json
index fd7b349a2..5eb5ab45d 100644
--- a/lighthouse-core/test/results/sample_v2.json
+++ b/lighthouse-core/test/results/sample_v2.json
@@ -2410,8 +2410,8 @@
     },
     "unused-css-rules": {
       "id": "unused-css-rules",
-      "title": "Defer unused CSS",
-      "description": "Remove unused rules from stylesheets to reduce unnecessary bytes consumed by network activity. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/unused-css).",
+      "title": "Remove unused CSS",
+      "description": "Remove dead rules from stylesheets and defer the loading of CSS not used for above-the-fold content to reduce unnecessary bytes consumed by network activity. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/unused-css).",
       "score": 1,
       "scoreDisplayMode": "numeric",
       "rawValue": 0,
@@ -3140,14 +3140,6 @@
       "scoreDisplayMode": "notApplicable",
       "rawValue": true
     },
-    "mobile-friendly": {
-      "id": "mobile-friendly",
-      "title": "Page is mobile friendly",
-      "description": "Take the [Mobile-Friendly Test](https://search.google.com/test/mobile-friendly) to check for audits not covered by Lighthouse, like sizing tap targets appropriately. [Learn more](https://developers.google.com/search/mobile-sites/).",
-      "score": null,
-      "scoreDisplayMode": "manual",
-      "rawValue": false
-    },
     "structured-data": {
       "id": "structured-data",
       "title": "Structured data is valid",
@@ -3726,10 +3718,6 @@
           "weight": 1,
           "group": "seo-mobile"
         },
-        {
-          "id": "mobile-friendly",
-          "weight": 0
-        },
         {
           "id": "structured-data",
           "weight": 0
@@ -4800,12 +4788,6 @@
         "duration": 100,
         "entryType": "measure"
       },
-      {
-        "startTime": 0,
-        "name": "lh:audit:mobile-friendly",
-        "duration": 100,
-        "entryType": "measure"
-      },
       {
         "startTime": 0,
         "name": "lh:audit:structured-data",
@@ -5455,6 +5437,18 @@
       "lighthouse-core/audits/dobetterweb/dom-size.js | statisticDOMWidth": [
         "audits[dom-size].details.items[2].statistic"
       ],
+      "lighthouse-core/audits/seo/meta-description.js | failureTitle": [
+        "audits[meta-description].title"
+      ],
+      "lighthouse-core/audits/seo/meta-description.js | description": [
+        "audits[meta-description].description"
+      ],
+      "lighthouse-core/audits/seo/http-status-code.js | title": [
+        "audits[http-status-code].title"
+      ],
+      "lighthouse-core/audits/seo/http-status-code.js | description": [
+        "audits[http-status-code].description"
+      ],
       "lighthouse-core/audits/seo/font-size.js | title": [
         "audits[font-size].title"
       ],
@@ -5469,12 +5463,54 @@
           "path": "audits[font-size].displayValue"
         }
       ],
+      "lighthouse-core/audits/seo/link-text.js | title": [
+        "audits[link-text].title"
+      ],
+      "lighthouse-core/audits/seo/link-text.js | description": [
+        "audits[link-text].description"
+      ],
+      "lighthouse-core/audits/seo/is-crawlable.js | failureTitle": [
+        "audits[is-crawlable].title"
+      ],
+      "lighthouse-core/audits/seo/is-crawlable.js | description": [
+        "audits[is-crawlable].description"
+      ],
+      "lighthouse-core/audits/seo/robots-txt.js | failureTitle": [
+        "audits[robots-txt].title"
+      ],
+      "lighthouse-core/audits/seo/robots-txt.js | description": [
+        "audits[robots-txt].description"
+      ],
       "lighthouse-core/audits/seo/tap-targets.js | failureTitle": [
         "audits[tap-targets].title"
       ],
       "lighthouse-core/audits/seo/tap-targets.js | description": [
         "audits[tap-targets].description"
       ],
+      "lighthouse-core/audits/seo/hreflang.js | title": [
+        "audits.hreflang.title"
+      ],
+      "lighthouse-core/audits/seo/hreflang.js | description": [
+        "audits.hreflang.description"
+      ],
+      "lighthouse-core/audits/seo/plugins.js | title": [
+        "audits.plugins.title"
+      ],
+      "lighthouse-core/audits/seo/plugins.js | description": [
+        "audits.plugins.description"
+      ],
+      "lighthouse-core/audits/seo/canonical.js | title": [
+        "audits.canonical.title"
+      ],
+      "lighthouse-core/audits/seo/canonical.js | description": [
+        "audits.canonical.description"
+      ],
+      "lighthouse-core/audits/seo/manual/structured-data.js | title": [
+        "audits[structured-data].title"
+      ],
+      "lighthouse-core/audits/seo/manual/structured-data.js | description": [
+        "audits[structured-data].description"
+      ],
       "lighthouse-core/config/default-config.js | performanceCategoryTitle": [
         "categories.performance.title"
       ],
@@ -5487,6 +5523,15 @@
       "lighthouse-core/config/default-config.js | a11yCategoryManualDescription": [
         "categories.accessibility.manualDescription"
       ],
+      "lighthouse-core/config/default-config.js | seoCategoryTitle": [
+        "categories.seo.title"
+      ],
+      "lighthouse-core/config/default-config.js | seoCategoryDescription": [
+        "categories.seo.description"
+      ],
+      "lighthouse-core/config/default-config.js | seoCategoryManualDescription": [
+        "categories.seo.manualDescription"
+      ],
       "lighthouse-core/config/default-config.js | metricGroupTitle": [
         "categoryGroups.metrics.title"
       ],