UNPKG

lighthouse

Version:

Automated auditing, performance metrics, and best practices for the web.

572 lines (506 loc) • 19.5 kB
/** * @license * Copyright 2020 Google LLC * SPDX-License-Identifier: Apache-2.0 */ /* global window */ import log from 'lighthouse-logger'; import {LighthouseError} from '../../lib/lh-error.js'; import {ExecutionContext} from './execution-context.js'; /** @typedef {InstanceType<import('./network-monitor.js')['NetworkMonitor']>} NetworkMonitor */ /** @typedef {import('./network-monitor.js').NetworkMonitorEvent} NetworkMonitorEvent */ /** * @template [T=void] * @typedef CancellableWait * @prop {Promise<T>} promise * @prop {() => void} cancel */ /** * @typedef WaitOptions * @prop {number} pauseAfterFcpMs * @prop {number} pauseAfterLoadMs * @prop {number} networkQuietThresholdMs * @prop {number} cpuQuietThresholdMs * @prop {number} maxWaitForLoadedMs * @prop {number|undefined} maxWaitForFcpMs * @prop {{waitForFcp: typeof waitForFcp, waitForLoadEvent: typeof waitForLoadEvent, waitForNetworkIdle: typeof waitForNetworkIdle, waitForCPUIdle: typeof waitForCPUIdle}} [_waitForTestOverrides] */ /** * Returns a promise that resolves immediately. * Used for placeholder conditions that we don't want to start waiting for just yet, but still want * to satisfy the same interface. * @return {{promise: Promise<void>, cancel: function(): void}} */ function waitForNothing() { return {promise: Promise.resolve(), cancel() {}}; } /** * Returns a promise that resolve when a frame has been navigated. * Used for detecting that our about:blank reset has been completed. * @param {LH.Gatherer.ProtocolSession} session * @return {CancellableWait<LH.Crdp.Page.FrameNavigatedEvent>} */ function waitForFrameNavigated(session) { /** @type {(() => void)} */ let cancel = () => { throw new Error('waitForFrameNavigated.cancel() called before it was defined'); }; /** @type {Promise<LH.Crdp.Page.FrameNavigatedEvent>} */ const promise = new Promise((resolve, reject) => { session.once('Page.frameNavigated', resolve); cancel = () => { session.off('Page.frameNavigated', resolve); reject(new Error('Wait for navigated cancelled')); }; }); return {promise, cancel}; } /** * Returns a promise that resolve when a frame has a FCP. * @param {LH.Gatherer.ProtocolSession} session * @param {number} pauseAfterFcpMs * @param {number} maxWaitForFcpMs * @return {CancellableWait} */ function waitForFcp(session, pauseAfterFcpMs, maxWaitForFcpMs) { /** @type {(() => void)} */ let cancel = () => { throw new Error('waitForFcp.cancel() called before it was defined'); }; /** @type {Promise<void>} */ const promise = new Promise((resolve, reject) => { const maxWaitTimeout = setTimeout(() => { reject(new LighthouseError(LighthouseError.errors.NO_FCP)); }, maxWaitForFcpMs); /** @type {NodeJS.Timeout|undefined} */ let loadTimeout; /** @param {LH.Crdp.Page.LifecycleEventEvent} e */ const lifecycleListener = e => { if (e.name === 'firstContentfulPaint') { loadTimeout = setTimeout(() => { resolve(); cancel(); }, pauseAfterFcpMs); } }; session.on('Page.lifecycleEvent', lifecycleListener); let canceled = false; cancel = () => { if (canceled) return; canceled = true; session.off('Page.lifecycleEvent', lifecycleListener); maxWaitTimeout && clearTimeout(maxWaitTimeout); loadTimeout && clearTimeout(loadTimeout); reject(new Error('Wait for FCP canceled')); }; }); return { promise, cancel, }; } /** * Returns a promise that resolves when the network has been idle (after DCL) for * `networkQuietThresholdMs` ms and a method to cancel internal network listeners/timeout. * @param {LH.Gatherer.ProtocolSession} session * @param {NetworkMonitor} networkMonitor * @param {{networkQuietThresholdMs: number, busyEvent: NetworkMonitorEvent, idleEvent: NetworkMonitorEvent, isIdle(recorder: NetworkMonitor): boolean, pretendDCLAlreadyFired?: boolean}} networkQuietOptions * @return {CancellableWait} */ function waitForNetworkIdle(session, networkMonitor, networkQuietOptions) { let hasDCLFired = false; /** @type {NodeJS.Timeout|undefined} */ let idleTimeout; /** @type {(() => void)} */ let cancel = () => { throw new Error('waitForNetworkIdle.cancel() called before it was defined'); }; const {networkQuietThresholdMs, busyEvent, idleEvent, isIdle} = networkQuietOptions; /** @type {Promise<void>} */ const promise = new Promise((resolve, reject) => { const onIdle = () => { networkMonitor.once(busyEvent, onBusy); idleTimeout = setTimeout(() => { cancel(); resolve(); }, networkQuietThresholdMs); }; const onBusy = () => { networkMonitor.once(idleEvent, onIdle); idleTimeout && clearTimeout(idleTimeout); }; const domContentLoadedListener = () => { hasDCLFired = true; if (isIdle(networkMonitor)) { onIdle(); } else { onBusy(); } }; // We frequently need to debug why LH is still waiting for the page. // This listener is added to all network events to verbosely log what URLs we're waiting on. const logStatus = () => { if (!hasDCLFired) { log.verbose('waitFor', 'Waiting on DomContentLoaded'); return; } const inflightRecords = networkMonitor.getInflightRequests(); // If there are more than 20 inflight requests, load is still in full swing. // Wait until it calms down a bit to be a little less spammy. if (log.isVerbose() && inflightRecords.length < 20 && inflightRecords.length > 0) { log.verbose('waitFor', `=== Waiting on ${inflightRecords.length} requests to finish`); for (const record of inflightRecords) { log.verbose('waitFor', `Waiting on ${record.url.slice(0, 120)} to finish`); } } }; networkMonitor.on('requeststarted', logStatus); networkMonitor.on('requestfinished', logStatus); networkMonitor.on(busyEvent, logStatus); if (!networkQuietOptions.pretendDCLAlreadyFired) { session.once('Page.domContentEventFired', domContentLoadedListener); } else { domContentLoadedListener(); } let canceled = false; cancel = () => { if (canceled) return; canceled = true; if (idleTimeout) clearTimeout(idleTimeout); if (!networkQuietOptions.pretendDCLAlreadyFired) { session.off('Page.domContentEventFired', domContentLoadedListener); } networkMonitor.removeListener(busyEvent, onBusy); networkMonitor.removeListener(idleEvent, onIdle); networkMonitor.removeListener('requeststarted', logStatus); networkMonitor.removeListener('requestfinished', logStatus); networkMonitor.removeListener(busyEvent, logStatus); }; }); return { promise, cancel, }; } /** * Resolves when there have been no long tasks for at least waitForCPUQuiet ms. * @param {LH.Gatherer.ProtocolSession} session * @param {number} waitForCPUQuiet * @return {CancellableWait} */ function waitForCPUIdle(session, waitForCPUQuiet) { if (!waitForCPUQuiet) { return { promise: Promise.resolve(), cancel: () => undefined, }; } /** @type {NodeJS.Timeout|undefined} */ let lastTimeout; let canceled = false; /** * @param {ExecutionContext} executionContext * @param {() => void} resolve * @return {Promise<void>} */ async function checkForQuiet(executionContext, resolve) { if (canceled) return; const timeSinceLongTask = await executionContext.evaluate( checkTimeSinceLastLongTaskInPage, {args: [], useIsolation: true}); if (canceled) return; if (typeof timeSinceLongTask === 'number') { if (timeSinceLongTask >= waitForCPUQuiet) { log.verbose('waitFor', `CPU has been idle for ${timeSinceLongTask} ms`); resolve(); } else { log.verbose('waitFor', `CPU has been idle for ${timeSinceLongTask} ms`); const timeToWait = waitForCPUQuiet - timeSinceLongTask; lastTimeout = setTimeout(() => checkForQuiet(executionContext, resolve), timeToWait); } } } /** @type {(() => void)} */ let cancel = () => { throw new Error('waitForCPUIdle.cancel() called before it was defined'); }; const executionContext = new ExecutionContext(session); /** @type {Promise<void>} */ const promise = new Promise((resolve, reject) => { executionContext.evaluate(registerPerformanceObserverInPage, {args: [], useIsolation: true}) .then(() => checkForQuiet(executionContext, resolve)) .catch(reject); cancel = () => { if (canceled) return; canceled = true; if (lastTimeout) clearTimeout(lastTimeout); reject(new Error('Wait for CPU idle canceled')); }; }); return { promise, cancel, }; } /* c8 ignore start */ /** * This function is executed in the page itself when the document is first loaded. * * Used by _waitForCPUIdle and executed in the context of the page, updates the ____lastLongTask * property on window to the end time of the last long task. */ function registerPerformanceObserverInPage() { // Do not re-register if we've already run this script. if (window.____lastLongTask !== undefined) return; window.____lastLongTask = performance.now(); const observer = new window.PerformanceObserver(entryList => { const entries = entryList.getEntries(); for (const entry of entries) { if (entry.entryType === 'longtask') { const taskEnd = entry.startTime + entry.duration; window.____lastLongTask = Math.max(window.____lastLongTask || 0, taskEnd); } } }); observer.observe({type: 'longtask', buffered: true}); } /** * This function is executed in the page itself. * * Used by _waitForCPUIdle and executed in the context of the page, returns time since last long task. * @return {Promise<number>} */ function checkTimeSinceLastLongTaskInPage() { // This function attempts to return the time since the last long task occurred. // `PerformanceObserver`s don't always immediately fire though, so we check twice with some time in // between to make sure nothing has happened very recently. // Chrome 88 introduced heavy throttling of timers which means our `setTimeout` will be executed // 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 Promise(resolve => { const firstAttemptTs = performance.now(); const firstAttemptLastLongTaskTs = window.____lastLongTask || 0; setTimeout(() => { // We can't be sure a long task hasn't occurred since our first attempt, but if the `____lastLongTask` // value is the same (i.e. the perf observer didn't have any new information), we can be pretty // confident that the long task info was accurate *at the time of our first attempt*. const secondAttemptLastLongTaskTs = window.____lastLongTask || 0; const timeSinceLongTask = firstAttemptLastLongTaskTs === secondAttemptLastLongTaskTs ? // The time of the last long task hasn't changed, the information from our first attempt is accurate. firstAttemptTs - firstAttemptLastLongTaskTs : // The time of the last long task *did* change, we can't really trust the information we have. 0; resolve(timeSinceLongTask); }, 150); }); } /* c8 ignore stop */ /** * Return a promise that resolves `pauseAfterLoadMs` after the load event * fires and a method to cancel internal listeners and timeout. * @param {LH.Gatherer.ProtocolSession} session * @param {number} pauseAfterLoadMs * @return {CancellableWait} */ function waitForLoadEvent(session, pauseAfterLoadMs) { /** @type {(() => void)} */ let cancel = () => { throw new Error('waitForLoadEvent.cancel() called before it was defined'); }; const promise = new Promise((resolve, reject) => { /** @type {NodeJS.Timeout|undefined} */ let loadTimeout; const loadListener = function() { loadTimeout = setTimeout(resolve, pauseAfterLoadMs); }; session.once('Page.loadEventFired', loadListener); let canceled = false; cancel = () => { if (canceled) return; canceled = true; session.off('Page.loadEventFired', loadListener); loadTimeout && clearTimeout(loadTimeout); }; }); return { promise, cancel, }; } /** * Returns whether the page appears to be hung. * @param {LH.Gatherer.ProtocolSession} session * @return {Promise<boolean>} */ async function isPageHung(session) { try { session.setNextProtocolTimeout(1000); await session.sendCommand('Runtime.evaluate', { expression: '"ping"', returnByValue: true, timeout: 1000, }); return false; } catch (err) { return true; } } /** @type {Required<WaitOptions>['_waitForTestOverrides']} */ const DEFAULT_WAIT_FUNCTIONS = {waitForFcp, waitForLoadEvent, waitForCPUIdle, waitForNetworkIdle}; /** * Returns a promise that resolves when: * - All of the following conditions have been met: * - page has no security issues * - pauseAfterLoadMs milliseconds have passed since the load event. * - networkQuietThresholdMs milliseconds have passed since the last network request that exceeded * 2 inflight requests (network-2-quiet has been reached). * - cpuQuietThresholdMs have passed since the last long task after network-2-quiet. * - maxWaitForLoadedMs milliseconds have passed. * See https://github.com/GoogleChrome/lighthouse/issues/627 for more. * @param {LH.Gatherer.ProtocolSession} session * @param {NetworkMonitor} networkMonitor * @param {WaitOptions} options * @return {Promise<{timedOut: boolean}>} */ async function waitForFullyLoaded(session, networkMonitor, options) { const {pauseAfterFcpMs, pauseAfterLoadMs, networkQuietThresholdMs, cpuQuietThresholdMs, maxWaitForLoadedMs, maxWaitForFcpMs} = options; const {waitForFcp, waitForLoadEvent, waitForNetworkIdle, waitForCPUIdle} = options._waitForTestOverrides || DEFAULT_WAIT_FUNCTIONS; /** @type {NodeJS.Timeout|undefined} */ let maxTimeoutHandle; // Listener for FCP. Resolves pauseAfterFcpMs ms after first FCP event. const resolveOnFcp = maxWaitForFcpMs ? waitForFcp(session, pauseAfterFcpMs, maxWaitForFcpMs) : waitForNothing(); // Listener for onload. Resolves pauseAfterLoadMs ms after load. const resolveOnLoadEvent = waitForLoadEvent(session, pauseAfterLoadMs); // General network listener. Resolves when the network has been 2-idle for networkQuietThresholdMs. const resolveOnNetworkIdle = waitForNetworkIdle(session, networkMonitor, { networkQuietThresholdMs, busyEvent: 'network-2-busy', idleEvent: 'network-2-idle', isIdle: recorder => recorder.is2Idle(), }); // Critical network listener. Resolves when the network has had 0 critical requests for networkQuietThresholdMs. const resolveOnCriticalNetworkIdle = waitForNetworkIdle(session, networkMonitor, { networkQuietThresholdMs, busyEvent: 'network-critical-busy', idleEvent: 'network-critical-idle', isIdle: recorder => recorder.isCriticalIdle(), }); // CPU listener. Resolves when the CPU has been idle for cpuQuietThresholdMs after network idle. let resolveOnCPUIdle = waitForNothing(); if (log.isVerbose()) { resolveOnFcp.promise.then(() => { log.verbose('waitFor', 'resolveOnFcp fired'); }); resolveOnLoadEvent.promise.then(() => { log.verbose('waitFor', 'resolveOnLoadEvent fired'); }); resolveOnNetworkIdle.promise.then(() => { log.verbose('waitFor', 'resolveOnNetworkIdle fired'); }); resolveOnCriticalNetworkIdle.promise.then(() => { log.verbose('waitFor', 'resolveOnCriticalNetworkIdle fired'); }); } // Wait for all initial load promises. Resolves on cleanup function the clears load // timeout timer. /** @type {Promise<() => Promise<{timedOut: boolean}>>} */ const loadPromise = Promise.all([ resolveOnFcp.promise, resolveOnLoadEvent.promise, resolveOnNetworkIdle.promise, resolveOnCriticalNetworkIdle.promise, ]).then(() => { resolveOnCPUIdle = waitForCPUIdle(session, cpuQuietThresholdMs); return resolveOnCPUIdle.promise; }).then(() => { /** @return {Promise<{timedOut: boolean}>} */ const cleanupFn = async function() { log.verbose('waitFor', 'loadEventFired and network considered idle'); return {timedOut: false}; }; return cleanupFn; }).catch(err => { // Throw the error in the cleanupFn so we still cleanup all our handlers. return function() { throw err; }; }); // Last resort timeout. Resolves maxWaitForLoadedMs ms from now on // cleanup function that removes loadEvent and network idle listeners. /** @type {Promise<() => Promise<{timedOut: boolean}>>} */ const maxTimeoutPromise = new Promise((resolve, reject) => { maxTimeoutHandle = setTimeout(resolve, maxWaitForLoadedMs); }).then(_ => { return async () => { log.warn('waitFor', 'Timed out waiting for page load. Checking if page is hung...'); if (await isPageHung(session)) { log.warn('waitFor', 'Page appears to be hung, killing JavaScript...'); // We don't await these, as we want to exit with PAGE_HUNG void session.sendCommandAndIgnore('Emulation.setScriptExecutionDisabled', {value: true}); void session.sendCommandAndIgnore('Runtime.terminateExecution'); throw new LighthouseError(LighthouseError.errors.PAGE_HUNG); } // Log remaining inflight requests if any. const inflightRequestUrls = networkMonitor .getInflightRequests() .map((request) => request.url); if (inflightRequestUrls.length > 0) { log.warn( 'waitFor', 'Remaining inflight requests URLs', inflightRequestUrls ); } return {timedOut: true}; }; }); // Wait for load or timeout and run the cleanup function the winner returns. const cleanupFn = await Promise.race([ loadPromise, maxTimeoutPromise, ]); maxTimeoutHandle && clearTimeout(maxTimeoutHandle); resolveOnFcp.cancel(); resolveOnLoadEvent.cancel(); resolveOnNetworkIdle.cancel(); resolveOnCPUIdle.cancel(); return cleanupFn(); } /** * @param {LH.Gatherer.Driver} driver */ function waitForUserToContinue(driver) { /* c8 ignore start */ function createInPagePromise() { let resolve = () => {}; /** @type {Promise<void>} */ const promise = new Promise(r => resolve = r); // eslint-disable-next-line no-console console.log([ `You have enabled Lighthouse navigation debug mode.`, `When you have finished inspecting the page, evaluate "continueLighthouseRun()"`, `in the console to continue with the Lighthouse run.`, ].join(' ')); window.continueLighthouseRun = resolve; return promise; } /* c8 ignore stop */ driver.defaultSession.setNextProtocolTimeout(Infinity); return driver.executionContext.evaluate(createInPagePromise, {args: []}); } export { waitForNothing, waitForFrameNavigated, waitForFcp, waitForLoadEvent, waitForNetworkIdle, waitForCPUIdle, waitForFullyLoaded, waitForUserToContinue, };