Repository: GoogleChrome/lighthouse
Pull request #12390 (2021-04-21 14:57:52)

docs: remove AMP Plugin example
Removed the "AMP Plugin" link from the plugins.md pointing to the lighthouse-plugin-amp plugin which seems to not be available anymore. The link points to a 404 page, the plugin seems to not be available anymore at the destination project.

<!--
Thank you for submitting a pull request!
See CONTRIBUTING.MD for help in getting a change landed.
  https://github.com/GoogleChrome/lighthouse/blob/master/CONTRIBUTING.md
-->

**Summary**
<!-- What kind of change does this PR introduce? -->
<!-- Is this a bugfix, feature, refactoring, build related change, etc? -->

<!-- Describe the need for this change -->

<!-- Link any documentation or information that would help understand this change -->

**Related Issues/PRs**
<!-- Provide any additional information we might need to understand the pull request -->


google-cla[bot]: All (the pull request submitter and all commit authors) CLAs are signed, **but** one or more commits were authored or co-authored by someone other than the pull request submitter.

We need to confirm that all authors are ok with their commits being contributed to this project.  Please have them confirm that by leaving a comment that contains only `@googlebot I consent.` in this pull request.

*Note to project maintainer:* There may be cases where the author cannot leave a comment, or the comment is not properly detected as consent.  In those cases, you can manually confirm consent of the commit author(s), and set the `cla` label to `yes` (if enabled on your project).

ℹ️ **Googlers: [Go here](https://goto.google.com/prinfo/https%3A%2F%2Fgithub.com%2FGoogleChrome%2Flighthouse%2Fpull%2F12390) for more info**.

<!-- need_author_consent -->
patrickhulce: @googlebot I consent.

Patch:
diff --git a/docs/plugins.md b/docs/plugins.md
index c6f0a6021..c6f4634af 100644
--- a/docs/plugins.md
+++ b/docs/plugins.md
@@ -368,7 +368,6 @@ Most artifacts will try to represent as truthfully as possible what was observed
 - [YouTube Embed](https://github.com/connorjclark/lighthouse-plugin-yt) - Identifies YouTube embeds
 - [Lighthouse Plugin Recipe](./recipes/lighthouse-plugin-example)
 - [Field Performance](https://github.com/treosh/lighthouse-plugin-field-performance) - A plugin to gather and display Chrome UX Report field data
-- [AMP Plugin](https://github.com/ampproject/amp-toolbox/tree/main/packages/lighthouse-plugin-amp) - Runs pages through the AMP validator.
 - [Publisher Ads Audits](https://github.com/googleads/pub-ads-lighthouse-plugin) - a well-written, but complex, plugin
 - [Green Web Foundation](https://github.com/thegreenwebfoundation/lighthouse-plugin-greenhouse) - A plugin to see which domains run on renewable power.
 - [requests-content-md5](https://www.npmjs.com/package/lighthouse-plugin-md5) - Generates MD5 hashes from the content of network requests..
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/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));


Test patch:
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/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/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 = {