Repository: GoogleChrome/lighthouse
Pull request #12381 (2021-04-20 14:41:48)

core(driver): move evaluateOnNewDocument to executionContext
**Summary**
Split out from #12366 and follow up to #12365, this moves the script evaluation on new document logic to executionContext, and aligns its call signature with our new preferred `evaluate` syntax that can be typechecked. This also completes the colocation of all the cached native definition and usage (except ElementMatches), so no need to reference `window.__nativePromise` ever again :D

**Related Issues/PRs**
ref #11313 #12366



Patch:
diff --git a/lighthouse-core/gather/driver.js b/lighthouse-core/gather/driver.js
index 33a88fb4b..cadb3e820 100644
--- a/lighthouse-core/gather/driver.js
+++ b/lighthouse-core/gather/driver.js
@@ -8,7 +8,6 @@
 const Fetcher = require('./fetcher.js');
 const ExecutionContext = require('./driver/execution-context.js');
 const {waitForFullyLoaded, waitForFrameNavigated} = require('./driver/wait-for-condition.js');
-const emulation = require('../lib/emulation.js');
 const LHElement = require('../lib/lh-element.js');
 const LHError = require('../lib/lh-error.js');
 const NetworkRequest = require('../lib/network-request.js');
@@ -398,17 +397,6 @@ class Driver {
     return !!this._domainEnabledCounts.get(domain);
   }
 
-  /**
-   * Add a script to run at load time of all future page loads.
-   * @param {string} scriptSource
-   * @return {Promise<LH.Crdp.Page.AddScriptToEvaluateOnLoadResponse>} Identifier of the added script.
-   */
-  evaluateScriptOnNewDocument(scriptSource) {
-    return this.sendCommand('Page.addScriptToEvaluateOnLoad', {
-      scriptSource,
-    });
-  }
-
   /**
    * @return {Promise<LH.Crdp.ServiceWorker.WorkerVersionUpdatedEvent>}
    */
@@ -762,35 +750,6 @@ class Driver {
     await this.sendCommand('Debugger.setAsyncCallStackDepth', {maxDepth: 8});
   }
 
-  /**
-   * @param {LH.Config.Settings} settings
-   * @return {Promise<void>}
-   */
-  async beginEmulation(settings) {
-    await emulation.emulate(this, settings);
-    await this.setThrottling(settings, {useThrottling: true});
-  }
-
-  /**
-   * @param {LH.Config.Settings} settings
-   * @param {{useThrottling?: boolean}} passConfig
-   * @return {Promise<void>}
-   */
-  async setThrottling(settings, passConfig) {
-    if (settings.throttlingMethod !== 'devtools') {
-      return emulation.clearAllNetworkEmulation(this);
-    }
-
-    const cpuPromise = passConfig.useThrottling ?
-        emulation.enableCPUThrottling(this, settings.throttling) :
-        emulation.disableCPUThrottling(this);
-    const networkPromise = passConfig.useThrottling ?
-        emulation.enableNetworkThrottling(this, settings.throttling) :
-        emulation.clearAllNetworkEmulation(this);
-
-    await Promise.all([cpuPromise, networkPromise]);
-  }
-
   /**
    * Clear the network cache on disk and in memory.
    * @return {Promise<void>}
@@ -885,20 +844,6 @@ class Driver {
     }
   }
 
-  /**
-   * Cache native functions/objects inside window
-   * so we are sure polyfills do not overwrite the native implementations
-   * @return {Promise<void>}
-   */
-  async cacheNatives() {
-    await this.evaluateScriptOnNewDocument(`
-        window.__nativePromise = Promise;
-        window.__nativeURL = URL;
-        window.__ElementMatches = Element.prototype.matches;
-        window.__perfNow = performance.now.bind(performance);
-    `);
-  }
-
   /**
    * Use a RequestIdleCallback shim for tests run with simulated throttling, so that the deadline can be used without
    * a penalty
@@ -907,9 +852,10 @@ class Driver {
    */
   async registerRequestIdleCallbackWrap(settings) {
     if (settings.throttlingMethod === 'simulate') {
-      const scriptStr = `(${pageFunctions.wrapRequestIdleCallbackString})
-        (${settings.throttling.cpuSlowdownMultiplier})`;
-      await this.evaluateScriptOnNewDocument(scriptStr);
+      await this.executionContext.evaluateOnNewDocument(
+        pageFunctions.wrapRequestIdleCallback,
+        {args: [settings.throttling.cpuSlowdownMultiplier]}
+      );
     }
   }
 
diff --git a/lighthouse-core/gather/driver/execution-context.js b/lighthouse-core/gather/driver/execution-context.js
index cc1536920..05e127373 100644
--- a/lighthouse-core/gather/driver/execution-context.js
+++ b/lighthouse-core/gather/driver/execution-context.js
@@ -5,6 +5,8 @@
  */
 'use strict';
 
+/* global window */
+
 const pageFunctions = require('../../lib/page-functions.js');
 
 class ExecutionContext {
@@ -83,11 +85,10 @@ class ExecutionContext {
       // 3. Ensure that errors captured in the Promise are converted into plain-old JS Objects
       //    so that they can be serialized properly b/c JSON.stringify(new Error('foo')) === '{}'
       expression: `(function wrapInNativePromise() {
-        const __nativePromise = globalThis.__nativePromise || Promise;
-        const URL = globalThis.__nativeURL || globalThis.URL;
+        ${ExecutionContext._cachedNativesPreamble};
         globalThis.__lighthouseExecutionContextId = ${contextId};
-        return new __nativePromise(function (resolve) {
-          return __nativePromise.resolve()
+        return new Promise(function (resolve) {
+          return Promise.resolve()
             .then(_ => ${expression})
             .catch(${pageFunctions.wrapRuntimeEvalErrorInBrowserString})
             .then(resolve);
@@ -156,7 +157,7 @@ class ExecutionContext {
    * If `useIsolation` is true, the function will be evaluated in a content script that has
    * access to the page's DOM but whose JavaScript state is completely separate.
    * Returns a promise that resolves on a value of `mainFn`'s return type.
-   * @template {any[]} T, R
+   * @template {unknown[]} T, R
    * @param {((...args: T) => R)} mainFn The main function to call.
    * @param {{args: T, useIsolation?: boolean, deps?: Array<Function|string>}} options `args` should
    *   match the args of `mainFn`, and can be any serializable value. `deps` are functions that must be
@@ -164,7 +165,7 @@ class ExecutionContext {
    * @return {FlattenedPromise<R>}
    */
   evaluate(mainFn, options) {
-    const argsSerialized = options.args.map(arg => JSON.stringify(arg)).join(',');
+    const argsSerialized = ExecutionContext.serializeArguments(options.args);
     const depsSerialized = options.deps ? options.deps.join('\n') : '';
     const expression = `(() => {
       ${depsSerialized}
@@ -172,6 +173,71 @@ class ExecutionContext {
     })()`;
     return this.evaluateAsync(expression, options);
   }
+
+  /**
+   * Evaluate a function on every new frame from now on.
+   * @template {unknown[]} T
+   * @param {((...args: T) => void)} mainFn The main function to call.
+   * @param {{args: T, deps?: Array<Function|string>}} options `args` should
+   *   match the args of `mainFn`, and can be any serializable value. `deps` are functions that must be
+   *   defined for `mainFn` to work.
+   * @return {Promise<void>}
+   */
+  async evaluateOnNewDocument(mainFn, options) {
+    const argsSerialized = ExecutionContext.serializeArguments(options.args);
+    const depsSerialized = options.deps ? options.deps.join('\n') : '';
+
+    const expression = `(() => {
+      ${ExecutionContext._cachedNativesPreamble};
+      ${depsSerialized};
+      (${mainFn})(${argsSerialized});
+    })()`;
+
+    await this._session.sendCommand('Page.addScriptToEvaluateOnNewDocument', {source: expression});
+  }
+
+  /**
+   * Cache native functions/objects inside window so we are sure polyfills do not overwrite the
+   * native implementations when the page loads.
+   * @return {Promise<void>}
+   */
+  async cacheNativesOnNewDocument() {
+    await this.evaluateOnNewDocument(() => {
+      /* c8 ignore start */
+      window.__nativePromise = window.Promise;
+      window.__nativeURL = window.URL;
+      window.__nativePerformance = window.performance;
+      window.__ElementMatches = window.Element.prototype.matches;
+      // Ensure the native `performance.now` is not overwritable.
+      const performance = window.performance;
+      const performanceNow = window.performance.now;
+      Object.defineProperty(performance, 'now', {
+        value: () => performanceNow.call(performance),
+        writable: false,
+      });
+      /* c8 ignore stop */
+    }, {args: []});
+  }
+
+  /**
+   * Prefix every script evaluation with a shadowing of common globals that tend to be ponyfilled
+   * incorrectly by many sites. This allows functions to still refer to `Promise` instead of
+   * Lighthouse-specific backups like `__nativePromise` (injected by `cacheNativesOnNewDocument` above).
+   */
+  static _cachedNativesPreamble = [
+    'const Promise = globalThis.__nativePromise || globalThis.Promise',
+    'const URL = globalThis.__nativeURL || globalThis.URL',
+    'const performance = globalThis.__nativePerformance || globalThis.performance',
+  ].join(';\n');
+
+  /**
+   * Serializes an array of arguments for use in an `eval` string across the protocol.
+   * @param {unknown[]} args
+   * @return {string}
+   */
+  static serializeArguments(args) {
+    return args.map(arg => arg === undefined ? 'undefined' : JSON.stringify(arg)).join(',');
+  }
 }
 
 module.exports = ExecutionContext;
diff --git a/lighthouse-core/gather/driver/wait-for-condition.js b/lighthouse-core/gather/driver/wait-for-condition.js
index bf7876e29..25cfd0e71 100644
--- a/lighthouse-core/gather/driver/wait-for-condition.js
+++ b/lighthouse-core/gather/driver/wait-for-condition.js
@@ -5,7 +5,7 @@
  */
 'use strict';
 
-/* global window */
+/* global window, performance */
 
 const log = require('lighthouse-logger');
 const LHError = require('../../lib/lh-error.js');
@@ -274,7 +274,7 @@ function registerPerformanceObserverInPage() {
   // Do not re-register if we've already run this script.
   if (window.____lastLongTask !== undefined) return;
 
-  window.____lastLongTask = window.__perfNow();
+  window.____lastLongTask = performance.now();
   const observer = new window.PerformanceObserver(entryList => {
     const entries = entryList.getEntries();
     for (const entry of entries) {
@@ -303,8 +303,8 @@ function checkTimeSinceLastLongTaskInPage() {
   // at some point farish (several hundred ms) into the future and the time at which it executes isn't
   // a reliable indicator of long task existence, instead we check if any information has changed.
   // See https://developer.chrome.com/blog/timer-throttling-in-chrome-88/
-  return new window.__nativePromise(resolve => {
-    const firstAttemptTs = window.__perfNow();
+  return new Promise(resolve => {
+    const firstAttemptTs = performance.now();
     const firstAttemptLastLongTaskTs = window.____lastLongTask || 0;
 
     setTimeout(() => {
diff --git a/lighthouse-core/gather/gather-runner.js b/lighthouse-core/gather/gather-runner.js
index cca8a8e07..4e084a3db 100644
--- a/lighthouse-core/gather/gather-runner.js
+++ b/lighthouse-core/gather/gather-runner.js
@@ -9,6 +9,7 @@ const log = require('lighthouse-logger');
 const LHError = require('../lib/lh-error.js');
 const NetworkAnalyzer = require('../lib/dependency-graph/simulator/network-analyzer.js');
 const NetworkRecorder = require('../lib/network-recorder.js');
+const emulation = require('../lib/emulation.js');
 const constants = require('../config/constants.js');
 const i18n = require('../lib/i18n/i18n.js');
 const URL = require('../lib/url-shim.js');
@@ -129,10 +130,10 @@ class GatherRunner {
     log.time(status);
     const resetStorage = !options.settings.disableStorageReset;
     await driver.assertNoSameOriginServiceWorkerClients(options.requestedUrl);
-    await driver.beginEmulation(options.settings);
+    await emulation.emulate(driver.defaultSession, options.settings);
     await driver.enableRuntimeEvents();
     await driver.enableAsyncStacks();
-    await driver.cacheNatives();
+    await driver.executionContext.cacheNativesOnNewDocument();
     await driver.dismissJavaScriptDialogs();
     await driver.registerRequestIdleCallbackWrap(options.settings);
     if (resetStorage) {
@@ -344,8 +345,10 @@ class GatherRunner {
     const status = {msg: 'Setting up network for the pass trace', id: `lh:gather:setupPassNetwork`};
     log.time(status);
 
+    const session = passContext.driver.defaultSession;
     const passConfig = passContext.passConfig;
-    await passContext.driver.setThrottling(passContext.settings, passConfig);
+    if (passConfig.useThrottling) await emulation.throttle(session, passContext.settings);
+    else await emulation.clearThrottling(session);
 
     const blockedUrls = (passContext.passConfig.blockedUrlPatterns || [])
       .concat(passContext.settings.blockedUrlPatterns || []);
@@ -752,7 +755,7 @@ class GatherRunner {
     const loadData = await GatherRunner.endRecording(passContext);
 
     // Disable throttling so the afterPass analysis isn't throttled
-    await driver.setThrottling(passContext.settings, {useThrottling: false});
+    await emulation.clearThrottling(driver.defaultSession);
 
     // In case of load error, save log and trace with an error prefix, return no artifacts for this pass.
     const pageLoadError = GatherRunner.getPageLoadError(passContext, loadData, possibleNavError);
diff --git a/lighthouse-core/gather/gatherers/dobetterweb/tags-blocking-first-paint.js b/lighthouse-core/gather/gatherers/dobetterweb/tags-blocking-first-paint.js
index 6d44efaca..288fa7c57 100644
--- a/lighthouse-core/gather/gatherers/dobetterweb/tags-blocking-first-paint.js
+++ b/lighthouse-core/gather/gatherers/dobetterweb/tags-blocking-first-paint.js
@@ -20,7 +20,7 @@
 
 const Gatherer = require('../gatherer.js');
 
-/* global document, window, HTMLLinkElement, SVGScriptElement */
+/* global document, window, performance, HTMLLinkElement, SVGScriptElement */
 
 /** @typedef {{href: string, media: string, msSinceHTMLEnd: number, matches: boolean}} MediaChange */
 /** @typedef {{tagName: 'LINK', url: string, href: string, rel: string, media: string, disabled: boolean, mediaChanges: Array<MediaChange>}} LinkTag */
@@ -37,7 +37,7 @@ function installMediaListener() {
       const mediaChange = {
         href: this.href,
         media: val,
-        msSinceHTMLEnd: Date.now() - window.performance.timing.responseEnd,
+        msSinceHTMLEnd: Date.now() - performance.timing.responseEnd,
         matches: window.matchMedia(val).matches,
       };
       // @ts-expect-error - `___linkMediaChanges` created above.
@@ -203,8 +203,9 @@ class TagsBlockingFirstPaint extends Gatherer {
    * @param {LH.Gatherer.PassContext} passContext
    */
   async beforePass(passContext) {
-    // Don't return return value of `evaluateScriptOnNewDocument`.
-    await passContext.driver.evaluateScriptOnNewDocument(`(${installMediaListener.toString()})()`);
+    const {executionContext} = passContext.driver;
+    // Don't return return value of `evaluateOnNewDocument`.
+    await executionContext.evaluateOnNewDocument(installMediaListener, {args: []});
   }
 
   /**
diff --git a/lighthouse-core/gather/gatherers/full-page-screenshot.js b/lighthouse-core/gather/gatherers/full-page-screenshot.js
index d52112306..6170f9297 100644
--- a/lighthouse-core/gather/gatherers/full-page-screenshot.js
+++ b/lighthouse-core/gather/gatherers/full-page-screenshot.js
@@ -8,6 +8,7 @@
 /* globals window document getBoundingClientRect */
 
 const Gatherer = require('./gatherer.js');
+const emulation = require('../../lib/emulation.js');
 const pageFunctions = require('../../lib/page-functions.js');
 
 /** @typedef {import('../driver.js')} Driver */
@@ -147,7 +148,7 @@ class FullPageScreenshot extends Gatherer {
     } finally {
       // Revert resized page.
       if (lighthouseControlsEmulation) {
-        await driver.beginEmulation(passContext.settings);
+        await emulation.emulate(driver.defaultSession, passContext.settings);
       } else {
         // Best effort to reset emulation to what it was.
         // https://github.com/GoogleChrome/lighthouse/pull/10716#discussion_r428970681
diff --git a/lighthouse-core/lib/emulation.js b/lighthouse-core/lib/emulation.js
index a96942bbf..798050849 100644
--- a/lighthouse-core/lib/emulation.js
+++ b/lighthouse-core/lib/emulation.js
@@ -5,8 +5,6 @@
  */
 'use strict';
 
-/** @typedef {import('../gather/driver.js')} Driver */
-
 const NO_THROTTLING_METRICS = {
   latency: 0,
   downloadThroughput: 0,
@@ -19,16 +17,15 @@ const NO_CPU_THROTTLE_METRICS = {
 };
 
 /**
- *
- * @param {Driver} driver
+ * @param {LH.Gatherer.FRProtocolSession} session
  * @param {LH.Config.Settings} settings
  * @return {Promise<void>}
  */
-async function emulate(driver, settings) {
+async function emulate(session, settings) {
   if (settings.emulatedUserAgent !== false) {
     // Network.enable must be called for UA overriding to work
-    await driver.sendCommand('Network.enable');
-    await driver.sendCommand('Network.setUserAgentOverride', {
+    await session.sendCommand('Network.enable');
+    await session.sendCommand('Network.setUserAgentOverride', {
       userAgent: /** @type {string} */ (settings.emulatedUserAgent),
     });
   }
@@ -36,20 +33,45 @@ async function emulate(driver, settings) {
   if (settings.screenEmulation.disabled !== true) {
     const {width, height, deviceScaleFactor, mobile} = settings.screenEmulation;
     const params = {width, height, deviceScaleFactor, mobile};
-    await driver.sendCommand('Emulation.setDeviceMetricsOverride', params);
-    await driver.sendCommand('Emulation.setTouchEmulationEnabled', {
+    await session.sendCommand('Emulation.setDeviceMetricsOverride', params);
+    await session.sendCommand('Emulation.setTouchEmulationEnabled', {
       enabled: params.mobile,
     });
   }
 }
 
+/**
+ * Sets the throttling options specified in config settings, clearing existing network throttling if
+ * throttlingMethod is not `devtools` (but not CPU throttling, suspected requirement of WPT-compat).
+ *
+ * @param {LH.Gatherer.FRProtocolSession} session
+ * @param {LH.Config.Settings} settings
+ * @return {Promise<void>}
+ */
+async function throttle(session, settings) {
+  // TODO(FR-COMPAT): reconsider if this should be resetting anything
+  if (settings.throttlingMethod !== 'devtools') return clearNetworkThrottling(session);
+
+  await Promise.all([
+    enableNetworkThrottling(session, settings.throttling),
+    enableCPUThrottling(session, settings.throttling),
+  ]);
+}
+
+/**
+ * @param {LH.Gatherer.FRProtocolSession} session
+ * @return {Promise<void>}
+ */
+async function clearThrottling(session) {
+  await Promise.all([clearNetworkThrottling(session), clearCPUThrottling(session)]);
+}
 
 /**
- * @param {Driver} driver
+ * @param {LH.Gatherer.FRProtocolSession} session
  * @param {Required<LH.ThrottlingSettings>} throttlingSettings
  * @return {Promise<void>}
  */
-function enableNetworkThrottling(driver, throttlingSettings) {
+function enableNetworkThrottling(session, throttlingSettings) {
   /** @type {LH.Crdp.Network.EmulateNetworkConditionsRequest} */
   const conditions = {
     offline: false,
@@ -61,39 +83,41 @@ function enableNetworkThrottling(driver, throttlingSettings) {
   // DevTools expects throughput in bytes per second rather than kbps
   conditions.downloadThroughput = Math.floor(conditions.downloadThroughput * 1024 / 8);
   conditions.uploadThroughput = Math.floor(conditions.uploadThroughput * 1024 / 8);
-  return driver.sendCommand('Network.emulateNetworkConditions', conditions);
+  return session.sendCommand('Network.emulateNetworkConditions', conditions);
 }
 
 /**
- * @param {Driver} driver
+ * @param {LH.Gatherer.FRProtocolSession} session
  * @return {Promise<void>}
  */
-function clearAllNetworkEmulation(driver) {
-  return driver.sendCommand('Network.emulateNetworkConditions', NO_THROTTLING_METRICS);
+function clearNetworkThrottling(session) {
+  return session.sendCommand('Network.emulateNetworkConditions', NO_THROTTLING_METRICS);
 }
 
 /**
- * @param {Driver} driver
+ * @param {LH.Gatherer.FRProtocolSession} session
  * @param {Required<LH.ThrottlingSettings>} throttlingSettings
  * @return {Promise<void>}
  */
-function enableCPUThrottling(driver, throttlingSettings) {
+function enableCPUThrottling(session, throttlingSettings) {
   const rate = throttlingSettings.cpuSlowdownMultiplier;
-  return driver.sendCommand('Emulation.setCPUThrottlingRate', {rate});
+  return session.sendCommand('Emulation.setCPUThrottlingRate', {rate});
 }
 
 /**
- * @param {Driver} driver
+ * @param {LH.Gatherer.FRProtocolSession} session
  * @return {Promise<void>}
  */
-function disableCPUThrottling(driver) {
-  return driver.sendCommand('Emulation.setCPUThrottlingRate', NO_CPU_THROTTLE_METRICS);
+function clearCPUThrottling(session) {
+  return session.sendCommand('Emulation.setCPUThrottlingRate', NO_CPU_THROTTLE_METRICS);
 }
 
 module.exports = {
   emulate,
+  throttle,
+  clearThrottling,
   enableNetworkThrottling,
-  clearAllNetworkEmulation,
+  clearNetworkThrottling,
   enableCPUThrottling,
-  disableCPUThrottling,
+  clearCPUThrottling,
 };
diff --git a/lighthouse-core/lib/page-functions.js b/lighthouse-core/lib/page-functions.js
index 8052f7038..c938752ae 100644
--- a/lighthouse-core/lib/page-functions.js
+++ b/lighthouse-core/lib/page-functions.js
@@ -542,6 +542,6 @@ module.exports = {
   getNodeLabel: getNodeLabel,
   getNodeLabelString: getNodeLabel.toString(),
   isPositionFixedString: isPositionFixed.toString(),
-  wrapRequestIdleCallbackString: wrapRequestIdleCallback.toString(),
+  wrapRequestIdleCallback,
   getBoundingClientRectString: getBoundingClientRect.toString(),
 };
diff --git a/types/externs.d.ts b/types/externs.d.ts
index 3af37c770..fffea2a07 100644
--- a/types/externs.d.ts
+++ b/types/externs.d.ts
@@ -393,11 +393,11 @@ declare global {
 
   interface Window {
     // Cached native functions/objects for use in case the page overwrites them.
-    // See: `driver.cacheNatives`.
+    // See: `executionContext.cacheNativesOnNewDocument`.
     __nativePromise: PromiseConstructor;
-    __nativeURL: URL;
+    __nativePerformance: Performance;
+    __nativeURL: typeof URL;
     __ElementMatches: Element['matches'];
-    __perfNow: Performance['now'];
 
     /** Used for monitoring long tasks in the test page. */
     ____lastLongTask?: number;


Test patch:
diff --git a/lighthouse-core/test/gather/driver/execution-context-test.js b/lighthouse-core/test/gather/driver/execution-context-test.js
index c6cbc32e3..bc00a83d3 100644
--- a/lighthouse-core/test/gather/driver/execution-context-test.js
+++ b/lighthouse-core/test/gather/driver/execution-context-test.js
@@ -206,11 +206,12 @@ describe('.evaluate', () => {
     const {expression} = mockFn.findInvocation('Runtime.evaluate');
     const expected = `
 (function wrapInNativePromise() {
-        const __nativePromise = globalThis.__nativePromise || Promise;
-        const URL = globalThis.__nativeURL || globalThis.URL;
+        const Promise = globalThis.__nativePromise || globalThis.Promise;
+const URL = globalThis.__nativeURL || globalThis.URL;
+const performance = globalThis.__nativePerformance || globalThis.performance;
         globalThis.__lighthouseExecutionContextId = undefined;
-        return new __nativePromise(function (resolve) {
-          return __nativePromise.resolve()
+        return new Promise(function (resolve) {
+          return Promise.resolve()
             .then(_ => (() => {
 
       return (function main(value) {
@@ -329,3 +330,12 @@ function square(val) {
     expect(eval(code)).toEqual({a: 5, b: 100, passThru: 'hello'});
   });
 });
+
+describe('.serializeArguments', () => {
+  it('should serialize a list of differently typed arguments', () => {
+    const args = [undefined, 1, 'foo', null, {x: {y: {z: [2]}}}];
+    expect(ExecutionContext.serializeArguments(args)).toEqual(
+      `undefined,1,"foo",null,{"x":{"y":{"z":[2]}}}`
+    );
+  });
+});
diff --git a/lighthouse-core/test/gather/fake-driver.js b/lighthouse-core/test/gather/fake-driver.js
index 555464093..55e8f3729 100644
--- a/lighthouse-core/test/gather/fake-driver.js
+++ b/lighthouse-core/test/gather/fake-driver.js
@@ -36,12 +36,6 @@ function makeFakeDriver({protocolGetVersionResponse}) {
     gotoURL(url) {
       return Promise.resolve({finalUrl: url, timedOut: false});
     },
-    beginEmulation() {
-      return Promise.resolve();
-    },
-    setThrottling() {
-      return Promise.resolve();
-    },
     dismissJavaScriptDialogs() {
       return Promise.resolve();
     },
@@ -57,17 +51,11 @@ function makeFakeDriver({protocolGetVersionResponse}) {
     enableAsyncStacks() {
       return Promise.resolve();
     },
-    evaluateScriptOnLoad() {
-      return Promise.resolve();
-    },
     cleanBrowserCaches() {},
     clearDataForOrigin() {},
     getImportantStorageWarning() {
       return Promise.resolve(undefined);
     },
-    cacheNatives() {
-      return Promise.resolve();
-    },
     executionContext: {
       evaluateAsync() {
         return Promise.resolve({});
@@ -75,6 +63,9 @@ function makeFakeDriver({protocolGetVersionResponse}) {
       evaluate() {
         return Promise.resolve({});
       },
+      cacheNativesOnNewDocument() {
+        return Promise.resolve();
+      },
     },
     /** @param {{x: number, y: number}} position */
     scrollTo(position) {
diff --git a/lighthouse-core/test/gather/gather-runner-test.js b/lighthouse-core/test/gather/gather-runner-test.js
index 463667cfe..8cf543f19 100644
--- a/lighthouse-core/test/gather/gather-runner-test.js
+++ b/lighthouse-core/test/gather/gather-runner-test.js
@@ -88,9 +88,6 @@ class EmulationDriver extends Driver {
   assertNoSameOriginServiceWorkerClients() {
     return Promise.resolve();
   }
-  cacheNatives() {
-    return Promise.resolve();
-  }
   registerPerformanceObserver() {
     return Promise.resolve();
   }
@@ -125,10 +122,22 @@ function resetDefaultMockResponses() {
     .mockResponse('Network.setBlockedURLs')
     .mockResponse('Network.setExtraHTTPHeaders')
     .mockResponse('Network.setUserAgentOverride')
+    .mockResponse('Page.addScriptToEvaluateOnNewDocument')
     .mockResponse('Page.enable')
     .mockResponse('ServiceWorker.enable');
 }
 
+/**
+ * Restore the emulation to its original implementation for testing emulation-sensitive logic.
+ */
+function restoreActualEmulation() {
+  const actualEmulation = jest.requireActual('../../lib/emulation.js');
+  const emulation = require('../../lib/emulation.js');
+  emulation.emulate = actualEmulation.emulate;
+  emulation.throttle = actualEmulation.throttle;
+  emulation.clearThrottling = actualEmulation.clearThrottling;
+}
+
 beforeEach(() => {
   // @ts-expect-error - connectionStub has a mocked version of sendCommand implemented in each test
   connectionStub = new Connection();
@@ -138,6 +147,11 @@ beforeEach(() => {
   };
   driver = new EmulationDriver(connectionStub);
   resetDefaultMockResponses();
+
+  const emulation = require('../../lib/emulation.js');
+  emulation.emulate = jest.fn();
+  emulation.throttle = jest.fn();
+  emulation.clearThrottling = jest.fn();
 });
 
 describe('GatherRunner', function() {
@@ -275,17 +289,14 @@ describe('GatherRunner', function() {
   });
 
   it('sets up the driver to begin emulation when all flags are undefined', async () => {
+    restoreActualEmulation();
     await GatherRunner.setupDriver(driver, {settings: getSettings('mobile')});
 
     connectionStub.sendCommand.findInvocation('Emulation.setDeviceMetricsOverride');
-    expect(connectionStub.sendCommand.findInvocation('Network.emulateNetworkConditions')).toEqual({
-      latency: 0, downloadThroughput: 0, uploadThroughput: 0, offline: false,
-    });
-    expect(() =>
-      connectionStub.sendCommand.findInvocation('Emulation.setCPUThrottlingRate')).toThrow();
   });
 
   it('applies the correct emulation given a particular formFactor', async () => {
+    restoreActualEmulation();
     await GatherRunner.setupDriver(driver, {settings: getSettings('mobile')});
     expect(connectionStub.sendCommand.findInvocation('Emulation.setDeviceMetricsOverride'))
       .toMatchObject({mobile: true});
@@ -296,30 +307,6 @@ describe('GatherRunner', function() {
       .toMatchObject({mobile: false});
   });
 
-  it('sets throttling according to settings', async () => {
-    await GatherRunner.setupDriver(driver, {
-      settings: {
-        formFactor: 'mobile',
-        screenEmulation: constants.screenEmulationMetrics.mobile,
-        throttlingMethod: 'devtools',
-        throttling: {
-          requestLatencyMs: 100,
-          downloadThroughputKbps: 8,
-          uploadThroughputKbps: 8,
-          cpuSlowdownMultiplier: 1,
-        },
-      },
-    });
-
-    connectionStub.sendCommand.findInvocation('Emulation.setDeviceMetricsOverride');
-    expect(connectionStub.sendCommand.findInvocation('Network.emulateNetworkConditions')).toEqual({
-      latency: 100, downloadThroughput: 1024, uploadThroughput: 1024, offline: false,
-    });
-    expect(connectionStub.sendCommand.findInvocation('Emulation.setCPUThrottlingRate')).toEqual({
-      rate: 1,
-    });
-  });
-
   it('clears origin storage', () => {
     const asyncFunc = () => Promise.resolve();
     /** @type {Record<string, boolean>} */
@@ -334,12 +321,10 @@ describe('GatherRunner', function() {
     };
     const driver = {
       assertNoSameOriginServiceWorkerClients: asyncFunc,
-      beginEmulation: asyncFunc,
-      setThrottling: asyncFunc,
       dismissJavaScriptDialogs: asyncFunc,
       enableRuntimeEvents: asyncFunc,
       enableAsyncStacks: asyncFunc,
-      cacheNatives: asyncFunc,
+      executionContext: {cacheNativesOnNewDocument: asyncFunc},
       gotoURL: asyncFunc,
       registerRequestIdleCallbackWrap: asyncFunc,
       cleanBrowserCaches: createCheck('calledCleanBrowserCaches'),
@@ -371,7 +356,6 @@ describe('GatherRunner', function() {
       beginTrace: asyncFunc,
       gotoURL: async () => ({}),
       cleanBrowserCaches: createCheck('calledCleanBrowserCaches'),
-      setThrottling: asyncFunc,
       blockUrlPatterns: asyncFunc,
       setExtraHTTPHeaders: asyncFunc,
       endTrace: asyncFunc,
@@ -525,12 +509,10 @@ describe('GatherRunner', function() {
     };
     const driver = {
       assertNoSameOriginServiceWorkerClients: asyncFunc,
-      beginEmulation: asyncFunc,
-      setThrottling: asyncFunc,
       dismissJavaScriptDialogs: asyncFunc,
       enableRuntimeEvents: asyncFunc,
       enableAsyncStacks: asyncFunc,
-      cacheNatives: asyncFunc,
+      executionContext: {cacheNativesOnNewDocument: asyncFunc},
       gotoURL: asyncFunc,
       registerPerformanceObserver: asyncFunc,
       registerRequestIdleCallbackWrap: asyncFunc,
@@ -548,6 +530,64 @@ describe('GatherRunner', function() {
     });
   });
 
+  it('sets throttling appropriately', async () => {
+    restoreActualEmulation();
+    await GatherRunner.setupPassNetwork({
+      driver,
+      settings: {
+        formFactor: 'mobile',
+        screenEmulation: constants.screenEmulationMetrics.mobile,
+        throttlingMethod: 'devtools',
+        throttling: {
+          requestLatencyMs: 100,
+          downloadThroughputKbps: 8,
+          uploadThroughputKbps: 8,
+          cpuSlowdownMultiplier: 2,
+        },
+      },
+      passConfig: {
+        useThrottling: true,
+        gatherers: [],
+      },
+    });
+
+    expect(connectionStub.sendCommand.findInvocation('Network.emulateNetworkConditions')).toEqual({
+      latency: 100, downloadThroughput: 1024, uploadThroughput: 1024, offline: false,
+    });
+    expect(connectionStub.sendCommand.findInvocation('Emulation.setCPUThrottlingRate')).toEqual({
+      rate: 2,
+    });
+  });
+
+  it('clears throttling when useThrottling=false', async () => {
+    restoreActualEmulation();
+    await GatherRunner.setupPassNetwork({
+      driver,
+      settings: {
+        formFactor: 'mobile',
+        screenEmulation: constants.screenEmulationMetrics.mobile,
+        throttlingMethod: 'devtools',
+        throttling: {
+          requestLatencyMs: 100,
+          downloadThroughputKbps: 8,
+          uploadThroughputKbps: 8,
+          cpuSlowdownMultiplier: 2,
+        },
+      },
+      passConfig: {
+        useThrottling: false,
+        gatherers: [],
+      },
+    });
+
+    expect(connectionStub.sendCommand.findInvocation('Network.emulateNetworkConditions')).toEqual({
+      latency: 0, downloadThroughput: 0, uploadThroughput: 0, offline: false,
+    });
+    expect(connectionStub.sendCommand.findInvocation('Emulation.setCPUThrottlingRate')).toEqual({
+      rate: 1,
+    });
+  });
+
   it('tells the driver to block given URL patterns when blockedUrlPatterns is given', async () => {
     await GatherRunner.setupPassNetwork({
       driver,
diff --git a/lighthouse-core/test/gather/gatherers/full-page-screenshot-test.js b/lighthouse-core/test/gather/gatherers/full-page-screenshot-test.js
index 9aa32015e..d5eda847c 100644
--- a/lighthouse-core/test/gather/gatherers/full-page-screenshot-test.js
+++ b/lighthouse-core/test/gather/gatherers/full-page-screenshot-test.js
@@ -7,6 +7,9 @@
 
 /* eslint-env jest */
 
+let mockEmulate = jest.fn();
+jest.mock('../../../lib/emulation.js', () => ({emulate: (...args) => mockEmulate(...args)}));
+
 const FullPageScreenshotGatherer = require('../../../gather/gatherers/full-page-screenshot.js');
 
 // Headless's default value is (1024 * 16), but this varies by device
@@ -16,6 +19,21 @@ const maxTextureSizeMock = 1024 * 8;
  * @param {{contentSize: {width: number, height: number}, screenSize: {width?: number, height?: number, dpr: number}, screenshotData: string[]}}
  */
 function createMockDriver({contentSize, screenSize, screenshotData}) {
+  const sendCommand = jest.fn().mockImplementation(method => {
+    if (method === 'Page.getLayoutMetrics') {
+      return {
+        contentSize,
+        // See comment within _takeScreenshot() implementation
+        layoutViewport: {clientWidth: contentSize.width, clientHeight: contentSize.height},
+      };
+    }
+    if (method === 'Page.captureScreenshot') {
+      return {
+        data: screenshotData && screenshotData.length ? screenshotData.shift() : 'abc',
+      };
+    }
+  });
+
   return {
     executionContext: {
       evaluate: async function(fn) {
@@ -40,25 +58,16 @@ function createMockDriver({contentSize, screenSize, screenshotData}) {
         }
       },
     },
-    beginEmulation: jest.fn(),
-    sendCommand: jest.fn().mockImplementation(method => {
-      if (method === 'Page.getLayoutMetrics') {
-        return {
-          contentSize,
-          // See comment within _takeScreenshot() implementation
-          layoutViewport: {clientWidth: contentSize.width, clientHeight: contentSize.height},
-        };
-      }
-      if (method === 'Page.captureScreenshot') {
-        return {
-          data: screenshotData && screenshotData.length ? screenshotData.shift() : 'abc',
-        };
-      }
-    }),
+    sendCommand,
+    defaultSession: {sendCommand},
   };
 }
 
 describe('FullPageScreenshot gatherer', () => {
+  beforeEach(() => {
+    mockEmulate = jest.fn();
+  });
+
   it('captures a full-page screenshot', async () => {
     const fpsGatherer = new FullPageScreenshotGatherer();
     const driver = createMockDriver({
@@ -113,8 +122,8 @@ describe('FullPageScreenshot gatherer', () => {
     await fpsGatherer.afterPass(passContext);
 
     const expectedArgs = {formFactor: 'mobile', screenEmulation: {disabled: false, mobile: true}};
-    expect(driver.beginEmulation).toHaveBeenCalledWith(expectedArgs);
-    expect(driver.beginEmulation).toHaveBeenCalledTimes(1);
+    expect(mockEmulate).toHaveBeenCalledTimes(1);
+    expect(mockEmulate).toHaveBeenCalledWith(driver.defaultSession, expectedArgs);
   });
 
   it('resets the emulation correctly when Lighthouse does not control it', async () => {
diff --git a/lighthouse-core/test/test-utils.js b/lighthouse-core/test/test-utils.js
index 7465e5191..2c9ea6dd3 100644
--- a/lighthouse-core/test/test-utils.js
+++ b/lighthouse-core/test/test-utils.js
@@ -235,6 +235,11 @@ function makeMocksForGatherRunner() {
   jest.mock('../gather/gatherers/web-app-manifest.js', () => ({
     getWebAppManifest: async () => null,
   }));
+  jest.mock('../lib/emulation.js', () => ({
+    emulate: jest.fn(),
+    throttle: jest.fn(),
+    clearThrottling: jest.fn(),
+  }));
 }
 
 module.exports = {