Repository: GoogleChrome/lighthouse
Pull request #12663 (2021-06-15 17:12:37)

core(fr): add snapshot support to ImageElements gatherer
This is what splitting `ImageElements` to add support for snapshot mode will look like.

Most of `ImageElements` has been moved to `ImageElementsSnapshot` which makes the diff hard to read. I couldn't extend `ImageElementsSnapshot` from `ImageElements` because `GathererMeta` is not assignable to `GathererMeta<'DevtoolsLog'>`.

If you want to inspect the changes made after the code was moved, you can use this command:

```sh
git diff master:./lighthouse-core/gather/gatherers/image-elements.js fr-image-elements-snapshot:./lighthouse-core/gather/gatherers/image-elements-snapshot.js
```

I'll also do my best to highlight the differences in comments.


adamraine: > In this particular instance, I think I'm leaning slightly toward the split + change approach. WDYT?

For `ImageElements` to work in snapshot mode, I needed to remove the dependency from `DevtoolsLog`/`NetworkRecords`. The network records are necessary for two things:
- **Getting the natural dimensions**: The network record isn't *really* used here, the existence of the network record is used to determine if creating a new image is worthwhile. Additionally, some image elements will include valid natural dimensions without needing to fetch them separately. This isn't really a new source of information IMO.
- **Getting the mime type**: This could be fully extracted to a separate gatherer, but I don't think we need a dedicated `ImageMimeType` gatherer.

For these reasons, I want to avoid splitting this into two sets of information. I think having `ImageElements` and `ImageElementsSnapshot` is more clear about how this single set of information should be used in different modes. I would also be interested in removing the dependency on network records entirely and having a single artifact.

patrickhulce: > I would also be interested in removing the dependency on network records entirely and having a single artifact.

Awesome 👍  that's basically what I was going for.

> The network record isn't really used here, the existence of the network record is used to determine if creating a new image is worthwhile. Additionally, some image elements will include valid natural dimensions without needing to fetch them separately. This isn't really a new source of information IMO.

Right on. That's why I think it might be OK if we don't depend on network records at all to fetch natural size :) Mainly because... "We're far more sophisticated now with our time budgets than we were when those restrictions were put into place."

> Getting the mime type: This could be fully extracted to a separate gatherer, but I don't think we need a dedicated ImageMimeType gatherer.

Split was a little unclear of me, sorry it's really just removal :) I'm saying this information already exists in another artifact, the DevToolsLogs and it doesn't need to be duplicated. It was optional to begin with so *maybe* this is OK without a breaking change? Though it's definitely fudging a bit.

Patch:
diff --git a/lighthouse-core/audits/byte-efficiency/uses-responsive-images.js b/lighthouse-core/audits/byte-efficiency/uses-responsive-images.js
index 64c67e43c..0b4d9e3f4 100644
--- a/lighthouse-core/audits/byte-efficiency/uses-responsive-images.js
+++ b/lighthouse-core/audits/byte-efficiency/uses-responsive-images.js
@@ -14,6 +14,7 @@
 'use strict';
 
 const ByteEfficiencyAudit = require('./byte-efficiency-audit.js');
+const ImageRecords = require('../../computed/image-records.js');
 const URL = require('../../lib/url-shim.js');
 const i18n = require('../../lib/i18n/i18n.js');
 
@@ -102,10 +103,14 @@ class UsesResponsiveImages extends ByteEfficiencyAudit {
   /**
    * @param {LH.Artifacts} artifacts
    * @param {Array<LH.Artifacts.NetworkRequest>} networkRecords
-   * @return {ByteEfficiencyAudit.ByteEfficiencyProduct}
+   * @param {LH.Audit.Context} context
+   * @return {Promise<ByteEfficiencyAudit.ByteEfficiencyProduct>}
    */
-  static audit_(artifacts, networkRecords) {
-    const images = artifacts.ImageElements;
+  static async audit_(artifacts, networkRecords, context) {
+    const images = await ImageRecords.request({
+      ImageElements: artifacts.ImageElements,
+      networkRecords,
+    }, context);
     const ViewportDimensions = artifacts.ViewportDimensions;
     /** @type {Map<string, LH.Audit.ByteEfficiencyItem>} */
     const resultsMap = new Map();
diff --git a/lighthouse-core/audits/csp-xss.js b/lighthouse-core/audits/csp-xss.js
index deaace778..93f83b488 100644
--- a/lighthouse-core/audits/csp-xss.js
+++ b/lighthouse-core/audits/csp-xss.js
@@ -21,7 +21,7 @@ const UIStrings = {
   /** Description of a Lighthouse audit that evaluates the security of a page's CSP. This is displayed after a user expands the section to see more. No character length limits. 'Learn More' becomes link text to additional documentation. "CSP" stands for "Content Security Policy". "XSS" stands for "Cross Site Scripting". "CSP" and "XSS" do not need to be translated. */
   description: 'A strong Content Security Policy (CSP) significantly ' +
     'reduces the risk of cross-site scripting (XSS) attacks. ' +
-    '[Learn more](https://web.dev/strict-csp/)',
+    '[Learn more](https://web.dev/csp-xss/)',
   /** Summary text for the results of a Lighthouse audit that evaluates the security of a page's CSP. This is displayed if no CSP is being enforced. "CSP" stands for "Content Security Policy". "CSP" does not need to be translated. */
   noCsp: 'No CSP found in enforcement mode',
   /** Message shown when one or more CSPs are defined in a <meta> tag. Shown in a table with a list of other CSP bypasses and warnings. "CSP" stands for "Content Security Policy". "CSP" and "HTTP" do not need to be translated. */
diff --git a/lighthouse-core/computed/image-records.js b/lighthouse-core/computed/image-records.js
new file mode 100644
index 000000000..74b1e04b5
--- /dev/null
+++ b/lighthouse-core/computed/image-records.js
@@ -0,0 +1,62 @@
+/**
+ * @license Copyright 2021 The Lighthouse Authors. 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 makeComputedArtifact = require('./computed-artifact.js');
+
+class ImageRecords {
+  /**
+   * @param {LH.Artifacts.NetworkRequest[]} networkRecords
+   */
+  static indexNetworkRecords(networkRecords) {
+    return networkRecords.reduce((map, record) => {
+      // An image response in newer formats is sometimes incorrectly marked as "application/octet-stream",
+      // so respect the extension too.
+      const isImage = /^image/.test(record.mimeType) || /\.(avif|webp)$/i.test(record.url);
+      // The network record is only valid for size information if it finished with a successful status
+      // code that indicates a complete image response.
+      if (isImage && record.finished && record.statusCode === 200) {
+        map[record.url] = record;
+      }
+
+      return map;
+    }, /** @type {Record<string, LH.Artifacts.NetworkRequest>} */ ({}));
+  }
+
+  /**
+   * @param {{ImageElements: LH.Artifacts['ImageElements'], networkRecords: LH.Artifacts.NetworkRequest[]}} data
+   * @return {Promise<LH.Artifacts.ImageElement[]>}
+   */
+  static async compute_(data) {
+    const indexedNetworkRecords = ImageRecords.indexNetworkRecords(data.networkRecords);
+
+    /** @type {LH.Artifacts.ImageElement[]} */
+    const imageRecords = [];
+
+    for (const element of data.ImageElements) {
+      const networkRecord = indexedNetworkRecords[element.src];
+      const mimeType = networkRecord && networkRecord.mimeType;
+
+      // Don't change the guessed mime type if no mime type was found.
+      imageRecords.push({
+        ...element,
+        mimeType: mimeType ? mimeType : element.mimeType,
+      });
+    }
+
+    // Sort (in-place) as largest images descending.
+    imageRecords.sort((a, b) => {
+      const aRecord = indexedNetworkRecords[a.src] || {};
+      const bRecord = indexedNetworkRecords[b.src] || {};
+      return bRecord.resourceSize - aRecord.resourceSize;
+    });
+
+    return imageRecords;
+  }
+}
+
+module.exports = makeComputedArtifact(ImageRecords);
+
diff --git a/lighthouse-core/fraggle-rock/config/default-config.js b/lighthouse-core/fraggle-rock/config/default-config.js
index e724a8abe..5ea13da23 100644
--- a/lighthouse-core/fraggle-rock/config/default-config.js
+++ b/lighthouse-core/fraggle-rock/config/default-config.js
@@ -41,6 +41,7 @@ const artifacts = {
   PasswordInputsWithPreventedPaste: '',
   ResponseCompression: '',
   RobotsTxt: '',
+  ServiceWorker: '',
   ScriptElements: '',
   SourceMaps: '',
   Stacks: '',
@@ -94,6 +95,7 @@ const defaultConfig = {
     {id: artifacts.PasswordInputsWithPreventedPaste, gatherer: 'dobetterweb/password-inputs-with-prevented-paste'},
     {id: artifacts.ResponseCompression, gatherer: 'dobetterweb/response-compression'},
     {id: artifacts.RobotsTxt, gatherer: 'seo/robots-txt'},
+    {id: artifacts.ServiceWorker, gatherer: 'service-worker'},
     {id: artifacts.ScriptElements, gatherer: 'script-elements'},
     {id: artifacts.SourceMaps, gatherer: 'source-maps'},
     {id: artifacts.Stacks, gatherer: 'stacks'},
@@ -149,6 +151,7 @@ const defaultConfig = {
         artifacts.PasswordInputsWithPreventedPaste,
         artifacts.ResponseCompression,
         artifacts.RobotsTxt,
+        artifacts.ServiceWorker,
         artifacts.ScriptElements,
         artifacts.SourceMaps,
         artifacts.Stacks,
diff --git a/lighthouse-core/fraggle-rock/config/filters.js b/lighthouse-core/fraggle-rock/config/filters.js
index 40d9c1b9d..c65cb419c 100644
--- a/lighthouse-core/fraggle-rock/config/filters.js
+++ b/lighthouse-core/fraggle-rock/config/filters.js
@@ -44,12 +44,29 @@ function filterArtifactsByGatherMode(artifacts, mode) {
 function filterAuditsByAvailableArtifacts(audits, availableArtifacts) {
   if (!audits) return null;
 
-  const availableAritfactIds = new Set(
+  const availableArtifactIds = new Set(
     availableArtifacts.map(artifact => artifact.id).concat(baseArtifactKeys)
   );
-  return audits.filter(audit =>
-    audit.implementation.meta.requiredArtifacts.every(id => availableAritfactIds.has(id))
-  );
+  return audits.filter(audit => {
+    const meta = audit.implementation.meta;
+    return meta.requiredArtifacts.every(id => availableArtifactIds.has(id));
+  });
+}
+
+/**
+ * Optional `supportedModes` property can explicitly exclude an audit even if all required artifacts are available.
+ *
+ * @param {LH.Config.FRConfig['audits']} audits
+ * @param {LH.Gatherer.GatherMode} mode
+ * @return {LH.Config.FRConfig['audits']}
+ */
+function filterAuditsByGatherMode(audits, mode) {
+  if (!audits) return null;
+
+  return audits.filter(audit => {
+    const meta = audit.implementation.meta;
+    return !meta.supportedModes || meta.supportedModes.includes(mode);
+  });
 }
 
 /**
@@ -100,7 +117,8 @@ function filterCategoriesByAvailableAudits(categories, availableAudits) {
  */
 function filterConfigByGatherMode(config, mode) {
   const artifacts = filterArtifactsByGatherMode(config.artifacts, mode);
-  const audits = filterAuditsByAvailableArtifacts(config.audits, artifacts || []);
+  const availableAudits = filterAuditsByAvailableArtifacts(config.audits, artifacts || []);
+  const audits = filterAuditsByGatherMode(availableAudits, mode);
   const categories = filterCategoriesByAvailableAudits(config.categories, audits || []);
 
   return {
@@ -115,5 +133,6 @@ module.exports = {
   filterConfigByGatherMode,
   filterArtifactsByGatherMode,
   filterAuditsByAvailableArtifacts,
+  filterAuditsByGatherMode,
   filterCategoriesByAvailableAudits,
 };
diff --git a/lighthouse-core/gather/gatherers/image-elements.js b/lighthouse-core/gather/gatherers/image-elements.js
index 2a47ebec0..22e2cd682 100644
--- a/lighthouse-core/gather/gatherers/image-elements.js
+++ b/lighthouse-core/gather/gatherers/image-elements.js
@@ -12,9 +12,8 @@
 const log = require('lighthouse-logger');
 const FRGatherer = require('../../fraggle-rock/gather/base-gatherer.js');
 const pageFunctions = require('../../lib/page-functions.js');
-const DevtoolsLog = require('./devtools-log.js');
+const URL = require('../../lib/url-shim.js');
 const FontSize = require('./seo/font-size.js');
-const NetworkRecords = require('../../computed/network-records.js');
 
 /* global window, getElementsInDocument, Image, getNodeDetails, ShadowRoot */
 
@@ -224,11 +223,21 @@ function getEffectiveSizingRule({attributesStyle, inlineStyle, matchedCSSRules},
   return null;
 }
 
+/**
+ * @param {LH.Artifacts.ImageElement} element
+ * @return {number}
+ */
+function getPixelArea(element) {
+  if (element.naturalDimensions) {
+    return element.naturalDimensions.height * element.naturalDimensions.width;
+  }
+  return element.displayedHeight * element.displayedWidth;
+}
+
 class ImageElements extends FRGatherer {
-  /** @type {LH.Gatherer.GathererMeta<'DevtoolsLog'>} */
+  /** @type {LH.Gatherer.GathererMeta} */
   meta = {
-    supportedModes: ['navigation'],
-    dependencies: {DevtoolsLog: DevtoolsLog.symbol},
+    supportedModes: ['snapshot', 'timespan', 'navigation'],
   };
 
   constructor() {
@@ -289,40 +298,19 @@ class ImageElements extends FRGatherer {
     }
   }
 
-  /**
-   * @param {LH.Artifacts.NetworkRequest[]} networkRecords
-   */
-  indexNetworkRecords(networkRecords) {
-    return networkRecords.reduce((map, record) => {
-      // An image response in newer formats is sometimes incorrectly marked as "application/octet-stream",
-      // so respect the extension too.
-      const isImage = /^image/.test(record.mimeType) || /\.(avif|webp)$/i.test(record.url);
-      // The network record is only valid for size information if it finished with a successful status
-      // code that indicates a complete image response.
-      if (isImage && record.finished && record.statusCode === 200) {
-        map[record.url] = record;
-      }
-
-      return map;
-    }, /** @type {Record<string, LH.Artifacts.NetworkRequest>} */ ({}));
-  }
-
   /**
    *
    * @param {LH.Gatherer.FRTransitionalDriver} driver
    * @param {LH.Artifacts.ImageElement[]} elements
-   * @param {Record<string, LH.Artifacts.NetworkRequest>} indexedNetworkRecords
    */
-  async collectExtraDetails(driver, elements, indexedNetworkRecords) {
+  async collectExtraDetails(driver, elements) {
     // Don't do more than 5s of this expensive devtools protocol work. See #11289
     let reachedGatheringBudget = false;
     setTimeout(_ => (reachedGatheringBudget = true), 5000);
     let skippedCount = 0;
 
     for (const element of elements) {
-      // Pull some of our information directly off the network record.
-      const networkRecord = indexedNetworkRecords[element.src];
-      element.mimeType = networkRecord && networkRecord.mimeType;
+      element.mimeType = URL.guessMimeType(element.src);
 
       if (reachedGatheringBudget) {
         skippedCount++;
@@ -335,8 +323,7 @@ class ImageElements extends FRGatherer {
       }
       // 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.srcset) && networkRecord) {
+      if (element.isPicture || element.isCss || element.srcset) {
         await this.fetchElementWithSizeInformation(driver, element);
       }
     }
@@ -348,13 +335,11 @@ class ImageElements extends FRGatherer {
 
   /**
    * @param {LH.Gatherer.FRTransitionalContext} context
-   * @param {LH.Artifacts.NetworkRequest[]} networkRecords
    * @return {Promise<LH.Artifacts['ImageElements']>}
    */
-  async _getArtifact(context, networkRecords) {
+  async getArtifact(context) {
     const session = context.driver.defaultSession;
     const executionContext = context.driver.executionContext;
-    const indexedNetworkRecords = this.indexNetworkRecords(networkRecords);
 
     const elements = await executionContext.evaluate(collectImageElementInfo, {
       args: [],
@@ -375,14 +360,11 @@ class ImageElements extends FRGatherer {
       session.sendCommand('DOM.getDocument', {depth: -1, pierce: true}),
     ]);
 
-    // Sort (in-place) as largest images descending.
-    elements.sort((a, b) => {
-      const aRecord = indexedNetworkRecords[a.src] || {};
-      const bRecord = indexedNetworkRecords[b.src] || {};
-      return bRecord.resourceSize - aRecord.resourceSize;
-    });
+    // Spend our extra details budget on highest impact images.
+    // Our best approximation of impact without network records is to use pixel area.
+    elements.sort((a, b) => getPixelArea(b) - getPixelArea(a));
 
-    await this.collectExtraDetails(context.driver, elements, indexedNetworkRecords);
+    await this.collectExtraDetails(context.driver, elements);
 
     await Promise.all([
       session.sendCommand('DOM.disable'),
@@ -391,25 +373,6 @@ class ImageElements extends FRGatherer {
 
     return elements;
   }
-
-  /**
-   * @param {LH.Gatherer.FRTransitionalContext<'DevtoolsLog'>} context
-   * @return {Promise<LH.Artifacts['ImageElements']>}
-   */
-  async getArtifact(context) {
-    const devtoolsLog = context.dependencies.DevtoolsLog;
-    const networkRecords = await NetworkRecords.request(devtoolsLog, context);
-    return this._getArtifact(context, networkRecords);
-  }
-
-  /**
-   * @param {LH.Gatherer.PassContext} passContext
-   * @param {LH.Gatherer.LoadData} loadData
-   * @return {Promise<LH.Artifacts['ImageElements']>}
-   */
-  async afterPass(passContext, loadData) {
-    return this._getArtifact({...passContext, dependencies: {}}, loadData.networkRecords);
-  }
 }
 
 module.exports = ImageElements;
diff --git a/lighthouse-core/gather/gatherers/service-worker.js b/lighthouse-core/gather/gatherers/service-worker.js
index 8d636b341..a04827285 100644
--- a/lighthouse-core/gather/gatherers/service-worker.js
+++ b/lighthouse-core/gather/gatherers/service-worker.js
@@ -5,16 +5,33 @@
  */
 'use strict';
 
-const Gatherer = require('./gatherer.js');
+const FRGatherer = require('../../fraggle-rock/gather/base-gatherer.js');
 const serviceWorkers = require('../driver/service-workers.js');
 
-class ServiceWorker extends Gatherer {
+class ServiceWorker extends FRGatherer {
+  /** @type {LH.Gatherer.GathererMeta} */
+  meta = {
+    supportedModes: ['navigation'],
+  }
+
   /**
    * @param {LH.Gatherer.PassContext} passContext
    * @return {Promise<LH.Artifacts['ServiceWorker']>}
    */
   async beforePass(passContext) {
-    const session = passContext.driver.defaultSession;
+    return this.getArtifact({...passContext, dependencies: {}});
+  }
+
+  // This gatherer is run in a separate pass for legacy mode.
+  // Legacy compat code is in `beforePass`.
+  async afterPass() { }
+
+  /**
+   * @param {LH.Gatherer.FRTransitionalContext} context
+   * @return {Promise<LH.Artifacts['ServiceWorker']>}
+   */
+  async getArtifact(context) {
+    const session = context.driver.defaultSession;
     const {versions} = await serviceWorkers.getServiceWorkerVersions(session);
     const {registrations} = await serviceWorkers.getServiceWorkerRegistrations(session);
 
diff --git a/lighthouse-core/lib/i18n/locales/en-US.json b/lighthouse-core/lib/i18n/locales/en-US.json
index cb9cfae6a..69350710b 100644
--- a/lighthouse-core/lib/i18n/locales/en-US.json
+++ b/lighthouse-core/lib/i18n/locales/en-US.json
@@ -588,7 +588,7 @@
     "message": "Severity"
   },
   "lighthouse-core/audits/csp-xss.js | description": {
-    "message": "A strong Content Security Policy (CSP) significantly reduces the risk of cross-site scripting (XSS) attacks. [Learn more](https://web.dev/strict-csp/)"
+    "message": "A strong Content Security Policy (CSP) significantly reduces the risk of cross-site scripting (XSS) attacks. [Learn more](https://web.dev/csp-xss/)"
   },
   "lighthouse-core/audits/csp-xss.js | itemSeveritySyntax": {
     "message": "Syntax"
diff --git a/lighthouse-core/lib/i18n/locales/en-XL.json b/lighthouse-core/lib/i18n/locales/en-XL.json
index c069493dd..1b2bd3183 100644
--- a/lighthouse-core/lib/i18n/locales/en-XL.json
+++ b/lighthouse-core/lib/i18n/locales/en-XL.json
@@ -588,7 +588,7 @@
     "message": "Ŝév̂ér̂ít̂ý"
   },
   "lighthouse-core/audits/csp-xss.js | description": {
-    "message": "Â śt̂ŕôńĝ Ćôńt̂én̂t́ Ŝéĉúr̂ít̂ý P̂ól̂íĉý (ĈŚP̂) śîǵn̂íf̂íĉán̂t́l̂ý r̂éd̂úĉéŝ t́ĥé r̂íŝḱ ôf́ ĉŕôśŝ-śît́ê śĉŕîṕt̂ín̂ǵ (X̂ŚŜ) át̂t́âćk̂ś. [L̂éâŕn̂ ḿôŕê](https://web.dev/strict-csp/)"
+    "message": "Â śt̂ŕôńĝ Ćôńt̂én̂t́ Ŝéĉúr̂ít̂ý P̂ól̂íĉý (ĈŚP̂) śîǵn̂íf̂íĉán̂t́l̂ý r̂éd̂úĉéŝ t́ĥé r̂íŝḱ ôf́ ĉŕôśŝ-śît́ê śĉŕîṕt̂ín̂ǵ (X̂ŚŜ) át̂t́âćk̂ś. [L̂éâŕn̂ ḿôŕê](https://web.dev/csp-xss/)"
   },
   "lighthouse-core/audits/csp-xss.js | itemSeveritySyntax": {
     "message": "Ŝýn̂t́âx́"
diff --git a/lighthouse-core/lib/url-shim.js b/lighthouse-core/lib/url-shim.js
index 8c6c3a148..6099ed2cd 100644
--- a/lighthouse-core/lib/url-shim.js
+++ b/lighthouse-core/lib/url-shim.js
@@ -215,6 +215,33 @@ class URLShim extends URL {
     const urlScheme = protocol.includes(':') ? protocol.slice(0, protocol.indexOf(':')) : protocol;
     return NON_NETWORK_SCHEMES.includes(urlScheme);
   }
+
+  /**
+   * @param {string} src
+   * @return {string|undefined}
+   */
+  static guessMimeType(src) {
+    let url;
+    try {
+      url = new URL(src);
+    } catch {
+      return undefined;
+    }
+
+    if (url.protocol === 'data:') {
+      const match = url.pathname.match(/image\/(png|jpeg|svg\+xml|webp|gif|avif)(?=;)/);
+      if (!match) return undefined;
+      return match[0];
+    }
+
+    const match = url.pathname.toLowerCase().match(/\.(png|jpeg|jpg|svg|webp|gif|avif)$/);
+    if (!match) return undefined;
+
+    const ext = match[1];
+    if (ext === 'svg') return 'image/svg+xml';
+    if (ext === 'jpg') return 'image/jpeg';
+    return `image/${ext}`;
+  }
 }
 
 URLShim.URL = URL;
diff --git a/types/artifacts.d.ts b/types/artifacts.d.ts
index d343cb699..023e90c7b 100644
--- a/types/artifacts.d.ts
+++ b/types/artifacts.d.ts
@@ -24,7 +24,6 @@ declare global {
       | 'HTTPRedirect'
       | 'Manifest'
       | 'MixedContent'
-      | 'ServiceWorker'
       | keyof FRBaseArtifacts
     >;
 
diff --git a/types/audit.d.ts b/types/audit.d.ts
index 687169392..73ccb8b3c 100644
--- a/types/audit.d.ts
+++ b/types/audit.d.ts
@@ -57,6 +57,8 @@ declare global {
       __internalOptionalArtifacts?: Array<keyof Artifacts>;
       /** A string identifying how the score should be interpreted for display. */
       scoreDisplayMode?: Audit.ScoreDisplayMode;
+      /** A list of gather modes that this audit is applicable to. */
+      supportedModes?: Gatherer.GatherMode[],
     }
 
     export interface ByteEfficiencyItem extends Audit.Details.OpportunityItem {


Test patch:
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 b24dc4aa7..5ca67679f 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
@@ -17,6 +17,8 @@ function generateRecord(resourceSizeInKb, durationInMs, url = 'https://google.co
     transferSize: resourceSizeInKb * 1024,
     endTime: durationInMs / 1000,
     responseReceivedTime: 0,
+    statusCode: 200,
+    finished: true,
     url,
   };
 }
@@ -48,9 +50,13 @@ describe('Page uses responsive images', () => {
         ),
       ],
     };
-    it(description, () => {
+    it(description, async () => {
       // eslint-disable-next-line max-len
-      const result = UsesResponsiveImagesAudit.audit_(artifacts, [generateRecord(data.sizeInKb, data.durationInMs || 200)]);
+      const result = await UsesResponsiveImagesAudit.audit_(
+        artifacts,
+        [generateRecord(data.sizeInKb, data.durationInMs || 200)],
+        {computedCache: new Map()}
+      );
       expect(result.items).toHaveLength(data.listed ? 1 : 0);
       if (data.listed) {
         assert.equal(Math.round(result.items[0].wastedBytes / 1024), data.expectedWaste);
@@ -109,8 +115,8 @@ describe('Page uses responsive images', () => {
     expectedWaste: 840, // 1000 * 21/25
   });
 
-  it('handles images without network record', () => {
-    const auditResult = UsesResponsiveImagesAudit.audit_({
+  it('handles images without network record', async () => {
+    const auditResult = await UsesResponsiveImagesAudit.audit_({
       ViewportDimensions: {innerWidth: 1000, innerHeight: 1000, devicePixelRatio: 2},
       ImageElements: [
         generateImage(
@@ -120,15 +126,16 @@ describe('Page uses responsive images', () => {
         ),
       ],
     },
-      []
+      [],
+      {computedCache: new Map()}
     );
 
     assert.equal(auditResult.items.length, 0);
   });
 
-  it('identifies when images are not wasteful', () => {
+  it('identifies when images are not wasteful', async () => {
     const networkRecords = [generateRecord(100, 300, 'https://google.com/logo.png'), generateRecord(90, 500, 'https://google.com/logo2.png'), generateRecord(20, 100, 'data:image/jpeg;base64,foobar')];
-    const auditResult = UsesResponsiveImagesAudit.audit_({
+    const auditResult = await UsesResponsiveImagesAudit.audit_({
       ViewportDimensions: {innerWidth: 1000, innerHeight: 1000, devicePixelRatio: 2},
       ImageElements: [
         generateImage(
@@ -148,60 +155,64 @@ describe('Page uses responsive images', () => {
         ),
       ],
     },
-      networkRecords
+      networkRecords,
+      {computedCache: new Map()}
     );
 
     assert.equal(auditResult.items.length, 2);
   });
 
-  it('ignores vectors', () => {
+  it('ignores vectors', async () => {
     const urlA = 'https://google.com/logo.svg';
     const naturalSizeA = {width: 450, height: 450};
     const image =
-      {...generateImage(generateSize(10, 10), naturalSizeA, urlA), mimeType: 'image/svg+xml'};
-    const auditResult = UsesResponsiveImagesAudit.audit_({
+      {...generateImage(generateSize(10, 10), naturalSizeA, urlA)};
+    const auditResult = await UsesResponsiveImagesAudit.audit_({
       ViewportDimensions: {innerWidth: 1000, innerHeight: 1000, devicePixelRatio: 1},
       ImageElements: [
         image,
       ],
     },
-      [generateRecord(100, 300, urlA, 'image/svg+xml')]
+      [generateRecord(100, 300, urlA, 'image/svg+xml')],
+      {computedCache: new Map()}
     );
     assert.equal(auditResult.items.length, 0);
   });
 
-  it('ignores CSS', () => {
+  it('ignores CSS', async () => {
     const urlA = 'https://google.com/logo.png';
     const naturalSizeA = {width: 450, height: 450};
 
-    const auditResult = UsesResponsiveImagesAudit.audit_({
+    const auditResult = await UsesResponsiveImagesAudit.audit_({
       ViewportDimensions: {innerWidth: 1000, innerHeight: 1000, devicePixelRatio: 1},
       ImageElements: [
         {...generateImage(generateSize(10, 10), naturalSizeA, urlA), isCss: true},
       ],
     },
-      [generateRecord(100, 300, urlA)]
+      [generateRecord(100, 300, urlA)],
+      {computedCache: new Map()}
     );
 
     assert.equal(auditResult.items.length, 0);
   });
 
-  it('handles failure', () => {
+  it('handles failure', async () => {
     const urlA = 'https://google.com/logo.png';
     const naturalSizeA = {width: NaN, height: 450};
-    const auditResult = UsesResponsiveImagesAudit.audit_({
+    const auditResult = await UsesResponsiveImagesAudit.audit_({
       ViewportDimensions: {innerWidth: 1000, innerHeight: 1000, devicePixelRatio: 1},
       ImageElements: [
         generateImage(generateSize(10, 10), naturalSizeA, urlA),
       ],
     },
-      [generateRecord(100, 300, urlA)]
+      [generateRecord(100, 300, urlA)],
+      {computedCache: new Map()}
     );
 
     assert.equal(auditResult.items.length, 0);
   });
 
-  it('de-dupes images', () => {
+  it('de-dupes images', async () => {
     const urlA = 'https://google.com/logo.png';
     const naturalSizeA = {width: 450, height: 450};
     const recordA = generateRecord(100, 300, urlA);
@@ -210,7 +221,7 @@ describe('Page uses responsive images', () => {
     const recordB = generateRecord(10, 20, urlB); // make it small to keep test passing
     const networkRecords = [recordA, recordB];
 
-    const auditResult = UsesResponsiveImagesAudit.audit_({
+    const auditResult = await UsesResponsiveImagesAudit.audit_({
       ViewportDimensions: {innerWidth: 1000, innerHeight: 1000, devicePixelRatio: 1},
       ImageElements: [
         generateImage(generateSize(10, 10), naturalSizeA, urlA),
@@ -220,7 +231,8 @@ describe('Page uses responsive images', () => {
         generateImage(generateSize(100, 100), naturalSizeB, urlB),
       ],
     },
-      networkRecords
+      networkRecords,
+      {computedCache: new Map()}
     );
 
     assert.equal(auditResult.items.length, 1);
diff --git a/lighthouse-core/test/audits/image-aspect-ratio-test.js b/lighthouse-core/test/audits/image-aspect-ratio-test.js
index b35ed93cd..6cf25aede 100644
--- a/lighthouse-core/test/audits/image-aspect-ratio-test.js
+++ b/lighthouse-core/test/audits/image-aspect-ratio-test.js
@@ -143,10 +143,12 @@ describe('Images: aspect-ratio audit', () => {
       ImageElements: [
         generateImage(
           {width: 150, height: 150},
-          {},
+          {width: 100, height: 200},
           {
             mimeType: 'image/svg+xml',
             isCss: false,
+            displayedWidth: 150,
+            displayedHeight: 150,
           }
         ),
       ],
diff --git a/lighthouse-core/test/computed/image-records-test.js b/lighthouse-core/test/computed/image-records-test.js
new file mode 100644
index 000000000..8b46bbc10
--- /dev/null
+++ b/lighthouse-core/test/computed/image-records-test.js
@@ -0,0 +1,192 @@
+/**
+ * @license Copyright 2021 The Lighthouse Authors. 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 ImageRecords = require('../../computed/image-records.js');
+const NetworkRequest = require('../../lib/network-request.js');
+
+/**
+ * @param {Partial<LH.Artifacts.NetworkRequest>=} partial
+ * @return {LH.Artifacts.NetworkRequest}
+ */
+function mockRequest(partial = {}) {
+  const request = new NetworkRequest();
+  return Object.assign(request, partial);
+}
+
+/**
+ * @param {Partial<LH.Artifacts.ImageElement>=} partial
+ * @return {LH.Artifacts.ImageElement}
+ */
+function mockElement(partial = {}) {
+  return {
+    src: 'https://example.com/img.png',
+    srcset: '',
+    displayedWidth: 200,
+    displayedHeight: 200,
+    clientRect: {
+      top: 50,
+      bottom: 250,
+      left: 50,
+      right: 250,
+    },
+    attributeWidth: '',
+    attributeHeight: '',
+    naturalDimensions: undefined,
+    cssEffectiveRules: undefined,
+    computedStyles: {position: 'absolute', objectFit: '', imageRendering: ''},
+    isCss: false,
+    isPicture: false,
+    isInShadowDOM: false,
+    node: {
+      lhId: '__nodeid__',
+      devtoolsNodePath: '1,HTML,1,BODY,1,DIV,1,IMG',
+      selector: 'body > div > img',
+      nodeLabel: 'img',
+      snippet: '<img src="https://example.com/img.png">',
+      boundingRect: {
+        top: 50,
+        bottom: 250,
+        left: 50,
+        right: 250,
+        width: 200,
+        height: 200,
+      },
+    },
+    ...partial,
+  };
+}
+
+describe('.indexNetworkRecords', () => {
+  it('maps image urls to network records', () => {
+    const networkRecords = [
+      mockRequest({
+        mimeType: 'image/png',
+        url: 'https://example.com/img.png',
+        finished: true,
+        statusCode: 200,
+      }),
+      mockRequest({
+        mimeType: 'application/octect-stream',
+        url: 'https://example.com/img.webp',
+        finished: true,
+        statusCode: 200,
+      }),
+      mockRequest({
+        mimeType: 'application/octect-stream',
+        url: 'https://example.com/img.avif',
+        finished: true,
+        statusCode: 200,
+      }),
+    ];
+
+    const index = ImageRecords.indexNetworkRecords(networkRecords);
+
+    expect(index).toEqual({
+      'https://example.com/img.avif': mockRequest({
+        finished: true,
+        mimeType: 'application/octect-stream',
+        statusCode: 200,
+        url: 'https://example.com/img.avif',
+      }),
+      'https://example.com/img.png': mockRequest({
+        finished: true,
+        mimeType: 'image/png',
+        statusCode: 200,
+        url: 'https://example.com/img.png',
+      }),
+      'https://example.com/img.webp': mockRequest({
+        finished: true,
+        mimeType: 'application/octect-stream',
+        statusCode: 200,
+        url: 'https://example.com/img.webp',
+      }),
+    });
+  });
+
+  it('ignores bad status codes', () => {
+    const networkRecords = [
+      mockRequest({
+        mimeType: 'image/png',
+        url: 'https://example.com/img.png',
+        finished: true,
+        statusCode: 200,
+      }),
+      mockRequest({
+        mimeType: 'application/octect-stream',
+        url: 'https://example.com/img.webp',
+        finished: false,
+      }),
+      mockRequest({
+        mimeType: 'application/octect-stream',
+        url: 'https://example.com/img.avif',
+        finished: true,
+        statusCode: 404,
+      }),
+    ];
+
+    const index = ImageRecords.indexNetworkRecords(networkRecords);
+
+    expect(index).toEqual({
+      'https://example.com/img.png': mockRequest({
+        finished: true,
+        mimeType: 'image/png',
+        statusCode: 200,
+        url: 'https://example.com/img.png',
+      }),
+    });
+  });
+});
+
+describe('compute_', () => {
+  it('takes mime type from network record', async () => {
+    const elements = await ImageRecords.compute_({
+      ImageElements: [
+        mockElement(),
+      ],
+      networkRecords: [
+        mockRequest({
+          mimeType: 'image/png',
+          url: 'https://example.com/img.png',
+          finished: true,
+          statusCode: 200,
+        }),
+      ],
+    });
+
+    expect(elements).toEqual([mockElement({mimeType: 'image/png'})]);
+  });
+
+  it('keep element with no request', async () => {
+    const elements = await ImageRecords.compute_({
+      ImageElements: [
+        mockElement({mimeType: 'image/png'}),
+      ],
+      networkRecords: [],
+    });
+
+    expect(elements).toEqual([mockElement({mimeType: 'image/png'})]);
+  });
+
+  it('keep guessed mime type of record has none', async () => {
+    const elements = await ImageRecords.compute_({
+      ImageElements: [
+        mockElement({mimeType: 'image/png'}),
+      ],
+      networkRecords: [
+        mockRequest({
+          url: 'https://example.com/img.png',
+          finished: true,
+          statusCode: 200,
+        }),
+      ],
+    });
+
+    expect(elements).toEqual([mockElement({mimeType: 'image/png'})]);
+  });
+});
diff --git a/lighthouse-core/test/fraggle-rock/api-test-pptr.js b/lighthouse-core/test/fraggle-rock/api-test-pptr.js
index 0b56eec3e..ef65f48b0 100644
--- a/lighthouse-core/test/fraggle-rock/api-test-pptr.js
+++ b/lighthouse-core/test/fraggle-rock/api-test-pptr.js
@@ -95,7 +95,7 @@ describe('Fraggle Rock API', () => {
 
       const {auditResults, erroredAudits, failedAudits} = getAuditsBreakdown(lhr);
       // TODO(FR-COMPAT): This assertion can be removed when full compatibility is reached.
-      expect(auditResults.length).toMatchInlineSnapshot(`78`);
+      expect(auditResults.length).toMatchInlineSnapshot(`81`);
 
       expect(erroredAudits).toHaveLength(0);
       expect(failedAudits.map(audit => audit.id)).toContain('label');
@@ -121,7 +121,7 @@ describe('Fraggle Rock API', () => {
 
       const {auditResults, erroredAudits, failedAudits} = getAuditsBreakdown(lhr);
       // TODO(FR-COMPAT): This assertion can be removed when full compatibility is reached.
-      expect(auditResults.length).toMatchInlineSnapshot(`36`);
+      expect(auditResults.length).toMatchInlineSnapshot(`38`);
 
       expect(erroredAudits).toHaveLength(0);
       expect(failedAudits.map(audit => audit.id)).toContain('errors-in-console');
@@ -159,7 +159,7 @@ describe('Fraggle Rock API', () => {
       const {lhr} = result;
       const {auditResults, failedAudits, erroredAudits} = getAuditsBreakdown(lhr);
       // TODO(FR-COMPAT): This assertion can be removed when full compatibility is reached.
-      expect(auditResults.length).toMatchInlineSnapshot(`151`);
+      expect(auditResults.length).toMatchInlineSnapshot(`152`);
       expect(erroredAudits).toHaveLength(0);
 
       const failedAuditIds = failedAudits.map(audit => audit.id);
diff --git a/lighthouse-core/test/fraggle-rock/config/config-test.js b/lighthouse-core/test/fraggle-rock/config/config-test.js
index 9e3af077f..169458e1c 100644
--- a/lighthouse-core/test/fraggle-rock/config/config-test.js
+++ b/lighthouse-core/test/fraggle-rock/config/config-test.js
@@ -63,8 +63,8 @@ describe('Fraggle Rock Config', () => {
   });
 
   it('should throw on invalid artifact definitions', () => {
-    const configJson = {artifacts: [{id: 'ServiceWorker', gatherer: 'service-worker'}]};
-    expect(() => initializeConfig(configJson, {gatherMode})).toThrow(/ServiceWorker gatherer/);
+    const configJson = {artifacts: [{id: 'HTTPRedirect', gatherer: 'http-redirect'}]};
+    expect(() => initializeConfig(configJson, {gatherMode})).toThrow(/HTTPRedirect gatherer/);
   });
 
   it('should resolve navigation definitions', () => {
diff --git a/lighthouse-core/test/fraggle-rock/config/filters-test.js b/lighthouse-core/test/fraggle-rock/config/filters-test.js
index df82b4061..107b2b1fd 100644
--- a/lighthouse-core/test/fraggle-rock/config/filters-test.js
+++ b/lighthouse-core/test/fraggle-rock/config/filters-test.js
@@ -69,6 +69,14 @@ describe('Fraggle Rock Config Filtering', () => {
       ...auditMeta,
     };
   }
+  class NavigationOnlyAudit extends BaseAudit {
+    static meta = {
+      id: 'navigation-only',
+      requiredArtifacts: /** @type {any} */ (['Snapshot', 'Timespan']),
+      supportedModes: /** @type {['navigation']} */ (['navigation']),
+      ...auditMeta,
+    };
+  }
 
   const audits = [SnapshotAudit, TimespanAudit, NavigationAudit, ManualAudit].map(audit => ({
     implementation: audit,
@@ -180,6 +188,33 @@ describe('Fraggle Rock Config Filtering', () => {
     });
   });
 
+  describe('filterAuditsByGatherMode', () => {
+    it('should handle null', () => {
+      expect(filters.filterAuditsByGatherMode(null, 'timespan')).toBeNull();
+    });
+
+    it('should filter unsupported audits', () => {
+      const timespanAudits = [TimespanAudit, NavigationOnlyAudit].map(audit => ({
+        implementation: audit,
+        options: {},
+      }));
+      expect(filters.filterAuditsByGatherMode(timespanAudits, 'timespan')).toEqual([
+        {implementation: TimespanAudit, options: {}},
+      ]);
+    });
+
+    it('should keep audits without explicit modes defined', () => {
+      const timespanAudits = [TimespanAudit, NavigationAudit].map(audit => ({
+        implementation: audit,
+        options: {},
+      }));
+      expect(filters.filterAuditsByGatherMode(timespanAudits, 'timespan')).toEqual([
+        {implementation: TimespanAudit, options: {}},
+        {implementation: NavigationAudit, options: {}},
+      ]);
+    });
+  });
+
   describe('filterConfigByGatherMode', () => {
     it('should filter the entire config', () => {
       const config = {
diff --git a/lighthouse-core/test/gather/gatherers/image-elements-test.js b/lighthouse-core/test/gather/gatherers/image-elements-test.js
index c7f1d1f9c..96f45b1fe 100644
--- a/lighthouse-core/test/gather/gatherers/image-elements-test.js
+++ b/lighthouse-core/test/gather/gatherers/image-elements-test.js
@@ -9,7 +9,6 @@
 
 const ImageElements = require('../../../gather/gatherers/image-elements.js');
 const NetworkRecorder = require('../../../lib/network-recorder.js');
-const NetworkRequest = require('../../../lib/network-request.js');
 const {
   createMockContext,
   createMockDriver,
@@ -21,15 +20,6 @@ const networkRecords = NetworkRecorder.recordsFromLogs(devtoolsLog);
 
 jest.useFakeTimers();
 
-/**
- * @param {Partial<LH.Artifacts.NetworkRequest>=} partial
- * @return {LH.Artifacts.NetworkRequest}
- */
-function mockRequest(partial = {}) {
-  const request = new NetworkRequest();
-  return Object.assign(request, partial);
-}
-
 /**
  * @param {Partial<LH.Artifacts.ImageElement>=} partial
  * @return {LH.Artifacts.ImageElement}
@@ -75,9 +65,7 @@ function mockElement(partial = {}) {
 
 function makeImageElements() {
   const gatherer = new ImageElements();
-  jest.spyOn(gatherer, '_getArtifact');
   jest.spyOn(gatherer, 'collectExtraDetails');
-  jest.spyOn(gatherer, 'indexNetworkRecords');
   jest.spyOn(gatherer, 'fetchSourceRules');
   jest.spyOn(gatherer, 'fetchElementWithSizeInformation');
   return gatherer;
@@ -242,89 +230,6 @@ describe('.fetchSourceRules', () => {
   });
 });
 
-describe('.indexNetworkRecords', () => {
-  it('maps image urls to network records', () => {
-    const gatherer = makeImageElements();
-    const networkRecords = [
-      mockRequest({
-        mimeType: 'image/png',
-        url: 'https://example.com/img.png',
-        finished: true,
-        statusCode: 200,
-      }),
-      mockRequest({
-        mimeType: 'application/octect-stream',
-        url: 'https://example.com/img.webp',
-        finished: true,
-        statusCode: 200,
-      }),
-      mockRequest({
-        mimeType: 'application/octect-stream',
-        url: 'https://example.com/img.avif',
-        finished: true,
-        statusCode: 200,
-      }),
-    ];
-
-    const index = gatherer.indexNetworkRecords(networkRecords);
-
-    expect(index).toEqual({
-      'https://example.com/img.avif': mockRequest({
-        finished: true,
-        mimeType: 'application/octect-stream',
-        statusCode: 200,
-        url: 'https://example.com/img.avif',
-      }),
-      'https://example.com/img.png': mockRequest({
-        finished: true,
-        mimeType: 'image/png',
-        statusCode: 200,
-        url: 'https://example.com/img.png',
-      }),
-      'https://example.com/img.webp': mockRequest({
-        finished: true,
-        mimeType: 'application/octect-stream',
-        statusCode: 200,
-        url: 'https://example.com/img.webp',
-      }),
-    });
-  });
-
-  it('ignores bad status codes', () => {
-    const gatherer = makeImageElements();
-    const networkRecords = [
-      mockRequest({
-        mimeType: 'image/png',
-        url: 'https://example.com/img.png',
-        finished: true,
-        statusCode: 200,
-      }),
-      mockRequest({
-        mimeType: 'application/octect-stream',
-        url: 'https://example.com/img.webp',
-        finished: false,
-      }),
-      mockRequest({
-        mimeType: 'application/octect-stream',
-        url: 'https://example.com/img.avif',
-        finished: true,
-        statusCode: 404,
-      }),
-    ];
-
-    const index = gatherer.indexNetworkRecords(networkRecords);
-
-    expect(index).toEqual({
-      'https://example.com/img.png': mockRequest({
-        finished: true,
-        mimeType: 'image/png',
-        statusCode: 200,
-        url: 'https://example.com/img.png',
-      }),
-    });
-  });
-});
-
 describe('.collectExtraDetails', () => {
   let gatherer = makeImageElements();
   let driver = createMockDriver().asDriver();
@@ -346,7 +251,7 @@ describe('.collectExtraDetails', () => {
       jest.advanceTimersByTime(6000);
     });
 
-    await gatherer.collectExtraDetails(driver, elements, {});
+    await gatherer.collectExtraDetails(driver, elements);
 
     expect(gatherer.fetchSourceRules).toHaveBeenCalledTimes(1);
   });
@@ -358,7 +263,7 @@ describe('.collectExtraDetails', () => {
       mockElement({isInShadowDOM: false, isCss: true}),
     ];
 
-    await gatherer.collectExtraDetails(driver, elements, {});
+    await gatherer.collectExtraDetails(driver, elements);
 
     expect(gatherer.fetchSourceRules).toHaveBeenCalledTimes(1);
   });
@@ -369,7 +274,7 @@ describe('.collectExtraDetails', () => {
       mockElement({isInShadowDOM: false, isCss: false}),
     ];
 
-    await gatherer.collectExtraDetails(driver, elements, {});
+    await gatherer.collectExtraDetails(driver, elements);
 
     expect(gatherer.fetchSourceRules).toHaveBeenCalledTimes(2);
   });
@@ -380,16 +285,9 @@ describe('.collectExtraDetails', () => {
       mockElement({src: 'https://example.com/b.png', isPicture: true, isCss: false, srcset: 'src'}),
       mockElement({src: 'https://example.com/c.png', isPicture: false, isCss: true}),
       mockElement({src: 'https://example.com/d.png', isPicture: false, isCss: false}),
-      mockElement({src: 'https://example.com/e.png', isPicture: false, isCss: true, srcset: 'src'}),
     ];
-    const indexedNetworkRecords = {
-      'https://example.com/a.png': mockRequest(),
-      'https://example.com/b.png': mockRequest(),
-      'https://example.com/c.png': mockRequest(),
-      'https://example.com/d.png': mockRequest(),
-    };
-
-    await gatherer.collectExtraDetails(driver, elements, indexedNetworkRecords);
+
+    await gatherer.collectExtraDetails(driver, elements);
 
     expect(gatherer.fetchElementWithSizeInformation).toHaveBeenCalledTimes(3);
   });
diff --git a/lighthouse-core/test/gather/gatherers/service-worker-test.js b/lighthouse-core/test/gather/gatherers/service-worker-test.js
index 21d5fae8f..6c4aea716 100644
--- a/lighthouse-core/test/gather/gatherers/service-worker-test.js
+++ b/lighthouse-core/test/gather/gatherers/service-worker-test.js
@@ -34,7 +34,7 @@ describe('service worker gatherer', () => {
     getServiceWorkerRegistrations.mockResolvedValue({registrations});
 
     const serviceWorkerGatherer = new ServiceWorkerGather();
-    const artifact = await serviceWorkerGatherer.beforePass({
+    const artifact = await serviceWorkerGatherer.getArtifact({
       driver: {},
       url,
     });
diff --git a/lighthouse-core/test/lib/url-shim-test.js b/lighthouse-core/test/lib/url-shim-test.js
index 413646e0c..258eb5b95 100644
--- a/lighthouse-core/test/lib/url-shim-test.js
+++ b/lighthouse-core/test/lib/url-shim-test.js
@@ -304,7 +304,7 @@ describe('URL Shim', () => {
     });
   });
 
-  describe('isLikeLocalhost', () => {
+  it('isLikeLocalhost', () => {
     assert.ok(URL.isLikeLocalhost(new URL('http://localhost/').hostname));
     assert.ok(URL.isLikeLocalhost(new URL('http://localhost:10200/').hostname));
     assert.ok(URL.isLikeLocalhost(new URL('http://127.0.0.1/page.html').hostname));
@@ -315,7 +315,7 @@ describe('URL Shim', () => {
     assert.ok(!URL.isLikeLocalhost(new URL('http://example.com/').hostname));
   });
 
-  describe('isSecureScheme', () => {
+  it('isSecureScheme', () => {
     assert.ok(URL.isSecureScheme('wss'));
     assert.ok(URL.isSecureScheme('about'));
     assert.ok(URL.isSecureScheme('data'));
@@ -325,7 +325,7 @@ describe('URL Shim', () => {
     assert.ok(!URL.isSecureScheme('ws'));
   });
 
-  describe('isNonNetworkProtocol', () => {
+  it('isNonNetworkProtocol', () => {
     assert.ok(URL.isNonNetworkProtocol('blob'));
     assert.ok(URL.isNonNetworkProtocol('data'));
     assert.ok(URL.isNonNetworkProtocol('data:'));
@@ -335,4 +335,30 @@ describe('URL Shim', () => {
     assert.ok(!URL.isNonNetworkProtocol('http'));
     assert.ok(!URL.isNonNetworkProtocol('ws'));
   });
+
+  describe('guessMimeType', () => {
+    it('handles invalid url', () => {
+      expect(URL.guessMimeType('')).toEqual(undefined);
+      expect(URL.guessMimeType('I_AM_NO_URL')).toEqual(undefined);
+    });
+
+    it('uses mime type from data URI', () => {
+      expect(URL.guessMimeType('data:image/png;DATA')).toEqual('image/png');
+      expect(URL.guessMimeType('data:image/jpeg;DATA')).toEqual('image/jpeg');
+      expect(URL.guessMimeType('data:image/svg+xml;DATA')).toEqual('image/svg+xml');
+      expect(URL.guessMimeType('data:text/html;DATA')).toEqual(undefined);
+      expect(URL.guessMimeType('data:image/jpg;DATA')).toEqual(undefined);
+    });
+
+    it('uses path extension for normal files', () => {
+      expect(URL.guessMimeType('https://example.com/img.png')).toEqual('image/png');
+      expect(URL.guessMimeType('https://example.com/img.png?test')).toEqual('image/png');
+      expect(URL.guessMimeType('https://example.com/IMG.PNG')).toEqual('image/png');
+      expect(URL.guessMimeType('https://example.com/img.jpeg')).toEqual('image/jpeg');
+      expect(URL.guessMimeType('https://example.com/img.jpg')).toEqual('image/jpeg');
+      expect(URL.guessMimeType('https://example.com/img.svg')).toEqual('image/svg+xml');
+      expect(URL.guessMimeType('https://example.com/page.html')).toEqual(undefined);
+      expect(URL.guessMimeType('https://example.com/')).toEqual(undefined);
+    });
+  });
 });
diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json
index 3fbafe5ec..288c44c81 100644
--- a/lighthouse-core/test/results/sample_v2.json
+++ b/lighthouse-core/test/results/sample_v2.json
@@ -2175,7 +2175,7 @@
     "csp-xss": {
       "id": "csp-xss",
       "title": "Ensure CSP is effective against XSS attacks",
-      "description": "A strong Content Security Policy (CSP) significantly reduces the risk of cross-site scripting (XSS) attacks. [Learn more](https://web.dev/strict-csp/)",
+      "description": "A strong Content Security Policy (CSP) significantly reduces the risk of cross-site scripting (XSS) attacks. [Learn more](https://web.dev/csp-xss/)",
       "score": null,
       "scoreDisplayMode": "informative",
       "details": {
@@ -6900,6 +6900,12 @@
         "duration": 100,
         "entryType": "measure"
       },
+      {
+        "startTime": 0,
+        "name": "lh:computed:ImageRecords",
+        "duration": 100,
+        "entryType": "measure"
+      },
       {
         "startTime": 0,
         "name": "lh:audit:efficient-animated-content",
diff --git a/third-party/chromium-webtests/webtests/http/tests/devtools/lighthouse/lighthouse-successful-run-expected.txt b/third-party/chromium-webtests/webtests/http/tests/devtools/lighthouse/lighthouse-successful-run-expected.txt
index 1bbaa0320..1d437a3ab 100644
--- a/third-party/chromium-webtests/webtests/http/tests/devtools/lighthouse/lighthouse-successful-run-expected.txt
+++ b/third-party/chromium-webtests/webtests/http/tests/devtools/lighthouse/lighthouse-successful-run-expected.txt
@@ -316,6 +316,7 @@ Auditing: Serve images in next-gen formats
 Auditing: Efficiently encode images
 Auditing: Enable text compression
 Auditing: Properly size images
+Computing artifact: ImageRecords
 Auditing: Use video formats for animated content
 Auditing: Remove duplicate modules in JavaScript bundles
 Computing artifact: ModuleDuplication