Repository: GoogleChrome/lighthouse
Pull request #12421 (2021-04-28 20:11:57)

core(driver): extract gotoURL to navigation module
**Summary**
Extracts `gotoURL` from driver to be based on the shared transitional driver. Also moves a few of the setup protocol commands that weren't really a part of navigation but just were just a convenient spot to put a few environmental fixes.

**Related Issues/PRs**
ref #11313 


Patch:
diff --git a/lighthouse-core/audits/metrics.js b/lighthouse-core/audits/metrics.js
index fa839d300..53820156c 100644
--- a/lighthouse-core/audits/metrics.js
+++ b/lighthouse-core/audits/metrics.js
@@ -44,7 +44,7 @@ class Metrics extends Audit {
     const trace = artifacts.traces[Audit.DEFAULT_PASS];
     const devtoolsLog = artifacts.devtoolsLogs[Audit.DEFAULT_PASS];
     const summary = await ComputedTimingSummary
-      .request({trace, devtoolsLog}, context);
+      .request({trace, devtoolsLog, settings: context.settings}, context);
     const metrics = summary.metrics;
     const debugInfo = summary.debugInfo;
 
diff --git a/lighthouse-core/audits/performance-budget.js b/lighthouse-core/audits/performance-budget.js
index c8e68a4da..217b51537 100644
--- a/lighthouse-core/audits/performance-budget.js
+++ b/lighthouse-core/audits/performance-budget.js
@@ -120,7 +120,8 @@ class ResourceBudget extends Audit {
    */
   static async audit(artifacts, context) {
     const devtoolsLog = artifacts.devtoolsLogs[Audit.DEFAULT_PASS];
-    const summary = await ResourceSummary.request({devtoolsLog, URL: artifacts.URL}, context);
+    const data = {devtoolsLog, URL: artifacts.URL, budgets: context.settings.budgets};
+    const summary = await ResourceSummary.request(data, context);
     const mainResource = await MainResource.request({URL: artifacts.URL, devtoolsLog}, context);
     const budget = Budget.getMatchingBudget(context.settings.budgets, mainResource.url);
 
diff --git a/lighthouse-core/audits/resource-summary.js b/lighthouse-core/audits/resource-summary.js
index e41da0885..5646b9fe1 100644
--- a/lighthouse-core/audits/resource-summary.js
+++ b/lighthouse-core/audits/resource-summary.js
@@ -45,7 +45,7 @@ class ResourceSummary extends Audit {
   static async audit(artifacts, context) {
     const devtoolsLog = artifacts.devtoolsLogs[Audit.DEFAULT_PASS];
     const summary = await ComputedResourceSummary
-      .request({devtoolsLog, URL: artifacts.URL}, context);
+      .request({devtoolsLog, URL: artifacts.URL, budgets: context.settings.budgets}, context);
 
     /** @type {LH.Audit.Details.Table['headings']} */
     const headings = [
diff --git a/lighthouse-core/audits/timing-budget.js b/lighthouse-core/audits/timing-budget.js
index 950c648d5..92e416ecb 100644
--- a/lighthouse-core/audits/timing-budget.js
+++ b/lighthouse-core/audits/timing-budget.js
@@ -143,7 +143,8 @@ class TimingBudget extends Audit {
     const devtoolsLog = artifacts.devtoolsLogs[Audit.DEFAULT_PASS];
     const trace = artifacts.traces[Audit.DEFAULT_PASS];
     const mainResource = await MainResource.request({URL: artifacts.URL, devtoolsLog}, context);
-    const summary = (await TimingSummary.request({trace, devtoolsLog}, context)).metrics;
+    const data = {trace, devtoolsLog, settings: context.settings};
+    const summary = (await TimingSummary.request(data, context)).metrics;
     const budget = Budget.getMatchingBudget(context.settings.budgets, mainResource.url);
 
     if (!budget) {
diff --git a/lighthouse-core/computed/computed-artifact.js b/lighthouse-core/computed/computed-artifact.js
index 268fee9d7..595ae93ce 100644
--- a/lighthouse-core/computed/computed-artifact.js
+++ b/lighthouse-core/computed/computed-artifact.js
@@ -11,7 +11,7 @@ const log = require('lighthouse-logger');
 /**
  * Decorate computableArtifact with a caching `request()` method which will
  * automatically call `computableArtifact.compute_()` under the hood.
- * @template {{name: string, compute_(artifacts: unknown, context: LH.Audit.Context): Promise<unknown>}} C
+ * @template {{name: string, compute_(artifacts: unknown, context: LH.Artifacts.ComputedContext): Promise<unknown>}} C
  * @param {C} computableArtifact
  */
 function makeComputedArtifact(computableArtifact) {
@@ -21,7 +21,7 @@ function makeComputedArtifact(computableArtifact) {
   /**
    * Return an automatically cached result from the computed artifact.
    * @param {FirstParamType<C['compute_']>} artifacts
-   * @param {LH.Audit.Context} context
+   * @param {LH.Artifacts.ComputedContext} context
    * @return {ReturnType<C['compute_']>}
    */
   const request = (artifacts, context) => {
diff --git a/lighthouse-core/computed/critical-request-chains.js b/lighthouse-core/computed/critical-request-chains.js
index 3d53c33f9..667097060 100644
--- a/lighthouse-core/computed/critical-request-chains.js
+++ b/lighthouse-core/computed/critical-request-chains.js
@@ -125,7 +125,7 @@ class CriticalRequestChains {
 
   /**
    * @param {{URL: LH.Artifacts['URL'], devtoolsLog: LH.DevtoolsLog, trace: LH.Trace}} data
-   * @param {LH.Audit.Context} context
+   * @param {LH.Artifacts.ComputedContext} context
    * @return {Promise<LH.Artifacts.CriticalRequestNode>}
    */
   static async compute_(data, context) {
diff --git a/lighthouse-core/computed/layout-shift-variants.js b/lighthouse-core/computed/layout-shift-variants.js
index ceafc060c..fca642ad8 100644
--- a/lighthouse-core/computed/layout-shift-variants.js
+++ b/lighthouse-core/computed/layout-shift-variants.js
@@ -140,7 +140,7 @@ class LayoutShiftVariants {
 
   /**
    * @param {LH.Trace} trace
-   * @param {LH.Audit.Context} context
+   * @param {LH.Artifacts.ComputedContext} context
    * @return {Promise<{avgSessionGap5s: number, maxSessionGap1s: number, maxSessionGap1sLimit5s: number, maxSliding1s: number, maxSliding300ms: number}>}
    */
   static async compute_(trace, context) {
diff --git a/lighthouse-core/computed/load-simulator.js b/lighthouse-core/computed/load-simulator.js
index af34f9d1d..21d5c3c72 100644
--- a/lighthouse-core/computed/load-simulator.js
+++ b/lighthouse-core/computed/load-simulator.js
@@ -13,7 +13,7 @@ const NetworkAnalysis = require('./network-analysis.js');
 class LoadSimulator {
   /**
    * @param {{devtoolsLog: LH.DevtoolsLog, settings: Immutable<LH.Config.Settings>}} data
-   * @param {LH.Audit.Context} context
+   * @param {LH.Artifacts.ComputedContext} context
    * @return {Promise<Simulator>}
    */
   static async compute_(data, context) {
diff --git a/lighthouse-core/computed/main-resource.js b/lighthouse-core/computed/main-resource.js
index 4a432328c..d4579fb03 100644
--- a/lighthouse-core/computed/main-resource.js
+++ b/lighthouse-core/computed/main-resource.js
@@ -16,7 +16,7 @@ const NetworkRecords = require('./network-records.js');
 class MainResource {
   /**
    * @param {{URL: LH.Artifacts['URL'], devtoolsLog: LH.DevtoolsLog}} data
-   * @param {LH.Audit.Context} context
+   * @param {LH.Artifacts.ComputedContext} context
    * @return {Promise<LH.Artifacts.NetworkRequest>}
    */
   static async compute_(data, context) {
diff --git a/lighthouse-core/computed/main-thread-tasks.js b/lighthouse-core/computed/main-thread-tasks.js
index dfda2d057..4f27ae7d9 100644
--- a/lighthouse-core/computed/main-thread-tasks.js
+++ b/lighthouse-core/computed/main-thread-tasks.js
@@ -12,7 +12,7 @@ const TraceOfTab = require('./trace-of-tab.js');
 class MainThreadTasks {
   /**
    * @param {LH.Trace} trace
-   * @param {LH.Audit.Context} context
+   * @param {LH.Artifacts.ComputedContext} context
    * @return {Promise<Array<LH.Artifacts.TaskNode>>}
    */
   static async compute_(trace, context) {
diff --git a/lighthouse-core/computed/metrics/cumulative-layout-shift-all-frames.js b/lighthouse-core/computed/metrics/cumulative-layout-shift-all-frames.js
index 15e3046b4..af0042135 100644
--- a/lighthouse-core/computed/metrics/cumulative-layout-shift-all-frames.js
+++ b/lighthouse-core/computed/metrics/cumulative-layout-shift-all-frames.js
@@ -15,7 +15,7 @@ const LayoutShiftVariants = require('../layout-shift-variants.js');
 class CumulativeLayoutShiftAllFrames {
   /**
    * @param {LH.Trace} trace
-   * @param {LH.Audit.Context} context
+   * @param {LH.Artifacts.ComputedContext} context
    * @return {Promise<{value: number}>}
    */
   static async compute_(trace, context) {
diff --git a/lighthouse-core/computed/metrics/cumulative-layout-shift.js b/lighthouse-core/computed/metrics/cumulative-layout-shift.js
index ff830c5b2..ce9ddcd0e 100644
--- a/lighthouse-core/computed/metrics/cumulative-layout-shift.js
+++ b/lighthouse-core/computed/metrics/cumulative-layout-shift.js
@@ -12,7 +12,7 @@ const LayoutShiftVariants = require('../layout-shift-variants.js');
 class CumulativeLayoutShift {
   /**
    * @param {LH.Trace} trace
-   * @param {LH.Audit.Context} context
+   * @param {LH.Artifacts.ComputedContext} context
    * @return {Promise<{value: number, debugInfo: Record<string,boolean> | null}>}
    */
   static async compute_(trace, context) {
diff --git a/lighthouse-core/computed/metrics/estimated-input-latency.js b/lighthouse-core/computed/metrics/estimated-input-latency.js
index 809f017d7..d38a0d76c 100644
--- a/lighthouse-core/computed/metrics/estimated-input-latency.js
+++ b/lighthouse-core/computed/metrics/estimated-input-latency.js
@@ -43,7 +43,7 @@ class EstimatedInputLatency extends ComputedMetric {
 
   /**
    * @param {LH.Artifacts.MetricComputationData} data
-   * @param {LH.Audit.Context} context
+   * @param {LH.Artifacts.ComputedContext} context
    * @return {Promise<LH.Artifacts.LanternMetric>}
    */
   static computeSimulatedMetric(data, context) {
diff --git a/lighthouse-core/computed/metrics/first-contentful-paint.js b/lighthouse-core/computed/metrics/first-contentful-paint.js
index 64efd5611..1b0346837 100644
--- a/lighthouse-core/computed/metrics/first-contentful-paint.js
+++ b/lighthouse-core/computed/metrics/first-contentful-paint.js
@@ -12,7 +12,7 @@ const LanternFirstContentfulPaint = require('./lantern-first-contentful-paint.js
 class FirstContentfulPaint extends ComputedMetric {
   /**
    * @param {LH.Artifacts.MetricComputationData} data
-   * @param {LH.Audit.Context} context
+   * @param {LH.Artifacts.ComputedContext} context
    * @return {Promise<LH.Artifacts.LanternMetric>}
    */
   static computeSimulatedMetric(data, context) {
diff --git a/lighthouse-core/computed/metrics/first-cpu-idle.js b/lighthouse-core/computed/metrics/first-cpu-idle.js
index a8e78f077..236d3fb30 100644
--- a/lighthouse-core/computed/metrics/first-cpu-idle.js
+++ b/lighthouse-core/computed/metrics/first-cpu-idle.js
@@ -167,7 +167,7 @@ class FirstCPUIdle extends ComputedMetric {
 
   /**
    * @param {LH.Artifacts.MetricComputationData} data
-   * @param {LH.Audit.Context} context
+   * @param {LH.Artifacts.ComputedContext} context
    * @return {Promise<LH.Artifacts.LanternMetric>}
    */
   static computeSimulatedMetric(data, context) {
diff --git a/lighthouse-core/computed/metrics/first-meaningful-paint.js b/lighthouse-core/computed/metrics/first-meaningful-paint.js
index ee9a5b97b..d952979ab 100644
--- a/lighthouse-core/computed/metrics/first-meaningful-paint.js
+++ b/lighthouse-core/computed/metrics/first-meaningful-paint.js
@@ -13,7 +13,7 @@ const LanternFirstMeaningfulPaint = require('./lantern-first-meaningful-paint.js
 class FirstMeaningfulPaint extends ComputedMetric {
   /**
    * @param {LH.Artifacts.MetricComputationData} data
-   * @param {LH.Audit.Context} context
+   * @param {LH.Artifacts.ComputedContext} context
    * @return {Promise<LH.Artifacts.LanternMetric>}
    */
   static computeSimulatedMetric(data, context) {
diff --git a/lighthouse-core/computed/metrics/interactive.js b/lighthouse-core/computed/metrics/interactive.js
index 89b5df3ad..414888013 100644
--- a/lighthouse-core/computed/metrics/interactive.js
+++ b/lighthouse-core/computed/metrics/interactive.js
@@ -142,7 +142,7 @@ class Interactive extends ComputedMetric {
 
   /**
    * @param {LH.Artifacts.MetricComputationData} data
-   * @param {LH.Audit.Context} context
+   * @param {LH.Artifacts.ComputedContext} context
    * @return {Promise<LH.Artifacts.LanternMetric>}
    */
   static computeSimulatedMetric(data, context) {
diff --git a/lighthouse-core/computed/metrics/lantern-estimated-input-latency.js b/lighthouse-core/computed/metrics/lantern-estimated-input-latency.js
index 95d746231..7bfdcec81 100644
--- a/lighthouse-core/computed/metrics/lantern-estimated-input-latency.js
+++ b/lighthouse-core/computed/metrics/lantern-estimated-input-latency.js
@@ -70,7 +70,7 @@ class LanternEstimatedInputLatency extends LanternMetric {
 
   /**
    * @param {LH.Artifacts.MetricComputationDataInput} data
-   * @param {LH.Audit.Context} context
+   * @param {LH.Artifacts.ComputedContext} context
    * @return {Promise<LH.Artifacts.LanternMetric>}
    */
   static async compute_(data, context) {
diff --git a/lighthouse-core/computed/metrics/lantern-first-cpu-idle.js b/lighthouse-core/computed/metrics/lantern-first-cpu-idle.js
index b228118ff..60fd60c47 100644
--- a/lighthouse-core/computed/metrics/lantern-first-cpu-idle.js
+++ b/lighthouse-core/computed/metrics/lantern-first-cpu-idle.js
@@ -60,7 +60,7 @@ class LanternFirstCPUIdle extends LanternInteractive {
 
   /**
    * @param {LH.Artifacts.MetricComputationDataInput} data
-   * @param {LH.Audit.Context} context
+   * @param {LH.Artifacts.ComputedContext} context
    * @return {Promise<LH.Artifacts.LanternMetric>}
    */
   static async compute_(data, context) {
diff --git a/lighthouse-core/computed/metrics/lantern-first-meaningful-paint.js b/lighthouse-core/computed/metrics/lantern-first-meaningful-paint.js
index 7edbe1b96..1abaa8dd5 100644
--- a/lighthouse-core/computed/metrics/lantern-first-meaningful-paint.js
+++ b/lighthouse-core/computed/metrics/lantern-first-meaningful-paint.js
@@ -66,7 +66,7 @@ class LanternFirstMeaningfulPaint extends LanternMetric {
 
   /**
    * @param {LH.Artifacts.MetricComputationDataInput} data
-   * @param {LH.Audit.Context} context
+   * @param {LH.Artifacts.ComputedContext} context
    * @return {Promise<LH.Artifacts.LanternMetric>}
    */
   static async compute_(data, context) {
diff --git a/lighthouse-core/computed/metrics/lantern-interactive.js b/lighthouse-core/computed/metrics/lantern-interactive.js
index aa311ef6d..326c8a35c 100644
--- a/lighthouse-core/computed/metrics/lantern-interactive.js
+++ b/lighthouse-core/computed/metrics/lantern-interactive.js
@@ -82,7 +82,7 @@ class LanternInteractive extends LanternMetric {
 
   /**
    * @param {LH.Artifacts.MetricComputationDataInput} data
-   * @param {LH.Audit.Context} context
+   * @param {LH.Artifacts.ComputedContext} context
    * @return {Promise<LH.Artifacts.LanternMetric>}
    */
   static async compute_(data, context) {
diff --git a/lighthouse-core/computed/metrics/lantern-largest-contentful-paint.js b/lighthouse-core/computed/metrics/lantern-largest-contentful-paint.js
index 3ad61c0a6..d0b3d529f 100644
--- a/lighthouse-core/computed/metrics/lantern-largest-contentful-paint.js
+++ b/lighthouse-core/computed/metrics/lantern-largest-contentful-paint.js
@@ -94,7 +94,7 @@ class LanternLargestContentfulPaint extends LanternMetric {
 
   /**
    * @param {LH.Artifacts.MetricComputationDataInput} data
-   * @param {LH.Audit.Context} context
+   * @param {LH.Artifacts.ComputedContext} context
    * @return {Promise<LH.Artifacts.LanternMetric>}
    */
   static async compute_(data, context) {
diff --git a/lighthouse-core/computed/metrics/lantern-max-potential-fid.js b/lighthouse-core/computed/metrics/lantern-max-potential-fid.js
index df9fc1c4a..6940dcbd6 100644
--- a/lighthouse-core/computed/metrics/lantern-max-potential-fid.js
+++ b/lighthouse-core/computed/metrics/lantern-max-potential-fid.js
@@ -67,7 +67,7 @@ class LanternMaxPotentialFID extends LanternMetricArtifact {
 
   /**
    * @param {LH.Artifacts.MetricComputationDataInput} data
-   * @param {LH.Audit.Context} context
+   * @param {LH.Artifacts.ComputedContext} context
    * @return {Promise<LH.Artifacts.LanternMetric>}
    */
   static async compute_(data, context) {
diff --git a/lighthouse-core/computed/metrics/lantern-metric.js b/lighthouse-core/computed/metrics/lantern-metric.js
index c2e0a3f6e..a1ee6f470 100644
--- a/lighthouse-core/computed/metrics/lantern-metric.js
+++ b/lighthouse-core/computed/metrics/lantern-metric.js
@@ -93,7 +93,7 @@ class LanternMetricArtifact {
 
   /**
    * @param {LH.Artifacts.MetricComputationDataInput} data
-   * @param {LH.Audit.Context} context
+   * @param {LH.Artifacts.ComputedContext} context
    * @param {Omit<Extras, 'optimistic'>=} extras
    * @return {Promise<LH.Artifacts.LanternMetric>}
    */
@@ -148,7 +148,7 @@ class LanternMetricArtifact {
 
   /**
    * @param {LH.Artifacts.MetricComputationDataInput} data
-   * @param {LH.Audit.Context} context
+   * @param {LH.Artifacts.ComputedContext} context
    * @return {Promise<LH.Artifacts.LanternMetric>}
    */
   static async compute_(data, context) {
diff --git a/lighthouse-core/computed/metrics/lantern-speed-index.js b/lighthouse-core/computed/metrics/lantern-speed-index.js
index f010dfbf7..10e1c4629 100644
--- a/lighthouse-core/computed/metrics/lantern-speed-index.js
+++ b/lighthouse-core/computed/metrics/lantern-speed-index.js
@@ -93,7 +93,7 @@ class LanternSpeedIndex extends LanternMetric {
 
   /**
    * @param {LH.Artifacts.MetricComputationDataInput} data
-   * @param {LH.Audit.Context} context
+   * @param {LH.Artifacts.ComputedContext} context
    * @return {Promise<LH.Artifacts.LanternMetric>}
    */
   static async compute_(data, context) {
diff --git a/lighthouse-core/computed/metrics/lantern-total-blocking-time.js b/lighthouse-core/computed/metrics/lantern-total-blocking-time.js
index a7b9d2427..082625327 100644
--- a/lighthouse-core/computed/metrics/lantern-total-blocking-time.js
+++ b/lighthouse-core/computed/metrics/lantern-total-blocking-time.js
@@ -87,7 +87,7 @@ class LanternTotalBlockingTime extends LanternMetric {
 
   /**
    * @param {LH.Artifacts.MetricComputationDataInput} data
-   * @param {LH.Audit.Context} context
+   * @param {LH.Artifacts.ComputedContext} context
    * @return {Promise<LH.Artifacts.LanternMetric>}
    */
   static async compute_(data, context) {
diff --git a/lighthouse-core/computed/metrics/largest-contentful-paint.js b/lighthouse-core/computed/metrics/largest-contentful-paint.js
index 3b3bd4558..a8162ad39 100644
--- a/lighthouse-core/computed/metrics/largest-contentful-paint.js
+++ b/lighthouse-core/computed/metrics/largest-contentful-paint.js
@@ -21,7 +21,7 @@ const LanternLargestContentfulPaint = require('./lantern-largest-contentful-pain
 class LargestContentfulPaint extends ComputedMetric {
   /**
    * @param {LH.Artifacts.MetricComputationData} data
-   * @param {LH.Audit.Context} context
+   * @param {LH.Artifacts.ComputedContext} context
    * @return {Promise<LH.Artifacts.LanternMetric>}
    */
   static computeSimulatedMetric(data, context) {
diff --git a/lighthouse-core/computed/metrics/max-potential-fid.js b/lighthouse-core/computed/metrics/max-potential-fid.js
index dc65bc583..efb31af40 100644
--- a/lighthouse-core/computed/metrics/max-potential-fid.js
+++ b/lighthouse-core/computed/metrics/max-potential-fid.js
@@ -14,7 +14,7 @@ const TracingProcessor = require('../../lib/tracehouse/trace-processor.js');
 class MaxPotentialFID extends MetricArtifact {
   /**
    * @param {LH.Artifacts.MetricComputationData} data
-   * @param {LH.Audit.Context} context
+   * @param {LH.Artifacts.ComputedContext} context
    * @return {Promise<LH.Artifacts.LanternMetric>}
    */
   static computeSimulatedMetric(data, context) {
diff --git a/lighthouse-core/computed/metrics/metric.js b/lighthouse-core/computed/metrics/metric.js
index f0ff896e9..153663fda 100644
--- a/lighthouse-core/computed/metrics/metric.js
+++ b/lighthouse-core/computed/metrics/metric.js
@@ -23,7 +23,7 @@ class ComputedMetric {
 
   /**
    * @param {LH.Artifacts.MetricComputationData} data
-   * @param {LH.Audit.Context} context
+   * @param {LH.Artifacts.ComputedContext} context
    * @return {Promise<LH.Artifacts.LanternMetric>}
    */
   static computeSimulatedMetric(data, context) { // eslint-disable-line no-unused-vars
@@ -32,7 +32,7 @@ class ComputedMetric {
 
   /**
    * @param {LH.Artifacts.MetricComputationData} data
-   * @param {LH.Audit.Context} context
+   * @param {LH.Artifacts.ComputedContext} context
    * @return {Promise<LH.Artifacts.Metric>}
    */
   static computeObservedMetric(data, context) { // eslint-disable-line no-unused-vars
@@ -41,7 +41,7 @@ class ComputedMetric {
 
   /**
    * @param {LH.Artifacts.MetricComputationDataInput} data
-   * @param {LH.Audit.Context} context
+   * @param {LH.Artifacts.ComputedContext} context
    * @return {Promise<LH.Artifacts.LanternMetric|LH.Artifacts.Metric>}
    */
   static async compute_(data, context) {
diff --git a/lighthouse-core/computed/metrics/speed-index.js b/lighthouse-core/computed/metrics/speed-index.js
index 2003e25b3..67dc97b1d 100644
--- a/lighthouse-core/computed/metrics/speed-index.js
+++ b/lighthouse-core/computed/metrics/speed-index.js
@@ -13,7 +13,7 @@ const Speedline = require('../speedline.js');
 class SpeedIndex extends ComputedMetric {
   /**
    * @param {LH.Artifacts.MetricComputationData} data
-   * @param {LH.Audit.Context} context
+   * @param {LH.Artifacts.ComputedContext} context
    * @return {Promise<LH.Artifacts.LanternMetric>}
    */
   static computeSimulatedMetric(data, context) {
@@ -22,7 +22,7 @@ class SpeedIndex extends ComputedMetric {
 
   /**
    * @param {LH.Artifacts.MetricComputationData} data
-   * @param {LH.Audit.Context} context
+   * @param {LH.Artifacts.ComputedContext} context
    * @return {Promise<LH.Artifacts.Metric>}
    */
   static async computeObservedMetric(data, context) {
diff --git a/lighthouse-core/computed/metrics/timing-summary.js b/lighthouse-core/computed/metrics/timing-summary.js
index 3e90253e2..e8ab0b2a2 100644
--- a/lighthouse-core/computed/metrics/timing-summary.js
+++ b/lighthouse-core/computed/metrics/timing-summary.js
@@ -27,15 +27,16 @@ class TimingSummary {
   /**
      * @param {LH.Trace} trace
      * @param {LH.DevtoolsLog} devtoolsLog
-     * @param {LH.Audit.Context} context
+     * @param {ImmutableObject<LH.Config.Settings>} settings
+     * @param {LH.Artifacts.ComputedContext} context
      * @return {Promise<{metrics: LH.Artifacts.TimingSummary, debugInfo: Record<string,boolean>}>}
      */
-  static async summarize(trace, devtoolsLog, context) {
-    const metricComputationData = {trace, devtoolsLog, settings: context.settings};
+  static async summarize(trace, devtoolsLog, settings, context) {
+    const metricComputationData = {trace, devtoolsLog, settings};
     /**
      * @template TArtifacts
      * @template TReturn
-     * @param {{request: (artifact: TArtifacts, context: LH.Audit.Context) => Promise<TReturn>}} Artifact
+     * @param {{request: (artifact: TArtifacts, context: LH.Artifacts.ComputedContext) => Promise<TReturn>}} Artifact
      * @param {TArtifacts} artifact
      * @return {Promise<TReturn|undefined>}
      */
@@ -141,12 +142,12 @@ class TimingSummary {
     return {metrics, debugInfo};
   }
   /**
-   * @param {{trace: LH.Trace, devtoolsLog: LH.DevtoolsLog}} data
-   * @param {LH.Audit.Context} context
+   * @param {{trace: LH.Trace, devtoolsLog: LH.DevtoolsLog, settings: ImmutableObject<LH.Config.Settings>}} data
+   * @param {LH.Artifacts.ComputedContext} context
    * @return {Promise<{metrics: LH.Artifacts.TimingSummary, debugInfo: Record<string,boolean>}>}
    */
   static async compute_(data, context) {
-    return TimingSummary.summarize(data.trace, data.devtoolsLog, context);
+    return TimingSummary.summarize(data.trace, data.devtoolsLog, data.settings, context);
   }
 }
 
diff --git a/lighthouse-core/computed/metrics/total-blocking-time.js b/lighthouse-core/computed/metrics/total-blocking-time.js
index d55318eb7..989272478 100644
--- a/lighthouse-core/computed/metrics/total-blocking-time.js
+++ b/lighthouse-core/computed/metrics/total-blocking-time.js
@@ -79,7 +79,7 @@ class TotalBlockingTime extends ComputedMetric {
 
   /**
    * @param {LH.Artifacts.MetricComputationData} data
-   * @param {LH.Audit.Context} context
+   * @param {LH.Artifacts.ComputedContext} context
    * @return {Promise<LH.Artifacts.LanternMetric>}
    */
   static computeSimulatedMetric(data, context) {
@@ -88,7 +88,7 @@ class TotalBlockingTime extends ComputedMetric {
 
   /**
    * @param {LH.Artifacts.MetricComputationData} data
-   * @param {LH.Audit.Context} context
+   * @param {LH.Artifacts.ComputedContext} context
    * @return {Promise<LH.Artifacts.Metric>}
    */
   static async computeObservedMetric(data, context) {
diff --git a/lighthouse-core/computed/module-duplication.js b/lighthouse-core/computed/module-duplication.js
index 60bca2adb..ce36daa62 100644
--- a/lighthouse-core/computed/module-duplication.js
+++ b/lighthouse-core/computed/module-duplication.js
@@ -75,7 +75,7 @@ class ModuleDuplication {
 
   /**
    * @param {LH.Artifacts} artifacts
-   * @param {LH.Audit.Context} context
+   * @param {LH.Artifacts.ComputedContext} context
    */
   static async compute_(artifacts, context) {
     const bundles = await JsBundles.request(artifacts, context);
diff --git a/lighthouse-core/computed/network-analysis.js b/lighthouse-core/computed/network-analysis.js
index f3b733db7..8c4d21bb0 100644
--- a/lighthouse-core/computed/network-analysis.js
+++ b/lighthouse-core/computed/network-analysis.js
@@ -50,7 +50,7 @@ class NetworkAnalysis {
 
   /**
    * @param {LH.DevtoolsLog} devtoolsLog
-   * @param {LH.Audit.Context} context
+   * @param {LH.Artifacts.ComputedContext} context
    * @return {Promise<LH.Artifacts.NetworkAnalysis>}
    */
   static async compute_(devtoolsLog, context) {
diff --git a/lighthouse-core/computed/page-dependency-graph.js b/lighthouse-core/computed/page-dependency-graph.js
index ecae3d832..74dffc38f 100644
--- a/lighthouse-core/computed/page-dependency-graph.js
+++ b/lighthouse-core/computed/page-dependency-graph.js
@@ -452,7 +452,7 @@ class PageDependencyGraph {
 
   /**
    * @param {{trace: LH.Trace, devtoolsLog: LH.DevtoolsLog}} data
-   * @param {LH.Audit.Context} context
+   * @param {LH.Artifacts.ComputedContext} context
    * @return {Promise<Node>}
    */
   static async compute_(data, context) {
diff --git a/lighthouse-core/computed/resource-summary.js b/lighthouse-core/computed/resource-summary.js
index bc3536ce8..da9e0774e 100644
--- a/lighthouse-core/computed/resource-summary.js
+++ b/lighthouse-core/computed/resource-summary.js
@@ -37,10 +37,10 @@ class ResourceSummary {
   /**
    * @param {Array<LH.Artifacts.NetworkRequest>} networkRecords
    * @param {string} mainResourceURL
-   * @param {LH.Audit.Context} context
+   * @param {ImmutableObject<LH.Budget[]|null>} budgets
    * @return {Record<LH.Budget.ResourceType, ResourceEntry>}
    */
-  static summarize(networkRecords, mainResourceURL, context) {
+  static summarize(networkRecords, mainResourceURL, budgets) {
     /** @type {Record<LH.Budget.ResourceType, ResourceEntry>} */
     const resourceSummary = {
       'stylesheet': {count: 0, resourceSize: 0, transferSize: 0},
@@ -53,7 +53,7 @@ class ResourceSummary {
       'total': {count: 0, resourceSize: 0, transferSize: 0},
       'third-party': {count: 0, resourceSize: 0, transferSize: 0},
     };
-    const budget = Budget.getMatchingBudget(context.settings.budgets, mainResourceURL);
+    const budget = Budget.getMatchingBudget(budgets, mainResourceURL);
     /** @type {ReadonlyArray<string>} */
     let firstPartyHosts = [];
     if (budget && budget.options && budget.options.firstPartyHostnames) {
@@ -102,16 +102,16 @@ class ResourceSummary {
   }
 
   /**
-   * @param {{URL: LH.Artifacts['URL'], devtoolsLog: LH.DevtoolsLog}} data
-   * @param {LH.Audit.Context} context
+   * @param {{URL: LH.Artifacts['URL'], devtoolsLog: LH.DevtoolsLog, budgets: ImmutableObject<LH.Budget[]|null>}} data
+   * @param {LH.Artifacts.ComputedContext} context
    * @return {Promise<Record<LH.Budget.ResourceType,ResourceEntry>>}
    */
   static async compute_(data, context) {
     const [networkRecords, mainResource] = await Promise.all([
       NetworkRecords.request(data.devtoolsLog, context),
-      MainResource.request(data, context),
+      MainResource.request({devtoolsLog: data.devtoolsLog, URL: data.URL}, context),
     ]);
-    return ResourceSummary.summarize(networkRecords, mainResource.url, context);
+    return ResourceSummary.summarize(networkRecords, mainResource.url, data.budgets);
   }
 }
 
diff --git a/lighthouse-core/computed/speedline.js b/lighthouse-core/computed/speedline.js
index aac3f855a..48c74ae53 100644
--- a/lighthouse-core/computed/speedline.js
+++ b/lighthouse-core/computed/speedline.js
@@ -13,7 +13,7 @@ const TraceOfTab = require('./trace-of-tab.js');
 class Speedline {
   /**
    * @param {LH.Trace} trace
-   * @param {LH.Audit.Context} context
+   * @param {LH.Artifacts.ComputedContext} context
    * @return {Promise<LH.Artifacts.Speedline>}
    */
   static async compute_(trace, context) {
diff --git a/lighthouse-core/computed/unused-css.js b/lighthouse-core/computed/unused-css.js
index 5ac250903..847446a79 100644
--- a/lighthouse-core/computed/unused-css.js
+++ b/lighthouse-core/computed/unused-css.js
@@ -136,7 +136,7 @@ class UnusedCSS {
 
   /**
    * @param {{CSSUsage: LH.Artifacts['CSSUsage'], URL: LH.Artifacts['URL'], devtoolsLog: LH.DevtoolsLog}} data
-   * @param {LH.Audit.Context} context
+   * @param {LH.Artifacts.ComputedContext} context
    * @return {Promise<LH.Audit.ByteEfficiencyItem[]>}
   */
   static async compute_(data, context) {
diff --git a/lighthouse-core/computed/user-timings.js b/lighthouse-core/computed/user-timings.js
index 78c75cb76..7ad702ea3 100644
--- a/lighthouse-core/computed/user-timings.js
+++ b/lighthouse-core/computed/user-timings.js
@@ -14,7 +14,7 @@ const TraceOfTab = require('./trace-of-tab.js');
 class UserTimings {
   /**
    * @param {LH.Trace} trace
-   * @param {LH.Audit.Context} context
+   * @param {LH.Artifacts.ComputedContext} context
    * @return {Promise<Array<MarkEvent|MeasureEvent>>}
    */
   static async compute_(trace, context) {
diff --git a/lighthouse-core/gather/driver.js b/lighthouse-core/gather/driver.js
index 8dfc27efc..00fc106ff 100644
--- a/lighthouse-core/gather/driver.js
+++ b/lighthouse-core/gather/driver.js
@@ -7,12 +7,10 @@
 
 const Fetcher = require('./fetcher.js');
 const ExecutionContext = require('./driver/execution-context.js');
-const {waitForFullyLoaded, waitForFrameNavigated} = require('./driver/wait-for-condition.js');
 const LHElement = require('../lib/lh-element.js');
 const LHError = require('../lib/lh-error.js');
 const NetworkRequest = require('../lib/network-request.js');
 const EventEmitter = require('events').EventEmitter;
-const constants = require('../config/constants.js');
 
 const log = require('lighthouse-logger');
 const DevtoolsLog = require('./devtools-log.js');
@@ -23,17 +21,8 @@ const pageFunctions = require('../lib/page-functions.js');
 // Pulled in for Connection type checking.
 // eslint-disable-next-line no-unused-vars
 const Connection = require('./connections/connection.js');
-const NetworkMonitor = require('./driver/network-monitor.js');
 const {getBrowserVersion} = require('./driver/environment.js');
 
-// Controls how long to wait after FCP before continuing
-const DEFAULT_PAUSE_AFTER_FCP = 0;
-// Controls how long to wait after onLoad before continuing
-const DEFAULT_PAUSE_AFTER_LOAD = 0;
-// Controls how long to wait between network requests before determining the network is quiet
-const DEFAULT_NETWORK_QUIET_THRESHOLD = 5000;
-// Controls how long to wait between longtasks before determining the CPU is idle, off by default
-const DEFAULT_CPU_QUIET_THRESHOLD = 0;
 // Controls how long to wait for a response after sending a DevTools protocol command.
 const DEFAULT_PROTOCOL_TIMEOUT = 30000;
 
@@ -85,12 +74,25 @@ class Driver {
    */
   constructor(connection) {
     this._connection = connection;
-    this._networkMonitor = new NetworkMonitor(this);
 
     this.on('Target.attachedToTarget', event => {
       this._handleTargetAttached(event).catch(this._handleEventError);
     });
 
+    this.on('Page.frameNavigated', event => {
+      // We're only interested in setting autoattach on the root via this method.
+      // `_handleTargetAttached` takes care of the recursive piece.
+      if (event.frame.parentId) return;
+
+      // Enable auto-attaching to subtargets so we receive iframe information.
+      this.sendCommand('Target.setAutoAttach', {
+        flatten: true,
+        autoAttach: true,
+        // Pause targets on startup so we don't miss anything
+        waitForDebuggerOnStart: true,
+      }).catch(this._handleEventError);
+    });
+
     connection.on('protocolevent', this._handleProtocolEvent.bind(this));
 
     /** @private @deprecated Only available for plugin backcompat. */
@@ -318,6 +320,7 @@ class Driver {
     /** @type {NodeJS.Timer|undefined} */
     let asyncTimeout;
     const timeoutPromise = new Promise((resolve, reject) => {
+      if (timeout === Infinity) return;
       asyncTimeout = setTimeout((() => {
         const err = new LHError(
           LHError.errors.PROTOCOL_TIMEOUT,
@@ -376,83 +379,6 @@ class Driver {
     return !!this._domainEnabledCounts.get(domain);
   }
 
-  /**
-   * Navigate to the given URL. Direct use of this method isn't advised: if
-   * the current page is already at the given URL, navigation will not occur and
-   * so the returned promise will only resolve after the MAX_WAIT_FOR_FULLY_LOADED
-   * timeout. See https://github.com/GoogleChrome/lighthouse/pull/185 for one
-   * possible workaround.
-   * Resolves on the url of the loaded page, taking into account any redirects.
-   * @param {string} url
-   * @param {{waitForFcp?: boolean, waitForLoad?: boolean, waitForNavigated?: boolean, passContext?: LH.Gatherer.PassContext}} options
-   * @return {Promise<{finalUrl: string, timedOut: boolean}>}
-   */
-  async gotoURL(url, options = {}) {
-    const waitForFcp = options.waitForFcp || false;
-    const waitForNavigated = options.waitForNavigated || false;
-    const waitForLoad = options.waitForLoad || false;
-    /** @type {Partial<LH.Gatherer.PassContext>} */
-    const passContext = options.passContext || {};
-
-    if (waitForNavigated && (waitForFcp || waitForLoad)) {
-      throw new Error('Cannot use both waitForNavigated and another event, pick just one');
-    }
-
-    await this._networkMonitor.enable();
-    await this.executionContext.clearContextId();
-
-    // Enable auto-attaching to subtargets so we receive iframe information
-    await this.sendCommand('Target.setAutoAttach', {
-      flatten: true,
-      autoAttach: true,
-      // Pause targets on startup so we don't miss anything
-      waitForDebuggerOnStart: true,
-    });
-
-    await this.sendCommand('Page.enable');
-    await this.sendCommand('Page.setLifecycleEventsEnabled', {enabled: true});
-    // No timeout needed for Page.navigate. See https://github.com/GoogleChrome/lighthouse/pull/6413.
-    const waitforPageNavigateCmd = this._innerSendCommand('Page.navigate', undefined, {url});
-
-    let timedOut = false;
-    if (waitForNavigated) {
-      await waitForFrameNavigated(this).promise;
-    } else if (waitForLoad) {
-      /** @type {Partial<LH.Config.Pass>} */
-      const passConfig = passContext.passConfig || {};
-
-      /* eslint-disable max-len */
-      let {pauseAfterFcpMs, pauseAfterLoadMs, networkQuietThresholdMs, cpuQuietThresholdMs} = passConfig;
-      let maxWaitMs = passContext.settings && passContext.settings.maxWaitForLoad;
-      let maxFCPMs = passContext.settings && passContext.settings.maxWaitForFcp;
-
-      if (typeof pauseAfterFcpMs !== 'number') pauseAfterFcpMs = DEFAULT_PAUSE_AFTER_FCP;
-      if (typeof pauseAfterLoadMs !== 'number') pauseAfterLoadMs = DEFAULT_PAUSE_AFTER_LOAD;
-      if (typeof networkQuietThresholdMs !== 'number') networkQuietThresholdMs = DEFAULT_NETWORK_QUIET_THRESHOLD;
-      if (typeof cpuQuietThresholdMs !== 'number') cpuQuietThresholdMs = DEFAULT_CPU_QUIET_THRESHOLD;
-      if (typeof maxWaitMs !== 'number') maxWaitMs = constants.defaultSettings.maxWaitForLoad;
-      if (typeof maxFCPMs !== 'number') maxFCPMs = constants.defaultSettings.maxWaitForFcp;
-      /* eslint-enable max-len */
-
-      if (!waitForFcp) maxFCPMs = undefined;
-      const waitOptions = {pauseAfterFcpMs, pauseAfterLoadMs, networkQuietThresholdMs,
-        cpuQuietThresholdMs, maxWaitForLoadedMs: maxWaitMs, maxWaitForFcpMs: maxFCPMs};
-      const loadResult = await waitForFullyLoaded(this, this._networkMonitor, waitOptions);
-      timedOut = loadResult.timedOut;
-    }
-
-    const finalUrl = await this._networkMonitor.getFinalNavigationUrl() || url;
-
-    // Bring `Page.navigate` errors back into the promise chain. See https://github.com/GoogleChrome/lighthouse/pull/6739.
-    await waitforPageNavigateCmd;
-    await this._networkMonitor.disable();
-
-    return {
-      finalUrl,
-      timedOut,
-    };
-  }
-
   /**
    * @param {string} objectId Object ID for the resolved DOM node
    * @param {string} propName Name of the property
diff --git a/lighthouse-core/gather/driver/navigation.js b/lighthouse-core/gather/driver/navigation.js
new file mode 100644
index 000000000..43cd16201
--- /dev/null
+++ b/lighthouse-core/gather/driver/navigation.js
@@ -0,0 +1,109 @@
+/**
+ * @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 NetworkMonitor = require('./network-monitor.js');
+const {waitForFullyLoaded, waitForFrameNavigated} = require('./wait-for-condition.js');
+const constants = require('../../config/constants.js');
+
+// Controls how long to wait after FCP before continuing
+const DEFAULT_PAUSE_AFTER_FCP = 0;
+// Controls how long to wait after onLoad before continuing
+const DEFAULT_PAUSE_AFTER_LOAD = 0;
+// Controls how long to wait between network requests before determining the network is quiet
+const DEFAULT_NETWORK_QUIET_THRESHOLD = 5000;
+// Controls how long to wait between longtasks before determining the CPU is idle, off by default
+const DEFAULT_CPU_QUIET_THRESHOLD = 0;
+
+/** @typedef {{waitUntil: Array<'fcp'|'load'|'navigated'>} & LH.Config.SharedPassNavigationJson & Partial<Pick<LH.Config.Settings, 'maxWaitForFcp'|'maxWaitForLoad'>>} NavigationOptions */
+
+/** @param {NavigationOptions} options */
+function resolveWaitForFullyLoadedOptions(options) {
+  /* eslint-disable max-len */
+  let {pauseAfterFcpMs, pauseAfterLoadMs, networkQuietThresholdMs, cpuQuietThresholdMs} = options;
+  let maxWaitMs = options.maxWaitForLoad;
+  let maxFCPMs = options.maxWaitForFcp;
+
+  if (typeof pauseAfterFcpMs !== 'number') pauseAfterFcpMs = DEFAULT_PAUSE_AFTER_FCP;
+  if (typeof pauseAfterLoadMs !== 'number') pauseAfterLoadMs = DEFAULT_PAUSE_AFTER_LOAD;
+  if (typeof networkQuietThresholdMs !== 'number') {
+    networkQuietThresholdMs = DEFAULT_NETWORK_QUIET_THRESHOLD;
+  }
+  if (typeof cpuQuietThresholdMs !== 'number') cpuQuietThresholdMs = DEFAULT_CPU_QUIET_THRESHOLD;
+  if (typeof maxWaitMs !== 'number') maxWaitMs = constants.defaultSettings.maxWaitForLoad;
+  if (typeof maxFCPMs !== 'number') maxFCPMs = constants.defaultSettings.maxWaitForFcp;
+  /* eslint-enable max-len */
+
+  if (!options.waitUntil.includes('fcp')) maxFCPMs = undefined;
+
+  return {
+    pauseAfterFcpMs,
+    pauseAfterLoadMs,
+    networkQuietThresholdMs,
+    cpuQuietThresholdMs,
+    maxWaitForLoadedMs: maxWaitMs,
+    maxWaitForFcpMs: maxFCPMs,
+  };
+}
+
+/**
+ * Navigates to the given URL, assuming that the page is not already on this URL.
+ * Resolves on the url of the loaded page, taking into account any redirects.
+ * Typical use of this method involves navigating to a neutral page such as `about:blank` in between
+ * navigations.
+ *
+ * @param {LH.Gatherer.FRTransitionalDriver} driver
+ * @param {string} url
+ * @param {NavigationOptions} options
+ * @return {Promise<{finalUrl: string, timedOut: boolean}>}
+ */
+async function gotoURL(driver, url, options) {
+  const session = driver.defaultSession;
+  const networkMonitor = new NetworkMonitor(driver.defaultSession);
+
+  // Enable the events and network monitor needed to track navigation progress.
+  await networkMonitor.enable();
+  await session.sendCommand('Page.enable');
+  await session.sendCommand('Page.setLifecycleEventsEnabled', {enabled: true});
+
+  // No timeout needed for Page.navigate. See https://github.com/GoogleChrome/lighthouse/pull/6413
+  session.setNextProtocolTimeout(Infinity);
+  const waitforPageNavigateCmd = session.sendCommand('Page.navigate', {url});
+
+  const waitForNavigated = options.waitUntil.includes('navigated');
+  const waitForLoad = options.waitUntil.includes('load');
+  const waitForFcp = options.waitUntil.includes('fcp');
+
+  /** @type {Array<Promise<{timedOut: boolean}>>} */
+  const waitConditionPromises = [];
+
+  if (waitForNavigated) {
+    const navigatedPromise = waitForFrameNavigated(session).promise;
+    waitConditionPromises.push(navigatedPromise.then(() => ({timedOut: false})));
+  }
+
+  if (waitForLoad) {
+    const waitOptions = resolveWaitForFullyLoadedOptions(options);
+    waitConditionPromises.push(waitForFullyLoaded(session, networkMonitor, waitOptions));
+  } else if (waitForFcp) {
+    throw new Error('Cannot wait for FCP without waiting for page load');
+  }
+
+  const waitConditions = await Promise.all(waitConditionPromises);
+  const timedOut = waitConditions.some(condition => condition.timedOut);
+  const finalUrl = (await networkMonitor.getFinalNavigationUrl()) || url;
+
+  // Bring `Page.navigate` errors back into the promise chain. See https://github.com/GoogleChrome/lighthouse/pull/6739.
+  await waitforPageNavigateCmd;
+  await networkMonitor.disable();
+
+  return {
+    finalUrl,
+    timedOut,
+  };
+}
+
+module.exports = {gotoURL};
diff --git a/lighthouse-core/gather/driver/network-monitor.js b/lighthouse-core/gather/driver/network-monitor.js
index cf09932c6..8ba4d1db1 100644
--- a/lighthouse-core/gather/driver/network-monitor.js
+++ b/lighthouse-core/gather/driver/network-monitor.js
@@ -74,6 +74,7 @@ class NetworkMonitor extends EventEmitter {
     this._session.on('Page.frameNavigated', this._onFrameNavigated);
     this._session.addProtocolMessageListener(this._onProtocolMessage);
 
+    await this._session.sendCommand('Page.enable');
     await this._session.sendCommand('Network.enable');
   }
 
diff --git a/lighthouse-core/gather/fetcher.js b/lighthouse-core/gather/fetcher.js
index 87fb7784b..51eaa023a 100644
--- a/lighthouse-core/gather/fetcher.js
+++ b/lighthouse-core/gather/fetcher.js
@@ -13,6 +13,8 @@
 
 /* global document */
 
+/** @typedef {{content: string|null, status: number|null}} FetchResponse */
+
 const log = require('lighthouse-logger');
 const {getBrowserVersion} = require('./driver/environment.js');
 
@@ -97,7 +99,7 @@ class Fetcher {
    *
    * @param {string} url
    * @param {{timeout: number}=} options timeout is in ms
-   * @return {Promise<string>}
+   * @return {Promise<FetchResponse>}
    */
   async fetchResource(url, options = {timeout: 500}) {
     if (!this._enabled) {
@@ -107,7 +109,7 @@ class Fetcher {
     // `Network.loadNetworkResource` was introduced in M88.
     // The long timeout bug with `IO.read` was fixed in M92:
     // https://bugs.chromium.org/p/chromium/issues/detail?id=1191757
-    const milestone = await getBrowserVersion(this.session).then(v => v.milestone);
+    const {milestone} = await getBrowserVersion(this.session);
     if (milestone >= 92) {
       return await this._fetchResourceOverProtocol(url, options);
     }
@@ -141,7 +143,7 @@ class Fetcher {
 
   /**
    * @param {string} url
-   * @return {Promise<LH.Crdp.IO.StreamHandle>}
+   * @return {Promise<{stream: LH.Crdp.IO.StreamHandle|null, status: number|null}>}
    */
   async _loadNetworkResource(url) {
     await this.session.sendCommand('Network.enable');
@@ -156,18 +158,28 @@ class Fetcher {
     });
     await this.session.sendCommand('Network.disable');
 
-    if (!networkResponse.resource.success || !networkResponse.resource.stream) {
-      const statusCode = networkResponse.resource.httpStatusCode || '';
-      throw new Error(`Loading network resource failed (${statusCode})`);
-    }
+    return {
+      stream: networkResponse.resource.success ? (networkResponse.resource.stream || null) : null,
+      status: networkResponse.resource.httpStatusCode || null,
+    };
+  }
 
-    return networkResponse.resource.stream;
+  /**
+   * @param {string} requestId
+   * @return {Promise<string>}
+   */
+  async _resolveResponseBody(requestId) {
+    const responseBody = await this.session.sendCommand('Fetch.getResponseBody', {requestId});
+    if (responseBody.base64Encoded) {
+      return Buffer.from(responseBody.body, 'base64').toString();
+    }
+    return responseBody.body;
   }
 
   /**
    * @param {string} url
    * @param {{timeout: number}} options timeout is in ms
-   * @return {Promise<string>}
+   * @return {Promise<FetchResponse>}
    */
   async _fetchResourceOverProtocol(url, options) {
     const startTime = Date.now();
@@ -180,21 +192,28 @@ class Fetcher {
       }, options.timeout);
     });
 
-    const fetchStreamPromise = this._loadNetworkResource(url);
-    const stream = await Promise.race([fetchStreamPromise, timeoutPromise])
+    const responsePromise = this._loadNetworkResource(url);
+
+    /** @type {{stream: LH.Crdp.IO.StreamHandle|null, status: number|null}} */
+    const response = await Promise.race([responsePromise, timeoutPromise])
       .finally(() => clearTimeout(timeoutHandle));
 
-    return await this._readIOStream(stream, {timeout: options.timeout - (Date.now() - startTime)});
+    const isOk = response.status && response.status >= 200 && response.status <= 299;
+    if (!response.stream || !isOk) return {status: response.status, content: null};
+
+    const timeout = options.timeout - (Date.now() - startTime);
+    const content = await this._readIOStream(response.stream, {timeout});
+    return {status: response.status, content};
   }
 
   /**
    * Fetches resource by injecting an iframe into the page.
    * @param {string} url
    * @param {{timeout: number}} options timeout is in ms
-   * @return {Promise<string>}
+   * @return {Promise<FetchResponse>}
    */
   async _fetchResourceIframe(url, options) {
-    /** @type {Promise<string>} */
+    /** @type {Promise<FetchResponse>} */
     const requestInterceptionPromise = new Promise((resolve, reject) => {
       /** @param {LH.Crdp.Fetch.RequestPausedEvent} event */
       const handlerAsync = async event => {
@@ -216,17 +235,13 @@ class Fetcher {
           return;
         }
 
-        // Now in the response stage, but the request failed.
-        if (!(responseStatusCode >= 200 && responseStatusCode < 300)) {
-          reject(new Error(`Invalid response status code: ${responseStatusCode}`));
-          return;
-        }
-
-        const responseBody = await this.session.sendCommand('Fetch.getResponseBody', {requestId});
-        if (responseBody.base64Encoded) {
-          resolve(Buffer.from(responseBody.body, 'base64').toString());
+        if (responseStatusCode >= 200 && responseStatusCode <= 299) {
+          resolve({
+            status: responseStatusCode,
+            content: await this._resolveResponseBody(requestId),
+          });
         } else {
-          resolve(responseBody.body);
+          resolve({status: responseStatusCode, content: null});
         }
 
         // Fail the request (from the page's perspective) so that the iframe never loads.
diff --git a/lighthouse-core/gather/gather-runner.js b/lighthouse-core/gather/gather-runner.js
index d95108ed9..53010dac5 100644
--- a/lighthouse-core/gather/gather-runner.js
+++ b/lighthouse-core/gather/gather-runner.js
@@ -15,6 +15,7 @@ const i18n = require('../lib/i18n/i18n.js');
 const URL = require('../lib/url-shim.js');
 const {getBenchmarkIndex} = require('./driver/environment.js');
 const storage = require('./driver/storage.js');
+const navigation = require('./driver/navigation.js');
 const serviceWorkers = require('./driver/service-workers.js');
 const WebAppManifest = require('./gatherers/web-app-manifest.js');
 const InstallabilityErrors = require('./gatherers/installability-errors.js');
@@ -79,7 +80,7 @@ class GatherRunner {
   static async loadBlank(driver, url = constants.defaultPassConfig.blankPage) {
     const status = {msg: 'Resetting state with about:blank', id: 'lh:gather:loadBlank'};
     log.time(status);
-    await driver.gotoURL(url, {waitForNavigated: true});
+    await navigation.gotoURL(driver, url, {waitUntil: ['navigated']});
     log.timeEnd(status);
   }
 
@@ -100,10 +101,12 @@ class GatherRunner {
     };
     log.time(status);
     try {
-      const {finalUrl, timedOut} = await driver.gotoURL(passContext.url, {
-        waitForFcp: passContext.passConfig.recordTrace,
-        waitForLoad: true,
-        passContext,
+      const {finalUrl, timedOut} = await navigation.gotoURL(driver, passContext.url, {
+        waitUntil: passContext.passConfig.recordTrace ?
+          ['load', 'fcp'] : ['load'],
+        maxWaitForFcp: passContext.settings.maxWaitForFcp,
+        maxWaitForLoad: passContext.settings.maxWaitForLoad,
+        ...passContext.passConfig,
       });
       passContext.url = finalUrl;
       if (timedOut) passContext.LighthouseRunWarnings.push(str_(UIStrings.warningTimeout));
diff --git a/lighthouse-core/gather/gatherers/seo/robots-txt.js b/lighthouse-core/gather/gatherers/seo/robots-txt.js
index 1490506bd..c6958a831 100644
--- a/lighthouse-core/gather/gatherers/seo/robots-txt.js
+++ b/lighthouse-core/gather/gatherers/seo/robots-txt.js
@@ -6,6 +6,7 @@
 'use strict';
 
 const FRGatherer = require('../../../fraggle-rock/gather/base-gatherer.js');
+const {getBrowserVersion} = require('../../driver/environment.js');
 
 /* global fetch, location */
 
@@ -36,11 +37,23 @@ class RobotsTxt extends FRGatherer {
    * @param {LH.Gatherer.FRTransitionalContext} passContext
    * @return {Promise<LH.Artifacts['RobotsTxt']>}
    */
-  getArtifact(passContext) {
-    return passContext.driver.executionContext.evaluate(getRobotsTxtContent, {
-      args: [],
-      useIsolation: true,
-    });
+  async getArtifact(passContext) {
+    const {milestone} = await getBrowserVersion(passContext.driver.defaultSession);
+
+    // TODO: Remove when 92 hits stable.
+    // Iframe fetcher still has issues with CSPs.
+    // Only use the fetcher if we are fetching over the CDP.
+    if (milestone < 92) {
+      return passContext.driver.executionContext.evaluate(getRobotsTxtContent, {
+        args: [],
+        useIsolation: true,
+      });
+    }
+
+    const robotsUrl = new URL('/robots.txt', passContext.url).href;
+    await passContext.driver.fetcher.enable();
+    return passContext.driver.fetcher.fetchResource(robotsUrl)
+      .catch(() => ({status: null, content: null}));
   }
 }
 
diff --git a/lighthouse-core/gather/gatherers/source-maps.js b/lighthouse-core/gather/gatherers/source-maps.js
index 3c1566f16..0b9d81d20 100644
--- a/lighthouse-core/gather/gatherers/source-maps.js
+++ b/lighthouse-core/gather/gatherers/source-maps.js
@@ -27,9 +27,11 @@ class SourceMaps extends Gatherer {
    * @return {Promise<LH.Artifacts.RawSourceMap>}
    */
   async fetchSourceMap(driver, sourceMapUrl) {
-    /** @type {string} */
-    const sourceMapJson = await driver.fetcher.fetchResource(sourceMapUrl, {timeout: 1500});
-    return JSON.parse(sourceMapJson);
+    const response = await driver.fetcher.fetchResource(sourceMapUrl, {timeout: 1500});
+    if (response.content === null) {
+      throw new Error(`Failed fetching source map (${response.status})`);
+    }
+    return JSON.parse(response.content);
   }
 
   /**
diff --git a/types/artifacts.d.ts b/types/artifacts.d.ts
index 073da753f..8275fd809 100644
--- a/types/artifacts.d.ts
+++ b/types/artifacts.d.ts
@@ -9,6 +9,7 @@ import _LanternSimulator = require('../lighthouse-core/lib/dependency-graph/simu
 import _NetworkRequest = require('../lighthouse-core/lib/network-request.js');
 import speedline = require('speedline-core');
 import TextSourceMap = require('../lighthouse-core/lib/cdt/generated/SourceMap.js');
+import ArbitraryEqualityMap = require('../lighthouse-core/lib/arbitrary-equality-map.js');
 
 type _TaskNode = import('../lighthouse-core/lib/tracehouse/main-thread-tasks.js').TaskNode;
 
@@ -189,6 +190,10 @@ declare global {
     }
 
     module Artifacts {
+      export type ComputedContext = Immutable<{
+        computedCache: Map<string, ArbitraryEqualityMap>;
+      }>;
+
       export type NetworkRequest = _NetworkRequest;
       export type TaskNode = _TaskNode;
       export type MetaElement = LH.Artifacts['MetaElements'][0];


Test patch:
diff --git a/lighthouse-cli/test/smokehouse/test-definitions/csp/csp-expectations.js b/lighthouse-cli/test/smokehouse/test-definitions/csp/csp-expectations.js
index 51b4fdad0..2ee560786 100644
--- a/lighthouse-cli/test/smokehouse/test-definitions/csp/csp-expectations.js
+++ b/lighthouse-cli/test/smokehouse/test-definitions/csp/csp-expectations.js
@@ -47,6 +47,7 @@ module.exports = [
   },
   {
     artifacts: {
+      _maxChromiumMilestone: 91,
       RobotsTxt: {
         status: null,
         content: null,
@@ -54,8 +55,9 @@ module.exports = [
       InspectorIssues: {
         contentSecurityPolicy: [
           {
-            // TODO: Fix connect-src violation when fetching robots.txt.
             // https://github.com/GoogleChrome/lighthouse/issues/10225
+            //
+            // Fixed with new fetcher using M92.
             blockedURL: 'http://localhost:10200/robots.txt',
             violatedDirective: 'connect-src',
             isReportOnly: false,
@@ -75,7 +77,6 @@ module.exports = [
         // https://github.com/GoogleChrome/lighthouse/pull/12044#issuecomment-788274938
         //
         // Fixed with new fetcher using M92.
-        _maxChromiumMilestone: 91,
         sourceMapUrl: 'http://localhost:10200/source-map/script.js.map',
         errorMessage: 'Error: Timed out fetching resource.',
         map: undefined,
@@ -87,4 +88,35 @@ module.exports = [
       audits: {},
     },
   },
+  {
+    // Same CSP as above, but verifies correct behavior for M92.
+    artifacts: {
+      _minChromiumMilestone: 92,
+      RobotsTxt: {
+        status: 404,
+        content: null,
+      },
+      InspectorIssues: {
+        contentSecurityPolicy: [
+          {
+            // TODO: Fix style-src-elem violation when checking tap targets.
+            // https://github.com/GoogleChrome/lighthouse/issues/11862
+            violatedDirective: 'style-src-elem',
+            isReportOnly: false,
+            contentSecurityPolicyViolationType: 'kInlineViolation',
+          },
+        ],
+      },
+      SourceMaps: [{
+        sourceMapUrl: 'http://localhost:10200/source-map/script.js.map',
+        map: {},
+        errorMessage: undefined,
+      }],
+    },
+    lhr: {
+      requestedUrl: 'http://localhost:10200/csp.html?' + blockAllExceptInlineScriptCsp,
+      finalUrl: 'http://localhost:10200/csp.html?' + blockAllExceptInlineScriptCsp,
+      audits: {},
+    },
+  },
 ];
diff --git a/lighthouse-core/test/chromium-web-tests/README.md b/lighthouse-core/test/chromium-web-tests/README.md
index 12a4e1ead..3a8b58338 100644
--- a/lighthouse-core/test/chromium-web-tests/README.md
+++ b/lighthouse-core/test/chromium-web-tests/README.md
@@ -14,6 +14,22 @@ yarn test-devtools
 yarn update:test-devtools
 ```
 
+### Prerequistes
+
+<details>
+  <summary> Install `requests`</summary>
+  
+  Ensure you have `requests` module available globally on your python 2.7 install. New Macs do not come with pip for python 2.7 which is deprecated, so you might have to install that too.
+  
+  
+  ```sh
+  curl https://bootstrap.pypa.io/pip/2.7/get-pip.py -o get-pip.py
+  python get-pip.py
+  pip -m pip install requests
+  ```
+  
+</details>
+
 ### Debugging
 
 * Want logs from Lighthouse? Add `log.log('status', '**** hello test output ' + JSON.stringify({obj}));` which will be visible in the `lighthouse-successful-run.js` output thanks to `LighthouseTestRunner.addStatusListener`.
diff --git a/lighthouse-core/test/computed/metrics/timing-summary-test.js b/lighthouse-core/test/computed/metrics/timing-summary-test.js
index d0bc696a8..481d38745 100644
--- a/lighthouse-core/test/computed/metrics/timing-summary-test.js
+++ b/lighthouse-core/test/computed/metrics/timing-summary-test.js
@@ -13,8 +13,9 @@ const devtoolsLog = require('../../fixtures/traces/frame-metrics-m90.devtools.lo
 /* eslint-env jest */
 describe('Timing summary', () => {
   it('contains the correct data', async () => {
-    const context = {settings: {throttlingMethod: 'devtools'}, computedCache: new Map()};
-    const result = await TimingSummary.request({trace, devtoolsLog}, context);
+    const artifacts = {settings: {throttlingMethod: 'devtools'}, trace, devtoolsLog};
+    const context = {computedCache: new Map()};
+    const result = await TimingSummary.request(artifacts, context);
 
     expect(result.metrics).toMatchInlineSnapshot(`
       Object {
diff --git a/lighthouse-core/test/computed/resource-summary-test.js b/lighthouse-core/test/computed/resource-summary-test.js
index ec17a1643..48e2eee2b 100644
--- a/lighthouse-core/test/computed/resource-summary-test.js
+++ b/lighthouse-core/test/computed/resource-summary-test.js
@@ -19,6 +19,7 @@ function mockArtifacts(networkRecords) {
   return {
     devtoolsLog: networkRecordsToDevtoolsLog(networkRecords),
     URL: {requestedUrl: networkRecords[0].url, finalUrl: networkRecords[0].url},
+    budgets: null,
   };
 }
 
@@ -32,7 +33,7 @@ describe('Resource summary computed', () => {
       {url: 'http://cdn.example.com/script.js', resourceType: 'Script', transferSize: 50},
       {url: 'http://third-party.com/file.jpg', resourceType: 'Image', transferSize: 70},
     ]);
-    context = {computedCache: new Map(), settings: {budgets: null}};
+    context = {computedCache: new Map()};
   });
 
   it('includes all resource types, regardless of whether page contains them', async () => {
@@ -64,7 +65,7 @@ describe('Resource summary computed', () => {
     }
 
     const result = ComputedResourceSummary.summarize(
-      networkRecords, networkRecords[0].url, context);
+      networkRecords, networkRecords[0].url, artifacts.budgets);
     assert.equal(result.other.count, 1);
     assert.equal(result.other.transferSize, 50);
   });
@@ -81,7 +82,7 @@ describe('Resource summary computed', () => {
   });
 
   it('ignores records with non-network protocols', async () => {
-    context.settings.budgets = [{
+    artifacts.budgets = [{
       path: '/',
       options: {
         firstPartyHostnames: ['example.com'],
@@ -117,7 +118,7 @@ describe('Resource summary computed', () => {
 
     describe('when firstPartyHostnames is not set', () => {
       it('the root domain and all subdomains are considered first-party', async () => {
-        context.settings.budgets = null;
+        artifacts.budgets = null;
         const result = await ComputedResourceSummary.request(artifacts, context);
         expect(result['third-party'].transferSize).toBe(25 + 50 + 70);
         expect(result['third-party'].count).toBe(3);
@@ -129,7 +130,7 @@ describe('Resource summary computed', () => {
           {url: 'http://es.shopping-mall.co.uk/file.html', resourceType: 'Script', transferSize: 7},
           {url: 'http://co.uk', resourceType: 'Script', transferSize: 10},
         ]);
-        context.settings.budgets = null;
+        artifacts.budgets = null;
         const result = await ComputedResourceSummary.request(artifacts, context);
         expect(result['third-party'].transferSize).toBe(10);
         expect(result['third-party'].count).toBe(1);
@@ -140,7 +141,7 @@ describe('Resource summary computed', () => {
       const allResourcesSize = 30 + 10 + 25 + 50 + 70;
       const allResourcesCount = 5;
       it('handles subdomain hostnames correctly', async () => {
-        context.settings.budgets = [{
+        artifacts.budgets = [{
           path: '/',
           options: {
             firstPartyHostnames: ['cdn.example.com'],
@@ -152,7 +153,7 @@ describe('Resource summary computed', () => {
       });
 
       it('handles wildcard expressions correctly', async () => {
-        context.settings.budgets = [{
+        artifacts.budgets = [{
           path: '/',
           options: {
             // Matches example.com and cdn.example.com
@@ -165,7 +166,7 @@ describe('Resource summary computed', () => {
       });
 
       it('handles root domain hostname correctly', async () => {
-        context.settings.budgets = [{
+        artifacts.budgets = [{
           path: '/',
           options: {
             // Matches example.com; does not match cdn.example.com
@@ -178,7 +179,7 @@ describe('Resource summary computed', () => {
       });
 
       it('handles multiple hostnames correctly', async () => {
-        context.settings.budgets = [{
+        artifacts.budgets = [{
           path: '/',
           options: {
             firstPartyHostnames: ['example.com', 'my-cdn.com'],
@@ -190,7 +191,7 @@ describe('Resource summary computed', () => {
       });
 
       it('handles duplication of hostnames', async () => {
-        context.settings.budgets = [{
+        artifacts.budgets = [{
           path: '/',
           options: {
             firstPartyHostnames: ['my-cdn.com', 'my-cdn.com', 'my-cdn.com'],
@@ -202,7 +203,7 @@ describe('Resource summary computed', () => {
       });
 
       it('handles logical duplication of hostnames', async () => {
-        context.settings.budgets = [{
+        artifacts.budgets = [{
           path: '/',
           options: {
             firstPartyHostnames: ['example.com', '*.example.com', 'cdn.example.com'],
@@ -214,7 +215,7 @@ describe('Resource summary computed', () => {
       });
 
       it('handles using top-level domains as firstPartyHostnames correctly', async () => {
-        context.settings.budgets = [{
+        artifacts.budgets = [{
           path: '/',
           options: {
             firstPartyHostnames: ['*.com'],
diff --git a/lighthouse-core/test/fraggle-rock/gather/mock-driver.js b/lighthouse-core/test/fraggle-rock/gather/mock-driver.js
index ee4bfa5b3..80c199007 100644
--- a/lighthouse-core/test/fraggle-rock/gather/mock-driver.js
+++ b/lighthouse-core/test/fraggle-rock/gather/mock-driver.js
@@ -27,6 +27,8 @@ function createMockSession() {
     once: createMockOnceFn(),
     on: createMockOnFn(),
     off: jest.fn(),
+    addProtocolMessageListener: createMockOnFn(),
+    removeProtocolMessageListener: jest.fn(),
 
     /** @return {LH.Gatherer.FRProtocolSession} */
     asSession() {
diff --git a/lighthouse-core/test/gather/driver-test.js b/lighthouse-core/test/gather/driver-test.js
index a140f654b..806a16f76 100644
--- a/lighthouse-core/test/gather/driver-test.js
+++ b/lighthouse-core/test/gather/driver-test.js
@@ -11,7 +11,6 @@ const LHElement = require('../../lib/lh-element.js');
 const {protocolGetVersionResponse} = require('./fake-driver.js');
 const {
   createMockSendCommandFn,
-  createMockOnceFn,
   makePromiseInspectable,
   flushAllTimersAndMicrotasks,
 } = require('../test-utils.js');
@@ -25,9 +24,6 @@ jest.useFakeTimers();
  * @typedef DriverMockMethods
  * @property {Driver['evaluate']} evaluate redefined to remove "private" designation
  * @property {Driver['evaluateAsync']} evaluateAsync redefined to remove "private" designation
- * @property {ReturnType<typeof createMockOnceFn>} on
- * @property {ReturnType<typeof createMockOnceFn>} once
- * @property {(...args: RecursivePartial<Parameters<Driver['gotoURL']>>) => ReturnType<Driver['gotoURL']>} gotoURL
 */
 
 /** @typedef {Omit<Driver, keyof DriverMockMethods> & DriverMockMethods} TestDriver */
@@ -202,85 +198,6 @@ describe('.beginTrace', () => {
   });
 });
 
-describe('.gotoURL', () => {
-  beforeEach(() => {
-    connectionStub.sendCommand = createMockSendCommandFn()
-      .mockResponse('Network.enable')
-      .mockResponse('Page.enable')
-      .mockResponse('Page.setLifecycleEventsEnabled')
-      .mockResponse('Emulation.setScriptExecutionDisabled')
-      .mockResponse('Page.navigate')
-      .mockResponse('Target.setAutoAttach')
-      .mockResponse('Runtime.evaluate')
-      .mockResponse('Page.getResourceTree', {frameTree: {frame: {id: 'ABC'}}});
-  });
-
-  it('will track redirects through gotoURL load', async () => {
-    driver.on = driver.once = createMockOnceFn();
-
-    const url = 'https://www.example.com';
-
-    const loadOptions = {
-      waitForNavigated: true,
-    };
-
-    const loadPromise = makePromiseInspectable(driver.gotoURL(url, loadOptions));
-    await flushAllTimersAndMicrotasks();
-    expect(loadPromise).not.toBeDone('Did not wait for frameNavigated');
-
-    // Use `getListeners` instead of `mockEvent` so we can control exactly when the promise resolves
-    // The first listener is from the network monitor and the second is from the load watcher.
-    const [networkMonitorListener, loadListener] = driver.on.getListeners('Page.frameNavigated');
-
-    /** @param {LH.Crdp.Page.Frame} frame */
-    const navigate = frame => networkMonitorListener({frame});
-    const baseFrame = {
-      id: 'ABC', loaderId: '', securityOrigin: '', mimeType: 'text/html', domainAndRegistry: '',
-      secureContextType: /** @type {'Secure'} */ ('Secure'),
-      crossOriginIsolatedContextType: /** @type {'Isolated'} */ ('Isolated'),
-      gatedAPIFeatures: [],
-    };
-    navigate({...baseFrame, url: 'http://example.com'});
-    navigate({...baseFrame, url: 'https://example.com'});
-    navigate({...baseFrame, url: 'https://www.example.com'});
-    navigate({...baseFrame, url: 'https://m.example.com'});
-    navigate({...baseFrame, id: 'ad1', url: 'https://frame-a.example.com'});
-    navigate({...baseFrame, url: 'https://m.example.com/client'});
-    navigate({...baseFrame, id: 'ad2', url: 'https://frame-b.example.com'});
-    navigate({...baseFrame, id: 'ad3', url: 'https://frame-c.example.com'});
-
-    loadListener(baseFrame);
-    await flushAllTimersAndMicrotasks();
-    expect(loadPromise).toBeDone('Did not resolve after frameNavigated');
-
-    const results = await loadPromise;
-    expect(results.finalUrl).toEqual('https://m.example.com/client');
-  });
-
-  describe('when waitForNavigated', () => {
-    it('waits for Page.frameNavigated', async () => {
-      driver.on = driver.once = createMockOnceFn();
-
-      const url = 'https://www.example.com';
-      const loadOptions = {
-        waitForNavigated: true,
-      };
-
-      const loadPromise = makePromiseInspectable(driver.gotoURL(url, loadOptions));
-      await flushAllTimersAndMicrotasks();
-      expect(loadPromise).not.toBeDone('Did not wait for frameNavigated');
-
-      // Use `getListeners` instead of `mockEvent` so we can control exactly when the promise resolves
-      const [_, listener] = driver.on.getListeners('Page.frameNavigated');
-      listener({frame: {url: 'https://www.example.com'}});
-      await flushAllTimersAndMicrotasks();
-      expect(loadPromise).toBeDone('Did not resolve after frameNavigated');
-
-      await loadPromise;
-    });
-  });
-});
-
 describe('Domain.enable/disable State', () => {
   it('dedupes (simple)', async () => {
     connectionStub.sendCommand = createMockSendCommandFn()
diff --git a/lighthouse-core/test/gather/driver/navigation-test.js b/lighthouse-core/test/gather/driver/navigation-test.js
new file mode 100644
index 000000000..99cca446d
--- /dev/null
+++ b/lighthouse-core/test/gather/driver/navigation-test.js
@@ -0,0 +1,175 @@
+/**
+ * @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 {gotoURL} = require('../../../gather/driver/navigation.js');
+const {createMockDriver} = require('../../fraggle-rock/gather/mock-driver.js');
+const {
+  createMockOnceFn,
+  makePromiseInspectable,
+  flushAllTimersAndMicrotasks,
+} = require('../../test-utils.js');
+
+/* eslint-env jest */
+
+jest.useFakeTimers();
+
+describe('.gotoURL', () => {
+  /** @type {LH.Gatherer.FRTransitionalDriver} */
+  let driver;
+  /** @type {ReturnType<typeof createMockDriver>} */
+  let mockDriver;
+
+  beforeEach(() => {
+    mockDriver = createMockDriver();
+    driver = mockDriver.asDriver();
+
+    mockDriver.defaultSession.sendCommand
+      .mockResponse('Page.enable') // network monitor's Page.enable
+      .mockResponse('Network.enable')
+      .mockResponse('Page.enable') // gotoURL's Page.enable
+      .mockResponse('Page.setLifecycleEventsEnabled')
+      .mockResponse('Page.navigate')
+      .mockResponse('Runtime.evaluate')
+      .mockResponse('Page.getResourceTree', {frameTree: {frame: {id: 'ABC'}}});
+  });
+
+  it('will track redirects through gotoURL load', async () => {
+    mockDriver.defaultSession.on = mockDriver.defaultSession.once = createMockOnceFn();
+
+    const url = 'https://www.example.com';
+
+    const loadPromise = makePromiseInspectable(gotoURL(driver, url, {waitUntil: ['navigated']}));
+    await flushAllTimersAndMicrotasks();
+    expect(loadPromise).not.toBeDone('Did not wait for frameNavigated');
+
+    // Use `getListeners` instead of `mockEvent` so we can control exactly when the promise resolves
+    // The first listener is from the network monitor and the second is from the load watcher.
+    const mockOn = mockDriver.defaultSession.on;
+    const [networkMonitorListener, loadListener] = mockOn.getListeners('Page.frameNavigated');
+
+    /** @param {LH.Crdp.Page.Frame} frame */
+    const navigate = frame => networkMonitorListener({frame});
+    const baseFrame = {
+      id: 'ABC',
+      loaderId: '',
+      securityOrigin: '',
+      mimeType: 'text/html',
+      domainAndRegistry: '',
+      secureContextType: /** @type {'Secure'} */ ('Secure'),
+      crossOriginIsolatedContextType: /** @type {'Isolated'} */ ('Isolated'),
+      gatedAPIFeatures: [],
+    };
+    navigate({...baseFrame, url: 'http://example.com'});
+    navigate({...baseFrame, url: 'https://example.com'});
+    navigate({...baseFrame, url: 'https://www.example.com'});
+    navigate({...baseFrame, url: 'https://m.example.com'});
+    navigate({...baseFrame, id: 'ad1', url: 'https://frame-a.example.com'});
+    navigate({...baseFrame, url: 'https://m.example.com/client'});
+    navigate({...baseFrame, id: 'ad2', url: 'https://frame-b.example.com'});
+    navigate({...baseFrame, id: 'ad3', url: 'https://frame-c.example.com'});
+
+    loadListener(baseFrame);
+    await flushAllTimersAndMicrotasks();
+    expect(loadPromise).toBeDone('Did not resolve after frameNavigated');
+
+    const results = await loadPromise;
+    expect(results.finalUrl).toEqual('https://m.example.com/client');
+  });
+
+  it('waits for Page.frameNavigated', async () => {
+    mockDriver.defaultSession.on = mockDriver.defaultSession.once = createMockOnceFn();
+
+    const url = 'https://www.example.com';
+
+    const loadPromise = makePromiseInspectable(gotoURL(driver, url, {waitUntil: ['navigated']}));
+    await flushAllTimersAndMicrotasks();
+    expect(loadPromise).not.toBeDone('Did not wait for frameNavigated');
+
+    // Use `getListeners` instead of `mockEvent` so we can control exactly when the promise resolves
+    const [_, listener] = mockDriver.defaultSession.on.getListeners('Page.frameNavigated');
+    listener({frame: {url: 'https://www.example.com'}});
+    await flushAllTimersAndMicrotasks();
+    expect(loadPromise).toBeDone('Did not resolve after frameNavigated');
+
+    await loadPromise;
+  });
+
+  it('waits for page load', async () => {
+    mockDriver.defaultSession.on = mockDriver.defaultSession.once = createMockOnceFn();
+
+    const url = 'https://www.example.com';
+
+    const loadPromise = makePromiseInspectable(gotoURL(driver, url, {
+      waitUntil: ['load', 'navigated'],
+      cpuQuietThresholdMs: 0,
+      networkQuietThresholdMs: 0,
+    }));
+    await flushAllTimersAndMicrotasks();
+    expect(loadPromise).not.toBeDone('Did not wait for frameNavigated/load');
+
+    // Use `getListeners` instead of `mockEvent` so we can control exactly when the promise resolves
+    const [_, navigatedListener] = mockDriver.defaultSession.on.getListeners('Page.frameNavigated');
+    navigatedListener({frame: {url: 'https://www.example.com'}});
+    await flushAllTimersAndMicrotasks();
+    expect(loadPromise).not.toBeDone('Did not wait for load');
+
+    const dclListeners = mockDriver.defaultSession.on.getListeners('Page.domContentEventFired');
+    const [loadListener] = mockDriver.defaultSession.on.getListeners('Page.loadEventFired');
+    for (const listener of dclListeners) listener();
+    loadListener();
+    await flushAllTimersAndMicrotasks();
+    expect(loadPromise).toBeDone('Did not resolve after load');
+
+    await loadPromise;
+  });
+
+  it('waits for page FCP', async () => {
+    mockDriver.defaultSession.on = mockDriver.defaultSession.once = createMockOnceFn();
+
+    const url = 'https://www.example.com';
+
+    const loadPromise = makePromiseInspectable(gotoURL(driver, url, {
+      waitUntil: ['load', 'navigated', 'fcp'],
+      cpuQuietThresholdMs: 0,
+      networkQuietThresholdMs: 0,
+    }));
+    await flushAllTimersAndMicrotasks();
+    expect(loadPromise).not.toBeDone('Did not wait for frameNavigated/load/fcp');
+
+    // Use `getListeners` instead of `mockEvent` so we can control exactly when the promise resolves
+    const [_, navigatedListener] = mockDriver.defaultSession.on.getListeners('Page.frameNavigated');
+    navigatedListener({frame: {url: 'https://www.example.com'}});
+    await flushAllTimersAndMicrotasks();
+    expect(loadPromise).not.toBeDone('Did not wait for load/fcp');
+
+    const dclListeners = mockDriver.defaultSession.on.getListeners('Page.domContentEventFired');
+    const [loadListener] = mockDriver.defaultSession.on.getListeners('Page.loadEventFired');
+    for (const listener of dclListeners) listener();
+    loadListener();
+    await flushAllTimersAndMicrotasks();
+    expect(loadPromise).not.toBeDone('Did not wait for fcp');
+
+    const [fcpListener] = mockDriver.defaultSession.on.getListeners('Page.lifecycleEvent');
+    fcpListener({name: 'firstContentfulPaint'});
+    await flushAllTimersAndMicrotasks();
+    expect(loadPromise).toBeDone('Did not resolve after fcp');
+
+    await loadPromise;
+  });
+
+  it('throws when asked to wait for FCP without waiting for load', async () => {
+    mockDriver.defaultSession.on = mockDriver.defaultSession.once = createMockOnceFn();
+
+    const url = 'https://www.example.com';
+
+    const loadPromise = makePromiseInspectable(gotoURL(driver, url, {waitUntil: ['fcp']}));
+    await flushAllTimersAndMicrotasks();
+    await expect(loadPromise).rejects.toMatchObject({
+      message: 'Cannot wait for FCP without waiting for page load',
+    });
+  });
+});
diff --git a/lighthouse-core/test/gather/driver/network-monitor-test.js b/lighthouse-core/test/gather/driver/network-monitor-test.js
index 1ed1678d6..373b856a6 100644
--- a/lighthouse-core/test/gather/driver/network-monitor-test.js
+++ b/lighthouse-core/test/gather/driver/network-monitor-test.js
@@ -40,6 +40,7 @@ describe('NetworkMonitor', () => {
     const on = (session.on = jest.fn());
     const addProtocolMessageListener = (session.addProtocolMessageListener = jest.fn());
     sendCommandMock = session.sendCommand = createMockSendCommandFn()
+      .mockResponse('Page.enable')
       .mockResponse('Network.enable');
     /** @type {(event: LH.Protocol.RawEventMessage) => void} */
     session.dispatch = event => {
@@ -109,7 +110,7 @@ describe('NetworkMonitor', () => {
       await monitor.enable();
       expect(sessionMock.on).toHaveBeenCalledTimes(1);
       expect(sessionMock.addProtocolMessageListener).toHaveBeenCalledTimes(1);
-      expect(sendCommandMock).toHaveBeenCalledTimes(1);
+      expect(sendCommandMock).toHaveBeenCalledTimes(2);
     });
   });
 
diff --git a/lighthouse-core/test/gather/fetcher-test.js b/lighthouse-core/test/gather/fetcher-test.js
index 17960359d..139201c39 100644
--- a/lighthouse-core/test/gather/fetcher-test.js
+++ b/lighthouse-core/test/gather/fetcher-test.js
@@ -133,10 +133,10 @@ describe('._fetchResourceOverProtocol', () => {
       .mockResponse('Network.disable');
 
     const data = await fetcher._fetchResourceOverProtocol('https://example.com', {timeout: 500});
-    expect(data).toEqual(streamContents);
+    expect(data).toEqual({content: streamContents, status: 200});
   });
 
-  it('throws when resource could not be fetched', async () => {
+  it('returns null when resource could not be fetched', async () => {
     connectionStub.sendCommand = createMockSendCommandFn()
       .mockResponse('Network.enable')
       .mockResponse('Page.getFrameTree', {frameTree: {frame: {id: 'FRAME'}}})
@@ -145,8 +145,8 @@ describe('._fetchResourceOverProtocol', () => {
       })
       .mockResponse('Network.disable');
 
-    const dataPromise = fetcher._fetchResourceOverProtocol('https://example.com', {timeout: 500});
-    await expect(dataPromise).rejects.toThrowError(/Loading network resource failed/);
+    const data = await fetcher._fetchResourceOverProtocol('https://example.com', {timeout: 500});
+    expect(data).toEqual({content: null, status: 404});
   });
 
   it('throws on timeout', async () => {
diff --git a/lighthouse-core/test/gather/gather-runner-test.js b/lighthouse-core/test/gather/gather-runner-test.js
index e42e47371..81886aa39 100644
--- a/lighthouse-core/test/gather/gather-runner-test.js
+++ b/lighthouse-core/test/gather/gather-runner-test.js
@@ -153,6 +153,9 @@ beforeEach(() => {
 
   const storage = require('../../gather/driver/storage.js');
   storage.clearDataForOrigin = jest.fn();
+
+  const navigation = jest.requireMock('../../gather/driver/navigation.js');
+  navigation.gotoURL = jest.fn().mockResolvedValue({finalUrl: 'https://example.com'});
 });
 
 afterEach(() => {
@@ -164,11 +167,9 @@ describe('GatherRunner', function() {
   it('loads a page and updates passContext.URL on redirect', () => {
     const url1 = 'https://example.com';
     const url2 = 'https://example.com/interstitial';
-    const driver = {
-      gotoURL() {
-        return Promise.resolve({finalUrl: url2, timedOut: false});
-      },
-    };
+    const driver = {};
+    const gotoURL = jest.requireMock('../../gather/driver/navigation.js').gotoURL;
+    gotoURL.mockResolvedValue({finalUrl: url2});
 
     const passContext = {
       url: url1,
@@ -186,11 +187,9 @@ describe('GatherRunner', function() {
   it('loads a page and returns a pageLoadError', async () => {
     const url = 'https://example.com';
     const error = new LHError(LHError.errors.NO_FCP);
-    const driver = {
-      gotoURL() {
-        return Promise.reject(error);
-      },
-    };
+    const driver = {};
+    const gotoURL = jest.requireMock('../../gather/driver/navigation.js').gotoURL;
+    gotoURL.mockRejectedValue(error);
 
     const passContext = {
       url,
@@ -237,13 +236,10 @@ describe('GatherRunner', function() {
   it('collects requested and final URLs as an artifact', () => {
     const requestedUrl = 'https://example.com';
     const finalUrl = 'https://example.com/interstitial';
-    const driver = Object.assign({}, fakeDriver, {
-      gotoURL() {
-        return Promise.resolve({finalUrl, timedOut: false});
-      },
-    });
+    const gotoURL = jest.requireMock('../../gather/driver/navigation.js').gotoURL;
+    gotoURL.mockResolvedValue({finalUrl, timedOut: false});
     const config = makeConfig({passes: [{passName: 'defaultPass'}]});
-    const options = {requestedUrl, driver, settings: config.settings};
+    const options = {requestedUrl, driver: fakeDriver, settings: config.settings};
 
     return GatherRunner.run(config.passes, options).then(artifacts => {
       assert.deepStrictEqual(artifacts.URL, {requestedUrl, finalUrl},
@@ -528,13 +524,17 @@ describe('GatherRunner', function() {
     const navigationError = new LHError(LHError.errors.NO_FCP);
     const driver = Object.assign({}, fakeDriver, {
       online: true,
-      /** @param {string} url */
-      gotoURL: url => url.includes('blank') ? null : Promise.reject(navigationError),
       endDevtoolsLog() {
         return networkRecordsToDevtoolsLog([{url: requestedUrl}]);
       },
     });
 
+    const gotoURL = jest.requireMock('../../gather/driver/navigation.js').gotoURL;
+    gotoURL.mockImplementation(
+      /** @param {any} _ @param {string} url */
+      (_, url) => url.includes('blank') ? null : Promise.reject(navigationError)
+    );
+
     const config = makeConfig({
       passes: [{
         recordTrace: true,
@@ -567,13 +567,17 @@ describe('GatherRunner', function() {
       .mockRejectedValueOnce(navigationError);
     const driver = Object.assign({}, fakeDriver, {
       online: true,
-      /** @param {string} url */
-      gotoURL: url => url.includes('blank') ? gotoUrlForAboutBlank() : gotoUrlForRealUrl(),
       endDevtoolsLog() {
         return networkRecordsToDevtoolsLog([{url: requestedUrl}]);
       },
     });
 
+    const gotoURL = jest.requireMock('../../gather/driver/navigation.js').gotoURL;
+    gotoURL.mockImplementation(
+      /** @param {any} _ @param {string} url */
+      (_, url) => url.includes('blank') ? gotoUrlForAboutBlank() : gotoUrlForRealUrl()
+    );
+
     const config = makeConfig({
       passes: [{passName: 'defaultPass', recordTrace: true}, {
         loadFailureMode: 'warn',
@@ -938,9 +942,17 @@ describe('GatherRunner', function() {
 
     const requestedUrl = 'https://www.reddit.com/r/nba';
     let firstLoad = true;
-    const driver = Object.assign({}, fakeDriver, {
-      /** @param {string} url Loads the page successfully in the first pass, fails with NO_FCP in the second. */
-      async gotoURL(url) {
+    const driver = Object.assign({}, fakeDriver, {online: true});
+
+    const gotoURL = jest.requireMock('../../gather/driver/navigation.js').gotoURL;
+
+    gotoURL.mockImplementation(
+      /**
+       * Loads the page successfully in the first pass, fails with NO_FCP in the second.
+       * @param {any} _
+       * @param {string} url
+       */
+      (_, url) => {
         if (url.includes('blank')) return null;
         if (firstLoad) {
           firstLoad = false;
@@ -948,9 +960,7 @@ describe('GatherRunner', function() {
         } else {
           throw new LHError(LHError.errors.NO_FCP);
         }
-      },
-      online: true,
-    });
+      });
     const options = {driver, requestedUrl, settings: config.settings};
     const artifacts = await GatherRunner.run(config.passes, options);
 
@@ -1704,11 +1714,11 @@ describe('GatherRunner', function() {
       const requestedUrl = 'http://www.slow-loading-page.com/';
       const timedoutDriver = Object.assign({}, fakeDriver, {
         online: true,
-        gotoURL() {
-          return Promise.resolve({finalUrl: requestedUrl, timedOut: true});
-        },
       });
 
+      const gotoURL = jest.requireMock('../../gather/driver/navigation.js').gotoURL;
+      gotoURL.mockResolvedValue({finalUrl: requestedUrl, timedOut: true});
+
       return GatherRunner.run(config.passes, {
         driver: timedoutDriver,
         requestedUrl,
diff --git a/lighthouse-core/test/gather/gatherers/source-maps-test.js b/lighthouse-core/test/gather/gatherers/source-maps-test.js
index 8ee4f4199..8f27f41d5 100644
--- a/lighthouse-core/test/gather/gatherers/source-maps-test.js
+++ b/lighthouse-core/test/gather/gatherers/source-maps-test.js
@@ -30,7 +30,7 @@ describe('SourceMaps gatherer', () => {
    * `resolvedSourceMapUrl` is used to assert that the SourceMaps gatherer is using the expected
    *                        url to fetch the source map.
    * `fetchError` mocks an error that happens in the page. Only fetch error message make sense.
-   * @param {Array<{scriptParsedEvent: LH.Crdp.Debugger.ScriptParsedEvent, map: string, resolvedSourceMapUrl?: string, fetchError: string}>} mapsAndEvents
+   * @param {Array<{scriptParsedEvent: LH.Crdp.Debugger.ScriptParsedEvent, map: string, status?: number, resolvedSourceMapUrl?: string, fetchError: string}>} mapsAndEvents
    * @return {Promise<LH.Artifacts['SourceMaps']>}
    */
   async function runSourceMaps(mapsAndEvents) {
@@ -50,7 +50,14 @@ describe('SourceMaps gatherer', () => {
       .mockResponse('Fetch.disable', {});
     const fetchMock = jest.fn();
 
-    for (const {scriptParsedEvent, map, resolvedSourceMapUrl, fetchError} of mapsAndEvents) {
+    for (const mapAndEvents of mapsAndEvents) {
+      const {
+        scriptParsedEvent,
+        map,
+        status = null,
+        resolvedSourceMapUrl,
+        fetchError,
+      } = mapAndEvents;
       onMock.mockEvent('protocolevent', {
         method: 'Debugger.scriptParsed',
         params: scriptParsedEvent,
@@ -71,7 +78,7 @@ describe('SourceMaps gatherer', () => {
           throw new Error(fetchError);
         }
 
-        return map;
+        return {content: map, status};
       });
     }
     const connectionStub = new Connection();
@@ -173,6 +180,28 @@ describe('SourceMaps gatherer', () => {
     ]);
   });
 
+  it('throws an error message when fetching map returns bad status code', async () => {
+    const mapsAndEvents = [
+      {
+        scriptParsedEvent: {
+          url: 'http://www.example.com/bundle.js',
+          sourceMapURL: 'http://www.example.com/bundle.js.map',
+        },
+        status: 404,
+        map: null,
+      },
+    ];
+    const artifact = await runSourceMaps(mapsAndEvents);
+    expect(artifact).toEqual([
+      {
+        scriptUrl: mapsAndEvents[0].scriptParsedEvent.url,
+        sourceMapUrl: mapsAndEvents[0].scriptParsedEvent.sourceMapURL,
+        errorMessage: 'Error: Failed fetching source map (404)',
+        map: undefined,
+      },
+    ]);
+  });
+
   it('generates an error message when fetching map fails', async () => {
     const mapsAndEvents = [
       {
diff --git a/lighthouse-core/test/gather/mock-commands.js b/lighthouse-core/test/gather/mock-commands.js
index e5efe27df..63d588e08 100644
--- a/lighthouse-core/test/gather/mock-commands.js
+++ b/lighthouse-core/test/gather/mock-commands.js
@@ -206,6 +206,12 @@ function createMockOnFn() {
       expect(mockFn).toHaveBeenCalledWith(event, expect.anything());
       return mockFn.mock.calls.find(call => call[0] === event)[1];
     },
+    /**
+     * @param {keyof LH.CrdpEvents} event
+     */
+    getListeners(event) {
+      return mockFn.mock.calls.filter(call => call[0] === event).map(call => call[1]);
+    },
   });
 
   return mockFn;
diff --git a/lighthouse-core/test/runner-test.js b/lighthouse-core/test/runner-test.js
index 49b8d30bf..a93530a63 100644
--- a/lighthouse-core/test/runner-test.js
+++ b/lighthouse-core/test/runner-test.js
@@ -799,15 +799,17 @@ describe('Runner', () => {
       const errorDriverMock = Object.assign({}, driverMock, {
         online: true,
         // Loads the page successfully in the first pass, fails with PAGE_HUNG in the second.
-        async gotoURL(url) {
-          if (url.includes('blank')) return {finalUrl: '', timedOut: false};
-          if (firstLoad) {
-            firstLoad = false;
-            return {finalUrl: url, timedOut: false};
-          } else {
-            throw new LHError(LHError.errors.PAGE_HUNG);
-          }
-        },
+      });
+
+      const gotoURL = jest.requireMock('../gather/driver/navigation.js').gotoURL;
+      gotoURL.mockImplementation((_, url) => {
+        if (url.includes('blank')) return {finalUrl: '', timedOut: false};
+        if (firstLoad) {
+          firstLoad = false;
+          return {finalUrl: url, timedOut: false};
+        } else {
+          throw new LHError(LHError.errors.PAGE_HUNG);
+        }
       });
 
       const config = new Config(configJson);
diff --git a/lighthouse-core/test/test-utils.js b/lighthouse-core/test/test-utils.js
index e3c518f51..c31186e58 100644
--- a/lighthouse-core/test/test-utils.js
+++ b/lighthouse-core/test/test-utils.js
@@ -246,6 +246,9 @@ function makeMocksForGatherRunner() {
     cleanBrowserCaches: jest.fn(),
     getImportantStorageWarning: jest.fn(),
   }));
+  jest.mock('../gather/driver/navigation.js', () => ({
+    gotoURL: jest.fn().mockResolvedValue({finalUrl: 'http://example.com', timedOut: false}),
+  }));
 }
 
 module.exports = {
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 b790273b8..ad9396418 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
@@ -112,6 +112,7 @@ Gathering: TagsBlockingFirstPaint
 Gathering: FontSize
 Gathering: EmbeddedContent
 Gathering: RobotsTxt
+Getting browser version
 Gathering: TapTargets
 Gathering: Accessibility
 Gathering: TraceElements