Repository: GoogleChrome/lighthouse
Pull request #7049 (2019-01-17 19:39:24)

misc: replace TODOs with github issue links
**Summary**
Cleans up my TODOs with github issues so they're more visible and easier to prioritize.


Patch:
diff --git a/lighthouse-core/audits/byte-efficiency/render-blocking-resources.js b/lighthouse-core/audits/byte-efficiency/render-blocking-resources.js
index 62d1bde7d..5e1e17e65 100644
--- a/lighthouse-core/audits/byte-efficiency/render-blocking-resources.js
+++ b/lighthouse-core/audits/byte-efficiency/render-blocking-resources.js
@@ -103,14 +103,12 @@ class RenderBlockingResources extends Audit {
     for (const resource of artifacts.TagsBlockingFirstPaint) {
       // Ignore any resources that finished after observed FCP (they're clearly not render-blocking)
       if (resource.endTime * 1000 > fcpTsInMs) continue;
-      // TODO(phulce): beacon these occurences to Sentry to improve FCP graph
+      // TODO: beacon to Sentry, https://github.com/GoogleChrome/lighthouse/issues/7041
       if (!nodesByUrl[resource.tag.url]) continue;
 
       const {node, nodeTiming} = nodesByUrl[resource.tag.url];
 
       // Mark this node and all its dependents as deferrable
-      // TODO(phulce): make this slightly more surgical
-      // i.e. the referenced font asset won't become inlined just because you inline the CSS
       node.traverse(node => deferredNodeIds.add(node.id));
 
       // "wastedMs" is the download time of the network request, responseReceived - requestSent
@@ -190,7 +188,6 @@ class RenderBlockingResources extends Audit {
   static async computeWastedCSSBytes(artifacts, context) {
     const wastedBytesByUrl = new Map();
     try {
-      // TODO(phulce): pull this out into computed artifact
       const results = await UnusedCSS.audit(artifacts, context);
       // @ts-ignore - TODO(bckenny): details types.
       for (const item of results.details.items) {
diff --git a/lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js b/lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js
index c00c19a47..2ce5f806e 100644
--- a/lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js
+++ b/lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js
@@ -6,7 +6,6 @@
 'use strict';
 
 const assert = require('assert');
-// @ts-ignore - typed where used.
 const parseCacheControl = require('parse-cache-control');
 const Audit = require('../audit');
 const NetworkRequest = require('../../lib/network-request');
@@ -105,22 +104,14 @@ class CacheHeaders extends Audit {
   }
 
   /**
-   * Computes the user-specified cache lifetime, 0 if explicit no-cache policy is in effect, and null if not
-   * user-specified. See https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html
-   *
+   * Return max-age if defined, otherwise expires header if defined, and null if not.
    * @param {Map<string, string>} headers
-   * @param {{'no-cache'?: boolean,'no-store'?: boolean, 'max-age'?: number}} cacheControl Follows the potential settings of cache-control, see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
+   * @param {ReturnType<typeof parseCacheControl>} cacheControl
    * @return {?number}
    */
   static computeCacheLifetimeInSeconds(headers, cacheControl) {
-    if (cacheControl) {
-      // Cache-Control takes precendence over expires
-      if (cacheControl['no-cache'] || cacheControl['no-store']) return 0;
-      const maxAge = cacheControl['max-age'];
-      if (maxAge !== undefined && Number.isFinite(maxAge)) return Math.max(maxAge, 0);
-    } else if ((headers.get('pragma') || '').includes('no-cache')) {
-      // The HTTP/1.0 Pragma header can disable caching if cache-control is not set, see https://tools.ietf.org/html/rfc7234#section-5.4
-      return 0;
+    if (cacheControl && cacheControl['max-age'] !== undefined) {
+      return cacheControl['max-age'];
     }
 
     const expiresHeaders = headers.get('expires');
@@ -128,7 +119,7 @@ class CacheHeaders extends Audit {
       const expires = new Date(expiresHeaders).getTime();
       // Invalid expires values MUST be treated as already expired
       if (!expires) return 0;
-      return Math.max(0, Math.ceil((expires - Date.now()) / 1000));
+      return Math.ceil((expires - Date.now()) / 1000);
     }
 
     return null;
@@ -169,6 +160,31 @@ class CacheHeaders extends Audit {
     );
   }
 
+  /**
+   * Returns true if headers suggest a record should not be cached for a long time.
+   * @param {Map<string, string>} headers
+   * @param {ReturnType<typeof parseCacheControl>} cacheControl
+   * @returns {boolean}
+   */
+  static shouldSkipRecord(headers, cacheControl) {
+    // The HTTP/1.0 Pragma header can disable caching if cache-control is not set, see https://tools.ietf.org/html/rfc7234#section-5.4
+    if (!cacheControl && (headers.get('pragma') || '').includes('no-cache')) {
+      return true;
+    }
+
+    // Ignore assets where policy implies they should not be cached long periods
+    if (cacheControl &&
+      (
+        cacheControl['must-validate'] ||
+        cacheControl['no-cache'] ||
+        cacheControl['no-store'] ||
+        cacheControl['private'])) {
+      return true;
+    }
+
+    return false;
+  }
+
   /**
    * @param {LH.Artifacts} artifacts
    * @param {LH.Audit.Context} context
@@ -197,15 +213,20 @@ class CacheHeaders extends Audit {
         }
 
         const cacheControl = parseCacheControl(headers.get('cache-control'));
-        let cacheLifetimeInSeconds = CacheHeaders.computeCacheLifetimeInSeconds(
-          headers,
-          cacheControl
-        );
+        if (this.shouldSkipRecord(headers, cacheControl)) {
+          continue;
+        }
 
-        // Ignore assets with an explicit no-cache policy
-        if (cacheLifetimeInSeconds === 0) continue;
+        // Ignore if cacheLifetimeInSeconds is a nonpositive number.
+        let cacheLifetimeInSeconds = CacheHeaders.computeCacheLifetimeInSeconds(
+          headers, cacheControl);
+        if (cacheLifetimeInSeconds !== null &&
+          (!Number.isFinite(cacheLifetimeInSeconds) || cacheLifetimeInSeconds <= 0)) {
+          continue;
+        }
         cacheLifetimeInSeconds = cacheLifetimeInSeconds || 0;
 
+        // Ignore assets whose cache lifetime is already high enough
         const cacheHitProbability = CacheHeaders.getCacheHitProbability(cacheLifetimeInSeconds);
         if (cacheHitProbability > IGNORE_THRESHOLD_IN_PERCENT) continue;
 
@@ -218,7 +239,10 @@ class CacheHeaders extends Audit {
 
         results.push({
           url,
-          cacheControl,
+          // Include cacheControl in results, but cast as any so table types
+          // are happy. cacheControl is not shown in the table so this is OK.
+          // TODO(bckenny): fix DetailsItem
+          cacheControl: /** @type {any} */ (cacheControl),
           cacheLifetimeMs: cacheLifetimeInSeconds * 1000,
           cacheHitProbability,
           totalBytes,
diff --git a/lighthouse-core/audits/content-width.js b/lighthouse-core/audits/content-width.js
index 7de0b62fb..3f4f06a6b 100644
--- a/lighthouse-core/audits/content-width.js
+++ b/lighthouse-core/audits/content-width.js
@@ -34,7 +34,6 @@ class ContentWidth extends Audit {
     const windowWidth = artifacts.ViewportDimensions.outerWidth;
     const widthsMatch = viewportWidth === windowWidth;
 
-    // TODO(phulce): refactor this `isMobile` boolean to be on context
     const isMobileHost = userAgent.includes('Android') || userAgent.includes('Mobile');
     const isMobile = context.settings.emulatedFormFactor === 'mobile' ||
       (context.settings.emulatedFormFactor !== 'desktop' && isMobileHost);
diff --git a/lighthouse-core/audits/seo/font-size.js b/lighthouse-core/audits/seo/font-size.js
index 352ffed80..440ea50ec 100644
--- a/lighthouse-core/audits/seo/font-size.js
+++ b/lighthouse-core/audits/seo/font-size.js
@@ -199,7 +199,7 @@ class FontSize extends Audit {
       title: str_(UIStrings.title),
       failureTitle: str_(UIStrings.failureTitle),
       description: str_(UIStrings.description),
-      requiredArtifacts: ['FontSize', 'URL', 'Viewport'],
+      requiredArtifacts: ['FontSize', 'URL', 'MetaElements'],
     };
   }
 
diff --git a/lighthouse-core/audits/seo/is-crawlable.js b/lighthouse-core/audits/seo/is-crawlable.js
index 2230f5a04..12454799f 100644
--- a/lighthouse-core/audits/seo/is-crawlable.js
+++ b/lighthouse-core/audits/seo/is-crawlable.js
@@ -69,7 +69,7 @@ class IsCrawlable extends Audit {
       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).',
-      requiredArtifacts: ['MetaRobots', 'RobotsTxt', 'URL'],
+      requiredArtifacts: ['MetaElements', 'RobotsTxt', 'URL'],
     };
   }
 
@@ -80,20 +80,22 @@ class IsCrawlable extends Audit {
    */
   static audit(artifacts, context) {
     const devtoolsLog = artifacts.devtoolsLogs[Audit.DEFAULT_PASS];
+    const metaRobots = artifacts.MetaElements.find(meta => meta.name === 'robots');
 
     return MainResource.request({devtoolsLog, URL: artifacts.URL}, context)
       .then(mainResource => {
         /** @type {Array<Object<string, LH.Audit.DetailsItem>>} */
         const blockingDirectives = [];
 
-        if (artifacts.MetaRobots) {
-          const isBlocking = hasBlockingDirective(artifacts.MetaRobots);
+        if (metaRobots) {
+          const metaRobotsContent = metaRobots.content || '';
+          const isBlocking = hasBlockingDirective(metaRobotsContent);
 
           if (isBlocking) {
             blockingDirectives.push({
               source: {
                 type: /** @type {'node'} */ ('node'),
-                snippet: `<meta name="robots" content="${artifacts.MetaRobots}" />`,
+                snippet: `<meta name="robots" content="${metaRobotsContent}" />`,
               },
             });
           }
diff --git a/lighthouse-core/audits/seo/meta-description.js b/lighthouse-core/audits/seo/meta-description.js
index e3767257e..cc9c93c55 100644
--- a/lighthouse-core/audits/seo/meta-description.js
+++ b/lighthouse-core/audits/seo/meta-description.js
@@ -19,7 +19,7 @@ class Description extends Audit {
       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).',
-      requiredArtifacts: ['MetaDescription'],
+      requiredArtifacts: ['MetaElements'],
     };
   }
 
@@ -28,13 +28,15 @@ class Description extends Audit {
    * @return {LH.Audit.Product}
    */
   static audit(artifacts) {
-    if (artifacts.MetaDescription === null) {
+    const metaDescription = artifacts.MetaElements.find(meta => meta.name === 'description');
+    if (!metaDescription) {
       return {
         rawValue: false,
       };
     }
 
-    if (artifacts.MetaDescription.trim().length === 0) {
+    const description = metaDescription.content || '';
+    if (description.trim().length === 0) {
       return {
         rawValue: false,
         explanation: 'Description text is empty.',
diff --git a/lighthouse-core/audits/themed-omnibox.js b/lighthouse-core/audits/themed-omnibox.js
index 25cfda040..c18c8f03b 100644
--- a/lighthouse-core/audits/themed-omnibox.js
+++ b/lighthouse-core/audits/themed-omnibox.js
@@ -30,7 +30,7 @@ class ThemedOmnibox extends MultiCheckAudit {
       failureTitle: 'Does not set an address-bar theme color',
       description: 'The browser address bar can be themed to match your site. ' +
           '[Learn more](https://developers.google.com/web/tools/lighthouse/audits/address-bar).',
-      requiredArtifacts: ['Manifest', 'ThemeColor'],
+      requiredArtifacts: ['Manifest', 'MetaElements'],
     };
   }
 
@@ -43,13 +43,13 @@ class ThemedOmnibox extends MultiCheckAudit {
   }
 
   /**
-   * @param {LH.Artifacts['ThemeColor']} themeColorMeta
+   * @param {LH.Artifacts.MetaElement|undefined} themeColorMeta
    * @param {Array<string>} failures
    */
   static assessMetaThemecolor(themeColorMeta, failures) {
-    if (themeColorMeta === null) {
+    if (!themeColorMeta) {
       failures.push('No `<meta name="theme-color">` tag found');
-    } else if (!ThemedOmnibox.isValidColor(themeColorMeta)) {
+    } else if (!ThemedOmnibox.isValidColor(themeColorMeta.content || '')) {
       failures.push('The theme-color meta tag did not contain a valid CSS color');
     }
   }
@@ -79,14 +79,15 @@ class ThemedOmnibox extends MultiCheckAudit {
     /** @type {Array<string>} */
     const failures = [];
 
+    const themeColorMeta = artifacts.MetaElements.find(meta => meta.name === 'theme-color');
     const manifestValues = await ManifestValues.request(artifacts.Manifest, context);
     ThemedOmnibox.assessManifest(manifestValues, failures);
-    ThemedOmnibox.assessMetaThemecolor(artifacts.ThemeColor, failures);
+    ThemedOmnibox.assessMetaThemecolor(themeColorMeta, failures);
 
     return {
       failures,
       manifestValues,
-      themeColor: artifacts.ThemeColor,
+      themeColor: (themeColorMeta && themeColorMeta.content) || null,
     };
   }
 }
diff --git a/lighthouse-core/audits/viewport.js b/lighthouse-core/audits/viewport.js
index 69c6b4f76..90216c10f 100644
--- a/lighthouse-core/audits/viewport.js
+++ b/lighthouse-core/audits/viewport.js
@@ -20,7 +20,7 @@ class Viewport extends Audit {
           'or `initial-scale`',
       description: 'Add a viewport meta tag to optimize your app for mobile screens. ' +
           '[Learn more](https://developers.google.com/web/tools/lighthouse/audits/has-viewport-meta-tag).',
-      requiredArtifacts: ['Viewport'],
+      requiredArtifacts: ['MetaElements'],
     };
   }
 
@@ -29,7 +29,8 @@ class Viewport extends Audit {
    * @return {LH.Audit.Product}
    */
   static audit(artifacts) {
-    if (artifacts.Viewport === null) {
+    const viewportMeta = artifacts.MetaElements.find(meta => meta.name === 'viewport');
+    if (!viewportMeta) {
       return {
         explanation: 'No viewport meta tag found',
         rawValue: false,
@@ -37,7 +38,7 @@ class Viewport extends Audit {
     }
 
     const warnings = [];
-    const parsedProps = Parser.parseMetaViewPortContent(artifacts.Viewport);
+    const parsedProps = Parser.parseMetaViewPortContent(viewportMeta.content || '');
 
     if (Object.keys(parsedProps.unknownProperties).length) {
       warnings.push(`Invalid properties found: ${JSON.stringify(parsedProps.unknownProperties)}`);
diff --git a/lighthouse-core/config/config.js b/lighthouse-core/config/config.js
index 54cf15322..5671e998c 100644
--- a/lighthouse-core/config/config.js
+++ b/lighthouse-core/config/config.js
@@ -184,7 +184,6 @@ function cleanFlagsForSettings(flags = {}) {
   return settings;
 }
 
-// TODO(phulce): disentangle this merge function
 /**
  * More widely typed than exposed merge() function, below.
  * @param {Object<string, any>|Array<any>|undefined|null} base
diff --git a/lighthouse-core/config/default-config.js b/lighthouse-core/config/default-config.js
index 29aaaff75..5fe0e3589 100644
--- a/lighthouse-core/config/default-config.js
+++ b/lighthouse-core/config/default-config.js
@@ -86,15 +86,14 @@ const defaultConfig = {
     gatherers: [
       'scripts',
       'css-usage',
-      'viewport',
       'viewport-dimensions',
-      'theme-color',
       'manifest',
       'runtime-exceptions',
       'chrome-console-messages',
       'accessibility',
       'image-elements',
       'link-elements',
+      'meta-elements',
       'dobetterweb/anchors-with-no-rel-noopener',
       'dobetterweb/appcache',
       'dobetterweb/doctype',
@@ -104,10 +103,8 @@ const defaultConfig = {
       'dobetterweb/password-inputs-with-prevented-paste',
       'dobetterweb/response-compression',
       'dobetterweb/tags-blocking-first-paint',
-      'seo/meta-description',
       'seo/font-size',
       'seo/crawlable-links',
-      'seo/meta-robots',
       'seo/hreflang',
       'seo/embedded-content',
       'seo/canonical',
diff --git a/lighthouse-core/gather/driver.js b/lighthouse-core/gather/driver.js
index a831effb9..b2e26267f 100644
--- a/lighthouse-core/gather/driver.js
+++ b/lighthouse-core/gather/driver.js
@@ -340,14 +340,9 @@ class Driver {
    * @param {{useIsolation?: boolean}=} options
    * @return {Promise<*>}
    */
-  evaluateAsync(expression, options = {}) {
-    // tsc won't convert {Promise<number>|Promise<undefined>}, so cast manually.
-    // https://github.com/Microsoft/TypeScript/issues/7294
-    /** @type {Promise<number|undefined>} */
-    const contextIdPromise = options.useIsolation ?
-        this._getOrCreateIsolatedContextId() :
-        Promise.resolve(undefined);
-    return contextIdPromise.then(contextId => this._evaluateInContext(expression, contextId));
+  async evaluateAsync(expression, options = {}) {
+    const contextId = options.useIsolation ? await this._getOrCreateIsolatedContextId() : undefined;
+    return this._evaluateInContext(expression, contextId);
   }
 
   /**
@@ -403,19 +398,28 @@ class Driver {
   /**
    * @return {Promise<{url: string, data: string}|null>}
    */
-  getAppManifest() {
-    return this.sendCommand('Page.getAppManifest')
-      .then(response => {
-        // We're not reading `response.errors` however it may contain critical and noncritical
-        // errors from Blink's manifest parser:
-        //   https://chromedevtools.github.io/debugger-protocol-viewer/tot/Page/#type-AppManifestError
-        if (!response.data) {
-          // If the data is empty, the page had no manifest.
-          return null;
-        }
+  async getAppManifest() {
+    this.setNextProtocolTimeout(3000);
+    const response = await this.sendCommand('Page.getAppManifest');
+    let data = response.data;
+
+    // We're not reading `response.errors` however it may contain critical and noncritical
+    // errors from Blink's manifest parser:
+    //   https://chromedevtools.github.io/debugger-protocol-viewer/tot/Page/#type-AppManifestError
+    if (!data) {
+      // If the data is empty, the page had no manifest.
+      return null;
+    }
 
-        return /** @type {Required<LH.Crdp.Page.GetAppManifestResponse>} */ (response);
-      });
+    const BOM_LENGTH = 3;
+    const BOM_FIRSTCHAR = 65279;
+    const isBomEncoded = data.charCodeAt(0) === BOM_FIRSTCHAR;
+
+    if (isBomEncoded) {
+      data = Buffer.from(data).slice(BOM_LENGTH).toString();
+    }
+
+    return {...response, data};
   }
 
   /**
@@ -1241,7 +1245,6 @@ class Driver {
    * @return {Promise<void>}
    */
   async beginEmulation(settings) {
-    // TODO(phulce): remove this flag on next breaking change
     if (!settings.disableDeviceEmulation) {
       if (settings.emulatedFormFactor === 'mobile') {
         await emulation.enableNexus5X(this);
diff --git a/lighthouse-core/gather/gather-runner.js b/lighthouse-core/gather/gather-runner.js
index 53a61a91c..86ae7a8f4 100644
--- a/lighthouse-core/gather/gather-runner.js
+++ b/lighthouse-core/gather/gather-runner.js
@@ -6,10 +6,11 @@
 'use strict';
 
 const log = require('lighthouse-logger');
-const LHError = require('../lib/lh-error');
-const URL = require('../lib/url-shim');
+const manifestParser = require('../lib/manifest-parser.js');
+const LHError = require('../lib/lh-error.js');
+const URL = require('../lib/url-shim.js');
 const NetworkRecorder = require('../lib/network-recorder.js');
-const constants = require('../config/constants');
+const constants = require('../config/constants.js');
 
 const Driver = require('../gather/driver.js'); // eslint-disable-line no-unused-vars
 
@@ -400,6 +401,7 @@ class GatherRunner {
       HostUserAgent: (await options.driver.getBrowserVersion()).userAgent,
       NetworkUserAgent: '', // updated later
       BenchmarkIndex: 0, // updated later
+      WebAppManifest: null, // updated later
       traces: {},
       devtoolsLogs: {},
       settings: options.settings,
@@ -408,6 +410,24 @@ class GatherRunner {
     };
   }
 
+  /**
+   * Uses the debugger protocol to fetch the manifest from within the context of
+   * the target page, reusing any credentials, emulation, etc, already established
+   * there.
+   *
+   * Returns the parsed manifest or null if the page had no manifest. If the manifest
+   * was unparseable as JSON, manifest.value will be undefined and manifest.warning
+   * will have the reason. See manifest-parser.js for more information.
+   *
+   * @param {LH.Gatherer.PassContext} passContext
+   * @return {Promise<LH.Artifacts.Manifest|null>}
+   */
+  static async getWebAppManifest(passContext) {
+    const response = await passContext.driver.getAppManifest();
+    if (!response) return null;
+    return manifestParser(response.data, response.url, passContext.url);
+  }
+
   /**
    * @param {Array<LH.Config.Pass>} passes
    * @param {{driver: Driver, requestedUrl: string, settings: LH.Config.Settings}} options
@@ -437,6 +457,7 @@ class GatherRunner {
           url: options.requestedUrl,
           settings: options.settings,
           passConfig,
+          baseArtifacts,
           // *pass() functions and gatherers can push to this warnings array.
           LighthouseRunWarnings: baseArtifacts.LighthouseRunWarnings,
         };
@@ -448,6 +469,9 @@ class GatherRunner {
         }
         await GatherRunner.beforePass(passContext, gathererResults);
         await GatherRunner.pass(passContext, gathererResults);
+        if (isFirstPass) {
+          baseArtifacts.WebAppManifest = await GatherRunner.getWebAppManifest(passContext);
+        }
         const passData = await GatherRunner.afterPass(passContext, gathererResults);
 
         // Save devtoolsLog, but networkRecords are discarded and not added onto artifacts.
diff --git a/lighthouse-core/gather/gatherers/dobetterweb/optimized-images.js b/lighthouse-core/gather/gatherers/dobetterweb/optimized-images.js
index 67484a2fd..6ca6a9a16 100644
--- a/lighthouse-core/gather/gatherers/dobetterweb/optimized-images.js
+++ b/lighthouse-core/gather/gatherers/dobetterweb/optimized-images.js
@@ -129,8 +129,6 @@ class OptimizedImages extends Gatherer {
    * @return {Promise<?{fromProtocol: boolean, originalSize: number, jpegSize: number, webpSize: number}>}
    */
   calculateImageStats(driver, networkRecord) {
-    // TODO(phulce): remove this dance of trying _getEncodedResponse with a fallback when Audits
-    // domain hits stable in Chrome 62
     return Promise.resolve(networkRecord.requestId).then(requestId => {
       if (this._getEncodedResponseUnsupported) return;
       return this._getEncodedResponse(driver, requestId, 'jpeg').then(jpegData => {
@@ -153,7 +151,7 @@ class OptimizedImages extends Gatherer {
     }).then(result => {
       if (result) return result;
 
-      // Take the slower fallback path if getEncodedResponse isn't available yet
+      // Take the slower fallback path if getEncodedResponse didn't work
       // CORS canvas tainting doesn't support cross-origin images, so skip them early
       if (!networkRecord.isSameOrigin && !networkRecord.isBase64DataUri) return null;
 
diff --git a/lighthouse-core/gather/gatherers/link-elements.js b/lighthouse-core/gather/gatherers/link-elements.js
index 4a74e3abe..9283265e0 100644
--- a/lighthouse-core/gather/gatherers/link-elements.js
+++ b/lighthouse-core/gather/gatherers/link-elements.js
@@ -16,7 +16,6 @@ class LinkElements extends Gatherer {
   async afterPass(passContext) {
     const driver = passContext.driver;
 
-    // TODO(phulce): merge this with the main resource header values too
     // We'll use evaluateAsync because the `node.getAttribute` method doesn't actually normalize
     // the values like access from JavaScript does.
     return driver.evaluateAsync(`(() => {
diff --git a/lighthouse-core/gather/gatherers/manifest.js b/lighthouse-core/gather/gatherers/manifest.js
index ff4268aff..5d0f72cbd 100644
--- a/lighthouse-core/gather/gatherers/manifest.js
+++ b/lighthouse-core/gather/gatherers/manifest.js
@@ -6,40 +6,17 @@
 'use strict';
 
 const Gatherer = require('./gatherer');
-const manifestParser = require('../../lib/manifest-parser');
-const BOM_LENGTH = 3;
-const BOM_FIRSTCHAR = 65279;
 
 /**
- * Uses the debugger protocol to fetch the manifest from within the context of
- * the target page, reusing any credentials, emulation, etc, already established
- * there. The artifact produced is the fetched string, if any, passed through
- * the manifest parser.
+ * TODO(phulce): delete this file and move usages over to WebAppManifest
  */
 class Manifest extends Gatherer {
   /**
-   * Returns the parsed manifest or null if the page had no manifest. If the manifest
-   * was unparseable as JSON, manifest.value will be undefined and manifest.warning
-   * will have the reason. See manifest-parser.js for more information.
    * @param {LH.Gatherer.PassContext} passContext
    * @return {Promise<LH.Artifacts['Manifest']>}
    */
   async afterPass(passContext) {
-    const manifestPromise = passContext.driver.getAppManifest();
-    /** @type {Promise<void>} */
-    const timeoutPromise = new Promise(resolve => setTimeout(resolve, 3000));
-
-    const response = await Promise.race([manifestPromise, timeoutPromise]);
-    if (!response) {
-      return null;
-    }
-
-    const isBomEncoded = response.data.charCodeAt(0) === BOM_FIRSTCHAR;
-    if (isBomEncoded) {
-      response.data = Buffer.from(response.data).slice(BOM_LENGTH).toString();
-    }
-
-    return manifestParser(response.data, response.url, passContext.url);
+    return passContext.baseArtifacts.WebAppManifest;
   }
 }
 
diff --git a/lighthouse-core/gather/gatherers/meta-elements.js b/lighthouse-core/gather/gatherers/meta-elements.js
new file mode 100644
index 000000000..3def86568
--- /dev/null
+++ b/lighthouse-core/gather/gatherers/meta-elements.js
@@ -0,0 +1,34 @@
+/**
+ * @license Copyright 2019 Google Inc. All Rights Reserved.
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
+ */
+'use strict';
+
+const Gatherer = require('./gatherer.js');
+const getElementsInDocumentString = require('../../lib/page-functions.js').getElementsInDocumentString; // eslint-disable-line max-len
+
+class MetaElements extends Gatherer {
+  /**
+   * @param {LH.Gatherer.PassContext} passContext
+   * @return {Promise<LH.Artifacts['MetaElements']>}
+   */
+  async afterPass(passContext) {
+    const driver = passContext.driver;
+
+    // We'll use evaluateAsync because the `node.getAttribute` method doesn't actually normalize
+    // the values like access from JavaScript does.
+    return driver.evaluateAsync(`(() => {
+      ${getElementsInDocumentString};
+
+      return getElementsInDocument('head meta').map(meta => {
+        return {
+          name: meta.name.toLowerCase(),
+          content: meta.content,
+        };
+      });
+    })()`, {useIsolation: true});
+  }
+}
+
+module.exports = MetaElements;
diff --git a/lighthouse-core/gather/gatherers/seo/meta-description.js b/lighthouse-core/gather/gatherers/seo/meta-description.js
deleted file mode 100644
index d34b4f2eb..000000000
--- a/lighthouse-core/gather/gatherers/seo/meta-description.js
+++ /dev/null
@@ -1,24 +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 Gatherer = require('../gatherer');
-
-class MetaDescription extends Gatherer {
-  /**
-   * @param {LH.Gatherer.PassContext} passContext
-   * @return {Promise<LH.Artifacts['MetaDescription']>}
-   */
-  afterPass(passContext) {
-    const driver = passContext.driver;
-
-    return driver.querySelector('head meta[name="description" i]')
-      .then(node => node && node.getAttribute('content'));
-  }
-}
-
-module.exports = MetaDescription;
-
diff --git a/lighthouse-core/gather/gatherers/start-url.js b/lighthouse-core/gather/gatherers/start-url.js
index 1d42543ac..a1a09099d 100644
--- a/lighthouse-core/gather/gatherers/start-url.js
+++ b/lighthouse-core/gather/gatherers/start-url.js
@@ -6,7 +6,6 @@
 'use strict';
 
 const Gatherer = require('./gatherer');
-const manifestParser = require('../../lib/manifest-parser');
 
 /** @typedef {import('../driver.js')} Driver */
 
@@ -16,27 +15,21 @@ class StartUrl extends Gatherer {
    * @param {LH.Gatherer.PassContext} passContext
    * @return {Promise<LH.Artifacts['StartUrl']>}
    */
-  afterPass(passContext) {
-    const driver = passContext.driver;
-    return driver.goOnline(passContext)
-      .then(() => driver.getAppManifest())
-      .then(response => driver.goOffline().then(() => response))
-      .then(response => response && manifestParser(response.data, response.url, passContext.url))
-      .then(manifest => {
-        const startUrlInfo = this._readManifestStartUrl(manifest);
-        if (startUrlInfo.isReadFailure) {
-          return {statusCode: -1, explanation: startUrlInfo.reason};
-        }
+  async afterPass(passContext) {
+    const manifest = passContext.baseArtifacts.WebAppManifest;
+    const startUrlInfo = this._readManifestStartUrl(manifest);
+    if (startUrlInfo.isReadFailure) {
+      return {statusCode: -1, explanation: startUrlInfo.reason};
+    }
 
-        return this._attemptManifestFetch(passContext.driver, startUrlInfo.startUrl);
-      }).catch(() => {
-        return {statusCode: -1, explanation: 'Unable to fetch start URL via service worker.'};
-      });
+    return this._attemptStartURLFetch(passContext.driver, startUrlInfo.startUrl).catch(() => {
+      return {statusCode: -1, explanation: 'Unable to fetch start URL via service worker.'};
+    });
   }
 
   /**
    * Read the parsed manifest and return failure reasons or the startUrl
-   * @param {?{value?: {start_url: {value: string, warning?: string}}, warning?: string}} manifest
+   * @param {LH.Artifacts.Manifest|null} manifest
    * @return {{isReadFailure: true, reason: string}|{isReadFailure: false, startUrl: string}}
    */
   _readManifestStartUrl(manifest) {
@@ -61,7 +54,8 @@ class StartUrl extends Gatherer {
    * @param {string} startUrl
    * @return {Promise<{statusCode: number, explanation: string}>}
    */
-  _attemptManifestFetch(driver, startUrl) {
+  _attemptStartURLFetch(driver, startUrl) {
+    // TODO(phulce): clean up this setTimeout once the response has happened
     // Wait up to 3s to get a matched network request from the fetch() to work
     const timeoutPromise = new Promise(resolve =>
       setTimeout(
diff --git a/lighthouse-core/gather/gatherers/theme-color.js b/lighthouse-core/gather/gatherers/theme-color.js
deleted file mode 100644
index 50917744f..000000000
--- a/lighthouse-core/gather/gatherers/theme-color.js
+++ /dev/null
@@ -1,23 +0,0 @@
-/**
- * @license Copyright 2016 Google Inc. All Rights Reserved.
- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
- * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
- */
-'use strict';
-
-const Gatherer = require('./gatherer');
-
-class ThemeColor extends Gatherer {
-  /**
-   * @param {LH.Gatherer.PassContext} passContext
-   * @return {Promise<LH.Artifacts['ThemeColor']>} The value of the theme-color meta's content attribute, or null
-   */
-  async afterPass(passContext) {
-    const driver = passContext.driver;
-
-    const metaEl = await driver.querySelector('head meta[name="theme-color" i]');
-    return metaEl && metaEl.getAttribute('content');
-  }
-}
-
-module.exports = ThemeColor;
diff --git a/lighthouse-core/gather/gatherers/viewport.js b/lighthouse-core/gather/gatherers/viewport.js
deleted file mode 100644
index 3de16c6b0..000000000
--- a/lighthouse-core/gather/gatherers/viewport.js
+++ /dev/null
@@ -1,23 +0,0 @@
-/**
- * @license Copyright 2016 Google Inc. All Rights Reserved.
- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
- * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
- */
-'use strict';
-
-const Gatherer = require('./gatherer');
-
-class Viewport extends Gatherer {
-  /**
-   * @param {LH.Gatherer.PassContext} passContext
-   * @return {Promise<LH.Artifacts['Viewport']>} The value of the viewport meta's content attribute, or null
-   */
-  async afterPass(passContext) {
-    const driver = passContext.driver;
-
-    const metaEl = await driver.querySelector('head meta[name="viewport" i]');
-    return metaEl && metaEl.getAttribute('content');
-  }
-}
-
-module.exports = Viewport;
diff --git a/lighthouse-core/lib/dependency-graph/simulator/network-analyzer.js b/lighthouse-core/lib/dependency-graph/simulator/network-analyzer.js
index ef2187df5..7f2dd6b0d 100644
--- a/lighthouse-core/lib/dependency-graph/simulator/network-analyzer.js
+++ b/lighthouse-core/lib/dependency-graph/simulator/network-analyzer.js
@@ -289,8 +289,6 @@ class NetworkAnalyzer {
         );
       }
 
-      // TODO(phulce): compute the maximum number of parallel requests (N) and ensure we have at
-      // least N requests that required new connections
       const firstRecord = originRecords.reduce((a, b) => (a.startTime > b.startTime ? b : a));
       connectionWasReused.set(firstRecord.requestId, false);
     }
@@ -448,7 +446,6 @@ class NetworkAnalyzer {
    * @return {LH.Artifacts.NetworkRequest}
    */
   static findMainDocument(records) {
-    // TODO(phulce): handle more edge cases like client redirects, or plumb through finalUrl
     const documentRequests = records.filter(record => record.resourceType ===
         NetworkRequest.TYPES.Document);
     // The main document is the earliest document request, using position in networkRecords array to break ties.
diff --git a/lighthouse-core/lib/dependency-graph/simulator/simulator.js b/lighthouse-core/lib/dependency-graph/simulator/simulator.js
index fe46d21c2..9c6651520 100644
--- a/lighthouse-core/lib/dependency-graph/simulator/simulator.js
+++ b/lighthouse-core/lib/dependency-graph/simulator/simulator.js
@@ -106,7 +106,6 @@ class Simulator {
     this._cachedNodeListByStartTime = [];
     // NOTE: We don't actually need *all* of these sets, but the clarity that each node progresses
     // through the system is quite nice.
-    // TODO(phulce): consider refactoring this so that it's easier to follow
     for (const state of Object.values(NodeState)) {
       this._nodes[state] = new Set();
     }
diff --git a/lighthouse-core/lib/network-recorder.js b/lighthouse-core/lib/network-recorder.js
index 4de1df7ff..4264e34eb 100644
--- a/lighthouse-core/lib/network-recorder.js
+++ b/lighthouse-core/lib/network-recorder.js
@@ -221,7 +221,7 @@ class NetworkRecorder extends EventEmitter {
       return;
     }
 
-    // TODO(phulce): log these to sentry?
+    // TODO: beacon to Sentry, https://github.com/GoogleChrome/lighthouse/issues/7041
     if (!data.redirectResponse) {
       return;
     }
@@ -231,7 +231,6 @@ class NetworkRecorder extends EventEmitter {
     const modifiedData = {
       ...data,
       // Copy over the initiator as well to match DevTools behavior
-      // TODO(phulce): abandon this DT hack and update Lantern graph to handle it
       initiator: originalRequest.initiator,
       requestId: `${originalRequest.requestId}:redirect`,
     };
diff --git a/lighthouse-core/lib/network-request.js b/lighthouse-core/lib/network-request.js
index 883a851df..8c5fc69ec 100644
--- a/lighthouse-core/lib/network-request.js
+++ b/lighthouse-core/lib/network-request.js
@@ -51,7 +51,6 @@ const RESOURCE_TYPES = {
 module.exports = class NetworkRequest {
   constructor() {
     this.requestId = '';
-    // TODO(phulce): remove default DevTools connectionId
     this.connectionId = '0';
     this.connectionReused = false;
 
diff --git a/lighthouse-core/report/html/renderer/report-ui-features.js b/lighthouse-core/report/html/renderer/report-ui-features.js
index bfc1b627c..4ed23e7fc 100644
--- a/lighthouse-core/report/html/renderer/report-ui-features.js
+++ b/lighthouse-core/report/html/renderer/report-ui-features.js
@@ -30,7 +30,7 @@ class ReportUIFeatures {
    * @param {DOM} dom
    */
   constructor(dom) {
-    /** @type {LH.ReportResult} */
+    /** @type {LH.Result} */
     this.json; // eslint-disable-line no-unused-expressions
     /** @type {DOM} */
     this._dom = dom;
@@ -80,7 +80,7 @@ class ReportUIFeatures {
   /**
    * Adds export button, print, and other functionality to the report. The method
    * should be called whenever the report needs to be re-rendered.
-   * @param {LH.ReportResult} report
+   * @param {LH.Result} report
    */
   initFeatures(report) {
     if (this._dom.isDevTools()) return;
@@ -358,7 +358,7 @@ class ReportUIFeatures {
   /**
    * Opens a new tab to the online viewer and sends the local page's JSON results
    * to the online viewer using postMessage.
-   * @param {LH.ReportResult} reportJson
+   * @param {LH.Result} reportJson
    * @param {string} viewerPath
    * @protected
    */
diff --git a/lighthouse-core/runner.js b/lighthouse-core/runner.js
index cb9fb5f02..e08294dc1 100644
--- a/lighthouse-core/runner.js
+++ b/lighthouse-core/runner.js
@@ -227,7 +227,6 @@ class Runner {
       const normalizedGatherSettings = Object.assign({}, artifacts.settings, overrides);
       const normalizedAuditSettings = Object.assign({}, settings, overrides);
 
-      // TODO(phulce): allow change of throttling method to `simulate`
       if (!isDeepEqual(normalizedGatherSettings, normalizedAuditSettings)) {
         throw new Error('Cannot change settings between gathering and auditing');
       }
diff --git a/lighthouse-core/scripts/assert-golden-lhr-unchanged.sh b/lighthouse-core/scripts/assert-golden-lhr-unchanged.sh
index 99bbc750b..7f2908446 100644
--- a/lighthouse-core/scripts/assert-golden-lhr-unchanged.sh
+++ b/lighthouse-core/scripts/assert-golden-lhr-unchanged.sh
@@ -28,7 +28,6 @@ trap teardown EXIT
 
 colorText "Generating a fresh LHR..." "$purple"
 set -x
-# TODO(phulce): add a lantern LHR-differ
 node "$lhroot_path/lighthouse-cli" -A="$lhroot_path/lighthouse-core/test/results/artifacts" --throttling-method=devtools --quiet --output=json --output-path="$freshLHRPath"
 set +x
 
diff --git a/lighthouse-viewer/app/src/github-api.js b/lighthouse-viewer/app/src/github-api.js
index ee6f4bec4..9a0cc319a 100644
--- a/lighthouse-viewer/app/src/github-api.js
+++ b/lighthouse-viewer/app/src/github-api.js
@@ -7,7 +7,7 @@
 
 /* global logger, FirebaseAuth, idbKeyval, getFilenamePrefix */
 
-/** @typedef {{etag: ?string, content: LH.ReportResult}} CachableGist */
+/** @typedef {{etag: ?string, content: LH.Result}} CachableGist */
 
 /**
  * Wrapper around the GitHub API for reading/writing gists.
@@ -24,7 +24,7 @@ class GithubApi {
 
   /**
    * Creates a gist under the users account.
-   * @param {LH.ReportResult} jsonFile The gist file body.
+   * @param {LH.Result} jsonFile The gist file body.
    * @return {Promise<string>} id of the created gist.
    */
   createGist(jsonFile) {
@@ -73,7 +73,7 @@ class GithubApi {
   /**
    * Fetches a Lighthouse report from a gist.
    * @param {string} id The id of a gist.
-   * @return {Promise<LH.ReportResult>}
+   * @return {Promise<LH.Result>}
    */
   getGistFileContentAsJson(id) {
     logger.log('Fetching report from GitHub...', false);
@@ -132,7 +132,7 @@ class GithubApi {
                 .then(resp => resp.json())
                 .then(content => ({etag, content}));
             }
-            const lhr = /** @type {LH.ReportResult} */ (JSON.parse(f.content));
+            const lhr = /** @type {LH.Result} */ (JSON.parse(f.content));
             return {etag, content: lhr};
           });
         });
diff --git a/lighthouse-viewer/app/src/lighthouse-report-viewer.js b/lighthouse-viewer/app/src/lighthouse-report-viewer.js
index f0464dfd5..e59551dfb 100644
--- a/lighthouse-viewer/app/src/lighthouse-report-viewer.js
+++ b/lighthouse-viewer/app/src/lighthouse-report-viewer.js
@@ -106,7 +106,7 @@ class LighthouseReportViewer {
 
   /**
    * Basic Lighthouse report JSON validation.
-   * @param {LH.ReportResult} reportJson
+   * @param {LH.Result} reportJson
    * @private
    */
   _validateReportJson(reportJson) {
@@ -132,10 +132,17 @@ class LighthouseReportViewer {
   }
 
   /**
-   * @param {LH.ReportResult} json
+   * @param {LH.Result} json
    * @private
    */
+  // TODO: Really, `json` should really have type `unknown` and
+  // we can have _validateReportJson verify that it's an LH.Result
   _replaceReportHtml(json) {
+    // Allow users to view the runnerResult
+    if ('lhr' in json) {
+      json = /** @type {LH.RunnerResult} */ (json).lhr;
+    }
+
     this._validateReportJson(json);
 
     // Redirect to old viewer if a v2 report. v3 and v4 both handled by v4 viewer.
@@ -202,7 +209,7 @@ class LighthouseReportViewer {
 
   /**
    * Stores v2.x report in IDB, then navigates to legacy viewer in current tab.
-   * @param {LH.ReportResult} reportJson
+   * @param {LH.Result} reportJson
    * @private
    */
   _loadInLegacyViewerVersion(reportJson) {
@@ -242,7 +249,7 @@ class LighthouseReportViewer {
 
   /**
    * Saves the current report by creating a gist on GitHub.
-   * @param {LH.ReportResult} reportJson
+   * @param {LH.Result} reportJson
    * @return {Promise<string|void>} id of the created gist.
    * @private
    */
diff --git a/lighthouse-viewer/app/src/viewer-ui-features.js b/lighthouse-viewer/app/src/viewer-ui-features.js
index a5406b72a..0cb8a27e1 100644
--- a/lighthouse-viewer/app/src/viewer-ui-features.js
+++ b/lighthouse-viewer/app/src/viewer-ui-features.js
@@ -14,7 +14,7 @@
 class ViewerUIFeatures extends ReportUIFeatures {
   /**
    * @param {DOM} dom
-   * @param {?function(LH.ReportResult): void} saveGistCallback
+   * @param {?function(LH.Result): void} saveGistCallback
    */
   constructor(dom, saveGistCallback) {
     super(dom);
@@ -23,7 +23,7 @@ class ViewerUIFeatures extends ReportUIFeatures {
   }
 
   /**
-   * @param {LH.ReportResult} report
+   * @param {LH.Result} report
    * @override
    */
   initFeatures(report) {
diff --git a/types/artifacts.d.ts b/types/artifacts.d.ts
index 6e5be8c9d..e2c3e55b1 100644
--- a/types/artifacts.d.ts
+++ b/types/artifacts.d.ts
@@ -29,6 +29,8 @@ declare global {
       NetworkUserAgent: string;
       /** The benchmark index that indicates rough device class. */
       BenchmarkIndex: number;
+      /** Parsed version of the page's Web App Manifest, or null if none found. */
+      WebAppManifest: Artifacts.Manifest | null;
       /** A set of page-load traces, keyed by passName. */
       traces: {[passName: string]: Trace};
       /** A set of DevTools debugger protocol records, keyed by passName. */
@@ -89,10 +91,8 @@ declare global {
       JsUsage: Crdp.Profiler.ScriptCoverage[];
       /** Parsed version of the page's Web App Manifest, or null if none found. */
       Manifest: Artifacts.Manifest | null;
-      /** The value of the <meta name="description">'s content attribute, or null. */
-      MetaDescription: string|null;
-      /** The value of the <meta name="robots">'s content attribute, or null. */
-      MetaRobots: string|null;
+      /** The values of the <meta> elements in the head. */
+      MetaElements: Array<{name: string, content?: string}>;
       /** The URL loaded with interception */
       MixedContent: {url: string};
       /** The status code of the attempted load of the page while network access is disabled. */
@@ -115,10 +115,6 @@ declare global {
       StartUrl: {statusCode: number, explanation?: string};
       /** Information on <script> and <link> tags blocking first paint. */
       TagsBlockingFirstPaint: Artifacts.TagBlockingFirstPaint[];
-      /** The value of the <meta name="theme=color">'s content attribute, or null. */
-      ThemeColor: string|null;
-      /** The value of the <meta name="viewport">'s content attribute, or null. */
-      Viewport: string|null;
       /** The dimensions and devicePixelRatio of the loaded viewport. */
       ViewportDimensions: Artifacts.ViewportDimensions;
     }
@@ -126,6 +122,7 @@ declare global {
     module Artifacts {
       export type NetworkRequest = _NetworkRequest;
       export type TaskNode = _TaskNode;
+      export type MetaElement = LH.Artifacts['MetaElements'][0];
 
       export interface Accessibility {
         violations: {
diff --git a/types/gatherer.d.ts b/types/gatherer.d.ts
index db4755b36..78e6c711c 100644
--- a/types/gatherer.d.ts
+++ b/types/gatherer.d.ts
@@ -20,6 +20,7 @@ declare global {
       options?: object;
       /** Push to this array to add top-level warnings to the LHR. */
       LighthouseRunWarnings: Array<string>;
+      baseArtifacts: BaseArtifacts;
     }
 
     export interface LoadData {
diff --git a/lighthouse-core/gather/gatherers/seo/meta-robots.js b/types/parse-cache-control/index.d.ts
similarity index 52%
rename from lighthouse-core/gather/gatherers/seo/meta-robots.js
rename to types/parse-cache-control/index.d.ts
index aa66b6009..e78a485b6 100644
--- a/lighthouse-core/gather/gatherers/seo/meta-robots.js
+++ b/types/parse-cache-control/index.d.ts
@@ -1,23 +1,19 @@
 /**
- * @license Copyright 2017 Google Inc. All Rights Reserved.
+ * @license Copyright 2019 Google Inc. All Rights Reserved.
  * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
  * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
  */
-'use strict';
 
-const Gatherer = require('../gatherer');
-
-class MetaRobots extends Gatherer {
-  /**
-   * @param {LH.Gatherer.PassContext} passContext
-   * @return {Promise<LH.Artifacts['MetaRobots']>}
-   */
-  afterPass(passContext) {
-    const driver = passContext.driver;
-
-    return driver.querySelector('head meta[name="robots" i]')
-      .then(node => node && node.getAttribute('content'));
+declare module 'parse-cache-control' {
+  // Follows the potential settings of cache-control, see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
+  interface CacheHeaders {
+    'max-age'?: number;
+    'must-validate'?: boolean;
+    'no-cache'?: boolean;
+    'no-store'?: boolean;
+    'private'?: boolean;
   }
-}
 
-module.exports = MetaRobots;
+  function ParseCacheControl(headers?: string): CacheHeaders | null;
+  export = ParseCacheControl;
+}


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 ff7cc9a7a..064368476 100644
--- a/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
+++ b/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
@@ -1038,15 +1038,9 @@ Object {
         Object {
           "path": "css-usage",
         },
-        Object {
-          "path": "viewport",
-        },
         Object {
           "path": "viewport-dimensions",
         },
-        Object {
-          "path": "theme-color",
-        },
         Object {
           "path": "manifest",
         },
@@ -1065,6 +1059,9 @@ Object {
         Object {
           "path": "link-elements",
         },
+        Object {
+          "path": "meta-elements",
+        },
         Object {
           "path": "dobetterweb/anchors-with-no-rel-noopener",
         },
@@ -1092,18 +1089,12 @@ Object {
         Object {
           "path": "dobetterweb/tags-blocking-first-paint",
         },
-        Object {
-          "path": "seo/meta-description",
-        },
         Object {
           "path": "seo/font-size",
         },
         Object {
           "path": "seo/crawlable-links",
         },
-        Object {
-          "path": "seo/meta-robots",
-        },
         Object {
           "path": "seo/hreflang",
         },
diff --git a/lighthouse-cli/test/fixtures/seo/seo-failure-cases.html b/lighthouse-cli/test/fixtures/seo/seo-failure-cases.html
index e5ad6d5d0..b3713a900 100644
--- a/lighthouse-cli/test/fixtures/seo/seo-failure-cases.html
+++ b/lighthouse-cli/test/fixtures/seo/seo-failure-cases.html
@@ -12,7 +12,8 @@
   <!-- FAIL(font-size): missing viewport -->
   <meta name="viewport" content="invalid-content=should_have_looked_it_up">
   <!-- no <meta name="description" content=""> -->
-  <meta name="robots" content="nofollow, NOINDEX, all">
+  <!-- FAIL(is-crawlable): noindex on root page, vary the case to make sure we are case insensitive -->
+  <meta name="RoBots" content="nofollow, NOINDEX, all">
   <!-- FAIL(hreflang): invalid language code -->
   <link rel="alternate" hreflang="xx" href="https://xx.example.com" />
   <!-- FAIL(hreflang): spece before a valid code -->
diff --git a/lighthouse-cli/test/smokehouse/byte-config.js b/lighthouse-cli/test/smokehouse/byte-config.js
index 78c58b7fb..6718ab526 100644
--- a/lighthouse-cli/test/smokehouse/byte-config.js
+++ b/lighthouse-cli/test/smokehouse/byte-config.js
@@ -22,8 +22,6 @@ module.exports = {
       'unused-css-rules',
       'unused-javascript',
     ],
-
-    // TODO(phulce): re-write testers to work with faster lantern loading
     throttlingMethod: 'devtools',
   },
 };
diff --git a/lighthouse-cli/test/smokehouse/byte-efficiency/expectations.js b/lighthouse-cli/test/smokehouse/byte-efficiency/expectations.js
index 0822e727c..d4a379638 100644
--- a/lighthouse-cli/test/smokehouse/byte-efficiency/expectations.js
+++ b/lighthouse-cli/test/smokehouse/byte-efficiency/expectations.js
@@ -5,9 +5,6 @@
  */
 'use strict';
 
-// TODO(phulce): assert more score values once Lantern can tie more trace events back to images.
-// See https://github.com/GoogleChrome/lighthouse/issues/4600.
-
 /**
  * Expected Lighthouse audit values for byte efficiency tests
  */
diff --git a/lighthouse-core/test/audits/byte-efficiency/uses-long-cache-ttl-test.js b/lighthouse-core/test/audits/byte-efficiency/uses-long-cache-ttl-test.js
index 46afd745b..a4fa6ac43 100644
--- a/lighthouse-core/test/audits/byte-efficiency/uses-long-cache-ttl-test.js
+++ b/lighthouse-core/test/audits/byte-efficiency/uses-long-cache-ttl-test.js
@@ -74,6 +74,22 @@ describe('Cache headers audit', () => {
     });
   });
 
+  it('ignores nonpositive and nonfinite max-age headers', () => {
+    const infinityMaxAgeStringValue = '1'.repeat(400);
+    assert.equal(Number.parseInt(infinityMaxAgeStringValue), Infinity);
+    const networkRecords = [
+      networkRecord({headers: {'cache-control': 'max-age=' + infinityMaxAgeStringValue}}),
+      networkRecord({headers: {'cache-control': 'max-age=-' + infinityMaxAgeStringValue}}),
+      networkRecord({headers: {'cache-control': 'max-age=-100'}}),
+    ];
+
+    const context = {options, computedCache: new Map()};
+    return CacheHeadersAudit.audit(getArtifacts(networkRecords), context).then(result => {
+      const items = result.details.items;
+      assert.equal(items.length, 0);
+    });
+  });
+
   it('detects low value expires headers', () => {
     const expiresIn = seconds => new Date(Date.now() + seconds * 1000).toGMTString();
     const closeEnough = (actual, exp) => assert.ok(Math.abs(actual - exp) <= 1, 'invalid expires');
@@ -103,11 +119,10 @@ describe('Cache headers audit', () => {
 
     const networkRecords = [
       networkRecord({headers: {
-        'cache-control': 'must-revalidate,max-age=3600',
+        'cache-control': 'no-transform,max-age=3600',
         'expires': expiresIn(86400),
       }}),
       networkRecord({headers: {
-        'cache-control': 'private,must-revalidate',
         'expires': expiresIn(86400),
       }}),
     ];
@@ -173,6 +188,21 @@ describe('Cache headers audit', () => {
     });
   });
 
+  it('ignores assets where policy implies they should not be cached long periods', () => {
+    const networkRecords = [
+      networkRecord({headers: {'cache-control': 'must-validate'}}),
+      networkRecord({headers: {'cache-control': 'no-cache'}}),
+      networkRecord({headers: {'cache-control': 'private'}}),
+    ];
+
+    const context = {options, computedCache: new Map()};
+    return CacheHeadersAudit.audit(getArtifacts(networkRecords), context).then(result => {
+      const items = result.extendedInfo.value.results;
+      assert.equal(result.score, 1);
+      assert.equal(items.length, 0);
+    });
+  });
+
   it('ignores potentially uncacheable records', () => {
     const networkRecords = [
       networkRecord({statusCode: 500}),
diff --git a/lighthouse-core/test/audits/seo/font-size-test.js b/lighthouse-core/test/audits/seo/font-size-test.js
index 947eb7cb5..bd33d1843 100644
--- a/lighthouse-core/test/audits/seo/font-size-test.js
+++ b/lighthouse-core/test/audits/seo/font-size-test.js
@@ -14,10 +14,12 @@ const validViewport = 'width=device-width';
 /* eslint-env jest */
 
 describe('SEO: Font size audit', () => {
+  const makeMetaElements = viewport => [{name: 'viewport', content: viewport}];
+
   it('fails when viewport is not set', () => {
     const artifacts = {
       URL,
-      Viewport: null,
+      MetaElements: [],
       FontSize: [],
     };
 
@@ -29,7 +31,7 @@ describe('SEO: Font size audit', () => {
   it('fails when less than 60% of text is legible', () => {
     const artifacts = {
       URL,
-      Viewport: validViewport,
+      MetaElements: makeMetaElements(validViewport),
       FontSize: {
         totalTextLength: 100,
         visitedTextLength: 100,
@@ -51,7 +53,7 @@ describe('SEO: Font size audit', () => {
   it('passes when there is no text', () => {
     const artifacts = {
       URL,
-      Viewport: validViewport,
+      MetaElements: makeMetaElements(validViewport),
       FontSize: {
         totalTextLength: 0,
         visitedTextLength: 0,
@@ -70,7 +72,7 @@ describe('SEO: Font size audit', () => {
   it('passes when more than 60% of text is legible', () => {
     const artifacts = {
       URL,
-      Viewport: validViewport,
+      MetaElements: makeMetaElements(validViewport),
       FontSize: {
         totalTextLength: 330,
         visitedTextLength: 330,
@@ -106,7 +108,7 @@ describe('SEO: Font size audit', () => {
     };
     const artifacts = {
       URL,
-      Viewport: validViewport,
+      MetaElements: makeMetaElements(validViewport),
       FontSize: {
         totalTextLength: 7,
         visitedTextLength: 7,
@@ -130,7 +132,7 @@ describe('SEO: Font size audit', () => {
   it('adds a category for failing text that wasn\'t analyzed', () => {
     const artifacts = {
       URL,
-      Viewport: validViewport,
+      MetaElements: makeMetaElements(validViewport),
       FontSize: {
         totalTextLength: 100,
         visitedTextLength: 100,
@@ -152,7 +154,7 @@ describe('SEO: Font size audit', () => {
   it('informs user if audit haven\'t covered all text on the page', () => {
     const artifacts = {
       URL,
-      Viewport: validViewport,
+      MetaElements: makeMetaElements(validViewport),
       FontSize: {
         totalTextLength: 100,
         visitedTextLength: 50,
@@ -172,7 +174,7 @@ describe('SEO: Font size audit', () => {
   it('maintains 2 trailing decimal places', () => {
     const artifacts = {
       URL,
-      Viewport: validViewport,
+      MetaElements: makeMetaElements(validViewport),
       FontSize: {
         totalTextLength: 323,
         visitedTextLength: 323,
@@ -191,7 +193,7 @@ describe('SEO: Font size audit', () => {
   it('maintains 2 trailing decimal places with only 1 leading digit', () => {
     const artifacts = {
       URL,
-      Viewport: validViewport,
+      MetaElements: makeMetaElements(validViewport),
       FontSize: {
         totalTextLength: 323,
         visitedTextLength: 323,
diff --git a/lighthouse-core/test/audits/seo/is-crawlable-test.js b/lighthouse-core/test/audits/seo/is-crawlable-test.js
index f5b77af05..1de1eecca 100644
--- a/lighthouse-core/test/audits/seo/is-crawlable-test.js
+++ b/lighthouse-core/test/audits/seo/is-crawlable-test.js
@@ -12,6 +12,8 @@ const networkRecordsToDevtoolsLog = require('../../network-records-to-devtools-l
 /* eslint-env jest */
 
 describe('SEO: Is page crawlable audit', () => {
+  const makeMetaElements = content => [{name: 'robots', content}];
+
   it('fails when page is blocked from indexing with a robots metatag', () => {
     const robotsValues = [
       'noindex',
@@ -33,7 +35,7 @@ describe('SEO: Is page crawlable audit', () => {
       const artifacts = {
         devtoolsLogs: {[IsCrawlableAudit.DEFAULT_PASS]: devtoolsLog},
         URL: {finalUrl},
-        MetaRobots: robotsValue,
+        MetaElements: makeMetaElements(robotsValue),
         RobotsTxt: {},
       };
 
@@ -58,7 +60,7 @@ describe('SEO: Is page crawlable audit', () => {
       devtoolsLogs: {[IsCrawlableAudit.DEFAULT_PASS]: devtoolsLog},
       URL: {finalUrl},
       requestMainResource: () => Promise.resolve(mainResource),
-      MetaRobots: 'all, noarchive',
+      MetaElements: makeMetaElements('all, noarchive'),
       RobotsTxt: {},
     };
 
@@ -78,7 +80,7 @@ describe('SEO: Is page crawlable audit', () => {
     const artifacts = {
       devtoolsLogs: {[IsCrawlableAudit.DEFAULT_PASS]: devtoolsLog},
       URL: {finalUrl},
-      MetaRobots: null,
+      MetaElements: [],
       RobotsTxt: {},
     };
 
@@ -121,7 +123,7 @@ describe('SEO: Is page crawlable audit', () => {
       const artifacts = {
         devtoolsLogs: {[IsCrawlableAudit.DEFAULT_PASS]: devtoolsLog},
         URL: {finalUrl},
-        MetaRobots: null,
+        MetaElements: [],
         RobotsTxt: {},
       };
 
@@ -148,7 +150,7 @@ describe('SEO: Is page crawlable audit', () => {
     const artifacts = {
       devtoolsLogs: {[IsCrawlableAudit.DEFAULT_PASS]: devtoolsLog},
       URL: {finalUrl},
-      MetaRobots: null,
+      MetaElements: [],
       RobotsTxt: {},
     };
 
@@ -168,7 +170,7 @@ describe('SEO: Is page crawlable audit', () => {
     const artifacts = {
       devtoolsLogs: {[IsCrawlableAudit.DEFAULT_PASS]: devtoolsLog},
       URL: {finalUrl},
-      MetaRobots: null,
+      MetaElements: [],
       RobotsTxt: {},
     };
 
@@ -191,7 +193,7 @@ describe('SEO: Is page crawlable audit', () => {
     const artifacts = {
       devtoolsLogs: {[IsCrawlableAudit.DEFAULT_PASS]: devtoolsLog},
       URL: {finalUrl},
-      MetaRobots: null,
+      MetaElements: [],
       RobotsTxt: {},
     };
 
@@ -239,7 +241,7 @@ describe('SEO: Is page crawlable audit', () => {
       const artifacts = {
         devtoolsLogs: {[IsCrawlableAudit.DEFAULT_PASS]: devtoolsLog},
         URL: {finalUrl},
-        MetaRobots: null,
+        MetaElements: [],
         RobotsTxt: robotsTxt,
       };
 
@@ -278,7 +280,7 @@ describe('SEO: Is page crawlable audit', () => {
       const artifacts = {
         devtoolsLogs: {[IsCrawlableAudit.DEFAULT_PASS]: devtoolsLog},
         URL: {finalUrl},
-        MetaRobots: null,
+        MetaElements: [],
         RobotsTxt: robotsTxt,
       };
 
@@ -308,7 +310,7 @@ describe('SEO: Is page crawlable audit', () => {
     const artifacts = {
       devtoolsLogs: {[IsCrawlableAudit.DEFAULT_PASS]: devtoolsLog},
       URL: {finalUrl},
-      MetaRobots: 'noindex',
+      MetaElements: makeMetaElements('noindex'),
       RobotsTxt: robotsTxt,
     };
 
diff --git a/lighthouse-core/test/audits/seo/meta-description-test.js b/lighthouse-core/test/audits/seo/meta-description-test.js
index 6f003654c..e62c39d5c 100644
--- a/lighthouse-core/test/audits/seo/meta-description-test.js
+++ b/lighthouse-core/test/audits/seo/meta-description-test.js
@@ -11,16 +11,18 @@ const assert = require('assert');
 /* eslint-env jest */
 
 describe('SEO: description audit', () => {
+  const makeMetaElements = content => [{name: 'description', content}];
+
   it('fails when HTML does not contain a description meta tag', () => {
     const auditResult = Audit.audit({
-      MetaDescription: null,
+      MetaElements: [],
     });
     assert.equal(auditResult.rawValue, false);
   });
 
   it('fails when HTML contains an empty description meta tag', () => {
     const auditResult = Audit.audit({
-      MetaDescription: '',
+      MetaElements: makeMetaElements(''),
     });
     assert.equal(auditResult.rawValue, false);
     assert.ok(auditResult.explanation.includes('empty'), auditResult.explanation);
@@ -28,7 +30,7 @@ describe('SEO: description audit', () => {
 
   it('fails when description consists only of whitespace', () => {
     const auditResult = Audit.audit({
-      MetaDescription: '\t\xa0',
+      MetaElements: makeMetaElements('\t\xa0'),
     });
     assert.equal(auditResult.rawValue, false);
     assert.ok(auditResult.explanation.includes('empty'), auditResult.explanation);
@@ -36,7 +38,7 @@ describe('SEO: description audit', () => {
 
   it('passes when a description text is provided', () => {
     return assert.equal(Audit.audit({
-      MetaDescription: 'description text',
+      MetaElements: makeMetaElements('description text'),
     }).rawValue, true);
   });
 });
diff --git a/lighthouse-core/test/audits/themed-omnibox-test.js b/lighthouse-core/test/audits/themed-omnibox-test.js
index 49878ae7e..5cd485d7e 100644
--- a/lighthouse-core/test/audits/themed-omnibox-test.js
+++ b/lighthouse-core/test/audits/themed-omnibox-test.js
@@ -17,7 +17,7 @@ const exampleManifest = noUrlManifestParser(manifestSrc);
 function generateMockArtifacts() {
   return {
     Manifest: exampleManifest,
-    ThemeColor: '#bada55',
+    MetaElements: [{name: 'theme-color', content: '#bada55'}],
   };
 }
 function generateMockAuditContext() {
@@ -88,7 +88,7 @@ describe('PWA: themed omnibox audit', () => {
 
   it('fails and warns when no theme-color meta tag found', () => {
     const artifacts = generateMockArtifacts();
-    artifacts.ThemeColor = null;
+    artifacts.MetaElements = [];
     const context = generateMockAuditContext();
     return ThemedOmniboxAudit.audit(artifacts, context).then(result => {
       assert.equal(result.rawValue, false);
@@ -98,7 +98,7 @@ describe('PWA: themed omnibox audit', () => {
 
   it('fails and warns when theme-color has an invalid CSS color', () => {
     const artifacts = generateMockArtifacts();
-    artifacts.ThemeColor = '#1234567';
+    artifacts.MetaElements = [{name: 'theme-color', content: '#1234567'}];
     const context = generateMockAuditContext();
     return ThemedOmniboxAudit.audit(artifacts, context).then(result => {
       assert.equal(result.rawValue, false);
@@ -108,7 +108,7 @@ describe('PWA: themed omnibox audit', () => {
 
   it('succeeds when theme-color present in the html', () => {
     const artifacts = generateMockArtifacts();
-    artifacts.ThemeColor = '#fafa33';
+    artifacts.MetaElements = [{name: 'theme-color', content: '#fafa33'}];
     const context = generateMockAuditContext();
     return ThemedOmniboxAudit.audit(artifacts, context).then(result => {
       assert.equal(result.rawValue, true);
@@ -118,7 +118,7 @@ describe('PWA: themed omnibox audit', () => {
 
   it('succeeds when theme-color has a CSS nickname content value', () => {
     const artifacts = generateMockArtifacts();
-    artifacts.ThemeColor = 'red';
+    artifacts.MetaElements = [{name: 'theme-color', content: 'red'}];
     const context = generateMockAuditContext();
     return ThemedOmniboxAudit.audit(artifacts, context).then(result => {
       assert.equal(result.rawValue, true);
@@ -141,7 +141,7 @@ describe('PWA: themed omnibox audit', () => {
 
   it('fails if HTML theme color is bad, and manifest themecolor is good', () => {
     const artifacts = generateMockArtifacts();
-    artifacts.ThemeColor = 'not a color';
+    artifacts.MetaElements = [{name: 'theme-color'}];
     const context = generateMockAuditContext();
     return ThemedOmniboxAudit.audit(artifacts, context).then(result => {
       assert.equal(result.rawValue, false);
diff --git a/lighthouse-core/test/audits/viewport-test.js b/lighthouse-core/test/audits/viewport-test.js
index 6b6c3201d..8756eb491 100644
--- a/lighthouse-core/test/audits/viewport-test.js
+++ b/lighthouse-core/test/audits/viewport-test.js
@@ -11,39 +11,41 @@ const assert = require('assert');
 /* eslint-env jest */
 
 describe('Mobile-friendly: viewport audit', () => {
+  const makeMetaElements = viewport => [{name: 'viewport', content: viewport}];
+
   it('fails when HTML does not contain a viewport meta tag', () => {
     return assert.equal(Audit.audit({
-      Viewport: null,
+      MetaElements: [],
     }).rawValue, false);
   });
 
   it('fails when HTML contains a non-mobile friendly viewport meta tag', () => {
     const viewport = 'maximum-scale=1';
-    assert.equal(Audit.audit({Viewport: viewport}).rawValue, false);
+    assert.equal(Audit.audit({MetaElements: makeMetaElements(viewport)}).rawValue, false);
     assert.equal(Audit.audit({
-      Viewport: viewport,
+      MetaElements: makeMetaElements(viewport),
     }).warnings[0], undefined);
   });
 
   it('fails when HTML contains an invalid viewport meta tag key', () => {
     const viewport = 'nonsense=true';
-    assert.equal(Audit.audit({Viewport: viewport}).rawValue, false);
+    assert.equal(Audit.audit({MetaElements: makeMetaElements(viewport)}).rawValue, false);
     assert.equal(Audit.audit({
-      Viewport: viewport,
+      MetaElements: makeMetaElements(viewport),
     }).warnings[0], 'Invalid properties found: {"nonsense":"true"}');
   });
 
   it('fails when HTML contains an invalid viewport meta tag value', () => {
     const viewport = 'initial-scale=microscopic';
-    assert.equal(Audit.audit({Viewport: viewport}).rawValue, false);
+    assert.equal(Audit.audit({MetaElements: makeMetaElements(viewport)}).rawValue, false);
     assert.equal(Audit.audit({
-      Viewport: viewport,
+      MetaElements: makeMetaElements(viewport),
     }).warnings[0], 'Invalid values found: {"initial-scale":"microscopic"}');
   });
 
   it('fails when HTML contains an invalid viewport meta tag key and value', () => {
     const viewport = 'nonsense=true, initial-scale=microscopic';
-    const {rawValue, warnings} = Audit.audit({Viewport: viewport});
+    const {rawValue, warnings} = Audit.audit({MetaElements: makeMetaElements(viewport)});
     assert.equal(rawValue, false);
     assert.equal(warnings[0], 'Invalid properties found: {"nonsense":"true"}');
     assert.equal(warnings[1], 'Invalid values found: {"initial-scale":"microscopic"}');
@@ -58,7 +60,7 @@ describe('Mobile-friendly: viewport audit', () => {
     ];
     viewports.forEach(viewport => {
       assert.equal(Audit.audit({
-        Viewport: viewport,
+        MetaElements: makeMetaElements(viewport),
       }).rawValue, true);
     });
   });
@@ -69,7 +71,7 @@ describe('Mobile-friendly: viewport audit', () => {
       'width=device-width, viewport-fit=cover',
     ];
     viewports.forEach(viewport => {
-      const result = Audit.audit({Viewport: viewport});
+      const result = Audit.audit({MetaElements: makeMetaElements(viewport)});
       assert.equal(result.rawValue, true);
       assert.equal(result.warnings[0], undefined);
     });
diff --git a/lighthouse-core/test/config/config-test.js b/lighthouse-core/test/config/config-test.js
index f3a9d1476..8ea5b0202 100644
--- a/lighthouse-core/test/config/config-test.js
+++ b/lighthouse-core/test/config/config-test.js
@@ -94,7 +94,7 @@ describe('Config', () => {
     const configJson = {
       passes: [{
         passName: unlikelyPassName,
-        gatherers: ['viewport'],
+        gatherers: ['meta-elements'],
       }, {
         passName: unlikelyPassName,
         gatherers: ['viewport-dimensions'],
@@ -139,7 +139,7 @@ describe('Config', () => {
       passes: [{
         gatherers: [
           'viewport-dimensions',
-          'viewport',
+          'meta-elements',
         ],
       }],
       audits: ['is-on-https'],
@@ -984,8 +984,8 @@ describe('Config', () => {
         passes: [{
           passName: 'defaultPass',
           gatherers: [
-            // `options` merged into default `viewport` gatherer.
-            {path: 'viewport', options: {gOpt}},
+            // `options` merged into default `scripts` gatherer.
+            {path: 'scripts', options: {gOpt}},
           ],
         }],
         audits: [
@@ -998,8 +998,8 @@ describe('Config', () => {
       const printedConfig = JSON.parse(printed);
 
       // Check that options weren't completely eliminated.
-      const viewportGatherer = printedConfig.passes[0].gatherers.find(g => g.path === 'viewport');
-      assert.strictEqual(viewportGatherer.options.gOpt, gOpt);
+      const scriptsGatherer = printedConfig.passes[0].gatherers.find(g => g.path === 'scripts');
+      assert.strictEqual(scriptsGatherer.options.gOpt, gOpt);
       const metricsAudit = printedConfig.audits.find(a => a.path === 'metrics');
       assert.strictEqual(metricsAudit.options.aOpt, aOpt);
 
diff --git a/lighthouse-core/test/fixtures/artifacts/perflog/artifacts.json b/lighthouse-core/test/fixtures/artifacts/perflog/artifacts.json
index 8af6b351a..2a08553da 100644
--- a/lighthouse-core/test/fixtures/artifacts/perflog/artifacts.json
+++ b/lighthouse-core/test/fixtures/artifacts/perflog/artifacts.json
@@ -9,7 +9,7 @@
     "requestedUrl": "https://www.reddit.com/r/nba",
     "finalUrl": "https://www.reddit.com/r/nba"
   },
-  "Viewport": null,
+  "MetaElements": [],
   "ViewportDimensions": {
     "innerWidth": 412,
     "innerHeight": 660,
diff --git a/lighthouse-core/test/gather/driver-test.js b/lighthouse-core/test/gather/driver-test.js
index 0030b03e2..9dc52c175 100644
--- a/lighthouse-core/test/gather/driver-test.js
+++ b/lighthouse-core/test/gather/driver-test.js
@@ -117,10 +117,11 @@ function sendCommandStub(command, params) {
 /* eslint-env jest */
 
 let driverStub;
+let connectionStub;
 beforeEach(() => {
-  const connection = new Connection();
-  connection.sendCommand = sendCommandStub;
-  driverStub = new Driver(connection);
+  connectionStub = new Connection();
+  connectionStub.sendCommand = sendCommandStub;
+  driverStub = new Driver(connectionStub);
 });
 
 describe('Browser Driver', () => {
@@ -329,11 +330,42 @@ describe('Browser Driver', () => {
       assert.equal(sendCommandParams[0], undefined);
     });
   });
+
+  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('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'});
+    });
+
+    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'});
+    });
+  });
 });
 
 describe('Multiple tab check', () => {
   beforeEach(() => {
     sendCommandParams = [];
+    sendCommandMockResponses.clear();
   });
 
   it('will pass if there are no current service workers', () => {
diff --git a/lighthouse-core/test/gather/fake-driver.js b/lighthouse-core/test/gather/fake-driver.js
index f90ed3b0f..04992eedc 100644
--- a/lighthouse-core/test/gather/fake-driver.js
+++ b/lighthouse-core/test/gather/fake-driver.js
@@ -22,6 +22,9 @@ const fakeDriver = {
   getBenchmarkIndex() {
     return Promise.resolve(125.2);
   },
+  getAppManifest() {
+    return Promise.resolve(null);
+  },
   connect() {
     return Promise.resolve();
   },
diff --git a/lighthouse-core/test/gather/gather-runner-test.js b/lighthouse-core/test/gather/gather-runner-test.js
index ae3fcd43b..44dbc1ed8 100644
--- a/lighthouse-core/test/gather/gather-runner-test.js
+++ b/lighthouse-core/test/gather/gather-runner-test.js
@@ -1070,4 +1070,36 @@ describe('GatherRunner', function() {
         });
     });
   });
+
+  describe('.getWebAppManifest', () => {
+    const MANIFEST_URL = 'https://example.com/manifest.json';
+    let passContext;
+
+    beforeEach(() => {
+      passContext = {
+        url: 'https://example.com/index.html',
+        baseArtifacts: {},
+        driver: fakeDriver,
+      };
+    });
+
+    it('should pass through manifest when null', async () => {
+      const getAppManifest = jest.spyOn(fakeDriver, 'getAppManifest');
+      getAppManifest.mockResolvedValueOnce(null);
+      const result = await GatherRunner.getWebAppManifest(passContext);
+      expect(result).toEqual(null);
+    });
+
+    it('should parse the manifest when found', async () => {
+      const manifest = {name: 'App'};
+      const getAppManifest = jest.spyOn(fakeDriver, 'getAppManifest');
+      getAppManifest.mockResolvedValueOnce({data: JSON.stringify(manifest), url: MANIFEST_URL});
+      const result = await GatherRunner.getWebAppManifest(passContext);
+      expect(result).toHaveProperty('raw', JSON.stringify(manifest));
+      expect(result.value).toMatchObject({
+        name: {value: 'App', raw: 'App'},
+        start_url: {value: passContext.url, raw: undefined},
+      });
+    });
+  });
 });
diff --git a/lighthouse-core/test/gather/gatherers/manifest-test.js b/lighthouse-core/test/gather/gatherers/manifest-test.js
deleted file mode 100644
index a22813ff9..000000000
--- a/lighthouse-core/test/gather/gatherers/manifest-test.js
+++ /dev/null
@@ -1,128 +0,0 @@
-/**
- * @license Copyright 2016 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';
-
-/* eslint-env jest */
-
-const ManifestGather = require('../../../gather/gatherers/manifest');
-const assert = require('assert');
-let manifestGather;
-
-const EXAMPLE_MANIFEST_URL = 'https://example.com/manifest.json';
-const EXAMPLE_DOC_URL = 'https://example.com/index.html';
-
-describe('Manifest gatherer', () => {
-  // Reset the Gatherer before each test.
-  beforeEach(() => {
-    manifestGather = new ManifestGather();
-  });
-
-  it('returns an artifact', () => {
-    return manifestGather.afterPass({
-      driver: {
-        getAppManifest() {
-          return Promise.resolve({
-            data: '{}',
-            errors: [],
-            url: EXAMPLE_MANIFEST_URL,
-          });
-        },
-      },
-      url: EXAMPLE_DOC_URL,
-    }).then(artifact => {
-      assert.ok(typeof artifact === 'object');
-    });
-  });
-
-  it('returns an artifact when manifest is saved as BOM UTF-8', () => {
-    const fs = require('fs');
-    const manifestWithoutBOM = fs.readFileSync(__dirname + '/../../fixtures/manifest.json')
-      .toString();
-    const manifestWithBOM = fs.readFileSync(__dirname + '/../../fixtures/manifest-bom.json')
-      .toString();
-
-    const getDriver = (data) => {
-      return {
-        driver: {
-          getAppManifest() {
-            return Promise.resolve({
-              data,
-              errors: [],
-              url: EXAMPLE_MANIFEST_URL,
-            });
-          },
-        },
-        url: EXAMPLE_DOC_URL,
-      };
-    };
-
-    const promises = [];
-    promises.push(manifestGather.afterPass(getDriver(manifestWithBOM))
-      .then(manifest => {
-        assert.strictEqual(manifest.raw, Buffer.from(manifestWithBOM).slice(3).toString());
-        assert.strictEqual(manifest.value.name.value, 'Example App');
-        assert.strictEqual(manifest.value.short_name.value, 'ExApp');
-      })
-    );
-
-    promises.push(manifestGather.afterPass(getDriver(manifestWithoutBOM))
-      .then(manifest => {
-        assert.strictEqual(manifest.raw, manifestWithoutBOM);
-        assert.strictEqual(manifest.value.name.value, 'Example App');
-        assert.strictEqual(manifest.value.short_name.value, 'ExApp');
-      })
-    );
-
-    return Promise.all(promises);
-  });
-
-  it('throws an error when unable to retrieve the manifest', () => {
-    return manifestGather.afterPass({
-      driver: {
-        getAppManifest() {
-          return Promise.reject(
-            new Error(`Unable to retrieve manifest at ${EXAMPLE_MANIFEST_URL}.`)
-          );
-        },
-      },
-    }).then(
-      _ => assert.ok(false),
-      err => assert.ok(err.message.includes(EXAMPLE_MANIFEST_URL)));
-  });
-
-  it('returns null when the page had no manifest', () => {
-    return manifestGather.afterPass({
-      driver: {
-        getAppManifest() {
-          return Promise.resolve(null);
-        },
-      },
-    }).then(artifact => {
-      assert.strictEqual(artifact, null);
-    });
-  });
-
-  it('creates a manifest object for valid manifest content', () => {
-    const data = JSON.stringify({
-      name: 'App',
-    });
-    return manifestGather.afterPass({
-      driver: {
-        getAppManifest() {
-          return Promise.resolve({
-            errors: [],
-            data,
-            url: EXAMPLE_MANIFEST_URL,
-          });
-        },
-      },
-      url: EXAMPLE_DOC_URL,
-    }).then(artifact => {
-      assert.ok(typeof artifact.value === 'object');
-      assert.strictEqual(artifact.explanation, undefined);
-    });
-  });
-});
diff --git a/lighthouse-core/test/gather/gatherers/seo/meta-description-test.js b/lighthouse-core/test/gather/gatherers/seo/meta-description-test.js
deleted file mode 100644
index 5b7292cf0..000000000
--- a/lighthouse-core/test/gather/gatherers/seo/meta-description-test.js
+++ /dev/null
@@ -1,34 +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';
-
-/* eslint-env jest */
-
-const MetaDescriptionGather = require('../../../../gather/gatherers/seo/meta-description');
-const assert = require('assert');
-const EXAMPLE_DESCRIPTION = 'description text';
-let metaDescriptionGather;
-
-describe('Meta description gatherer', () => {
-  // Reset the Gatherer before each test.
-  beforeEach(() => {
-    metaDescriptionGather = new MetaDescriptionGather();
-  });
-
-  it('returns an artifact', () => {
-    return metaDescriptionGather.afterPass({
-      driver: {
-        querySelector() {
-          return Promise.resolve({
-            getAttribute: () => EXAMPLE_DESCRIPTION,
-          });
-        },
-      },
-    }).then(artifact => {
-      assert.equal(artifact, EXAMPLE_DESCRIPTION);
-    });
-  });
-});
diff --git a/lighthouse-core/test/gather/gatherers/start-url-test.js b/lighthouse-core/test/gather/gatherers/start-url-test.js
index df9b0fd37..3868ec625 100644
--- a/lighthouse-core/test/gather/gatherers/start-url-test.js
+++ b/lighthouse-core/test/gather/gatherers/start-url-test.js
@@ -7,9 +7,9 @@
 
 /* eslint-env jest */
 
-const StartUrlGatherer = require('../../../gather/gatherers/start-url');
+const StartUrlGatherer = require('../../../gather/gatherers/start-url.js');
+const parseManifest = require('../../../lib/manifest-parser.js');
 const assert = require('assert');
-const tracingData = require('../../fixtures/traces/network-records.json');
 
 const mockDriver = {
   goOffline() {
@@ -28,18 +28,25 @@ const wrapSendCommand = (mockDriver, url, status = 200, fromServiceWorker = fals
     cb({response: {status, url, fromServiceWorker}});
   };
 
-  mockDriver.getAppManifest = () => {
-    return Promise.resolve({
-      data: '{"start_url": "' + url + '"}',
-      errors: [],
-      url,
-    });
-  };
-
   return mockDriver;
 };
 
 describe('Start-url gatherer', () => {
+  let baseArtifacts;
+
+  function createArtifactsWithURL(url) {
+    return {WebAppManifest: {value: {start_url: {value: url}}}};
+  }
+
+  beforeEach(() => {
+    jest.useFakeTimers();
+    baseArtifacts = {WebAppManifest: null};
+  });
+
+  afterEach(() => {
+    jest.advanceTimersByTime(5000);
+  });
+
   it('returns an artifact set to -1 when offline loading fails', () => {
     const startUrlGatherer = new StartUrlGatherer();
     const startUrlGathererWithQueryString = new StartUrlGatherer();
@@ -58,23 +65,26 @@ describe('Start-url gatherer', () => {
       return mockDriver;
     };
 
-    const options = {
+    const passContext = {
       url: 'https://do-not-match.com/',
+      baseArtifacts,
       driver: throwOnEvaluate(wrapSendCommand(mockDriver, 'https://do-not-match.com/', -1)),
     };
-    const optionsWithQueryString = {
+    const passContextWithFragment = {
+      baseArtifacts,
       url: 'https://ifixit-pwa.appspot.com/?history',
       driver: throwOnEvaluate(wrapSendCommand(mockDriver, 'https://ifixit-pwa.appspot.com/?history', -1)),
     };
-    const optionsWithResponseNotFromSW = {
+    const passContextWithResponseNotFromSW = {
       url: 'https://do-not-match.com/',
+      baseArtifacts: createArtifactsWithURL('https://do-not-match.com/'),
       driver: wrapSendCommand(mockDriver, 'https://do-not-match.com/', 200),
     };
 
     return Promise.all([
-      startUrlGatherer.afterPass(options),
-      startUrlGathererWithQueryString.afterPass(optionsWithQueryString),
-      startUrlGathererWithResponseNotFromSW.afterPass(optionsWithResponseNotFromSW),
+      startUrlGatherer.afterPass(passContext),
+      startUrlGathererWithQueryString.afterPass(passContextWithFragment),
+      startUrlGathererWithResponseNotFromSW.afterPass(passContextWithResponseNotFromSW),
     ]).then(([artifact, artifactWithQueryString, artifactWithResponseNotFromSW]) => {
       assert.equal(artifact.statusCode, -1);
       assert.ok(artifact.explanation, 'did not set debug string');
@@ -89,18 +99,20 @@ describe('Start-url gatherer', () => {
   it('returns an artifact set to 200 when offline loading from service worker succeeds', () => {
     const startUrlGatherer = new StartUrlGatherer();
     const startUrlGathererWithFragment = new StartUrlGatherer();
-    const options = {
+    const passContext = {
       url: 'https://ifixit-pwa.appspot.com/',
+      baseArtifacts: createArtifactsWithURL('https://ifixit-pwa.appspot.com/'),
       driver: wrapSendCommand(mockDriver, 'https://ifixit-pwa.appspot.com/', 200, true),
     };
-    const optionsWithQueryString = {
+    const passContextWithFragment = {
       url: 'https://ifixit-pwa.appspot.com/#/history',
+      baseArtifacts: createArtifactsWithURL('https://ifixit-pwa.appspot.com/#/history'),
       driver: wrapSendCommand(mockDriver, 'https://ifixit-pwa.appspot.com/#/history', 200, true),
     };
 
     return Promise.all([
-      startUrlGatherer.afterPass(options, tracingData),
-      startUrlGathererWithFragment.afterPass(optionsWithQueryString, tracingData),
+      startUrlGatherer.afterPass(passContext),
+      startUrlGathererWithFragment.afterPass(passContextWithFragment),
     ]).then(([artifact, artifactWithFragment]) => {
       assert.equal(artifact.statusCode, 200);
       assert.equal(artifactWithFragment.statusCode, 200);
@@ -110,14 +122,13 @@ describe('Start-url gatherer', () => {
   it('returns a explanation when manifest cannot be found', () => {
     const driver = Object.assign({}, mockDriver);
     const startUrlGatherer = new StartUrlGatherer();
-    const options = {
+    const passContext = {
+      baseArtifacts,
       url: 'https://ifixit-pwa.appspot.com/',
       driver,
     };
 
-    driver.getAppManifest = () => Promise.resolve(null);
-
-    return startUrlGatherer.afterPass(options, tracingData)
+    return startUrlGatherer.afterPass(passContext)
       .then(artifact => {
         assert.equal(artifact.explanation,
           `No usable web app manifest found on page.`);
@@ -127,17 +138,19 @@ describe('Start-url gatherer', () => {
   it('returns a explanation when manifest cannot be parsed', () => {
     const driver = Object.assign({}, mockDriver);
     const startUrlGatherer = new StartUrlGatherer();
-    const options = {
+    const passContext = {
+      baseArtifacts,
       url: 'https://ifixit-pwa.appspot.com/',
       driver,
     };
 
-    driver.getAppManifest = () => Promise.resolve({
-      data: 'this is invalid',
-      url: 'https://ifixit-pwa.appspot.com/manifest.json',
-    });
+    baseArtifacts.WebAppManifest = parseManifest(
+      'this is invalid',
+      'https://ifixit-pwa.appspot.com/manifest.json',
+      passContext.url
+    );
 
-    return startUrlGatherer.afterPass(options, tracingData)
+    return startUrlGatherer.afterPass(passContext)
       .then(artifact => {
         assert.equal(artifact.explanation,
           `Error fetching web app manifest: ERROR: file isn't valid JSON: ` +
@@ -145,16 +158,17 @@ describe('Start-url gatherer', () => {
       });
   });
 
-  it('times out when a start_url is too slow to respond', () => {
+  it('times out when a start_url is too slow to respond', async () => {
     const startUrlGatherer = new StartUrlGatherer();
-    const options = {
+    const passContext = {
       url: 'https://ifixit-pwa.appspot.com/',
+      baseArtifacts: createArtifactsWithURL('https://ifixit-pwa.appspot.com/'),
       driver: wrapSendCommand(mockDriver, ''),
     };
 
-    return startUrlGatherer.afterPass(options, tracingData)
-      .then(artifact => {
-        assert.equal(artifact.explanation, 'Timed out waiting for fetched start_url.');
-      });
+    const resultPromise = startUrlGatherer.afterPass(passContext);
+    jest.advanceTimersByTime(5000);
+    const artifact = await resultPromise;
+    assert.equal(artifact.explanation, 'Timed out waiting for fetched start_url.');
   });
 });
diff --git a/lighthouse-core/test/gather/gatherers/theme-color-test.js b/lighthouse-core/test/gather/gatherers/theme-color-test.js
deleted file mode 100644
index 0acee411e..000000000
--- a/lighthouse-core/test/gather/gatherers/theme-color-test.js
+++ /dev/null
@@ -1,35 +0,0 @@
-/**
- * @license Copyright 2016 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';
-
-/* eslint-env jest */
-
-const ThemeColorGather = require('../../../gather/gatherers/theme-color');
-const assert = require('assert');
-let themeColorGather;
-
-describe('Theme Color gatherer', () => {
-  // Reset the Gatherer before each test.
-  beforeEach(() => {
-    themeColorGather = new ThemeColorGather();
-  });
-
-  it('returns the correct URL from options', () => {
-    return themeColorGather.afterPass({
-      driver: {
-        querySelector() {
-          return Promise.resolve({
-            getAttribute() {
-              return '#288A76';
-            },
-          });
-        },
-      },
-    }).then(artifact => {
-      assert.equal(artifact, '#288A76');
-    });
-  });
-});
diff --git a/lighthouse-core/test/gather/gatherers/viewport-test.js b/lighthouse-core/test/gather/gatherers/viewport-test.js
deleted file mode 100644
index 9536a40c6..000000000
--- a/lighthouse-core/test/gather/gatherers/viewport-test.js
+++ /dev/null
@@ -1,34 +0,0 @@
-/**
- * @license Copyright 2016 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';
-
-/* eslint-env jest */
-
-const ViewportGather = require('../../../gather/gatherers/viewport');
-const assert = require('assert');
-let viewportGather;
-
-describe('Viewport gatherer', () => {
-  // Reset the Gatherer before each test.
-  beforeEach(() => {
-    viewportGather = new ViewportGather();
-  });
-
-  it('returns an artifact', () => {
-    return viewportGather.afterPass({
-      driver: {
-        querySelector() {
-          return Promise.resolve({
-            getAttribute: () => 'width=device-width',
-          });
-        },
-      },
-    }).then(artifact => {
-      assert.ok(typeof artifact === 'string');
-      assert.ok(/width=/gim.test(artifact));
-    });
-  });
-});
diff --git a/lighthouse-core/test/index-test.js b/lighthouse-core/test/index-test.js
index 3728566af..d9a3adc97 100644
--- a/lighthouse-core/test/index-test.js
+++ b/lighthouse-core/test/index-test.js
@@ -73,7 +73,7 @@ describe('Module Tests', function() {
     return lighthouse('chrome://version', {}, {
       passes: [{
         gatherers: [
-          'viewport',
+          'scripts',
         ],
       }],
       audits: [
diff --git a/lighthouse-core/test/report/html/renderer/report-ui-features-test.js b/lighthouse-core/test/report/html/renderer/report-ui-features-test.js
index 1b3566fca..b9d4b8d40 100644
--- a/lighthouse-core/test/report/html/renderer/report-ui-features-test.js
+++ b/lighthouse-core/test/report/html/renderer/report-ui-features-test.js
@@ -19,7 +19,7 @@ const CategoryRenderer = require('../../../../report/html/renderer/category-rend
 const CriticalRequestChainRenderer = require(
     '../../../../report/html/renderer/crc-details-renderer.js');
 const ReportRenderer = require('../../../../report/html/renderer/report-renderer.js');
-const sampleResultsOrig = require('../../../results/sample_v2.json');
+const sampleResults = require('../../../results/sample_v2.json');
 
 const TEMPLATE_FILE = fs.readFileSync(__dirname +
     '/../../../../report/html/templates.html', 'utf8');
@@ -29,7 +29,6 @@ const TEMPLATE_FILE_REPORT = fs.readFileSync(__dirname +
 describe('ReportUIFeatures', () => {
   let renderer;
   let reportUIFeatures;
-  let sampleResults;
 
   beforeAll(() => {
     global.URL = URL;
@@ -74,7 +73,6 @@ describe('ReportUIFeatures', () => {
     const detailsRenderer = new DetailsRenderer(dom);
     const categoryRenderer = new CategoryRenderer(dom, detailsRenderer);
     renderer = new ReportRenderer(dom, categoryRenderer);
-    sampleResults = Util.prepareReportResult(sampleResultsOrig);
     reportUIFeatures = new ReportUIFeatures(dom2);
   });
 
@@ -100,7 +98,7 @@ describe('ReportUIFeatures', () => {
       renderer.renderReport(sampleResults, container);
 
       assert.equal(reportUIFeatures.json, undefined);
-      reportUIFeatures.initFeatures(Util.prepareReportResult(sampleResults));
+      reportUIFeatures.initFeatures(sampleResults);
       assert.ok(reportUIFeatures.json);
     });
   });
diff --git a/lighthouse-core/test/results/artifacts/artifacts.json b/lighthouse-core/test/results/artifacts/artifacts.json
index 1cca9a73c..9c77c5d16 100644
--- a/lighthouse-core/test/results/artifacts/artifacts.json
+++ b/lighthouse-core/test/results/artifacts/artifacts.json
@@ -121,7 +121,6 @@
       }
     ]
   },
-  "Viewport": "width=device-width, initial-scale=1, minimum-scale=1",
   "ViewportDimensions": {
     "innerWidth": 412,
     "innerHeight": 660,
@@ -129,8 +128,10 @@
     "outerHeight": 660,
     "devicePixelRatio": 2.625
   },
-  "ThemeColor": null,
   "Manifest": null,
+  "MetaElements": [
+    {"name": "viewport", "content": "width=device-width, initial-scale=1, minimum-scale=1"}
+  ],
   "RuntimeExceptions": [
     {
       "timestamp": 1519974785784.3982,
@@ -2480,7 +2481,6 @@
     "name": "mydb",
     "version": "1.0"
   },
-  "MetaDescription": null,
   "FontSize": {
     "analyzedFailingNodesData": [],
     "analyzedFailingTextLength": 0,
@@ -2510,7 +2510,6 @@
       "text": ""
     }
   ],
-  "MetaRobots": null,
   "Hreflang": [],
   "EmbeddedContent": [],
   "Canonical": [],
diff --git a/lighthouse-core/test/runner-test.js b/lighthouse-core/test/runner-test.js
index c0f11d7da..52230e3a6 100644
--- a/lighthouse-core/test/runner-test.js
+++ b/lighthouse-core/test/runner-test.js
@@ -487,7 +487,7 @@ describe('Runner', () => {
     const config = new Config({
       passes: [{
         passName: 'firstPass',
-        gatherers: ['viewport', 'viewport-dimensions'],
+        gatherers: ['meta-elements', 'viewport-dimensions'],
       }],
 
       audits: [