Repository: GoogleChrome/lighthouse
Pull request #12478 (2021-05-13 21:32:23)

core(fr): align navigation-runner with legacy gather-runner
**Summary**
~~Won't work until #12469 is merged, but~~ this brings fraggle rock up to snuff with gather runner and makes use of all the refactoring over the past month! 🎉 

**Related Issues/PRs**
ref #11313 



Patch:
diff --git a/lighthouse-core/fraggle-rock/config/default-config.js b/lighthouse-core/fraggle-rock/config/default-config.js
index 7deeeddb1..2a2c0a05d 100644
--- a/lighthouse-core/fraggle-rock/config/default-config.js
+++ b/lighthouse-core/fraggle-rock/config/default-config.js
@@ -95,6 +95,10 @@ const defaultConfig = {
   navigations: [
     {
       id: 'default',
+      pauseAfterFcpMs: 1000,
+      pauseAfterLoadMs: 1000,
+      networkQuietThresholdMs: 1000,
+      cpuQuietThresholdMs: 1000,
       artifacts: [
         // Artifacts which can be depended on come first.
         artifacts.DevtoolsLog,
diff --git a/lighthouse-core/fraggle-rock/gather/navigation-runner.js b/lighthouse-core/fraggle-rock/gather/navigation-runner.js
index 93da91937..824fc9f4b 100644
--- a/lighthouse-core/fraggle-rock/gather/navigation-runner.js
+++ b/lighthouse-core/fraggle-rock/gather/navigation-runner.js
@@ -5,6 +5,7 @@
  */
 'use strict';
 
+const log = require('lighthouse-logger');
 const Driver = require('./driver.js');
 const Runner = require('../../runner.js');
 const {
@@ -12,88 +13,185 @@ const {
   collectPhaseArtifacts,
   awaitArtifacts,
 } = require('./runner-helpers.js');
+const prepare = require('../../gather/driver/prepare.js');
+const {gotoURL} = require('../../gather/driver/navigation.js');
+const storage = require('../../gather/driver/storage.js');
+const emulation = require('../../lib/emulation.js');
 const {defaultNavigationConfig} = require('../../config/constants.js');
 const {initializeConfig} = require('../config/config.js');
 const {getBaseArtifacts} = require('./base-artifacts.js');
+const i18n = require('../../lib/i18n/i18n.js');
+const LighthouseError = require('../../lib/lh-error.js');
+const {getPageLoadError} = require('../../lib/navigation-error.js');
+const DevtoolsLog = require('../../gather/gatherers/devtools-log.js');
+const NetworkRecords = require('../../computed/network-records.js');
 
 /**
  * @typedef NavigationContext
  * @property {Driver} driver
+ * @property {LH.Config.FRConfig} config
  * @property {LH.Config.NavigationDefn} navigation
  * @property {string} requestedUrl
+ * @property {Map<string, LH.ArbitraryEqualityMap>} computedCache
  */
 
+/** @typedef {Omit<Parameters<typeof collectPhaseArtifacts>[0], 'phase'>} PhaseState */
 
 /**
  * @param {{driver: Driver, config: LH.Config.FRConfig, requestedUrl: string}} args
+ * @return {Promise<{baseArtifacts: LH.FRBaseArtifacts}>}
  */
 async function _setup({driver, config, requestedUrl}) {
   await driver.connect();
-  // TODO(FR-COMPAT): use frameNavigated-based navigation
-  await driver._page.goto(defaultNavigationConfig.blankPage);
-
-  // TODO(FR-COMPAT): setupDriver
+  await gotoURL(driver, defaultNavigationConfig.blankPage, {waitUntil: ['navigated']});
 
   const baseArtifacts = await getBaseArtifacts(config, driver);
   baseArtifacts.URL.requestedUrl = requestedUrl;
 
+  await prepare.prepareTargetForNavigationMode(driver, config.settings);
+
   return {baseArtifacts};
 }
 
 /**
  * @param {NavigationContext} navigationContext
+ * @return {Promise<{warnings: Array<LH.IcuMessage>}>}
  */
-async function _setupNavigation({driver, navigation}) {
-  // TODO(FR-COMPAT): use frameNavigated-based navigation
-  await driver._page.goto(navigation.blankPage);
+async function _setupNavigation({requestedUrl, driver, navigation, config}) {
+  await gotoURL(driver, navigation.blankPage, {...navigation, waitUntil: ['navigated']});
+  const {warnings} = await prepare.prepareTargetForIndividualNavigation(
+    driver.defaultSession,
+    config.settings,
+    {
+      ...navigation,
+      url: requestedUrl,
+    }
+  );
+
+  return {warnings};
+}
 
-  // TODO(FR-COMPAT): setup network conditions (throttling & cache state)
+/**
+ * @param {NavigationContext} navigationContext
+ */
+async function _cleanupNavigation({driver}) {
+  await emulation.clearThrottling(driver.defaultSession);
 }
 
 /**
  * @param {NavigationContext} navigationContext
+ * @return {Promise<{finalUrl: string, navigationError: LH.LighthouseError | undefined, warnings: Array<LH.IcuMessage>}>}
  */
 async function _navigate(navigationContext) {
-  const {driver, requestedUrl} = navigationContext;
-  // TODO(FR-COMPAT): use waitForCondition-based navigation
-  await driver._page.goto(requestedUrl, {waitUntil: ['load', 'networkidle2']});
+  const {driver, config, requestedUrl} = navigationContext;
+
+  try {
+    const {finalUrl, warnings} = await gotoURL(driver, requestedUrl, {
+      ...navigationContext.navigation,
+      maxWaitForFcp: config.settings.maxWaitForFcp,
+      maxWaitForLoad: config.settings.maxWaitForLoad,
+      waitUntil: navigationContext.navigation.pauseAfterFcpMs ? ['fcp', 'load'] : ['load'],
+    });
 
-  // TODO(FR-COMPAT): disable all throttling settings
-  // TODO(FR-COMPAT): capture page load errors
+    return {finalUrl, navigationError: undefined, warnings};
+  } catch (err) {
+    if (!(err instanceof LighthouseError)) throw err;
+    if (err.code !== 'NO_FCP' && err.code !== 'PAGE_HUNG') throw err;
+    return {finalUrl: requestedUrl, navigationError: err, warnings: []};
+  }
 }
 
+/**
+ * @param {NavigationContext} navigationContext
+ * @param {PhaseState} phaseState
+ * @return {Promise<Array<LH.Artifacts.NetworkRequest> | undefined>}
+ */
+async function _collectNetworkRecords(navigationContext, phaseState) {
+  const devtoolsLogArtifactDefn = phaseState.artifactDefinitions.find(
+    definition => definition.gatherer.instance.meta.symbol === DevtoolsLog.symbol
+  );
+  if (!devtoolsLogArtifactDefn) return undefined;
+
+  const devtoolsLogArtifactId = devtoolsLogArtifactDefn.id;
+  const artifactDefinitions = [devtoolsLogArtifactDefn];
+  await collectPhaseArtifacts({...phaseState, phase: 'getArtifact', artifactDefinitions});
+
+  const devtoolsLog = await phaseState.artifactState.getArtifact[devtoolsLogArtifactId];
+  const records = await NetworkRecords.request(devtoolsLog, navigationContext);
+  return records;
+}
 
 /**
  * @param {NavigationContext} navigationContext
+ * @param {PhaseState} phaseState
+ * @param {UnPromise<ReturnType<typeof _setupNavigation>>} setupResult
+ * @param {UnPromise<ReturnType<typeof _navigate>>} navigateResult
+ * @return {Promise<{artifacts: Partial<LH.GathererArtifacts>, warnings: Array<LH.IcuMessage>, pageLoadError: LH.LighthouseError | undefined}>}
+ */
+async function _computeNavigationResult(
+  navigationContext,
+  phaseState,
+  setupResult,
+  navigateResult
+) {
+  const {navigationError, finalUrl} = navigateResult;
+  const warnings = [...setupResult.warnings, ...navigateResult.warnings];
+  const networkRecords = await _collectNetworkRecords(navigationContext, phaseState);
+  const pageLoadError = networkRecords
+    ? getPageLoadError(navigationError, {
+      url: finalUrl,
+      loadFailureMode: navigationContext.navigation.loadFailureMode,
+      networkRecords,
+    })
+    : navigationError;
+
+  if (pageLoadError) {
+    const locale = navigationContext.config.settings.locale;
+    const localizedMessage = i18n.getFormatted(pageLoadError.friendlyMessage, locale);
+    log.error('NavigationRunner', localizedMessage, navigationContext.requestedUrl);
+
+    return {artifacts: {}, warnings: [...warnings, pageLoadError.friendlyMessage], pageLoadError};
+  } else {
+    await collectPhaseArtifacts({phase: 'getArtifact', ...phaseState});
+
+    const artifacts = await awaitArtifacts(phaseState.artifactState);
+    return {artifacts, warnings, pageLoadError: undefined};
+  }
+}
+
+/**
+ * @param {NavigationContext} navigationContext
+ * @return {ReturnType<typeof _computeNavigationResult>}
  */
 async function _navigation(navigationContext) {
   const artifactState = getEmptyArtifactState();
-  const options = {
+  const phaseState = {
     gatherMode: /** @type {'navigation'} */ ('navigation'),
     driver: navigationContext.driver,
+    computedCache: navigationContext.computedCache,
     artifactDefinitions: navigationContext.navigation.artifacts,
     artifactState,
   };
 
-  await _setupNavigation(navigationContext);
-  await collectPhaseArtifacts({phase: 'startInstrumentation', ...options});
-  await collectPhaseArtifacts({phase: 'startSensitiveInstrumentation', ...options});
-  await _navigate(navigationContext);
-  await collectPhaseArtifacts({phase: 'stopSensitiveInstrumentation', ...options});
-  await collectPhaseArtifacts({phase: 'stopInstrumentation', ...options});
-  await collectPhaseArtifacts({phase: 'getArtifact', ...options});
+  const setupResult = await _setupNavigation(navigationContext);
+  await collectPhaseArtifacts({phase: 'startInstrumentation', ...phaseState});
+  await collectPhaseArtifacts({phase: 'startSensitiveInstrumentation', ...phaseState});
+  const navigateResult = await _navigate(navigationContext);
+  await collectPhaseArtifacts({phase: 'stopSensitiveInstrumentation', ...phaseState});
+  await collectPhaseArtifacts({phase: 'stopInstrumentation', ...phaseState});
+  await _cleanupNavigation(navigationContext);
 
-  const artifacts = await awaitArtifacts(artifactState);
-  return {artifacts};
+  return _computeNavigationResult(navigationContext, phaseState, setupResult, navigateResult);
 }
 
 /**
- * @param {{driver: Driver, config: LH.Config.FRConfig, requestedUrl: string}} args
+ * @param {{driver: Driver, config: LH.Config.FRConfig, requestedUrl: string; computedCache: NavigationContext['computedCache']}} args
+ * @return {Promise<{artifacts: Partial<LH.FRArtifacts & LH.FRBaseArtifacts>}>}
  */
-async function _navigations({driver, config, requestedUrl}) {
+async function _navigations({driver, config, requestedUrl, computedCache}) {
   if (!config.navigations) throw new Error('No navigations configured');
 
-  /** @type {Partial<LH.GathererArtifacts>} */
+  /** @type {Partial<LH.FRArtifacts & LH.FRBaseArtifacts>} */
   const artifacts = {};
 
   for (const navigation of config.navigations) {
@@ -101,9 +199,17 @@ async function _navigations({driver, config, requestedUrl}) {
       driver,
       navigation,
       requestedUrl,
+      config,
+      computedCache,
     };
 
     const navigationResult = await _navigation(navigationContext);
+    if (navigationResult.pageLoadError && navigation.loadFailureMode === 'fatal') {
+      artifacts.PageLoadError = navigationResult.pageLoadError;
+      break;
+    }
+
+    // TODO(FR-COMPAT): merge RunWarnings between navigations
     Object.assign(artifacts, navigationResult.artifacts);
   }
 
@@ -111,10 +217,13 @@ async function _navigations({driver, config, requestedUrl}) {
 }
 
 /**
- * @param {{driver: Driver}} args
+ * @param {{requestedUrl: string, driver: Driver, config: LH.Config.FRConfig}} args
  */
-async function _cleanup({driver}) { // eslint-disable-line no-unused-vars
-  // TODO(FR-COMPAT): clear storage if necessary
+async function _cleanup({requestedUrl, driver, config}) {
+  const didResetStorage = !config.settings.disableStorageReset;
+  if (didResetStorage) await storage.clearDataForOrigin(driver.defaultSession, requestedUrl);
+
+  // TODO(FR-COMPAT): add driver.disconnect session tracking
 }
 
 /**
@@ -124,13 +233,14 @@ async function _cleanup({driver}) { // eslint-disable-line no-unused-vars
 async function navigation(options) {
   const {url: requestedUrl, page} = options;
   const {config} = initializeConfig(options.config, {gatherMode: 'navigation'});
+  const computedCache = new Map();
 
   return Runner.run(
     async () => {
       const driver = new Driver(page);
       const {baseArtifacts} = await _setup({driver, config, requestedUrl});
-      const {artifacts} = await _navigations({driver, config, requestedUrl});
-      await _cleanup({driver});
+      const {artifacts} = await _navigations({driver, config, requestedUrl, computedCache});
+      await _cleanup({driver, config, requestedUrl});
 
       return /** @type {LH.Artifacts} */ ({...baseArtifacts, ...artifacts}); // Cast to drop Partial<>
     },
diff --git a/lighthouse-core/fraggle-rock/gather/runner-helpers.js b/lighthouse-core/fraggle-rock/gather/runner-helpers.js
index f360f3a58..676ed2451 100644
--- a/lighthouse-core/fraggle-rock/gather/runner-helpers.js
+++ b/lighthouse-core/fraggle-rock/gather/runner-helpers.js
@@ -12,6 +12,7 @@
  * @property {ArtifactState} artifactState
  * @property {LH.Gatherer.FRGatherPhase} phase
  * @property {LH.Gatherer.GatherMode} gatherMode
+ * @property {Map<string, LH.ArbitraryEqualityMap>} computedCache
  */
 
 /** @typedef {Record<string, Promise<any>>} IntermediateArtifacts */
@@ -58,7 +59,7 @@ const phaseToPriorPhase = {
  * @param {CollectPhaseArtifactOptions} options
  */
 async function collectPhaseArtifacts(options) {
-  const {driver, artifactDefinitions, artifactState, phase, gatherMode} = options;
+  const {driver, artifactDefinitions, artifactState, phase, gatherMode, computedCache} = options;
   const priorPhase = phaseToPriorPhase[phase];
   const priorPhaseArtifacts = (priorPhase && artifactState[priorPhase]) || {};
 
@@ -76,7 +77,7 @@ async function collectPhaseArtifacts(options) {
         gatherMode,
         driver,
         dependencies,
-        computedCache: new Map(),
+        computedCache,
       });
     });
 
diff --git a/lighthouse-core/fraggle-rock/gather/snapshot-runner.js b/lighthouse-core/fraggle-rock/gather/snapshot-runner.js
index 30ac7dabf..07831ad44 100644
--- a/lighthouse-core/fraggle-rock/gather/snapshot-runner.js
+++ b/lighthouse-core/fraggle-rock/gather/snapshot-runner.js
@@ -21,6 +21,8 @@ async function snapshot(options) {
   const driver = new Driver(options.page);
   await driver.connect();
 
+  /** @type {Map<string, LH.ArbitraryEqualityMap>} */
+  const computedCache = new Map();
   const url = await options.page.url();
 
   return Runner.run(
@@ -37,6 +39,7 @@ async function snapshot(options) {
         driver,
         artifactDefinitions,
         artifactState,
+        computedCache,
       });
 
       const artifacts = await awaitArtifacts(artifactState);
@@ -45,7 +48,7 @@ async function snapshot(options) {
     {
       url,
       config,
-      computedCache: new Map(),
+      computedCache,
     }
   );
 }
diff --git a/lighthouse-core/fraggle-rock/gather/timespan-runner.js b/lighthouse-core/fraggle-rock/gather/timespan-runner.js
index e36ea00c2..8207f5f2c 100644
--- a/lighthouse-core/fraggle-rock/gather/timespan-runner.js
+++ b/lighthouse-core/fraggle-rock/gather/timespan-runner.js
@@ -24,11 +24,20 @@ async function startTimespan(options) {
   const driver = new Driver(options.page);
   await driver.connect();
 
+  /** @type {Map<string, LH.ArbitraryEqualityMap>} */
+  const computedCache = new Map();
   const artifactDefinitions = config.artifacts || [];
   const requestedUrl = await options.page.url();
   const artifactState = getEmptyArtifactState();
   /** @type {Omit<import('./runner-helpers.js').CollectPhaseArtifactOptions, 'phase'>} */
-  const phaseOptions = {driver, artifactDefinitions, artifactState, gatherMode: 'timespan'};
+  const phaseOptions = {
+    driver,
+    artifactDefinitions,
+    artifactState,
+    computedCache,
+    gatherMode: 'timespan',
+  };
+
   await collectPhaseArtifacts({phase: 'startInstrumentation', ...phaseOptions});
   await collectPhaseArtifacts({phase: 'startSensitiveInstrumentation', ...phaseOptions});
 
@@ -51,7 +60,7 @@ async function startTimespan(options) {
         {
           url: finalUrl,
           config,
-          computedCache: new Map(),
+          computedCache,
         }
       );
     },
diff --git a/types/artifacts.d.ts b/types/artifacts.d.ts
index aed8b7a8c..cdf50ba89 100644
--- a/types/artifacts.d.ts
+++ b/types/artifacts.d.ts
@@ -9,7 +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');
+import _ArbitraryEqualityMap = require('../lighthouse-core/lib/arbitrary-equality-map.js');
 
 type _TaskNode = import('../lighthouse-core/lib/tracehouse/main-thread-tasks.js').TaskNode;
 
@@ -180,6 +180,8 @@ declare global {
       TraceElements: Artifacts.TraceElement[];
     }
 
+    export type ArbitraryEqualityMap = _ArbitraryEqualityMap;
+
     module Artifacts {
       export type ComputedContext = Immutable<{
         computedCache: Map<string, ArbitraryEqualityMap>;
diff --git a/types/externs.d.ts b/types/externs.d.ts
index fffea2a07..09ad3f31e 100644
--- a/types/externs.d.ts
+++ b/types/externs.d.ts
@@ -85,6 +85,8 @@ declare global {
 
   type FlattenedPromise<A> = Promise<A extends Promise<infer X> ? X : A>;
 
+  type UnPromise<T> = T extends Promise<infer U> ? U : T
+
   /**
    * Split string `S` on delimiter `D`.
    * From https://github.com/microsoft/TypeScript/pull/40336#issue-476562046


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 64fb2e8e0..51f945bac 100644
--- a/lighthouse-core/test/fraggle-rock/gather/mock-driver.js
+++ b/lighthouse-core/test/fraggle-rock/gather/mock-driver.js
@@ -149,9 +149,50 @@ function createMockContext() {
   };
 }
 
+function mockDriverSubmodules() {
+  const navigationMock = {gotoURL: jest.fn(),
+  };
+  const prepareMock = {
+    prepareTargetForNavigationMode: jest.fn(),
+    prepareTargetForIndividualNavigation: jest.fn(),
+  };
+  const storageMock = {clearDataForOrigin: jest.fn()};
+  const emulationMock = {clearThrottling: jest.fn()};
+
+  function reset() {
+    navigationMock.gotoURL = jest.fn().mockResolvedValue({finalUrl: 'https://example.com', warnings: [], timedOut: false});
+    prepareMock.prepareTargetForNavigationMode = jest.fn().mockResolvedValue({warnings: []});
+    prepareMock.prepareTargetForIndividualNavigation = jest.fn().mockResolvedValue({warnings: []});
+    storageMock.clearDataForOrigin = jest.fn();
+    emulationMock.clearThrottling = jest.fn();
+  }
+
+  /**
+   * @param {Record<string, (...args: any[]) => any>} target
+   * @param {string} name
+   * @return {(...args: any[]) => void}
+   */
+  const get = (target, name) => {
+    return (...args) => target[name](...args);
+  };
+  jest.mock('../../../gather/driver/navigation.js', () => new Proxy(navigationMock, {get}));
+  jest.mock('../../../gather/driver/prepare.js', () => new Proxy(prepareMock, {get}));
+  jest.mock('../../../gather/driver/storage.js', () => new Proxy(storageMock, {get}));
+  jest.mock('../../../lib/emulation.js', () => new Proxy(emulationMock, {get}));
+
+  return {
+    navigationMock,
+    prepareMock,
+    storageMock,
+    emulationMock,
+    reset,
+  };
+}
+
 module.exports = {
   mockRunnerModule,
   mockDriverModule,
+  mockDriverSubmodules,
   createMockDriver,
   createMockPage,
   createMockSession,
diff --git a/lighthouse-core/test/fraggle-rock/gather/navigation-runner-test.js b/lighthouse-core/test/fraggle-rock/gather/navigation-runner-test.js
index 05ad6d806..74a9b9932 100644
--- a/lighthouse-core/test/fraggle-rock/gather/navigation-runner-test.js
+++ b/lighthouse-core/test/fraggle-rock/gather/navigation-runner-test.js
@@ -5,12 +5,16 @@
  */
 'use strict';
 
-const {createMockDriver} = require('./mock-driver.js');
+/* eslint-env jest */
+
+const {createMockDriver, mockDriverSubmodules} = require('./mock-driver.js');
+const mocks = mockDriverSubmodules();
 const runner = require('../../../fraggle-rock/gather/navigation-runner.js');
 const {initializeConfig} = require('../../../fraggle-rock/config/config.js');
 const {defaultNavigationConfig} = require('../../../config/constants.js');
-
-/* eslint-env jest */
+const LighthouseError = require('../../../lib/lh-error.js');
+const DevtoolsLogGatherer = require('../../../gather/gatherers/devtools-log.js');
+const toDevtoolsLog = require('../../network-records-to-devtools-log.js');
 
 /** @typedef {{meta: LH.Gatherer.GathererMeta<'Accessibility'>, getArtifact: jest.Mock<any, any>, startInstrumentation:jest.Mock<any, any>, stopInstrumentation: jest.Mock<any, any>, startSensitiveInstrumentation:jest.Mock<any, any>, stopSensitiveInstrumentation: jest.Mock<any, any>}} MockGatherer */
 
@@ -24,6 +28,8 @@ describe('NavigationRunner', () => {
   let config;
   /** @type {LH.Config.NavigationDefn} */
   let navigation;
+  /** @type {Map<string, LH.ArbitraryEqualityMap>} */
+  let computedCache;
 
   /** @return {LH.Config.FRGathererDefn} */
   function createGathererDefn() {
@@ -50,9 +56,7 @@ describe('NavigationRunner', () => {
     snapshotGatherer.instance.getArtifact = jest.fn().mockResolvedValue({type: 'snapshot'});
     const navigationGatherer = createGathererDefn();
     navigationGatherer.instance.meta.supportedModes = ['navigation'];
-    navigationGatherer.instance.getArtifact = jest
-      .fn()
-      .mockResolvedValue({type: 'navigation'});
+    navigationGatherer.instance.getArtifact = jest.fn().mockResolvedValue({type: 'navigation'});
 
     const navigation = {
       ...defaultNavigationConfig,
@@ -77,9 +81,12 @@ describe('NavigationRunner', () => {
     requestedUrl = 'http://example.com';
     config = initializeConfig(undefined, {gatherMode: 'navigation'}).config;
     navigation = createNavigation().navigation;
+    computedCache = new Map();
 
     mockDriver = createMockDriver();
     driver = mockDriver.asDriver();
+
+    mocks.reset();
   });
 
   describe('_setup', () => {
@@ -97,22 +104,39 @@ describe('NavigationRunner', () => {
 
     it('should navigate to the blank page', async () => {
       await runner._setup({driver, config, requestedUrl});
-      expect(mockDriver._page.goto).toHaveBeenCalledWith('about:blank');
+      expect(mocks.navigationMock.gotoURL).toHaveBeenCalledTimes(1);
+      expect(mocks.navigationMock.gotoURL).toHaveBeenCalledWith(
+        expect.anything(),
+        'about:blank',
+        expect.anything()
+      );
+    });
+
+    it('should collect base artifacts', async () => {
+      const {baseArtifacts} = await runner._setup({driver, config, requestedUrl});
+      expect(baseArtifacts).toMatchObject({HostUserAgent: 'Chrome', URL: {requestedUrl}});
+    });
+
+    it('should prepare the target for navigation', async () => {
+      await runner._setup({driver, config, requestedUrl});
+      expect(mocks.prepareMock.prepareTargetForNavigationMode).toHaveBeenCalledTimes(1);
     });
 
-    it.todo('should throw if service worker is currently controlling the page');
-    it.todo('should enable emulation');
-    it.todo('should enable important CDP domains');
-    it.todo('should register the performance observer for navigation conditions');
-    it.todo('should shim requestIdleCallback');
-    it.todo('should reset storage');
-    it.todo('should not reset storage when skipped');
+    it('should prepare the target for navigation *after* base artifact collection', async () => {
+      mockDriver._session.sendCommand.mockReset();
+      mockDriver._session.sendCommand.mockRejectedValue(new Error('Not available'));
+      const setupPromise = runner._setup({driver, config, requestedUrl});
+      await expect(setupPromise).rejects.toThrowError(/Not available/);
+      expect(mocks.prepareMock.prepareTargetForNavigationMode).not.toHaveBeenCalled();
+    });
   });
 
   describe('_navigations', () => {
+    const run = () => runner._navigations({driver, config, requestedUrl, computedCache});
+
     it('should throw if no navigations available', async () => {
       config = {...config, navigations: null};
-      await expect(runner._navigations({driver, requestedUrl, config})).rejects.toBeTruthy();
+      await expect(run()).rejects.toBeTruthy();
     });
 
     it('should navigate as many times as there are navigations', async () => {
@@ -124,9 +148,9 @@ describe('NavigationRunner', () => {
         {gatherMode: 'navigation'}
       ).config;
 
-      await runner._navigations({driver, config, requestedUrl});
-      const navigations = mockDriver._page.goto.mock.calls;
-      const pageNavigations = navigations.filter(call => call[0] === requestedUrl);
+      await run();
+      const navigations = mocks.navigationMock.gotoURL.mock.calls;
+      const pageNavigations = navigations.filter(call => call[1] === requestedUrl);
       expect(pageNavigations).toHaveLength(4);
     });
 
@@ -144,7 +168,7 @@ describe('NavigationRunner', () => {
 
       // Both gatherers will error in these test conditions, but artifact errors
       // will be merged into single `artifacts` object.
-      const {artifacts} = await runner._navigations({driver, config, requestedUrl});
+      const {artifacts} = await run();
       const artifactIds = Object.keys(artifacts);
       expect(artifactIds).toContain('FontSize');
       expect(artifactIds).toContain('ConsoleMessages');
@@ -152,17 +176,21 @@ describe('NavigationRunner', () => {
   });
 
   describe('_navigation', () => {
+    /** @param {LH.Config.NavigationDefn} navigation */
+    const run = navigation =>
+      runner._navigation({driver, config, navigation, requestedUrl, computedCache});
+
     it('completes an end-to-end navigation', async () => {
-      const {artifacts} = await runner._navigation({driver, navigation, requestedUrl});
+      const {artifacts} = await run(navigation);
       const artifactIds = Object.keys(artifacts);
       expect(artifactIds).toContain('Timespan');
       expect(artifactIds).toContain('Snapshot');
 
-      expect(mockDriver._page.goto).toHaveBeenCalled();
+      expect(mocks.navigationMock.gotoURL).toHaveBeenCalled();
     });
 
     it('collects timespan, snapshot, and navigation artifacts', async () => {
-      const {artifacts} = await runner._navigation({driver, navigation, requestedUrl});
+      const {artifacts} = await run(navigation);
       expect(artifacts).toEqual({
         Navigation: {type: 'navigation'},
         Timespan: {type: 'timespan'},
@@ -175,7 +203,7 @@ describe('NavigationRunner', () => {
       navigation.artifacts[1].dependencies = {Accessibility: {id: 'Timespan'}};
       navigation.artifacts[2].dependencies = {Accessibility: {id: 'Timespan'}};
 
-      const {artifacts} = await runner._navigation({driver, navigation, requestedUrl});
+      const {artifacts} = await run(navigation);
       expect(artifacts).toEqual({
         Navigation: {type: 'navigation'},
         Timespan: {type: 'timespan'},
@@ -200,7 +228,7 @@ describe('NavigationRunner', () => {
       navigation.artifacts[1].dependencies = {Accessibility: {id: 'Timespan'}};
       navigation.artifacts[2].dependencies = {Accessibility: {id: 'Timespan'}};
 
-      const {artifacts} = await runner._navigation({driver, navigation, requestedUrl});
+      const {artifacts} = await run(navigation);
 
       expect(artifacts).toEqual({
         Navigation: expect.any(Error),
@@ -214,7 +242,7 @@ describe('NavigationRunner', () => {
       const err = new Error('Error in startSensitiveInstrumentation');
       gatherers.navigation.startSensitiveInstrumentation.mockRejectedValue(err);
 
-      const {artifacts} = await runner._navigation({driver, navigation, requestedUrl});
+      const {artifacts} = await run(navigation);
 
       expect(artifacts).toEqual({
         Navigation: err,
@@ -228,7 +256,7 @@ describe('NavigationRunner', () => {
       const err = new Error('Error in startInstrumentation');
       gatherers.timespan.startInstrumentation.mockRejectedValue(err);
 
-      const {artifacts} = await runner._navigation({driver, navigation, requestedUrl});
+      const {artifacts} = await run(navigation);
 
       expect(artifacts).toEqual({
         Navigation: {type: 'navigation'},
@@ -236,28 +264,121 @@ describe('NavigationRunner', () => {
         Snapshot: {type: 'snapshot'},
       });
     });
+
+    it('returns navigate errors', async () => {
+      const {navigation} = createNavigation();
+      const noFcp = new LighthouseError(LighthouseError.errors.NO_FCP);
+
+      mocks.navigationMock.gotoURL.mockImplementation(
+        /** @param {*} context @param {string} url */
+        (context, url) => {
+          if (url.includes('blank')) return {finalUrl: 'about:blank', warnings: []};
+          throw noFcp;
+        }
+      );
+
+      const {artifacts, pageLoadError} = await run(navigation);
+      expect(pageLoadError).toBe(noFcp);
+      expect(artifacts).toEqual({});
+    });
+
+    it('finds page load errors in network records when available', async () => {
+      const {navigation, gatherers} = createNavigation();
+      mocks.navigationMock.gotoURL.mockResolvedValue({finalUrl: requestedUrl, warnings: []});
+      const devtoolsLog = toDevtoolsLog([{url: requestedUrl, failed: true}]);
+      gatherers.timespan.meta.symbol = DevtoolsLogGatherer.symbol;
+      gatherers.timespan.getArtifact = jest.fn().mockResolvedValue(devtoolsLog);
+
+      const {artifacts, pageLoadError} = await run(navigation);
+      expect(pageLoadError).toBeInstanceOf(LighthouseError);
+      expect(artifacts).toEqual({});
+    });
+
+    it('cleans up throttling before getArtifact', async () => {
+      const {navigation, gatherers} = createNavigation();
+      gatherers.navigation.getArtifact = jest.fn().mockImplementation(() => {
+        expect(mocks.emulationMock.clearThrottling).toHaveBeenCalled();
+      });
+
+      await run(navigation);
+      expect(mocks.emulationMock.clearThrottling).toHaveBeenCalledTimes(1);
+    });
   });
 
   describe('_setupNavigation', () => {
     it('should setup the page on the blankPage', async () => {
       navigation.blankPage = 'data:text/html;...';
-      await runner._setupNavigation({driver, navigation, requestedUrl});
-      expect(mockDriver._page.goto).toHaveBeenCalledWith('data:text/html;...');
+      await runner._setupNavigation({driver, navigation, requestedUrl, config, computedCache});
+      expect(mocks.navigationMock.gotoURL).toHaveBeenCalledWith(
+        expect.anything(),
+        'data:text/html;...',
+        expect.anything()
+      );
+    });
+
+    it('should prepare target for navigation', async () => {
+      await runner._setupNavigation({driver, navigation, requestedUrl, config, computedCache});
+      expect(mocks.prepareMock.prepareTargetForIndividualNavigation).toHaveBeenCalled();
     });
 
-    it.todo('should setup throttling');
-    it.todo('should clear cache');
-    it.todo('should skip clear cache when requested');
+    it('should return the warnings from preparation', async () => {
+      const warnings = ['Warning A', 'Warning B'];
+      mocks.prepareMock.prepareTargetForIndividualNavigation.mockResolvedValue({warnings});
+      const result = await runner._setupNavigation({
+        driver,
+        navigation,
+        requestedUrl,
+        config,
+        computedCache,
+      });
+      expect(result).toEqual({warnings});
+    });
   });
 
   describe('_navigate', () => {
+    const run = () => runner._navigate({driver, navigation, requestedUrl, config, computedCache});
+
     it('should navigate the page', async () => {
-      await runner._navigate({driver, navigation, requestedUrl});
-      expect(mockDriver._page.goto).toHaveBeenCalledWith(requestedUrl, expect.anything());
+      await run();
+      expect(mocks.navigationMock.gotoURL).toHaveBeenCalledWith(
+        expect.anything(),
+        requestedUrl,
+        expect.anything()
+      );
     });
 
-    it.todo('should wait for page conditions');
-    it.todo('should disable throttling when finished');
-    it.todo('should capture page load errors');
+    it('should return navigate results', async () => {
+      const finalUrl = 'https://lighthouse.example.com/nested/page';
+      const warnings = ['Warning A', 'Warning B'];
+      mocks.navigationMock.gotoURL.mockResolvedValue({finalUrl, warnings});
+      const result = await run();
+      expect(result).toEqual({finalUrl, warnings, navigationError: undefined});
+    });
+
+    it('should catch navigation errors', async () => {
+      const navigationError = new LighthouseError(LighthouseError.errors.PAGE_HUNG);
+      mocks.navigationMock.gotoURL.mockRejectedValue(navigationError);
+      const result = await run();
+      expect(result).toEqual({finalUrl: requestedUrl, navigationError, warnings: []});
+    });
+
+    it('should throw regular errors', async () => {
+      mocks.navigationMock.gotoURL.mockRejectedValue(new Error('Other fatal error'));
+      await expect(run()).rejects.toThrowError('Other fatal error');
+    });
+  });
+
+  describe('_cleanup', () => {
+    it('should clear storage when storage was reset', async () => {
+      config.settings.disableStorageReset = false;
+      await runner._cleanup({requestedUrl, driver, config});
+      expect(mocks.storageMock.clearDataForOrigin).toHaveBeenCalled();
+    });
+
+    it('should not clear storage when storage reset was disabled', async () => {
+      config.settings.disableStorageReset = true;
+      await runner._cleanup({requestedUrl, driver, config});
+      expect(mocks.storageMock.clearDataForOrigin).not.toHaveBeenCalled();
+    });
   });
 });
diff --git a/lighthouse-core/test/fraggle-rock/gather/runner-helpers-test.js b/lighthouse-core/test/fraggle-rock/gather/runner-helpers-test.js
index 832d37ac0..6d4208983 100644
--- a/lighthouse-core/test/fraggle-rock/gather/runner-helpers-test.js
+++ b/lighthouse-core/test/fraggle-rock/gather/runner-helpers-test.js
@@ -129,6 +129,7 @@ describe('collectPhaseArtifacts', () => {
           artifactState,
           phase,
           gatherMode: /** @type {any} */ (gatherMode),
+          computedCache: new Map(),
         });
         expect(artifactState[phase]).toEqual({
           Timespan: expect.any(Promise),
@@ -150,6 +151,7 @@ describe('collectPhaseArtifacts', () => {
       artifactState,
       gatherMode: 'navigation',
       phase: 'getArtifact',
+      computedCache: new Map(),
     });
     expect(await artifactState.getArtifact.Snapshot).toEqual({type: 'snapshot'});
     expect(await artifactState.getArtifact.Timespan).toEqual({type: 'timespan'});
@@ -170,6 +172,7 @@ describe('collectPhaseArtifacts', () => {
       artifactState,
       gatherMode: 'navigation',
       phase: 'getArtifact',
+      computedCache: new Map(),
     });
     expect(artifactState.getArtifact).toEqual({
       Dependency: expect.any(Promise),
@@ -200,6 +203,7 @@ describe('collectPhaseArtifacts', () => {
       artifactState,
       gatherMode: 'navigation',
       phase: 'getArtifact',
+      computedCache: new Map(),
     });
     expect(artifactState.getArtifact).toEqual({
       Snapshot: expect.any(Promise),
diff --git a/lighthouse-core/test/network-records-to-devtools-log.js b/lighthouse-core/test/network-records-to-devtools-log.js
index c0e5196e1..24f44f247 100644
--- a/lighthouse-core/test/network-records-to-devtools-log.js
+++ b/lighthouse-core/test/network-records-to-devtools-log.js
@@ -154,6 +154,21 @@ function getLoadingFinishedEvent(networkRecord, index) {
   };
 }
 
+/**
+ * @param {Partial<NetworkRequest>} networkRecord
+ * @return {LH.Protocol.RawEventMessage}
+ */
+function getLoadingFailedEvent(networkRecord, index) {
+  return {
+    method: 'Network.loadingFailed',
+    params: {
+      requestId: getBaseRequestId(networkRecord) || `${idBase}.${index}`,
+      timestamp: networkRecord.endTime || 3,
+      errorText: networkRecord.localizedFailDescription || 'Request failed',
+    },
+  };
+}
+
 /**
  * Returns true if `record` is redirected by another record.
  * @param {Array<Partial<NetworkRequest>>} networkRecords
@@ -222,6 +237,11 @@ function networkRecordsToDevtoolsLog(networkRecords, options = {}) {
       devtoolsLog.push(getRequestServedFromCacheEvent(networkRecord, index));
     }
 
+    if (networkRecord.failed) {
+      devtoolsLog.push(getLoadingFailedEvent(networkRecord, index));
+      return;
+    }
+
     devtoolsLog.push(getResponseReceivedEvent(networkRecord, index));
     devtoolsLog.push(getDataReceivedEvent(networkRecord, index));
     devtoolsLog.push(getLoadingFinishedEvent(networkRecord, index));