Repository: GoogleChrome/lighthouse
Pull request #7025 (2019-01-16 03:41:12)

core(meta): refactor all meta audits to single artifact
**Summary**
Replace all our scattered meta gatherers with a single `MetaElements` artifact.

**Related Issues/PRs**
#6747



Patch:
diff --git a/build/build-extension.js b/build/build-extension.js
index cf7e9d1bd..9c1085fc9 100644
--- a/build/build-extension.js
+++ b/build/build-extension.js
@@ -78,7 +78,7 @@ async function packageExtension() {
     writeStream.on('error', reject);
 
     archive.pipe(writeStream);
-    archive.glob(`${distDir}/**`);
+    archive.directory(distDir, false);
     archive.finalize();
   });
 }
diff --git a/changelog.md b/changelog.md
index bd7cc4c6e..69365365e 100644
--- a/changelog.md
+++ b/changelog.md
@@ -1,11 +1,11 @@
  <a name="4.0.0"></a>
 # 4.0.0 (2019-01-16)
 
-## New Contributors!
+## New contributors!
 
 @mattzeunert, @dima74, @jeffbcross, @knoxmic, and @shogunsea. Thanks!
 
-## Major Changes
+## Major changes
 
 * A bevy of bug fixes and performance improvements.
 
@@ -15,6 +15,23 @@
 
 <a href="https://user-images.githubusercontent.com/316891/51218948-6e60ef00-18e3-11e9-8a48-535b59a41301.png"><img height="300" alt="Lighthouse 4.0 PWA category" src="https://user-images.githubusercontent.com/316891/51218948-6e60ef00-18e3-11e9-8a48-535b59a41301.png"></a>
 
+## Breaking changes
+
+* New PWA category, organization, and scoring ([#6395](https://github.com/GoogleChrome/lighthouse/issues/6395))
+  - the PWA section of the report has a set of badges instead of a numeric score gauge ([#6526](https://github.com/googlechrome/lighthouse/pull/6526), [#6670](https://github.com/googlechrome/lighthouse/pull/6670))
+  - the `webapp-install-banner` audit is now `installable-manifest` ([#6630](https://github.com/googlechrome/lighthouse/pull/6630))
+  - the offline check formerly in `webapp-install-banner` is the new audit `offline-start-url` ([#6397](https://github.com/googlechrome/lighthouse/pull/6397))
+* audits' `scoreDisplayMode` `'not-applicable'` is now `'notApplicable'` ([#6783](https://github.com/googlechrome/lighthouse/pull/6783))
+* `no-websql` audit removed due to performance cost ([#6293](https://github.com/googlechrome/lighthouse/pull/6293))
+* `speed-index` scoring now scales based on throttling ([#7007](https://github.com/googlechrome/lighthouse/pull/7007))
+* empty children arrays are now removed from `critical-request-chain` audit result ([#6211](https://github.com/googlechrome/lighthouse/pull/6211))
+* the correct Nexus 5X screen height of 660 now used instead of 732 ([#6932](https://github.com/googlechrome/lighthouse/pull/6932))
+* throttling constants under `mobile3G` renamed to the more accurate `mobileSlow4G` with no change in values ([#6163](https://github.com/googlechrome/lighthouse/pull/6163))
+* typescript definition files are now located under `types/` ([#6617](https://github.com/googlechrome/lighthouse/pull/6617))
+* computed artifact files are now located under `lighthouse-core/computed/` ([#6618](https://github.com/googlechrome/lighthouse/pull/6618))
+
+<hr>
+
 [Full Changelog](https://github.com/googlechrome/lighthouse/compare/v4.0.0-beta...v4.0.0) (in addition to the changes in [4.0.0-beta](https://github.com/GoogleChrome/lighthouse/releases/tag/v4.0.0-beta), [4.0.0-alpha.1](https://github.com/GoogleChrome/lighthouse/releases/tag/v4.0.0-alpha.1), and [4.0.0-alpha.0](https://github.com/GoogleChrome/lighthouse/releases/tag/4.0.0-alpha.0))
 
 ## Core
diff --git a/lighthouse-core/audits/byte-efficiency/offscreen-images.js b/lighthouse-core/audits/byte-efficiency/offscreen-images.js
index 305e0a558..79472b92d 100644
--- a/lighthouse-core/audits/byte-efficiency/offscreen-images.js
+++ b/lighthouse-core/audits/byte-efficiency/offscreen-images.js
@@ -47,7 +47,7 @@ class OffscreenImages extends ByteEfficiencyAudit {
       title: str_(UIStrings.title),
       description: str_(UIStrings.description),
       scoreDisplayMode: ByteEfficiencyAudit.SCORING_MODES.NUMERIC,
-      requiredArtifacts: ['ImageUsage', 'ViewportDimensions', 'devtoolsLogs', 'traces'],
+      requiredArtifacts: ['ImageElements', 'ViewportDimensions', 'devtoolsLogs', 'traces'],
     };
   }
 
@@ -69,21 +69,23 @@ class OffscreenImages extends ByteEfficiencyAudit {
   }
 
   /**
-   * @param {LH.Artifacts.SingleImageUsage} image
+   * @param {LH.Artifacts.ImageElement} image
    * @param {{innerWidth: number, innerHeight: number}} viewportDimensions
+   * @param {Array<LH.Artifacts.NetworkRequest>} networkRecords
    * @return {null|Error|WasteResult}
    */
-  static computeWaste(image, viewportDimensions) {
-    if (!image.networkRecord) {
+  static computeWaste(image, viewportDimensions, networkRecords) {
+    const networkRecord = networkRecords.find(record => record.url === image.src);
+    if (!image.resourceSize || !networkRecord) {
       return null;
     }
 
     const url = URL.elideDataURI(image.src);
-    const totalPixels = image.clientWidth * image.clientHeight;
+    const totalPixels = image.displayedWidth * image.displayedHeight;
     const visiblePixels = this.computeVisiblePixels(image.clientRect, viewportDimensions);
     // Treat images with 0 area as if they're offscreen. See https://github.com/GoogleChrome/lighthouse/issues/1914
     const wastedRatio = totalPixels === 0 ? 1 : 1 - visiblePixels / totalPixels;
-    const totalBytes = image.networkRecord.resourceSize;
+    const totalBytes = image.resourceSize;
     const wastedBytes = Math.round(totalBytes * wastedRatio);
 
     if (!Number.isFinite(wastedRatio)) {
@@ -92,7 +94,7 @@ class OffscreenImages extends ByteEfficiencyAudit {
 
     return {
       url,
-      requestStartTime: image.networkRecord.startTime,
+      requestStartTime: networkRecord.startTime,
       totalBytes,
       wastedBytes,
       wastedPercent: 100 * wastedRatio,
@@ -168,7 +170,7 @@ class OffscreenImages extends ByteEfficiencyAudit {
    * @return {Promise<ByteEfficiencyAudit.ByteEfficiencyProduct>}
    */
   static async audit_(artifacts, networkRecords, context) {
-    const images = artifacts.ImageUsage;
+    const images = artifacts.ImageElements;
     const viewportDimensions = artifacts.ViewportDimensions;
     const trace = artifacts.traces[ByteEfficiencyAudit.DEFAULT_PASS];
     const devtoolsLog = artifacts.devtoolsLogs[ByteEfficiencyAudit.DEFAULT_PASS];
@@ -176,7 +178,7 @@ class OffscreenImages extends ByteEfficiencyAudit {
     /** @type {string[]} */
     const warnings = [];
     const resultsMap = images.reduce((results, image) => {
-      const processed = OffscreenImages.computeWaste(image, viewportDimensions);
+      const processed = OffscreenImages.computeWaste(image, viewportDimensions, networkRecords);
       if (processed === null) {
         return results;
       }
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/byte-efficiency/uses-responsive-images.js b/lighthouse-core/audits/byte-efficiency/uses-responsive-images.js
index bd2293524..9769ca6ca 100644
--- a/lighthouse-core/audits/byte-efficiency/uses-responsive-images.js
+++ b/lighthouse-core/audits/byte-efficiency/uses-responsive-images.js
@@ -42,26 +42,26 @@ class UsesResponsiveImages extends ByteEfficiencyAudit {
       title: str_(UIStrings.title),
       description: str_(UIStrings.description),
       scoreDisplayMode: ByteEfficiencyAudit.SCORING_MODES.NUMERIC,
-      requiredArtifacts: ['ImageUsage', 'ViewportDimensions', 'devtoolsLogs', 'traces'],
+      requiredArtifacts: ['ImageElements', 'ViewportDimensions', 'devtoolsLogs', 'traces'],
     };
   }
 
   /**
-   * @param {LH.Artifacts.SingleImageUsage} image
+   * @param {LH.Artifacts.ImageElement} image
    * @param {number} DPR devicePixelRatio
    * @return {null|Error|LH.Audit.ByteEfficiencyItem};
    */
   static computeWaste(image, DPR) {
     // Nothing can be done without network info.
-    if (!image.networkRecord) {
+    if (!image.resourceSize) {
       return null;
     }
 
     const url = URL.elideDataURI(image.src);
     const actualPixels = image.naturalWidth * image.naturalHeight;
-    const usedPixels = image.clientWidth * image.clientHeight * Math.pow(DPR, 2);
+    const usedPixels = image.displayedWidth * image.displayedHeight * Math.pow(DPR, 2);
     const wastedRatio = 1 - (usedPixels / actualPixels);
-    const totalBytes = image.networkRecord.resourceSize;
+    const totalBytes = image.resourceSize;
     const wastedBytes = Math.round(totalBytes * wastedRatio);
 
     // If the image has 0 dimensions, it's probably hidden/offscreen, so let the offscreen-images
@@ -87,7 +87,7 @@ class UsesResponsiveImages extends ByteEfficiencyAudit {
    * @return {ByteEfficiencyAudit.ByteEfficiencyProduct}
    */
   static audit_(artifacts) {
-    const images = artifacts.ImageUsage;
+    const images = artifacts.ImageElements;
     const DPR = artifacts.ViewportDimensions.devicePixelRatio;
 
     /** @type {string[]} */
@@ -96,7 +96,7 @@ class UsesResponsiveImages extends ByteEfficiencyAudit {
     const resultsMap = new Map();
     images.forEach(image => {
       // TODO: give SVG a free pass until a detail per pixel metric is available
-      if (!image.networkRecord || image.networkRecord.mimeType === 'image/svg+xml') {
+      if (!image.resourceSize || image.mimeType === 'image/svg+xml') {
         return;
       }
 
diff --git a/lighthouse-core/audits/image-aspect-ratio.js b/lighthouse-core/audits/image-aspect-ratio.js
index 750acafff..37dafc981 100644
--- a/lighthouse-core/audits/image-aspect-ratio.js
+++ b/lighthouse-core/audits/image-aspect-ratio.js
@@ -16,7 +16,7 @@ const Audit = require('./audit');
 const URL = require('../lib/url-shim');
 const THRESHOLD_PX = 2;
 
-/** @typedef {Required<LH.Artifacts.SingleImageUsage>} WellDefinedImage */
+/** @typedef {Required<LH.Artifacts.ImageElement>} WellDefinedImage */
 
 class ImageAspectRatio extends Audit {
   /**
@@ -29,7 +29,7 @@ class ImageAspectRatio extends Audit {
       failureTitle: 'Displays images with incorrect aspect ratio',
       description: 'Image display dimensions should match natural aspect ratio. ' +
         '[Learn more](https://developers.google.com/web/tools/lighthouse/audits/aspect-ratio).',
-      requiredArtifacts: ['ImageUsage'],
+      requiredArtifacts: ['ImageElements'],
     };
   }
 
@@ -40,10 +40,10 @@ class ImageAspectRatio extends Audit {
   static computeAspectRatios(image) {
     const url = URL.elideDataURI(image.src);
     const actualAspectRatio = image.naturalWidth / image.naturalHeight;
-    const displayedAspectRatio = image.width / image.height;
+    const displayedAspectRatio = image.displayedWidth / image.displayedHeight;
 
-    const targetDisplayHeight = image.width / actualAspectRatio;
-    const doRatiosMatch = Math.abs(targetDisplayHeight - image.height) < THRESHOLD_PX;
+    const targetDisplayHeight = image.displayedWidth / actualAspectRatio;
+    const doRatiosMatch = Math.abs(targetDisplayHeight - image.displayedHeight) < THRESHOLD_PX;
 
     if (!Number.isFinite(actualAspectRatio) ||
       !Number.isFinite(displayedAspectRatio)) {
@@ -52,7 +52,7 @@ class ImageAspectRatio extends Audit {
 
     return {
       url,
-      displayedAspectRatio: `${image.width} x ${image.height}
+      displayedAspectRatio: `${image.displayedWidth} x ${image.displayedHeight}
         (${displayedAspectRatio.toFixed(2)})`,
       actualAspectRatio: `${image.naturalWidth} x ${image.naturalHeight}
         (${actualAspectRatio.toFixed(2)})`,
@@ -65,7 +65,7 @@ class ImageAspectRatio extends Audit {
    * @return {LH.Audit.Product}
    */
   static audit(artifacts) {
-    const images = artifacts.ImageUsage;
+    const images = artifacts.ImageElements;
 
     /** @type {string[]} */
     const warnings = [];
@@ -75,12 +75,12 @@ class ImageAspectRatio extends Audit {
       // - filter out images that don't have following properties:
       //   networkRecord, width, height, images that use `object-fit`: `cover` or `contain`
       // - filter all svgs as they have no natural dimensions to audit
-      return image.networkRecord &&
-        image.networkRecord.mimeType !== 'image/svg+xml' &&
+      return image.mimeType &&
+        image.mimeType !== 'image/svg+xml' &&
         image.naturalHeight > 5 &&
         image.naturalWidth > 5 &&
-        image.width &&
-        image.height &&
+        image.displayedWidth &&
+        image.displayedHeight &&
         !image.usesObjectFit;
     }).forEach(image => {
       const wellDefinedImage = /** @type {WellDefinedImage} */ (image);
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/default-config.js b/lighthouse-core/config/default-config.js
index 75df84c46..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',
-      'image-usage',
       '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..60f59ee30 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};
   }
 
   /**
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/image-usage.js b/lighthouse-core/gather/gatherers/image-elements.js
similarity index 78%
rename from lighthouse-core/gather/gatherers/image-usage.js
rename to lighthouse-core/gather/gatherers/image-elements.js
index 6071b86af..ebb01908b 100644
--- a/lighthouse-core/gather/gatherers/image-usage.js
+++ b/lighthouse-core/gather/gatherers/image-elements.js
@@ -15,7 +15,7 @@ const Driver = require('../driver.js'); // eslint-disable-line no-unused-vars
 
 /* global window, getElementsInDocument, Image */
 
-/** @return {Array<LH.Artifacts.SingleImageUsage>} */
+/** @return {Array<LH.Artifacts.ImageElement>} */
 /* istanbul ignore next */
 function collectImageElementInfo() {
   /** @param {Element} element */
@@ -37,21 +37,20 @@ function collectImageElementInfo() {
     return element.localName === 'img';
   }));
 
-  /** @type {Array<LH.Artifacts.SingleImageUsage>} */
+  /** @type {Array<LH.Artifacts.ImageElement>} */
   const htmlImages = allImageElements.map(element => {
     const computedStyle = window.getComputedStyle(element);
     return {
       // currentSrc used over src to get the url as determined by the browser
       // after taking into account srcset/media/sizes/etc.
       src: element.currentSrc,
-      width: element.width,
-      height: element.height,
-      clientWidth: element.clientWidth,
-      clientHeight: element.clientHeight,
+      displayedWidth: element.width,
+      displayedHeight: element.height,
       clientRect: getClientRect(element),
       naturalWidth: element.naturalWidth,
       naturalHeight: element.naturalHeight,
       isCss: false,
+      resourceSize: 0, // this will get overwritten below
       isPicture: !!element.parentElement && element.parentElement.tagName === 'PICTURE',
       usesObjectFit: ['cover', 'contain', 'scale-down', 'none'].includes(
         computedStyle.getPropertyValue('object-fit')
@@ -84,8 +83,8 @@ function collectImageElementInfo() {
 
     images.push({
       src: url,
-      clientWidth: element.clientWidth,
-      clientHeight: element.clientHeight,
+      displayedWidth: element.clientWidth,
+      displayedHeight: element.clientHeight,
       clientRect: getClientRect(element),
       // CSS Images do not expose natural size, we'll determine the size later
       naturalWidth: Number.MAX_VALUE,
@@ -93,10 +92,11 @@ function collectImageElementInfo() {
       isCss: true,
       isPicture: false,
       usesObjectFit: false,
+      resourceSize: 0, // this will get overwritten below
     });
 
     return images;
-  }, /** @type {Array<LH.Artifacts.SingleImageUsage>} */ ([]));
+  }, /** @type {Array<LH.Artifacts.ImageElement>} */ ([]));
 
   return htmlImages.concat(cssImages);
 }
@@ -121,11 +121,11 @@ function determineNaturalSize(url) {
   });
 }
 
-class ImageUsage extends Gatherer {
+class ImageElements extends Gatherer {
   /**
    * @param {Driver} driver
-   * @param {LH.Artifacts.SingleImageUsage} element
-   * @return {Promise<LH.Artifacts.SingleImageUsage>}
+   * @param {LH.Artifacts.ImageElement} element
+   * @return {Promise<LH.Artifacts.ImageElement>}
    */
   async fetchElementWithSizeInformation(driver, element) {
     const url = JSON.stringify(element.src);
@@ -142,42 +142,43 @@ class ImageUsage extends Gatherer {
   /**
    * @param {LH.Gatherer.PassContext} passContext
    * @param {LH.Gatherer.LoadData} loadData
-   * @return {Promise<LH.Artifacts['ImageUsage']>}
+   * @return {Promise<LH.Artifacts['ImageElements']>}
    */
   async afterPass(passContext, loadData) {
     const driver = passContext.driver;
     const indexedNetworkRecords = loadData.networkRecords.reduce((map, record) => {
       if (/^image/.test(record.mimeType) && record.finished) {
-        map[record.url] = {
-          url: record.url,
-          resourceSize: Math.min(record.resourceSize || 0, record.transferSize),
-          startTime: record.startTime,
-          endTime: record.endTime,
-          responseReceivedTime: record.responseReceivedTime,
-          mimeType: record.mimeType,
-        };
+        map[record.url] = record;
       }
 
       return map;
-    }, /** @type {Object<string, LH.Artifacts.SingleImageUsage['networkRecord']>} */ ({}));
+    }, /** @type {Object<string, LH.Artifacts.NetworkRequest>} */ ({}));
 
     const expression = `(function() {
       ${pageFunctions.getElementsInDocumentString}; // define function on page
       return (${collectImageElementInfo.toString()})();
     })()`;
 
-    /** @type {Array<LH.Artifacts.SingleImageUsage>} */
+    /** @type {Array<LH.Artifacts.ImageElement>} */
     const elements = await driver.evaluateAsync(expression);
 
     const imageUsage = [];
     for (let element of elements) {
-      // link up the image with its network record
-      element.networkRecord = indexedNetworkRecords[element.src];
+      // Pull some of our information directly off the network record.
+      const networkRecord = indexedNetworkRecords[element.src] || {};
+      element.mimeType = networkRecord.mimeType;
+      // Resource size is almost always the right one to be using because of the below:
+      //     transferSize = resourceSize + headers.length
+      // HOWEVER, there are some cases where an image is compressed again over the network and transfer size
+      // is smaller (see https://github.com/GoogleChrome/lighthouse/pull/4968).
+      // Use the min of the two numbers to be safe.
+      const {resourceSize = 0, transferSize = 0} = networkRecord;
+      element.resourceSize = Math.min(resourceSize, transferSize);
 
       // Images within `picture` behave strangely and natural size information isn't accurate,
       // CSS images have no natural size information at all. Try to get the actual size if we can.
       // Additional fetch is expensive; don't bother if we don't have a networkRecord for the image.
-      if ((element.isPicture || element.isCss) && element.networkRecord) {
+      if ((element.isPicture || element.isCss) && networkRecord) {
         element = await this.fetchElementWithSizeInformation(driver, element);
       }
 
@@ -188,4 +189,4 @@ class ImageUsage extends Gatherer {
   }
 }
 
-module.exports = ImageUsage;
+module.exports = ImageElements;
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/font-size.js b/lighthouse-core/gather/gatherers/seo/font-size.js
index 5fe2bdeba..76493ef66 100644
--- a/lighthouse-core/gather/gatherers/seo/font-size.js
+++ b/lighthouse-core/gather/gatherers/seo/font-size.js
@@ -182,7 +182,8 @@ function getEffectiveFontRule({inlineStyle, matchedCSSRules, inherited}) {
  * @returns {number}
  */
 function getNodeTextLength(node) {
-  return !node.nodeValue ? 0 : node.nodeValue.trim().length;
+  // Array.from to count symbols not unicode code points. See: #6973
+  return !node.nodeValue ? 0 : Array.from(node.nodeValue.trim()).length;
 }
 
 /**
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-viewer/app/src/lighthouse-report-viewer.js b/lighthouse-viewer/app/src/lighthouse-report-viewer.js
index c8a76a158..f0464dfd5 100644
--- a/lighthouse-viewer/app/src/lighthouse-report-viewer.js
+++ b/lighthouse-viewer/app/src/lighthouse-report-viewer.js
@@ -138,13 +138,12 @@ class LighthouseReportViewer {
   _replaceReportHtml(json) {
     this._validateReportJson(json);
 
+    // Redirect to old viewer if a v2 report. v3 and v4 both handled by v4 viewer.
     if (json.lighthouseVersion.startsWith('2')) {
       this._loadInLegacyViewerVersion(json);
       return;
     }
 
-    // TODO: viewer3x :)
-
     const dom = new DOM(document);
     const renderer = new ReportRenderer(dom);
 
@@ -202,7 +201,7 @@ class LighthouseReportViewer {
   }
 
   /**
-   * Stores v2.x report in IDB, then navigates to legacy viewer in current tab
+   * Stores v2.x report in IDB, then navigates to legacy viewer in current tab.
    * @param {LH.ReportResult} reportJson
    * @private
    */
@@ -210,6 +209,7 @@ class LighthouseReportViewer {
     const warnMsg = `Version mismatch between viewer and JSON. Opening compatible viewer...`;
     logger.log(warnMsg, false);
 
+    // TODO: Handle 4x reports if we break viewer compat moving to v5.
     // Place report in IDB, then navigate current tab to the legacy viewer
     const viewerPath = new URL('../viewer2x/', location.href);
     idbKeyval.set('2xreport', reportJson).then(_ => {
diff --git a/readme.md b/readme.md
index d0cc15dc9..7ffe97c9a 100644
--- a/readme.md
+++ b/readme.md
@@ -267,6 +267,8 @@ This section details services that have integrated Lighthouse data. If you're wo
 
 * **[Calibre](https://calibreapp.com)** - Calibre is a web performance monitoring tool running Lighthouse continuously or on-demand via an API. Test using emulated devices and connection speeds from a number of geographical locations. Set budgets and improve performance with actionable guidelines. Calibre comes with a free 14-day trial.
 
+* **[DebugBear](https://www.debugbear.com/)** - DebugBear is a website monitoring tool based on Lighthouse. See how your scores and metrics changed over time, with a focus on understanding what caused each change. DebugBear is a paid product with a free 30-day trial.
+
 * **[HTTPArchive](http://httparchive.org/)** - HTTPArchive tracks how the web is built by crawling 500k pages with Web Page Test, including Lighthouse results, and stores the information in BigQuery where it is [publicly available](https://discuss.httparchive.org/t/quickstart-guide-to-exploring-the-http-archive/682).
 
 * **[SpeedCurve](https://speedcurve.com)** — SpeedCurve is a tool for continuously monitoring web performance across different browsers, devices, and regions. It can aggregate any metric including Lighthouse scores across multiple pages and sites, and allows you to set performance budgets with Slack or email alerts. SpeedCurve is a paid product with a free 30-day trial.
diff --git a/types/artifacts.d.ts b/types/artifacts.d.ts
index d4ccf5024..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. */
@@ -82,17 +84,15 @@ declare global {
       /** Whether the page ended up on an HTTPS page after attempting to load the HTTP version. */
       HTTPRedirect: {value: boolean};
       /** Information on size and loading for all the images in the page. */
-      ImageUsage: Artifacts.SingleImageUsage[];
+      ImageElements: Artifacts.ImageElement[];
       /** Information on JS libraries and versions used by the page. */
       JSLibraries: {name: string, version: string, npmPkgName: string}[];
       /** JS coverage information for code used during page load. */
       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: {
@@ -224,31 +221,33 @@ declare global {
       // TODO(bckenny): real type for parsed manifest.
       export type Manifest = ReturnType<typeof parseManifest>;
 
-      export interface SingleImageUsage {
+      export interface ImageElement {
         src: string;
-        clientWidth: number;
-        clientHeight: number;
+        /** The displayed width of the image, uses img.width when available falling back to clientWidth. See https://codepen.io/patrickhulce/pen/PXvQbM for examples. */
+        displayedWidth: number;
+        /** The displayed height of the image, uses img.height when available falling back to clientHeight. See https://codepen.io/patrickhulce/pen/PXvQbM for examples. */
+        displayedHeight: number;
+        /** The natural width of the underlying image, uses img.naturalWidth. See https://codepen.io/patrickhulce/pen/PXvQbM for examples. */
         naturalWidth: number;
+        /** The natural height of the underlying image, uses img.naturalHeight. See https://codepen.io/patrickhulce/pen/PXvQbM for examples. */
         naturalHeight: number;
-        isCss: boolean;
-        isPicture: boolean;
-        usesObjectFit: boolean;
+        /** The BoundingClientRect of the element. */
         clientRect: {
           top: number;
           bottom: number;
           left: number;
           right: number;
         };
-        networkRecord?: {
-          url: string;
-          resourceSize: number;
-          startTime: number;
-          endTime: number;
-          responseReceivedTime: number;
-          mimeType: string;
-        };
-        width?: number;
-        height?: number;
+        /** Flags whether this element was an image via CSS background-image rather than <img> tag. */
+        isCss: boolean;
+        /** Flags whether this element was contained within a <picture> tag. */
+        isPicture: boolean;
+        /** Flags whether this element was sized using a non-default `object-fit` CSS property. */
+        usesObjectFit: boolean;
+        /** The size of the underlying image file in bytes. 0 if the file could not be identified. */
+        resourceSize: number;
+        /** The MIME type of the underlying image file. */
+        mimeType?: string;
       }
 
       export interface OptimizedImage {
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 884aaa7bd..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",
         },
@@ -1057,14 +1051,17 @@ Object {
           "path": "chrome-console-messages",
         },
         Object {
-          "path": "image-usage",
+          "path": "accessibility",
         },
         Object {
-          "path": "accessibility",
+          "path": "image-elements",
         },
         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-core/test/audits/byte-efficiency/offscreen-images-test.js b/lighthouse-core/test/audits/byte-efficiency/offscreen-images-test.js
index 8c8e8e9b4..b9f3ccc7e 100644
--- a/lighthouse-core/test/audits/byte-efficiency/offscreen-images-test.js
+++ b/lighthouse-core/test/audits/byte-efficiency/offscreen-images-test.js
@@ -12,15 +12,21 @@ const createTestTrace = require('../../create-test-trace.js');
 const networkRecordsToDevtoolsLog = require('../../network-records-to-devtools-log.js');
 
 /* eslint-env jest */
-function generateRecord(resourceSizeInKb, startTime = 0, mimeType = 'image/png') {
+function generateRecord({
+  resourceSizeInKb,
+  url = 'https://google.com/logo.png',
+  startTime = 0,
+  mimeType = 'image/png',
+}) {
   return {
+    url,
     mimeType,
     startTime, // DevTools timestamp which is in seconds
     resourceSize: resourceSizeInKb * 1024,
   };
 }
 
-function generateSize(width, height, prefix = 'client') {
+function generateSize(width, height, prefix = 'displayed') {
   const size = {};
   size[`${prefix}Width`] = width;
   size[`${prefix}Height`] = height;
@@ -35,11 +41,11 @@ function generateImage(size, coords, networkRecord, src = 'https://google.com/lo
 
   const clientRect = {
     top: y,
-    bottom: y + size.clientHeight,
+    bottom: y + size.displayedHeight,
     left: x,
-    right: x + size.clientWidth,
+    right: x + size.displayedWidth,
   };
-  const image = {src, networkRecord, clientRect};
+  const image = {src, clientRect, ...networkRecord};
   Object.assign(image, size);
   return image;
 }
@@ -56,7 +62,7 @@ describe('OffscreenImages audit', () => {
     const topLevelTasks = [{ts: 1900, duration: 100}];
     const artifacts = {
       ViewportDimensions: DEFAULT_DIMENSIONS,
-      ImageUsage: [
+      ImageElements: [
         generateImage(generateSize(100, 100), [0, 0]),
       ],
       traces: {defaultPass: createTestTrace({topLevelTasks})},
@@ -68,64 +74,73 @@ describe('OffscreenImages audit', () => {
     });
   });
 
-  it('does not find used images', () => {
+  it('does not find used images', async () => {
     const urlB = 'https://google.com/logo2.png';
     const urlC = 'data:image/jpeg;base64,foobar';
+    const recordA = generateRecord({resourceSizeInKb: 100});
+    const recordB = generateRecord({url: urlB, resourceSizeInKb: 100});
+    const recordC = generateRecord({url: urlC, resourceSizeInKb: 3});
     const topLevelTasks = [{ts: 1900, duration: 100}];
     const artifacts = {
       ViewportDimensions: DEFAULT_DIMENSIONS,
-      ImageUsage: [
-        generateImage(generateSize(200, 200), [0, 0], generateRecord(100)),
-        generateImage(generateSize(100, 100), [0, 1080], generateRecord(100), urlB),
-        generateImage(generateSize(400, 400), [1720, 1080], generateRecord(3), urlC),
+      ImageElements: [
+        generateImage(generateSize(200, 200), [0, 0], recordA),
+        generateImage(generateSize(100, 100), [0, 1080], recordB, urlB),
+        generateImage(generateSize(400, 400), [1720, 1080], recordC, urlC),
       ],
       traces: {defaultPass: createTestTrace({topLevelTasks})},
       devtoolsLogs: {},
     };
 
-    return UnusedImages.audit_(artifacts, [], context).then(auditResult => {
-      assert.equal(auditResult.items.length, 0);
-    });
+    const auditResult = await UnusedImages.audit_(artifacts, [recordA, recordB, recordC], context);
+    assert.equal(auditResult.items.length, 0);
   });
 
-  it('finds unused images', () => {
+  it('finds unused images', async () => {
     const url = s => `https://google.com/logo${s}.png`;
     const topLevelTasks = [{ts: 1900, duration: 100}];
+    const networkRecords = [
+      generateRecord({url: url(''), resourceSizeInKb: 100}),
+      generateRecord({url: url('B'), resourceSizeInKb: 100}),
+      generateRecord({url: url('C'), resourceSizeInKb: 100}),
+      generateRecord({url: url('D'), resourceSizeInKb: 100}),
+      generateRecord({url: url('E'), resourceSizeInKb: 100}),
+    ];
     const artifacts = {
       ViewportDimensions: DEFAULT_DIMENSIONS,
-      ImageUsage: [
+      ImageElements: [
         // offscreen to the right
-        generateImage(generateSize(200, 200), [3000, 0], generateRecord(100)),
+        generateImage(generateSize(200, 200), [3000, 0], networkRecords[0]),
         // offscreen to the bottom
-        generateImage(generateSize(100, 100), [0, 2000], generateRecord(100), url('B')),
+        generateImage(generateSize(100, 100), [0, 2000], networkRecords[1], url('B')),
         // offscreen to the top-left
-        generateImage(generateSize(100, 100), [-2000, -1000], generateRecord(100), url('C')),
+        generateImage(generateSize(100, 100), [-2000, -1000], networkRecords[2], url('C')),
         // offscreen to the bottom-right
-        generateImage(generateSize(100, 100), [3000, 2000], generateRecord(100), url('D')),
+        generateImage(generateSize(100, 100), [3000, 2000], networkRecords[3], url('D')),
         // half offscreen to the top, should not warn
-        generateImage(generateSize(1000, 1000), [0, -500], generateRecord(100), url('E')),
+        generateImage(generateSize(1000, 1000), [0, -500], networkRecords[4], url('E')),
       ],
       traces: {defaultPass: createTestTrace({topLevelTasks})},
       devtoolsLogs: {},
     };
 
-    return UnusedImages.audit_(artifacts, [], context).then(auditResult => {
-      assert.equal(auditResult.items.length, 4);
-    });
+    const auditResult = await UnusedImages.audit_(artifacts, networkRecords, context);
+    assert.equal(auditResult.items.length, 4);
   });
 
   it('finds images with 0 area', () => {
     const topLevelTasks = [{ts: 1900, duration: 100}];
+    const networkRecord = generateRecord({resourceSizeInKb: 100});
     const artifacts = {
       ViewportDimensions: DEFAULT_DIMENSIONS,
-      ImageUsage: [
-        generateImage(generateSize(0, 0), [0, 0], generateRecord(100)),
+      ImageElements: [
+        generateImage(generateSize(0, 0), [0, 0], networkRecord),
       ],
       traces: {defaultPass: createTestTrace({topLevelTasks})},
       devtoolsLogs: {},
     };
 
-    return UnusedImages.audit_(artifacts, [], context).then(auditResult => {
+    return UnusedImages.audit_(artifacts, [networkRecord], context).then(auditResult => {
       assert.equal(auditResult.items.length, 1);
       assert.equal(auditResult.items[0].wastedBytes, 100 * 1024);
     });
@@ -134,68 +149,77 @@ describe('OffscreenImages audit', () => {
   it('de-dupes images', () => {
     const urlB = 'https://google.com/logo2.png';
     const topLevelTasks = [{ts: 1900, duration: 100}];
+    const networkRecords = [
+      generateRecord({resourceSizeInKb: 50}),
+      generateRecord({resourceSizeInKb: 50}),
+      generateRecord({url: urlB, resourceSizeInKb: 200}),
+      generateRecord({url: urlB, resourceSizeInKb: 90}),
+    ];
     const artifacts = {
       ViewportDimensions: DEFAULT_DIMENSIONS,
-      ImageUsage: [
-        generateImage(generateSize(50, 50), [0, 0], generateRecord(50)),
-        generateImage(generateSize(1000, 1000), [1000, 1000], generateRecord(50)),
-        generateImage(generateSize(50, 50), [0, 1500], generateRecord(200), urlB),
-        generateImage(generateSize(400, 400), [0, 1500], generateRecord(90), urlB),
+      ImageElements: [
+        generateImage(generateSize(50, 50), [0, 0], networkRecords[0]),
+        generateImage(generateSize(1000, 1000), [1000, 1000], networkRecords[1]),
+        generateImage(generateSize(50, 50), [0, 1500], networkRecords[2], urlB),
+        generateImage(generateSize(400, 400), [0, 1500], networkRecords[3], urlB),
       ],
       traces: {defaultPass: createTestTrace({topLevelTasks})},
       devtoolsLogs: {},
     };
 
-    return UnusedImages.audit_(artifacts, [], context).then(auditResult => {
+    return UnusedImages.audit_(artifacts, networkRecords, context).then(auditResult => {
       assert.equal(auditResult.items.length, 1);
     });
   });
 
   it('disregards images loaded after TTI', () => {
     const topLevelTasks = [{ts: 1900, duration: 100}];
+    const networkRecord = generateRecord({resourceSizeInKb: 100, startTime: 3});
     const artifacts = {
       ViewportDimensions: DEFAULT_DIMENSIONS,
-      ImageUsage: [
+      ImageElements: [
         // offscreen to the right
-        generateImage(generateSize(200, 200), [3000, 0], generateRecord(100, 3)),
+        generateImage(generateSize(200, 200), [3000, 0], networkRecord),
       ],
       traces: {defaultPass: createTestTrace({topLevelTasks})},
       devtoolsLogs: {},
     };
 
-    return UnusedImages.audit_(artifacts, [], context).then(auditResult => {
+    return UnusedImages.audit_(artifacts, [networkRecord], context).then(auditResult => {
       assert.equal(auditResult.items.length, 0);
     });
   });
 
   it('disregards images loaded after Trace End when interactive throws error', () => {
+    const networkRecord = generateRecord({resourceSizeInKb: 100, startTime: 3});
     const artifacts = {
       ViewportDimensions: DEFAULT_DIMENSIONS,
-      ImageUsage: [
+      ImageElements: [
         // offscreen to the right
-        generateImage(generateSize(200, 200), [3000, 0], generateRecord(100, 3)),
+        generateImage(generateSize(200, 200), [3000, 0], networkRecord),
       ],
       traces: {defaultPass: createTestTrace({traceEnd: 2000})},
       devtoolsLogs: {},
     };
 
-    return UnusedImages.audit_(artifacts, [], context).then(auditResult => {
+    return UnusedImages.audit_(artifacts, [networkRecord], context).then(auditResult => {
       assert.equal(auditResult.items.length, 0);
     });
   });
 
   it('finds images loaded before Trace End when TTI when interactive throws error', () => {
+    const networkRecord = generateRecord({resourceSizeInKb: 100});
     const artifacts = {
       ViewportDimensions: DEFAULT_DIMENSIONS,
-      ImageUsage: [
+      ImageElements: [
         // offscreen to the right
-        generateImage(generateSize(100, 100), [0, 2000], generateRecord(100)),
+        generateImage(generateSize(100, 100), [0, 2000], networkRecord),
       ],
       traces: {defaultPass: createTestTrace({traceEnd: 2000})},
       devtoolsLogs: {},
     };
 
-    return UnusedImages.audit_(artifacts, [], context).then(auditResult => {
+    return UnusedImages.audit_(artifacts, [networkRecord], context).then(auditResult => {
       assert.equal(auditResult.items.length, 1);
     });
   });
@@ -226,7 +250,7 @@ describe('OffscreenImages audit', () => {
     ];
     const artifacts = {
       ViewportDimensions: DEFAULT_DIMENSIONS,
-      ImageUsage: [
+      ImageElements: [
         generateImage(generateSize(0, 0), [0, 0], recordA, recordA.url),
         generateImage(generateSize(200, 200), [3000, 0], recordB, recordB.url),
       ],
@@ -234,7 +258,7 @@ describe('OffscreenImages audit', () => {
       devtoolsLogs: {defaultPass: devtoolsLog},
     };
 
-    return UnusedImages.audit_(artifacts, [], context).then(auditResult => {
+    return UnusedImages.audit_(artifacts, [recordA, recordB], context).then(auditResult => {
       assert.equal(auditResult.items.length, 1);
       assert.equal(auditResult.items[0].url, recordA.url);
       assert.equal(auditResult.items[0].wastedBytes, wastedSize);
@@ -270,7 +294,7 @@ describe('OffscreenImages audit', () => {
     ];
     const artifacts = {
       ViewportDimensions: DEFAULT_DIMENSIONS,
-      ImageUsage: [
+      ImageElements: [
         generateImage(generateSize(0, 0), [0, 0], recordA, recordA.url),
         generateImage(generateSize(200, 200), [3000, 0], recordB, recordB.url),
       ],
@@ -278,7 +302,7 @@ describe('OffscreenImages audit', () => {
       devtoolsLogs: {defaultPass: devtoolsLog},
     };
 
-    return UnusedImages.audit_(artifacts, [], context).then(auditResult => {
+    return UnusedImages.audit_(artifacts, [recordA, recordB], context).then(auditResult => {
       assert.equal(auditResult.items.length, 2);
       assert.equal(auditResult.items[0].url, recordA.url);
       assert.equal(auditResult.items[0].wastedBytes, wastedSize);
@@ -289,18 +313,22 @@ describe('OffscreenImages audit', () => {
 
   it('rethrow error when interactive throws error in Lantern', async () => {
     context = {settings: {throttlingMethod: 'simulate'}, computedCache: new Map()};
+    const networkRecords = [
+      generateRecord({url: 'a', resourceSizeInKb: 100, startTime: 3}),
+      generateRecord({url: 'b', resourceSizeInKb: 100, startTime: 4}),
+    ];
     const artifacts = {
       ViewportDimensions: DEFAULT_DIMENSIONS,
-      ImageUsage: [
-        generateImage(generateSize(0, 0), [0, 0], generateRecord(100, 3), 'a'),
-        generateImage(generateSize(200, 200), [3000, 0], generateRecord(100, 4), 'b'),
+      ImageElements: [
+        generateImage(generateSize(0, 0), [0, 0], networkRecords[0], 'a'),
+        generateImage(generateSize(200, 200), [3000, 0], networkRecords[1], 'b'),
       ],
       traces: {defaultPass: createTestTrace({traceEnd: 2000})},
       devtoolsLogs: {},
     };
 
     try {
-      await UnusedImages.audit_(artifacts, [], context);
+      await UnusedImages.audit_(artifacts, networkRecords, context);
     } catch (err) {
       assert.ok(err.message.includes('Did not provide necessary metric computation data'));
       return;
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/byte-efficiency/uses-responsive-images-test.js b/lighthouse-core/test/audits/byte-efficiency/uses-responsive-images-test.js
index 6f4173a8d..68aec09f8 100644
--- a/lighthouse-core/test/audits/byte-efficiency/uses-responsive-images-test.js
+++ b/lighthouse-core/test/audits/byte-efficiency/uses-responsive-images-test.js
@@ -19,7 +19,7 @@ function generateRecord(resourceSizeInKb, durationInMs, mimeType = 'image/png')
   };
 }
 
-function generateSize(width, height, prefix = 'client') {
+function generateSize(width, height, prefix = 'displayed') {
   const size = {};
   size[`${prefix}Width`] = width;
   size[`${prefix}Height`] = height;
@@ -28,7 +28,7 @@ function generateSize(width, height, prefix = 'client') {
 
 function generateImage(clientSize, naturalSize, networkRecord, src = 'https://google.com/logo.png') {
   Object.assign(networkRecord || {}, {url: src});
-  const image = {src, networkRecord};
+  const image = {src, ...networkRecord};
   Object.assign(image, clientSize, naturalSize);
   return image;
 }
@@ -39,7 +39,7 @@ describe('Page uses responsive images', () => {
     it(description, () => {
       const result = UsesResponsiveImagesAudit.audit_({
         ViewportDimensions: {devicePixelRatio: data.devicePixelRatio || 1},
-        ImageUsage: [
+        ImageElements: [
           generateImage(
             generateSize(...data.clientSize),
             generateSize(...data.naturalSize, 'natural'),
@@ -92,7 +92,7 @@ describe('Page uses responsive images', () => {
   it('handles images without network record', () => {
     const auditResult = UsesResponsiveImagesAudit.audit_({
       ViewportDimensions: {devicePixelRatio: 2},
-      ImageUsage: [
+      ImageElements: [
         generateImage(
           generateSize(100, 100),
           generateSize(300, 300, 'natural'),
@@ -107,7 +107,7 @@ describe('Page uses responsive images', () => {
   it('identifies when images are not wasteful', () => {
     const auditResult = UsesResponsiveImagesAudit.audit_({
       ViewportDimensions: {devicePixelRatio: 2},
-      ImageUsage: [
+      ImageElements: [
         generateImage(
           generateSize(200, 200),
           generateSize(450, 450, 'natural'),
@@ -139,7 +139,7 @@ describe('Page uses responsive images', () => {
 
     const auditResult = UsesResponsiveImagesAudit.audit_({
       ViewportDimensions: {devicePixelRatio: 1},
-      ImageUsage: [
+      ImageElements: [
         generateImage(generateSize(10, 10), naturalSizeA, recordA, urlA),
       ],
     });
@@ -157,7 +157,7 @@ describe('Page uses responsive images', () => {
 
     const auditResult = UsesResponsiveImagesAudit.audit_({
       ViewportDimensions: {devicePixelRatio: 1},
-      ImageUsage: [
+      ImageElements: [
         generateImage(generateSize(10, 10), naturalSizeA, recordA, urlA),
         generateImage(generateSize(450, 450), naturalSizeA, recordA, urlA),
         generateImage(generateSize(30, 30), naturalSizeA, recordA, urlA),
diff --git a/lighthouse-core/test/audits/image-aspect-ratio-test.js b/lighthouse-core/test/audits/image-aspect-ratio-test.js
index ac438abe9..6b3902db4 100644
--- a/lighthouse-core/test/audits/image-aspect-ratio-test.js
+++ b/lighthouse-core/test/audits/image-aspect-ratio-test.js
@@ -9,16 +9,9 @@ const ImageAspectRatioAudit = require('../../audits/image-aspect-ratio.js');
 const assert = require('assert');
 
 /* eslint-env jest */
-function generateRecord(url = 'https://google.com/logo.png', mimeType = 'image/png') {
-  return {
-    url,
-    mimeType,
-  };
-}
 
-function generateImage(clientSize, naturalSize, networkRecord, props, src = 'https://google.com/logo.png') {
-  Object.assign(networkRecord || {}, {url: src});
-  const image = {src, networkRecord};
+function generateImage(clientSize, naturalSize, props, src = 'https://google.com/logo.png') {
+  const image = {src, mimeType: 'image/png'};
   Object.assign(image, clientSize, naturalSize, props);
   return image;
 }
@@ -28,11 +21,10 @@ describe('Images: aspect-ratio audit', () => {
     const description = `identifies when an image ${condition}`;
     it(description, () => {
       const result = ImageAspectRatioAudit.audit({
-        ImageUsage: [
+        ImageElements: [
           generateImage(
-            {width: data.clientSize[0], height: data.clientSize[1]},
+            {displayedWidth: data.clientSize[0], displayedHeight: data.clientSize[1]},
             {naturalWidth: data.naturalSize[0], naturalHeight: data.naturalSize[1]},
-            generateRecord(),
             data.props
           ),
         ],
@@ -149,15 +141,12 @@ describe('Images: aspect-ratio audit', () => {
 
   it('skips svg images', () => {
     const result = ImageAspectRatioAudit.audit({
-      ImageUsage: [
+      ImageElements: [
         generateImage(
           {width: 150, height: 150},
           {},
           {
-            url: 'https://google.com/logo.png',
             mimeType: 'image/svg+xml',
-          },
-          {
             isCss: false,
             usesObjectFit: false,
           }
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/font-size-test.js b/lighthouse-core/test/gather/gatherers/seo/font-size-test.js
index 4f0b74b2d..58b09939f 100644
--- a/lighthouse-core/test/gather/gatherers/seo/font-size-test.js
+++ b/lighthouse-core/test/gather/gatherers/seo/font-size-test.js
@@ -10,9 +10,9 @@
 const FontSizeGather = require('../../../../gather/gatherers/seo/font-size');
 let fontSizeGather;
 
-const smallText = ' body small text ';
-const bigText = 'body big text';
-const failingText = 'failing text';
+const smallText = ' body sm𝐀ll text ';
+const bigText = 'body 𝐁ig text';
+const failingText = 'failing text 💩';
 const bodyNode = {nodeId: 3, nodeName: 'BODY', parentId: 1};
 const failingNode = {nodeId: 10, nodeName: 'P', parentId: 3};
 const nodes = [
@@ -93,9 +93,10 @@ describe('Font size gatherer', () => {
     };
 
     const artifact = await fontSizeGather.afterPass({driver});
-    const expectedFailingTextLength = smallText.trim().length;
-    const expectedVisitedTextLength = bigText.trim().length + expectedFailingTextLength;
-    const expectedTotalTextLength = failingText.trim().length + expectedVisitedTextLength;
+    const expectedFailingTextLength = Array.from(smallText.trim()).length;
+    const expectedVisitedTextLength = Array.from(bigText.trim()).length + expectedFailingTextLength;
+    const expectedTotalTextLength = Array.from(failingText.trim()).length +
+      expectedVisitedTextLength;
     const expectedAnalyzedFailingTextLength = expectedFailingTextLength;
 
     expect(artifact).toEqual({
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/results/artifacts/artifacts.json b/lighthouse-core/test/results/artifacts/artifacts.json
index 52e44891f..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,
@@ -777,13 +778,11 @@
       }
     }
   ],
-  "ImageUsage": [
+  "ImageElements": [
     {
       "src": "http://localhost:10200/dobetterweb/lighthouse-480x318.jpg",
-      "width": 480,
-      "height": 57,
-      "clientWidth": 480,
-      "clientHeight": 57,
+      "displayedWidth": 480,
+      "displayedHeight": 57,
       "clientRect": {
         "top": 103.8125,
         "bottom": 160.8125,
@@ -795,21 +794,13 @@
       "isCss": false,
       "isPicture": false,
       "usesObjectFit": false,
-      "networkRecord": {
-        "url": "http://localhost:10200/dobetterweb/lighthouse-480x318.jpg",
-        "resourceSize": 24620,
-        "startTime": 185607.272846,
-        "endTime": 185608.100862,
-        "responseReceivedTime": 185607.847168,
-        "mimeType": "image/jpeg"
-      }
+      "resourceSize": 24620,
+      "mimeType": "image/jpeg"
     },
     {
       "src": "http://localhost:10200/dobetterweb/lighthouse-480x318.jpg",
-      "width": 480,
-      "height": 318,
-      "clientWidth": 480,
-      "clientHeight": 318,
+      "displayedWidth": 480,
+      "displayedHeight": 318,
       "clientRect": {
         "top": 164.8125,
         "bottom": 482.8125,
@@ -821,21 +812,13 @@
       "isCss": false,
       "isPicture": false,
       "usesObjectFit": false,
-      "networkRecord": {
-        "url": "http://localhost:10200/dobetterweb/lighthouse-480x318.jpg",
-        "resourceSize": 24620,
-        "startTime": 185607.272846,
-        "endTime": 185608.100862,
-        "responseReceivedTime": 185607.847168,
-        "mimeType": "image/jpeg"
-      }
+      "resourceSize": 24620,
+      "mimeType": "image/jpeg"
     },
     {
       "src": "blob:http://localhost:10200/ae0eac03-ab9b-4a6a-b299-f5212153e277",
-      "width": 16,
-      "height": 16,
-      "clientWidth": 16,
-      "clientHeight": 16,
+      "displayedWidth": 16,
+      "displayedHeight": 16,
       "clientRect": {
         "top": 559.8125,
         "bottom": 575.8125,
@@ -2498,7 +2481,6 @@
     "name": "mydb",
     "version": "1.0"
   },
-  "MetaDescription": null,
   "FontSize": {
     "analyzedFailingNodesData": [],
     "analyzedFailingTextLength": 0,
@@ -2528,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: [