Open28

lighthouse読む

mizchimizchi

Lighthouse計測で様々な条件をシミュレーションしたい


$ git clone https://github.com/GoogleChrome/lighthouse

puppeteer を使って、CDPセッションに接続


  • core/gather がデータを収集する処理
  • core/gather/driver が chrome を操作する処理
mizchimizchi
core/index.js
/**
 * Run Lighthouse.
 * @param {string=} url The URL to test. Optional if running in auditMode.
 * @param {LH.Flags=} flags Optional settings for the Lighthouse run. If present,
 *   they will override any settings in the config.
 * @param {LH.Config=} config Configuration for the Lighthouse run. If
 *   not present, the default config is used.
 * @param {LH.Puppeteer.Page=} page
 * @return {Promise<LH.RunnerResult|undefined>}
 */
async function lighthouse(url, flags = {}, config, page) {
  return navigation(page, url, {config, flags});
}

navigation を呼ぶ

/**
 * @param {LH.Puppeteer.Page|undefined} page
 * @param {LH.NavigationRequestor|undefined} requestor
 * @param {{config?: LH.Config, flags?: LH.Flags}} [options]
 * @return {Promise<LH.RunnerResult|undefined>}
 */
async function navigation(page, requestor, options) {
  const gatherResult = await navigationGather(page, requestor, options);
  return Runner.audit(gatherResult.artifacts, gatherResult.runnerOptions);
}
core/gather/navigation-runner.js
/**
 * @param {LH.Puppeteer.Page|undefined} page
 * @param {LH.NavigationRequestor|undefined} requestor
 * @param {{config?: LH.Config, flags?: LH.Flags}} [options]
 * @return {Promise<LH.Gatherer.GatherResult>}
 */
async function navigationGather(page, requestor, options = {}) {
  const {flags = {}, config} = options;
  log.setLevel(flags.logLevel || 'error');

  const {resolvedConfig} = await initializeConfig('navigation', config, flags);
  const computedCache = new Map();

  const isCallback = typeof requestor === 'function';

  const runnerOptions = {resolvedConfig, computedCache};

  const gatherFn = async () => {
    const normalizedRequestor = isCallback ? requestor : UrlUtils.normalizeUrl(requestor);

    /** @type {LH.Puppeteer.Browser|undefined} */
    let lhBrowser = undefined;
    /** @type {LH.Puppeteer.Page|undefined} */
    let lhPage = undefined;

    // For navigation mode, we shouldn't connect to a browser in audit mode,
    // therefore we connect to the browser in the gatherFn callback.
    if (!page) {
      const {hostname = DEFAULT_HOSTNAME, port = DEFAULT_PORT} = flags;
      lhBrowser = await puppeteer.connect({browserURL: `http://${hostname}:${port}`, defaultViewport: null});
      lhPage = await lhBrowser.newPage();
      page = lhPage;
    }

    const driver = new Driver(page);
    const context = {
      driver,
      lhBrowser,
      lhPage,
      page,
      resolvedConfig,
      requestor: normalizedRequestor,
      computedCache,
    };
    const {baseArtifacts} = await _setup(context);

    const artifacts = await _navigation({...context, baseArtifacts});

    await _cleanup(context);

    return finalizeArtifacts(baseArtifacts, artifacts);
  };
  const artifacts = await Runner.gather(gatherFn, runnerOptions);
  return {artifacts, runnerOptions};
}
  • requestor が関数ならば実行してURL先を確定
  • 未設定ならば lighthouse.Page を初期化している。
  • Lighthouse の実行コンテキストを作っている
  • driver を初期化
  • _setupに driver を渡して初期化

Driver 初期化自体では何も初期化しない

/** @implements {LH.Gatherer.Driver} */
class Driver {
  /**
   * @param {LH.Puppeteer.Page} page
   */
  constructor(page) {
    this._page = page;
    /** @type {TargetManager|undefined} */
    this._targetManager = undefined;
    /** @type {NetworkMonitor|undefined} */
    this._networkMonitor = undefined;
    /** @type {ExecutionContext|undefined} */
    this._executionContext = undefined;
    /** @type {Fetcher|undefined} */
    this._fetcher = undefined;

    this.defaultSession = throwingSession;
  }

connect を呼ばれると、driver のメンバが初期化される(まだ呼ばれてない)

  /** @return {Promise<void>} */
  async connect() {
    if (this.defaultSession !== throwingSession) return;
    const status = {msg: 'Connecting to browser', id: 'lh:driver:connect'};
    log.time(status);
    const cdpSession = await this._page.target().createCDPSession();
    this._targetManager = new TargetManager(cdpSession);
    await this._targetManager.enable();
    this._networkMonitor = new NetworkMonitor(this._targetManager);
    await this._networkMonitor.enable();
    this.defaultSession = this._targetManager.rootSession();
    this._executionContext = new ExecutionContext(this.defaultSession);
    this._fetcher = new Fetcher(this.defaultSession);
    log.timeEnd(status);
  }
  • cdpSession を作る
  • TargetManager に渡す
// navigationGatherer
    const {baseArtifacts} = await _setup(context);

/**
 * @param {{driver: Driver, resolvedConfig: LH.Config.ResolvedConfig, requestor: LH.NavigationRequestor}} args
 * @return {Promise<{baseArtifacts: LH.BaseArtifacts}>}
 */
async function _setup({driver, resolvedConfig, requestor}) {
  await driver.connect();

  // We can't trigger the navigation through user interaction if we reset the page before starting.
  if (typeof requestor === 'string' && !resolvedConfig.settings.skipAboutBlank) {
    // Disable network monitor on the blank page to prevent it from picking up network requests and
    // frame navigated events before the run starts.
    await driver._networkMonitor?.disable();

    await gotoURL(driver, resolvedConfig.settings.blankPage, {waitUntil: ['navigated']});

    await driver._networkMonitor?.enable();
  }

  const baseArtifacts = await getBaseArtifacts(resolvedConfig, driver, {gatherMode: 'navigation'});

  const {warnings} =
    await prepare.prepareTargetForNavigationMode(driver, resolvedConfig.settings, requestor);

  baseArtifacts.LighthouseRunWarnings.push(...warnings);

  return {baseArtifacts};
}

BaseArtifacts と TargetManager が何なのかを調べる

core/gather/driver/target-manager.js
/**
 * Tracks targets (the page itself, its iframes, their iframes, etc) as they
 * appear and allows listeners to the flattened protocol events from all targets.
 */
class TargetManager extends ProtocolEventEmitter {
  /** @param {LH.Puppeteer.CDPSession} cdpSession */
  constructor(cdpSession) {
    super();

    this._enabled = false;
    this._rootCdpSession = cdpSession;
    this._mainFrameId = '';

    /**
     * A map of target id to target/session information. Used to ensure unique
     * attached targets.
     * @type {Map<string, TargetWithSession>}
     */
    this._targetIdToTargets = new Map();
    /** @type {Map<string, LH.Crdp.Runtime.ExecutionContextDescription>} */
    this._executionContextIdToDescriptions = new Map();

    this._onSessionAttached = this._onSessionAttached.bind(this);
    this._onFrameNavigated = this._onFrameNavigated.bind(this);
    this._onExecutionContextCreated = this._onExecutionContextCreated.bind(this);
    this._onExecutionContextDestroyed = this._onExecutionContextDestroyed.bind(this);
    this._onExecutionContextsCleared = this._onExecutionContextsCleared.bind(this);
  }

どうやら、CDPを直接ハンドルするリスナーを定義している

  /**
   * @param {LH.Crdp.Page.FrameNavigatedEvent} frameNavigatedEvent
   */
  async _onFrameNavigated(frameNavigatedEvent) {
    // Child frames are handled in `_onSessionAttached`.
    if (frameNavigatedEvent.frame.parentId) return;
    if (!this._enabled) return;

    // It's not entirely clear when this is necessary, but when the page switches processes on
    // navigating from about:blank to the `requestedUrl`, resetting `setAutoAttach` has been
    // necessary in the past.
    try {
      await this._rootCdpSession.send('Target.setAutoAttach', {
        autoAttach: true,
        flatten: true,
        waitForDebuggerOnStart: true,
      });
    } catch (err) {
      // The page can be closed at the end of the run before this CDP function returns.
      // In these cases, just ignore the error since we won't need the page anyway.
      if (this._enabled) throw err;
    }
  }
mizchimizchi

Target は計測対象のセッションに接続したり、デタッチしている?

mizchimizchi

ここの onProtocolEvent で全部のイベントを監視するリスナーが設定されている

  /**
   * Returns a listener for all protocol events from session, and augments the
   * event with the sessionId.
   * @param {LH.Protocol.TargetType} targetType
   * @param {string} sessionId
   */
  _getProtocolEventListener(targetType, sessionId) {
    /**
     * @template {keyof LH.Protocol.RawEventMessageRecord} EventName
     * @param {EventName} method
     * @param {LH.Protocol.RawEventMessageRecord[EventName]['params']} params
     */
    const onProtocolEvent = (method, params) => {
      // Cast because tsc 4.7 still can't quite track the dependent parameters.
      const payload = /** @type {LH.Protocol.RawEventMessage} */ (
        {method, params, targetType, sessionId});
      this.emit('protocolevent', payload);
    };

    return onProtocolEvent;
  }
      const trueProtocolListener = this._getProtocolEventListener(targetType, newSession.id());
      /** @type {(event: unknown) => void} */
      // @ts-expect-error - pptr currently typed only for single arg emits.
      const protocolListener = trueProtocolListener;
      cdpSession.on('*', protocolListener);
      cdpSession.on('sessionattached', this._onSessionAttached);

mizchimizchi

TargetManager 自体がイベントリスナーなので、これを監視することでCDPSession全イベントが取れるみたいだ

mizchimizchi

Session とは

  /**
   * @param {LH.Puppeteer.CDPSession} cdpSession
   */
  async _onSessionAttached(cdpSession) {
    const newSession = new ProtocolSession(cdpSession);

    let targetType;

    try {
      const {targetInfo} = await newSession.sendCommand('Target.getTargetInfo');
      targetType = targetInfo.type;

mizchimizchi

core/gather/session.js

cdpSession のラッパー

/**
 * Puppeteer timeouts must fit into an int32 and the maximum timeout for `setTimeout` is a *signed*
 * int32. However, this also needs to account for the puppeteer buffer we add to the timeout later.
 *
 * So this is defined as the max *signed* int32 minus PPTR_BUFFER.
 *
 * In human terms, this timeout is ~25 days which is as good as infinity for all practical purposes.
 */
const MAX_TIMEOUT = 2147483647 - PPTR_BUFFER;

/** @typedef {LH.Protocol.StrictEventEmitterClass<LH.CrdpEvents>} CrdpEventMessageEmitter */
const CrdpEventEmitter = /** @type {CrdpEventMessageEmitter} */ (EventEmitter);

/** @implements {LH.Gatherer.ProtocolSession} */
class ProtocolSession extends CrdpEventEmitter {
  /**
   * @param {LH.Puppeteer.CDPSession} cdpSession
   */
  constructor(cdpSession) {
    super();
mizchimizchi

Lantern

https://github.com/GoogleChrome/lighthouse/blob/main/docs/lantern.md

Project Lantern は、ページ アクティビティをモデル化し、ブラウザーの実行をシミュレートすることで、Lighthouse の実行時間を短縮し、監査の品質を向上させるための継続的な取り組みです。このドキュメントでは、これらのモデルの精度について詳しく説明し、予想される自然な変動性を把握します。

mizchimizchi

TargetManager

targetManager.enable() で cdp を初期化する

core/gather/driver/target-manager.js
  /**
   * @return {Promise<void>}
   */
  async enable() {
    if (this._enabled) return;

    this._enabled = true;
    this._targetIdToTargets = new Map();
    this._executionContextIdToDescriptions = new Map();

    this._rootCdpSession.on('Page.frameNavigated', this._onFrameNavigated);
    this._rootCdpSession.on('Runtime.executionContextCreated', this._onExecutionContextCreated);
    this._rootCdpSession.on('Runtime.executionContextDestroyed', this._onExecutionContextDestroyed);
    this._rootCdpSession.on('Runtime.executionContextsCleared', this._onExecutionContextsCleared);

    await this._rootCdpSession.send('Page.enable');
    await this._rootCdpSession.send('Runtime.enable');

    this._mainFrameId = (await this._rootCdpSession.send('Page.getFrameTree')).frameTree.frame.id;

    // Start with the already attached root session.
    await this._onSessionAttached(this._rootCdpSession);
  }

onSessionnAttached

  /**
   * @param {LH.Puppeteer.CDPSession} cdpSession
   */
  async _onSessionAttached(cdpSession) {
    const newSession = new ProtocolSession(cdpSession);
    // ...

class ProtocolSession

/** @implements {LH.Gatherer.ProtocolSession} */
class ProtocolSession extends CrdpEventEmitter {
  /**
   * @param {LH.Puppeteer.CDPSession} cdpSession
   */
  constructor(cdpSession) {
    super();

    this._cdpSession = cdpSession;
    /** @type {LH.Crdp.Target.TargetInfo|undefined} */
    this._targetInfo = undefined;
    /** @type {number|undefined} */
    this._nextProtocolTimeout = undefined;

    this._handleProtocolEvent = this._handleProtocolEvent.bind(this);
    // @ts-expect-error Puppeteer expects the handler params to be type `unknown`
    this._cdpSession.on('*', this._handleProtocolEvent);

    // If the target crashes, we can't continue gathering.
    // FWIW, if the target unexpectedly detaches (eg the user closed the tab), pptr will
    // catch that and reject in this._cdpSession.send, which is caught by us.
    /** @param {Error} _ */
    let rej = _ => {}; // Poor man's Promise.withResolvers()
    this._targetCrashedPromise = /** @type {Promise<never>} */ (
      new Promise((_, theRej) => rej = theRej));
    this.on('Inspector.targetCrashed', async () => {
      log.error('TargetManager', 'Inspector.targetCrashed');
      // Manually detach so no more CDP traffic is attempted.
      // Don't await, else our rejection will be a 'Target closed' protocol error on cross-talk
      // CDP calls.
      void this.dispose();
      rej(new LighthouseError(LighthouseError.errors.TARGET_CRASHED));
    });
  }

クラッシュレポーターを差し込みつつ、ラップしている。

多分重要なのは sendCommand

  /**
   * @template {keyof LH.CrdpCommands} C
   * @param {C} method
   * @param {LH.CrdpCommands[C]['paramsType']} params
   * @return {Promise<LH.CrdpCommands[C]['returnType']>}
   */
  sendCommand(method, ...params) {
    const timeoutMs = this.getNextProtocolTimeout();
    this._nextProtocolTimeout = undefined;

    /** @type {NodeJS.Timeout|undefined} */
    let timeout;
    const timeoutPromise = new Promise((resolve, reject) => {
      // Unexpected setTimeout invocation to preserve the error stack. https://github.com/GoogleChrome/lighthouse/issues/13332
      // eslint-disable-next-line max-len
      timeout = setTimeout(reject, timeoutMs, new LighthouseError(LighthouseError.errors.PROTOCOL_TIMEOUT, {
        protocolMethod: method,
      }));
    });

    const resultPromise = this._cdpSession.send(method, ...params, {
      // Add 50ms to the Puppeteer timeout to ensure the Lighthouse timeout finishes first.
      timeout: timeoutMs + PPTR_BUFFER,
    }).catch((error) => {
      log.formatProtocol('method <= browser ERR', {method}, 'error');
      throw LighthouseError.fromProtocolMessage(method, error);
    });
    const resultWithTimeoutPromise =
      Promise.race([resultPromise, timeoutPromise, this._targetCrashedPromise]);

    return resultWithTimeoutPromise.finally(() => {
      if (timeout) clearTimeout(timeout);
    });
  }

実態は cdp.send だが、タイムアウト付きでラップしている

mizchimizchi

ここまでに得た知識で、lighthouse と同等の初期化処理でCDPに直接アクセスしてみる。

import puppeteer from "puppeteer";

import * as chromeLauncher from "chrome-launcher";
const chrome = await chromeLauncher.launch({
  userDataDir: false,
  chromeFlags: ["--no-sandbox"],
  chromePath: "/snap/bin/chromium",
});

const browser = await puppeteer.connect({
  browserURL: `http://localhost:${chrome.port}`,
  defaultViewport: null,
});

const page = await browser.newPage();
const cdp = await page.createCDPSession();
const conn = cdp.connection()!;

// hooks
cdp.on("*", (...args) => {
  console.log("[cdp]", ...args);
});
conn.on("*", (...args) => {
  console.log("[conn]", ...args);
});

// send
await cdp.send("Target.setAutoAttach", {
  autoAttach: true,
  flatten: true,
  waitForDebuggerOnStart: true,
});
await cdp.send("Page.enable");
await cdp.send("Runtime.enable");

const frameTree = await cdp.send("Page.getFrameTree");
console.log("mainId", frameTree.frameTree.frame);

await page.goto("https://github.com", {
  waitUntil: "networkidle2",
});

const res = await cdp.send("Target.getTargetInfo");
console.log(res);

結果

$ node script/02-run.ts
[cdp] Runtime.executionContextCreated {
  context: {
    id: 2,
    origin: '',
    name: '__puppeteer_utility_world__24.2.1',
    uniqueId: '-1957335987268521367.7472334966428414066',
    auxData: {
      isDefault: false,
      type: 'isolated',
      frameId: '95D444464DC5FA3BF51F80FC5400C38D'
    }
  }
}
[cdp] Runtime.executionContextCreated {
  context: {
    id: 1,
    origin: '://',
    name: '',
    uniqueId: '-8097433944122872796.-8927146037938287057',
    auxData: {
      isDefault: true,
      type: 'default',
      frameId: '95D444464DC5FA3BF51F80FC5400C38D'
    }
  }
}
mainId {
  id: '95D444464DC5FA3BF51F80FC5400C38D',
  loaderId: 'C599BE1BBE44CB7E4E4DFF7B54D9F9EE',
  url: 'about:blank',
  domainAndRegistry: '',
  securityOrigin: '://',
  mimeType: 'text/html',
  adFrameStatus: { adFrameType: 'none' },
  secureContextType: 'InsecureScheme',
  crossOriginIsolatedContextType: 'NotIsolated',
  gatedAPIFeatures: []
}
[cdp] Page.frameStartedLoading { frameId: '95D444464DC5FA3BF51F80FC5400C38D' }
[cdp] Runtime.executionContextsCleared {}
[cdp] Page.frameNavigated {
  frame: {
    id: '95D444464DC5FA3BF51F80FC5400C38D',
    loaderId: '0FD4137EE31A1580DDE74D5A00FD46BA',
    url: 'https://github.com/',
    domainAndRegistry: 'github.com',
    securityOrigin: 'https://github.com',
    mimeType: 'text/html',
    adFrameStatus: { adFrameType: 'none' },
    secureContextType: 'Secure',
    crossOriginIsolatedContextType: 'NotIsolated',
    gatedAPIFeatures: []
  },
  type: 'Navigation'
}
[conn] Target.targetInfoChanged {
  targetInfo: {
    targetId: '95D444464DC5FA3BF51F80FC5400C38D',
    type: 'page',
    title: 'github.com',
    url: 'https://github.com/',
    attached: true,
    canAccessOpener: false,
    browserContextId: '3C42CF0F6F6E7B157BE26304344BF956'
  }
}
[cdp] Runtime.executionContextCreated {
  context: {
    id: 3,
    origin: 'https://github.com',
    name: '',
    uniqueId: '2024160153867014494.-7013613868782907409',
    auxData: {
      isDefault: true,
      type: 'default',
      frameId: '95D444464DC5FA3BF51F80FC5400C38D'
    }
  }
}
[cdp] Runtime.executionContextCreated {
  context: {
    id: 4,
    origin: '://',
    name: '__puppeteer_utility_world__24.2.1',
    uniqueId: '-1813183885552145059.293750994607843554',
    auxData: {
      isDefault: false,
      type: 'isolated',
      frameId: '95D444464DC5FA3BF51F80FC5400C38D'
    }
  }
}
[conn] Target.targetInfoChanged {
  targetInfo: {
    targetId: '95D444464DC5FA3BF51F80FC5400C38D',
    type: 'page',
    title: 'GitHub · Build and ship software on a single, collaborative platform · GitHub',
    url: 'https://github.com/',
    attached: true,
    canAccessOpener: false,
    browserContextId: '3C42CF0F6F6E7B157BE26304344BF956'
  }
}
[conn] Target.targetInfoChanged {
  targetInfo: {
    targetId: '95D444464DC5FA3BF51F80FC5400C38D',
    type: 'page',
    title: 'GitHub · Build and ship software on a single, collaborative platform · GitHub',
    url: 'https://github.com/',
    attached: true,
    canAccessOpener: false,
    browserContextId: '3C42CF0F6F6E7B157BE26304344BF956'
  }
}
[cdp] Page.navigatedWithinDocument {
  frameId: '95D444464DC5FA3BF51F80FC5400C38D',
  url: 'https://github.com/',
  navigationType: 'historyApi'
}
[cdp] Page.navigatedWithinDocument {
  frameId: '95D444464DC5FA3BF51F80FC5400C38D',
  url: 'https://github.com/',
  navigationType: 'historyApi'
}
[cdp] Page.domContentEventFired { timestamp: 296366.681765 }
[conn] Target.targetInfoChanged {
  targetInfo: {
    targetId: '95D444464DC5FA3BF51F80FC5400C38D',
    type: 'page',
    title: 'GitHub · Build and ship software on a single, collaborative platform · GitHub',
    url: 'https://github.com/',
    attached: true,
    canAccessOpener: false,
    browserContextId: '3C42CF0F6F6E7B157BE26304344BF956'
  }
}
[cdp] Page.navigatedWithinDocument {
  frameId: '95D444464DC5FA3BF51F80FC5400C38D',
  url: 'https://github.com/',
  navigationType: 'historyApi'
}
[cdp] Page.loadEventFired { timestamp: 296366.898252 }
[cdp] Page.frameStoppedLoading { frameId: '95D444464DC5FA3BF51F80FC5400C38D' }
{
  targetInfo: {
    targetId: '95D444464DC5FA3BF51F80FC5400C38D',
    type: 'page',
    title: 'GitHub · Build and ship software on a single, collaborative platform · GitHub',
    url: 'https://github.com/',
    attached: true,
    canAccessOpener: false,
    browserContextId: '3C42CF0F6F6E7B157BE26304344BF956'
  }
}
[conn] sessiondetached CdpCDPSession {}
[conn] Target.targetInfoChanged {
  targetInfo: {
    targetId: '95D444464DC5FA3BF51F80FC5400C38D',
    type: 'page',
    title: 'GitHub · Build and ship software on a single, collaborative platform · GitHub',
    url: 'https://github.com/',
    attached: false,
    canAccessOpener: false,
    browserContextId: '3C42CF0F6F6E7B157BE26304344BF956'
  }
}
[cdp] Symbol(CDPSession.Disconnected) undefined
[conn] sessiondetached CdpCDPSession {}
[conn] Target.detachedFromTarget {
  sessionId: 'A83C7CE4B7173FF7E1E2F10B1CBB98D7',
  targetId: '95D444464DC5FA3BF51F80FC5400C38D'
}
[conn] Target.targetDestroyed { targetId: '95D444464DC5FA3BF51F80FC5400C38D' }
[conn] Target.targetInfoChanged {
  targetInfo: {
    targetId: '90761FADCC4C5126FFC1B3A4F1DCCB21',
    type: 'tab',
    title: '',
    url: '',
    attached: false,
    canAccessOpener: false,
    browserContextId: '3C42CF0F6F6E7B157BE26304344BF956'
  }
}
[conn] sessiondetached CdpCDPSession {}
[conn] Target.detachedFromTarget {
  sessionId: '0CC4723A8E46D35A6B17CB4F6DA19EA0',
  targetId: '90761FADCC4C5126FFC1B3A4F1DCCB21'
}
[conn] Target.targetDestroyed { targetId: '90761FADCC4C5126FFC1B3A4F1DCCB21' }
[conn] Target.targetInfoChanged {
  targetInfo: {
    targetId: '6A598B86AC255479555BE92FBE4D9DE0',
    type: 'page',
    title: 'about:blank',
    url: 'about:blank',
    attached: false,
    canAccessOpener: false,
    browserContextId: '3C42CF0F6F6E7B157BE26304344BF956'
  }
}
[conn] sessiondetached CdpCDPSession {}
[conn] Target.targetDestroyed { targetId: '6A598B86AC255479555BE92FBE4D9DE0' }
[conn] Target.targetInfoChanged {
  targetInfo: {
    targetId: 'D55134CF007137F13ABF7DC689D2366D',
    type: 'tab',
    title: '',
    url: '',
    attached: false,
    canAccessOpener: false,
    browserContextId: '3C42CF0F6F6E7B157BE26304344BF956'
  }
}
[conn] sessiondetached CdpCDPSession {}
[conn] Target.detachedFromTarget {
  sessionId: '4D36A7402414F79ADFD8570635B65095',
  targetId: 'D55134CF007137F13ABF7DC689D2366D'
}
[conn] Target.targetDestroyed { targetId: 'D55134CF007137F13ABF7DC689D2366D' }
[conn] Symbol(CDPSession.Disconnected) undefine
mizchimizchi

CDP の初期化は、ここが肝っぽい

const cdp = await page.createCDPSession();
await cdp.send("Target.setAutoAttach", {
  autoAttach: true,
  flatten: true,
  waitForDebuggerOnStart: true,
});
await cdp.send("Page.enable");
await cdp.send("Runtime.enable");
mizchimizchi

ここまでまだ driver.connect() を見ている途中だった

  /** @return {Promise<void>} */
  async connect() {
    if (this.defaultSession !== throwingSession) return;
    const status = {msg: 'Connecting to browser', id: 'lh:driver:connect'};
    log.time(status);
    const cdpSession = await this._page.target().createCDPSession();
    this._targetManager = new TargetManager(cdpSession);
    await this._targetManager.enable();

この先にいく

    this._networkMonitor = new NetworkMonitor(this._targetManager);
    await this._networkMonitor.enable();
    this.defaultSession = this._targetManager.rootSession();
    this._executionContext = new ExecutionContext(this.defaultSession);
    this._fetcher = new Fetcher(this.defaultSession);
    log.timeEnd(status);
  • networkMonitor.enable
  • new ExecutionContext
  • new Fetcher

NetwokrMonitor

/**
 * @fileoverview This class wires up the procotol to a network recorder and provides overall
 * status inspection state.
 */

class NetworkMonitor extends NetworkMonitorEventEmitter {
  /** @type {NetworkRecorder|undefined} */
  _networkRecorder = undefined;
  /** @type {Array<LH.Crdp.Page.Frame>} */
  _frameNavigations = [];

  /** @param {LH.Gatherer.Driver['targetManager']} targetManager */
  constructor(targetManager) {
    super();

    /** @type {LH.Gatherer.Driver['targetManager']} */
    this._targetManager = targetManager;

    /** @type {LH.Gatherer.ProtocolSession} */
    this._session = targetManager.rootSession();

    /** @param {LH.Crdp.Page.FrameNavigatedEvent} event */
    this._onFrameNavigated = event => this._frameNavigations.push(event.frame);

    /** @param {LH.Protocol.RawEventMessage} event */
    this._onProtocolMessage = event => {
      if (!this._networkRecorder) return;
      this._networkRecorder.dispatch(event);
    };
  }

  /**
   * @return {Promise<void>}
   */
  async enable() {
    if (this._networkRecorder) return;

    this._frameNavigations = [];
    this._networkRecorder = new NetworkRecorder();

    /**
     * Reemit the same network recorder events.
     * @param {keyof NetworkRecorderEventMap} event
     * @return {(r: NetworkRequest) => void}
     */
    const reEmit = event => r => {
      this.emit(event, r);
      this._emitNetworkStatus();
    };

    this._networkRecorder.on('requeststarted', reEmit('requeststarted'));
    this._networkRecorder.on('requestfinished', reEmit('requestfinished'));

    this._session.on('Page.frameNavigated', this._onFrameNavigated);
    this._targetManager.on('protocolevent', this._onProtocolMessage);
  }

NetworkRecorder を初期化していて、ハンドラを設定して、一部のイベントを再発火。

NetworkRecorder

core/lib/network-recorder.js
class NetworkRecorder extends RequestEventEmitter {
  /**
   * Creates an instance of NetworkRecorder.
   */
  constructor() {
    super();

    /** @type {NetworkRequest[]} */
    this._records = [];
    /** @type {Map<string, NetworkRequest>} */
    this._recordsById = new Map();
  }
mizchimizchi

結局ほぼ requeststarted と requestfinishide を横流ししているだけ

mizchimizchi

ExectuteContext

class ExecutionContext {
  /** @param {LH.Gatherer.ProtocolSession} session */
  constructor(session) {
    this._session = session;

    /** @type {number|undefined} */
    this._executionContextId = undefined;
    /**
     * Marks how many execution context ids have been created, for purposes of having a unique
     * value (that doesn't expose the actual execution context id) to
     * use for __lighthouseExecutionContextUniqueIdentifier.
     * @type {number}
     */
    this._executionContextIdentifiersCreated = 0;

    // We use isolated execution contexts for `evaluateAsync` that can be destroyed through navigation
    // and other page actions. Cleanup our relevant bookkeeping as we see those events.
    // Domains are enabled when a dedicated execution context is requested.
    session.on("Page.frameNavigated", () => this.clearContextId());
    session.on("Runtime.executionContextDestroyed", (event) => {
      if (event.executionContextId === this._executionContextId) {
        this.clearContextId();
      }
    });
  }

page.evaluate() 相当っぽい

mizchimizchi

Fetcher

core/gather/fetcher.js
/**
 * @fileoverview Fetcher is a utility for making requests to any arbitrary resource,
 * ignoring normal browser constraints such as CORS.
 */

class Fetcher {
  /**
   * @param {LH.Gatherer.ProtocolSession} session
   */
  constructor(session) {
    this.session = session;
  }

  /**
   * Fetches any resource using the network directly.
   *
   * @param {string} url
   * @param {{timeout: number}=} options timeout is in ms
   * @return {Promise<FetchResponse>}
   */
  async fetchResource(url, options = {timeout: 2_000}) {
    // In Lightrider, `Network.loadNetworkResource` is not implemented, but fetch
    // is configured to work for any resource.
    if (global.isLightrider) {
      return this._wrapWithTimeout(this._fetchWithFetchApi(url), options.timeout);
    }

    return this._fetchResourceOverProtocol(url, options);
  }

//....

  /**
   * @param {string} url
   * @param {{timeout: number}} options timeout is in ms
   * @return {Promise<FetchResponse>}
   */
  async _fetchResourceOverProtocol(url, options) {
    const startTime = Date.now();
    const response = await this._wrapWithTimeout(this._loadNetworkResource(url), options.timeout);

    const isOk = response.status && response.status >= 200 && response.status <= 299;
    if (!response.stream || !isOk) return {status: response.status, content: null};

    const timeout = options.timeout - (Date.now() - startTime);
    const content = await this._readIOStream(response.stream, {timeout});
    return {status: response.status, content};
  }

_loadNetworkResource が実体

  /**
   * @param {string} url
   * @return {Promise<{stream: LH.Crdp.IO.StreamHandle|null, status: number|null}>}
   */
  async _loadNetworkResource(url) {
    const frameTreeResponse = await this.session.sendCommand('Page.getFrameTree');
    const networkResponse = await this.session.sendCommand('Network.loadNetworkResource', {
      frameId: frameTreeResponse.frameTree.frame.id,
      url,
      options: {
        disableCache: true,
        includeCredentials: true,
      },
    });

    return {
      stream: networkResponse.resource.success ? (networkResponse.resource.stream || null) : null,
      status: networkResponse.resource.httpStatusCode || null,
    };
  }

この処理を真似ると、実際のセッションを処理とは無関係にネットワークリクエストを発行できそうにみえる。


やってみた

import puppeteer from "puppeteer";
import * as chromeLauncher from "chrome-launcher";

const chrome = await chromeLauncher.launch({
  userDataDir: false,
  chromeFlags: ["--no-sandbox"],
  chromePath: "/snap/bin/chromium",
});

const browser = await puppeteer.connect({
  browserURL: `http://localhost:${chrome.port}`,
  defaultViewport: null,
});

const page = await browser.newPage();
const cdp = await page.createCDPSession();
await cdp.send("Page.enable");
await cdp.send("Network.enable");
await cdp.send("Runtime.enable");

await page.goto("https://github.com", {
  waitUntil: "networkidle2",
});

const frameTreeResponse = await cdp.send("Page.getFrameTree");
const reqUrl = "https://jsonplaceholder.typicode.com/todos/1";
const networkResponse = await cdp.send("Network.loadNetworkResource", {
  frameId: frameTreeResponse.frameTree.frame.id,
  url: reqUrl,
  options: {
    disableCache: true,
    includeCredentials: true,
  },
});

console.log("req", networkResponse);
$ node script/04-req.ts
req {
  resource: {
    success: true,
    httpStatusCode: 200,
    stream: '1',
    headers: {
      'access-control-allow-credentials': 'true',
      age: '22495',
      'alt-svc': 'h3=":443"; ma=86400',
      'cache-control': 'max-age=43200',
      'cf-cache-status': 'HIT',
      'cf-ray': '9125ca6cc9a9d791-NRT',
      'content-encoding': 'zstd',
      'content-type': 'application/json; charset=utf-8',
      date: 'Sat, 15 Feb 2025 13:54:35 GMT',
      etag: 'W/"53-hfEnumeNh6YirfjyjaujcOPPT+s"',
      expires: '-1',
      nel: '{"report_to":"heroku-nel","max_age":3600,"success_fraction":0.005,"failure_fraction":0.05,"response_headers":["Via"]}',
      pragma: 'no-cache',
      'report-to': '{"group":"heroku-nel","max_age":3600,"endpoints":[{"url":"https://nel.heroku.com/reports?ts=1735436050&sid=e11707d5-02a7-43ef-b45e-2cf4d2036f7d&s=J1iPYscdGSQTZP%2FIp%2BrdPCguI3kwdYvzxKny%2FGIiv%2Bw%3D"}]}',
      'reporting-endpoints': 'heroku-nel=https://nel.heroku.com/reports?ts=1735436050&sid=e11707d5-02a7-43ef-b45e-2cf4d2036f7d&s=J1iPYscdGSQTZP%2FIp%2BrdPCguI3kwdYvzxKny%2FGIiv%2Bw%3D',
      server: 'cloudflare',
      'server-timing': 'cfL4;desc="?proto=TCP&rtt=3309&min_rtt=2699&rtt_var=935&sent=8&recv=10&lost=0&retrans=0&sent_bytes=4016&recv_bytes=2252&delivery_rate=1230594&cwnd=253&unsent_bytes=0&cid=b8edc79947bf92d2&ts=19&x=0"',
      vary: 'Origin, Accept-Encoding',
      via: '1.1 vegur',
      'x-content-type-options': 'nosniff',
      'x-powered-by': 'Express',
      'x-ratelimit-limit': '1000',
      'x-ratelimit-remaining': '999',
      'x-ratelimit-reset': '1735436088'
    }
  }
}
mizchimizchi

ここまでで driver.connect() の処理が終了。

/**
 * @param {{driver: Driver, resolvedConfig: LH.Config.ResolvedConfig, requestor: LH.NavigationRequestor}} args
 * @return {Promise<{baseArtifacts: LH.BaseArtifacts}>}
 */
async function _setup({driver, resolvedConfig, requestor}) {
  await driver.connect();

  // We can't trigger the navigation through user interaction if we reset the page before starting.
  if (typeof requestor === 'string' && !resolvedConfig.settings.skipAboutBlank) {
    // Disable network monitor on the blank page to prevent it from picking up network requests and
    // frame navigated events before the run starts.
    await driver._networkMonitor?.disable();

    await gotoURL(driver, resolvedConfig.settings.blankPage, {waitUntil: ['navigated']});

    await driver._networkMonitor?.enable();
  }

  const baseArtifacts = await getBaseArtifacts(resolvedConfig, driver, {gatherMode: 'navigation'});

  const {warnings} =
    await prepare.prepareTargetForNavigationMode(driver, resolvedConfig.settings, requestor);

  baseArtifacts.LighthouseRunWarnings.push(...warnings);

  return {baseArtifacts};
}
  • gotoURL() でページをロードする
  • ベースアーティファクトを取得
mizchimizchi
core/gather/driver/navigation.js
/**
 * 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.Driver} driver
 * @param {LH.NavigationRequestor} requestor
 * @param {NavigationOptions} options
 * @return {Promise<{requestedUrl: string, mainDocumentUrl: string, warnings: Array<LH.IcuMessage>}>}
 */
async function gotoURL(driver, requestor, options) {
  const status = typeof requestor === 'string' ?
    {msg: `Navigating to ${requestor}`, id: 'lh:driver:navigate'} :
    {msg: 'Navigating using a user defined function', id: 'lh:driver:navigate'};
  log.time(status);

  const session = driver.defaultSession;
  const networkMonitor = driver.networkMonitor;

  // Enable the events and network monitor needed to track navigation progress.
  await session.sendCommand('Page.enable');
  await session.sendCommand('Page.setLifecycleEventsEnabled', {enabled: true});

  let waitForNavigationTriggered;
  if (typeof requestor === 'string') {
    // No timeout needed for Page.navigate. See https://github.com/GoogleChrome/lighthouse/pull/6413
    session.setNextProtocolTimeout(Infinity);
    waitForNavigationTriggered = session.sendCommand('Page.navigate', {url: requestor});
  } else {
    waitForNavigationTriggered = requestor();
  }

  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.race([
    session.onCrashPromise(),
    Promise.all(waitConditionPromises),
  ]);
  const timedOut = waitConditions.some(condition => condition.timedOut);
  const navigationUrls = await networkMonitor.getNavigationUrls();

  let requestedUrl = navigationUrls.requestedUrl;
  if (typeof requestor === 'string') {
    if (requestedUrl && !UrlUtils.equalWithExcludedFragments(requestor, requestedUrl)) {
      log.error(
        'Navigation',
        `Provided URL (${requestor}) did not match initial navigation URL (${requestedUrl})`
      );
    }
    requestedUrl = requestor;
  }
  if (!requestedUrl) throw Error('No navigations detected when running user defined requestor.');

  const mainDocumentUrl = navigationUrls.mainDocumentUrl || requestedUrl;

  // Bring `Page.navigate` errors back into the promise chain. See https://github.com/GoogleChrome/lighthouse/pull/6739.
  await waitForNavigationTriggered;

  if (options.debugNavigation) {
    await waitForUserToContinue(driver);
  }

  log.timeEnd(status);
  return {
    requestedUrl,
    mainDocumentUrl,
    warnings: getNavigationWarnings({timedOut, mainDocumentUrl, requestedUrl}),
  };
}

await session.sendCommand('Page.setLifecycleEventsEnabled', {enabled: true}) は何が取れるようになる?

enable にするとこういうデータが取れた。

[cdp] Page.lifecycleEvent {
  frameId: '0E4E40F5368C6780E86C7B5FDF48036C',
  loaderId: '265A206D552C3B597EF26B1F4AE80157',
  name: 'DOMContentLoaded',
  timestamp: 299405.805636
}
[cdp] Page.lifecycleEvent {
  frameId: '0E4E40F5368C6780E86C7B5FDF48036C',
  loaderId: '265A206D552C3B597EF26B1F4AE80157',
  name: 'firstMeaningfulPaintCandidate',
  timestamp: 299405.875966
}
[cdp] Page.lifecycleEvent {
  frameId: '0E4E40F5368C6780E86C7B5FDF48036C',
  loaderId: '265A206D552C3B597EF26B1F4AE80157',
  name: 'firstPaint',
  timestamp: 299405.875966
}
[cdp] Page.lifecycleEvent {
  frameId: '0E4E40F5368C6780E86C7B5FDF48036C',
  loaderId: '265A206D552C3B597EF26B1F4AE80157',
  name: 'firstMeaningfulPaintCandidate',
  timestamp: 299405.875966
}
[cdp] Page.lifecycleEvent {
  frameId: '0E4E40F5368C6780E86C7B5FDF48036C',
  loaderId: '265A206D552C3B597EF26B1F4AE80157',
  name: 'firstContentfulPaint',
  times
[cdp] Page.lifecycleEvent {
  frameId: '0E4E40F5368C6780E86C7B5FDF48036C',
  loaderId: '265A206D552C3B597EF26B1F4AE80157',
  name: 'firstImagePaint',
  timestamp: 299406.075167
}
[cdp] Page.lifecycleEvent {
  frameId: '0E4E40F5368C6780E86C7B5FDF48036C',
  loaderId: '265A206D552C3B597EF26B1F4AE80157',
  name: 'networkAlmostIdle',
  timestamp: 299406.498319
}
[cdp] Page.lifecycleEvent {
  frameId: '0E4E40F5368C6780E86C7B5FDF48036C',
  loaderId: '265A206D552C3B597EF26B1F4AE80157',
  name: 'firstMeaningfulPaint',
  timestamp: 299405.875966
}
mizchimizchi

要は page.goto() 相当で、waitFor 等も自分でやってるみたいだった。

自分でも動かしてみたら、確かに遷移する

import puppeteer from "puppeteer";
import * as chromeLauncher from "chrome-launcher";
const chrome = await chromeLauncher.launch({
  userDataDir: false,
  chromeFlags: ["--no-sandbox"],
  chromePath: "/snap/bin/chromium",
});
const browser = await puppeteer.connect({
  browserURL: `http://localhost:${chrome.port}`,
  defaultViewport: null,
});

const page = await browser.newPage();
const cdp = await page.createCDPSession();
await cdp.send("Page.enable");
await cdp.send("Network.enable");
await cdp.send("Runtime.enable");

const url = "https://github.com";
await cdp.send("Page.navigate", { url });

mizchimizchi

雰囲気をわかってきた。
puppeteer からCDPセッションはもらいたいが、基本的に puppeteer に依存したくない。
これもしかして、依存から puppeteer を剥がそうとしている?

mizchimizchi

core/gather/base-artifacts.js

/**
 * @param {LH.Config.ResolvedConfig} resolvedConfig
 * @param {LH.Gatherer.Driver} driver
 * @param {{gatherMode: LH.Gatherer.GatherMode}} context
 * @return {Promise<LH.BaseArtifacts>}
 */
async function getBaseArtifacts(resolvedConfig, driver, context) {
  const BenchmarkIndex = await getBenchmarkIndex(driver.executionContext);
  const {userAgent, product} = await getBrowserVersion(driver.defaultSession);

  return {
    // Meta artifacts.
    fetchTime: new Date().toJSON(),
    Timing: [],
    LighthouseRunWarnings: [],
    settings: resolvedConfig.settings,
    // Environment artifacts that can always be computed.
    BenchmarkIndex,
    HostUserAgent: userAgent,
    HostFormFactor: userAgent.includes('Android') || userAgent.includes('Mobile') ?
      'mobile' : 'desktop',
    HostProduct: product,
    // Contextual artifacts whose collection changes based on gather mode.
    URL: {
      finalDisplayedUrl: '',
    },
    PageLoadError: null,
    GatherContext: context,
  };
}

core/gather/driver/environment.js

/**
 * Computes the benchmark index to get a rough estimate of device class.
 * @param {LH.Gatherer.Driver['executionContext']} executionContext
 * @return {Promise<number>}
 */
async function getBenchmarkIndex(executionContext) {
  const status = {msg: 'Benchmarking machine', id: 'lh:gather:getBenchmarkIndex'};
  log.time(status);
  const indexVal = await executionContext.evaluate(pageFunctions.computeBenchmarkIndex, {
    args: [],
  });
  log.timeEnd(status);
  return indexVal;
}
core/lib/page-functions.js
function computeBenchmarkIndex() {
  /**
   * The GC-heavy benchmark that creates a string of length 10000 in a loop.
   * The returned index is the number of times per second the string can be created divided by 10.
   * The division by 10 is to keep similar magnitudes to an earlier version of BenchmarkIndex that
   * used a string length of 100000 instead of 10000.
   */
  function benchmarkIndexGC() {
    const start = Date.now();
    let iterations = 0;

    while (Date.now() - start < 500) {
      let s = '';
      for (let j = 0; j < 10000; j++) s += 'a';
      if (s.length === 1) throw new Error('will never happen, but prevents compiler optimizations');

      iterations++;
    }

    const durationInSeconds = (Date.now() - start) / 1000;
    return Math.round(iterations / 10 / durationInSeconds);
  }

  /**
   * The non-GC-dependent benchmark that copies integers back and forth between two arrays of length 100000.
   * The returned index is the number of times per second a copy can be made, divided by 10.
   * The division by 10 is to keep similar magnitudes to the GC-dependent version.
   */
  function benchmarkIndexNoGC() {
    const arrA = [];
    const arrB = [];
    for (let i = 0; i < 100000; i++) arrA[i] = arrB[i] = i;

    const start = Date.now();
    let iterations = 0;

    // Some Intel CPUs have a performance cliff due to unlucky JCC instruction alignment.
    // Two possible fixes: call Date.now less often, or manually unroll the inner loop a bit.
    // We'll call Date.now less and only check the duration on every 10th iteration for simplicity.
    // See https://bugs.chromium.org/p/v8/issues/detail?id=10954#c1.
    while (iterations % 10 !== 0 || Date.now() - start < 500) {
      const src = iterations % 2 === 0 ? arrA : arrB;
      const tgt = iterations % 2 === 0 ? arrB : arrA;

      for (let j = 0; j < src.length; j++) tgt[j] = src[j];

      iterations++;
    }

    const durationInSeconds = (Date.now() - start) / 1000;
    return Math.round(iterations / 10 / durationInSeconds);
  }

  // The final BenchmarkIndex is a simple average of the two components.
  return (benchmarkIndexGC() + benchmarkIndexNoGC()) / 2;
}
mizchimizchi

得られた base-artifacts からエミュレーション設定等をセットアップ

page.emulate() 相当

/**
 * Prepares a target to be analyzed in navigation mode by enabling protocol domains, emulation, and new document
 * handlers for global APIs or error handling.
 *
 * This method should be used in combination with `prepareTargetForIndividualNavigation` before a specific navigation occurs.
 *
 * @param {LH.Gatherer.Driver} driver
 * @param {LH.Config.Settings} settings
 * @param {LH.NavigationRequestor} requestor
 * @return {Promise<{warnings: Array<LH.IcuMessage>}>}
 */
async function prepareTargetForNavigationMode(driver, settings, requestor) {
  const status = {msg: 'Preparing target for navigation mode', id: 'lh:prepare:navigationMode'};
  log.time(status);

  /** @type {Array<LH.IcuMessage>} */
  const warnings = [];

  await prepareDeviceEmulation(driver, settings);

  // Automatically handle any JavaScript dialogs to prevent a hung renderer.
  await dismissJavaScriptDialogs(driver.defaultSession);

  // Inject our snippet to cache important web platform APIs before they're (possibly) ponyfilled by the page.
  await driver.executionContext.cacheNativesOnNewDocument();

  // Wrap requestIdleCallback so pages under simulation receive the correct rIC deadlines.
  if (settings.throttlingMethod === 'simulate') {
    await shimRequestIdleCallbackOnNewDocument(driver, settings);
  }

  await warmUpIntlSegmenter(driver);

  const shouldResetStorage =
    !settings.disableStorageReset &&
    // Without prior knowledge of the destination, we cannot know which URL to clear storage for.
    typeof requestor === 'string';
  if (shouldResetStorage) {
    const {warnings: storageWarnings} = await resetStorageForUrl(driver.defaultSession,
      requestor,
      settings.clearStorageTypes);
    warnings.push(...storageWarnings);
  }

  await prepareThrottlingAndNetwork(driver.defaultSession, settings);

  log.timeEnd(status);

  return {warnings};
}

ダイアログを開いたら自動で閉じる処理が入っている。

/**
 * Dismiss JavaScript dialogs (alert, confirm, prompt), providing a
 * generic promptText in case the dialog is a prompt.
 * @param {LH.Gatherer.ProtocolSession} session
 * @return {Promise<void>}
 */
async function dismissJavaScriptDialogs(session) {
  session.on('Page.javascriptDialogOpening', data => {
    log.warn('Driver', `${data.type} dialog opened by the page automatically suppressed.`);

    session
      .sendCommand('Page.handleJavaScriptDialog', {
        accept: true,
        promptText: 'Lighthouse prompt response',
      })
      .catch(err => log.warn('Driver', err));
  });

  await session.sendCommand('Page.enable');
}

mizchimizchi
core/gather/driver/execution-context.js
  /**
   * 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.__nativeFetch = window.fetch;
        window.__ElementMatches = window.Element.prototype.matches;
        window.__HTMLElementBoundingClientRect =
          window.HTMLElement.prototype.getBoundingClientRect;
        /* c8 ignore stop */
      },
      { args: [] }
    );
  }

ネイティブオブジェクトを退避しておく

mizchimizchi
/**
 * RequestIdleCallback shim that calculates the remaining deadline time in order to avoid a potential lighthouse
 * penalty for tests run with simulated throttling. Reduces the deadline time to (50 - safetyAllowance) / cpuSlowdownMultiplier to
 * ensure a long task is very unlikely if using the API correctly.
 * @param {number} cpuSlowdownMultiplier
 */
function wrapRequestIdleCallback(cpuSlowdownMultiplier) {
  const safetyAllowanceMs = 10;
  const maxExecutionTimeMs = Math.floor((50 - safetyAllowanceMs) / cpuSlowdownMultiplier);
  const nativeRequestIdleCallback = window.requestIdleCallback;
  window.requestIdleCallback = (cb, options) => {
    /**
     * @type {Parameters<typeof window['requestIdleCallback']>[0]}
     */
    const cbWrap = (deadline) => {
      const start = Date.now();
      // @ts-expect-error - save original on non-standard property.
      deadline.__timeRemaining = deadline.timeRemaining;
      deadline.timeRemaining = () => {
        // @ts-expect-error - access non-standard property.
        const timeRemaining = deadline.__timeRemaining();
        return Math.min(timeRemaining, Math.max(0, maxExecutionTimeMs - (Date.now() - start))
        );
      };
      deadline.timeRemaining.toString = () => {
        return 'function timeRemaining() { [native code] }';
      };
      cb(deadline);
    };
    return nativeRequestIdleCallback(cbWrap, options);
  };
  window.requestIdleCallback.toString = () => {
    return 'function requestIdleCallback() { [native code] }';
  };
}

Copilot に explain させた

関数は、requestIdleCallback API をラップして、Lighthouse のテストでシミュレートされたスロットリング(CPUの遅延)によるペナルティを回避するための関数です。この関数は、requestIdleCallback のコールバックが実行される際の残り時間を調整し、長いタスクが発生しないようにします。

mizchimizchi
/**
 * Reset the storage and warn if any stored data could be affecting the scores.
 * @param {LH.Gatherer.ProtocolSession} session
 * @param {string} url
 * @param {LH.Config.Settings['clearStorageTypes']} clearStorageTypes
 * @return {Promise<{warnings: Array<LH.IcuMessage>}>}
 */
async function resetStorageForUrl(session, url, clearStorageTypes) {
  /** @type {Array<LH.IcuMessage>} */
  const warnings = [];

  const clearDataWarnings = await storage.clearDataForOrigin(session, url, clearStorageTypes);
  warnings.push(...clearDataWarnings);

  const clearCacheWarnings = await storage.clearBrowserCaches(session);
  warnings.push(...clearCacheWarnings);

  const importantStorageWarning = await storage.getImportantStorageWarning(session, url);
  if (importantStorageWarning) warnings.push(importantStorageWarning);

  return {warnings};
}

ストレージデータを消す

/**
 * @param {LH.Gatherer.ProtocolSession} session
 * @param {string} url
 * @param {LH.Config.Settings['clearStorageTypes']} clearStorageTypes
 * @return {Promise<LH.IcuMessage[]>}
 */
async function clearDataForOrigin(session, url, clearStorageTypes) {
  const status = {msg: 'Cleaning origin data', id: 'lh:storage:clearDataForOrigin'};
  log.time(status);

  const warnings = [];

  const origin = new URL(url).origin;

  const typesToClear = clearStorageTypes.join(',');

  // `Storage.clearDataForOrigin` is one of our PROTOCOL_TIMEOUT culprits and this command is also
  // run in the context of PAGE_HUNG to cleanup. We'll keep the timeout low and just warn if it fails.
  session.setNextProtocolTimeout(5000);

  try {
    await session.sendCommand('Storage.clearDataForOrigin', {
      origin: origin,
      storageTypes: typesToClear,
    });
  } catch (err) {
    if (/** @type {LH.LighthouseError} */ (err).code === 'PROTOCOL_TIMEOUT') {
      log.warn('Driver', 'clearDataForOrigin timed out');
      warnings.push(str_(UIStrings.warningOriginDataTimeout));
    } else {
      throw err;
    }
  } finally {
    log.timeEnd(status);
  }

  return warnings;
}