fix: prevent asset conflicts between React and Grid.js versions

Add coexistence checks to all enqueue methods to prevent loading
both React and Grid.js assets simultaneously.

Changes:
- ReactAdmin.php: Only enqueue React assets when ?react=1
- Init.php: Skip Grid.js when React active on admin pages
- Form.php, Coupon.php, Access.php: Restore classic assets when ?react=0
- Customer.php, Product.php, License.php: Add coexistence checks

Now the toggle between Classic and React versions works correctly.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
dwindown
2026-04-18 17:02:14 +07:00
parent bd9cdac02e
commit e8fbfb14c1
74973 changed files with 6658406 additions and 71 deletions

View File

@@ -0,0 +1,16 @@
/**
* @param {LH.Config.ResolvedConfig} resolvedConfig
* @param {LH.Gatherer.FRTransitionalDriver} driver
* @param {{gatherMode: LH.Gatherer.GatherMode}} context
* @return {Promise<LH.BaseArtifacts>}
*/
export function getBaseArtifacts(resolvedConfig: LH.Config.ResolvedConfig, driver: LH.Gatherer.FRTransitionalDriver, context: {
gatherMode: LH.Gatherer.GatherMode;
}): Promise<LH.BaseArtifacts>;
/**
* @param {LH.FRBaseArtifacts} baseArtifacts
* @param {Partial<LH.Artifacts>} gathererArtifacts
* @return {LH.Artifacts}
*/
export function finalizeArtifacts(baseArtifacts: LH.FRBaseArtifacts, gathererArtifacts: Partial<LH.Artifacts>): LH.Artifacts;
//# sourceMappingURL=base-artifacts.d.ts.map

95
node_modules/lighthouse/core/gather/base-artifacts.js generated vendored Normal file
View File

@@ -0,0 +1,95 @@
/**
* @license Copyright 2021 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
import log from 'lighthouse-logger';
import isDeepEqual from 'lodash/isEqual.js';
import {
getBrowserVersion, getBenchmarkIndex, getEnvironmentWarnings,
} from './driver/environment.js';
/**
* @param {LH.Config.ResolvedConfig} resolvedConfig
* @param {LH.Gatherer.FRTransitionalDriver} 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} = 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',
// Contextual artifacts whose collection changes based on gather mode.
URL: {
finalDisplayedUrl: '',
},
PageLoadError: null,
GatherContext: context,
// Artifacts that have been replaced by regular gatherers in Fraggle Rock.
NetworkUserAgent: '',
traces: {},
devtoolsLogs: {},
};
}
/**
* Deduplicates identical warnings.
* @param {Array<string | LH.IcuMessage>} warnings
* @return {Array<string | LH.IcuMessage>}
*/
function deduplicateWarnings(warnings) {
/** @type {Array<string | LH.IcuMessage>} */
const unique = [];
for (const warning of warnings) {
if (unique.some(existing => isDeepEqual(warning, existing))) continue;
unique.push(warning);
}
return unique;
}
/**
* @param {LH.FRBaseArtifacts} baseArtifacts
* @param {Partial<LH.Artifacts>} gathererArtifacts
* @return {LH.Artifacts}
*/
function finalizeArtifacts(baseArtifacts, gathererArtifacts) {
const warnings = baseArtifacts.LighthouseRunWarnings
.concat(gathererArtifacts.LighthouseRunWarnings || [])
.concat(getEnvironmentWarnings({settings: baseArtifacts.settings, baseArtifacts}));
// Cast to remove the partial from gathererArtifacts.
const artifacts = /** @type {LH.Artifacts} */ ({...baseArtifacts, ...gathererArtifacts});
// Set the post-run meta artifacts.
artifacts.Timing = log.getTimeEntries();
artifacts.LighthouseRunWarnings = deduplicateWarnings(warnings);
if (artifacts.PageLoadError && !artifacts.URL.finalDisplayedUrl) {
artifacts.URL.finalDisplayedUrl = artifacts.URL.requestedUrl || '';
}
// Check that the runner remembered to mutate the special-case URL artifact.
if (!artifacts.URL.finalDisplayedUrl) throw new Error('Runner did not set finalDisplayedUrl');
return artifacts;
}
export {
getBaseArtifacts,
finalizeArtifacts,
};

70
node_modules/lighthouse/core/gather/base-gatherer.d.ts generated vendored Normal file
View File

@@ -0,0 +1,70 @@
export default FRGatherer;
/**
* Base class for all gatherers.
*
* @implements {LH.Gatherer.GathererInstance}
* @implements {LH.Gatherer.FRGathererInstance}
*/
declare class FRGatherer implements LH.Gatherer.GathererInstance, LH.Gatherer.FRGathererInstance {
/** @type {LH.Gatherer.GathererMeta} */
meta: LH.Gatherer.GathererMeta;
/**
* Method to start observing a page for an arbitrary period of time.
* @param {LH.Gatherer.FRTransitionalContext} passContext
* @return {Promise<void>|void}
*/
startInstrumentation(passContext: LH.Gatherer.FRTransitionalContext): Promise<void> | void;
/**
* Method to start observing a page when the measurements are very sensitive and
* should observe as little Lighthouse-induced work as possible.
* @param {LH.Gatherer.FRTransitionalContext} passContext
* @return {Promise<void>|void}
*/
startSensitiveInstrumentation(passContext: LH.Gatherer.FRTransitionalContext): Promise<void> | void;
/**
* Method to stop observing a page when the measurements are very sensitive and
* should observe as little Lighthouse-induced work as possible.
*
* @param {LH.Gatherer.FRTransitionalContext} passContext
* @return {Promise<void>|void}
*/
stopSensitiveInstrumentation(passContext: LH.Gatherer.FRTransitionalContext): Promise<void> | void;
/**
* Method to end observing a page after an arbitrary period of time.
* @param {LH.Gatherer.FRTransitionalContext} passContext
* @return {Promise<void>|void}
*/
stopInstrumentation(passContext: LH.Gatherer.FRTransitionalContext): Promise<void> | void;
/**
* Method to gather results about a page.
* @param {LH.Gatherer.FRTransitionalContext} passContext
* @return {LH.Gatherer.PhaseResult}
*/
getArtifact(passContext: LH.Gatherer.FRTransitionalContext): LH.Gatherer.PhaseResult;
/**
* Legacy property used to define the artifact ID. In Fraggle Rock, the artifact ID lives on the config.
* @return {keyof LH.GathererArtifacts}
*/
get name(): keyof LH.GathererArtifacts;
/**
* Legacy method. Called before navigation to target url, roughly corresponds to `startInstrumentation`.
* @param {LH.Gatherer.PassContext} passContext
* @return {Promise<LH.Gatherer.PhaseResultNonPromise>}
*/
beforePass(passContext: LH.Gatherer.PassContext): Promise<LH.Gatherer.PhaseResultNonPromise>;
/**
* Legacy method. Should never be used by a Fraggle Rock gatherer, here for compat only.
* @param {LH.Gatherer.PassContext} passContext
* @return {LH.Gatherer.PhaseResult}
*/
pass(passContext: LH.Gatherer.PassContext): LH.Gatherer.PhaseResult;
/**
* Legacy method. Roughly corresponds to `stopInstrumentation` or `getArtifact` depending on type of gatherer.
* @param {LH.Gatherer.PassContext} passContext
* @param {LH.Gatherer.LoadData} loadData
* @return {Promise<LH.Gatherer.PhaseResultNonPromise>}
*/
afterPass(passContext: LH.Gatherer.PassContext, loadData: LH.Gatherer.LoadData): Promise<LH.Gatherer.PhaseResultNonPromise>;
}
import * as LH from '../../types/lh.js';
//# sourceMappingURL=base-gatherer.d.ts.map

106
node_modules/lighthouse/core/gather/base-gatherer.js generated vendored Normal file
View File

@@ -0,0 +1,106 @@
/**
* @license Copyright 2021 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
import * as LH from '../../types/lh.js';
/* eslint-disable no-unused-vars */
/**
* Base class for all gatherers.
*
* @implements {LH.Gatherer.GathererInstance}
* @implements {LH.Gatherer.FRGathererInstance}
*/
class FRGatherer {
/** @type {LH.Gatherer.GathererMeta} */
meta = {supportedModes: []};
/**
* Method to start observing a page for an arbitrary period of time.
* @param {LH.Gatherer.FRTransitionalContext} passContext
* @return {Promise<void>|void}
*/
startInstrumentation(passContext) { }
/**
* Method to start observing a page when the measurements are very sensitive and
* should observe as little Lighthouse-induced work as possible.
* @param {LH.Gatherer.FRTransitionalContext} passContext
* @return {Promise<void>|void}
*/
startSensitiveInstrumentation(passContext) { }
/**
* Method to stop observing a page when the measurements are very sensitive and
* should observe as little Lighthouse-induced work as possible.
*
* @param {LH.Gatherer.FRTransitionalContext} passContext
* @return {Promise<void>|void}
*/
stopSensitiveInstrumentation(passContext) { }
/**
* Method to end observing a page after an arbitrary period of time.
* @param {LH.Gatherer.FRTransitionalContext} passContext
* @return {Promise<void>|void}
*/
stopInstrumentation(passContext) { }
/**
* Method to gather results about a page.
* @param {LH.Gatherer.FRTransitionalContext} passContext
* @return {LH.Gatherer.PhaseResult}
*/
getArtifact(passContext) { }
/**
* Legacy property used to define the artifact ID. In Fraggle Rock, the artifact ID lives on the config.
* @return {keyof LH.GathererArtifacts}
*/
get name() {
let name = this.constructor.name;
// Rollup will mangle class names in an known wayjust trim until `$`.
if (name.includes('$')) {
name = name.substr(0, name.indexOf('$'));
}
// @ts-expect-error - assume that class name has been added to LH.GathererArtifacts.
return name;
}
/**
* Legacy method. Called before navigation to target url, roughly corresponds to `startInstrumentation`.
* @param {LH.Gatherer.PassContext} passContext
* @return {Promise<LH.Gatherer.PhaseResultNonPromise>}
*/
async beforePass(passContext) {
await this.startInstrumentation({...passContext, dependencies: {}});
await this.startSensitiveInstrumentation({...passContext, dependencies: {}});
}
/**
* Legacy method. Should never be used by a Fraggle Rock gatherer, here for compat only.
* @param {LH.Gatherer.PassContext} passContext
* @return {LH.Gatherer.PhaseResult}
*/
pass(passContext) { }
/**
* Legacy method. Roughly corresponds to `stopInstrumentation` or `getArtifact` depending on type of gatherer.
* @param {LH.Gatherer.PassContext} passContext
* @param {LH.Gatherer.LoadData} loadData
* @return {Promise<LH.Gatherer.PhaseResultNonPromise>}
*/
async afterPass(passContext, loadData) {
if ('dependencies' in this.meta) {
throw Error('Gatherer with dependencies should override afterPass');
}
await this.stopSensitiveInstrumentation({...passContext, dependencies: {}});
await this.stopInstrumentation({...passContext, dependencies: {}});
return this.getArtifact({...passContext, dependencies: {}});
}
}
export default FRGatherer;

30
node_modules/lighthouse/core/gather/driver.d.ts generated vendored Normal file
View File

@@ -0,0 +1,30 @@
/** @implements {LH.Gatherer.FRTransitionalDriver} */
export class Driver implements LH.Gatherer.FRTransitionalDriver {
/**
* @param {LH.Puppeteer.Page} page
*/
constructor(page: LH.Puppeteer.Page);
_page: import("../../types/puppeteer.js").default.Page;
/** @type {TargetManager|undefined} */
_targetManager: TargetManager | undefined;
/** @type {ExecutionContext|undefined} */
_executionContext: ExecutionContext | undefined;
/** @type {Fetcher|undefined} */
_fetcher: Fetcher | undefined;
defaultSession: import("../../types/gatherer.js").default.FRProtocolSession;
/** @return {LH.Gatherer.FRTransitionalDriver['executionContext']} */
get executionContext(): ExecutionContext;
/** @return {Fetcher} */
get fetcher(): Fetcher;
get targetManager(): any;
/** @return {Promise<string>} */
url(): Promise<string>;
/** @return {Promise<void>} */
connect(): Promise<void>;
/** @return {Promise<void>} */
disconnect(): Promise<void>;
}
import { TargetManager } from './driver/target-manager.js';
import { ExecutionContext } from './driver/execution-context.js';
import { Fetcher } from './fetcher.js';
//# sourceMappingURL=driver.d.ts.map

92
node_modules/lighthouse/core/gather/driver.js generated vendored Normal file
View File

@@ -0,0 +1,92 @@
/**
* @license Copyright 2020 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
import log from 'lighthouse-logger';
import {ExecutionContext} from './driver/execution-context.js';
import {TargetManager} from './driver/target-manager.js';
import {Fetcher} from './fetcher.js';
/** @return {*} */
const throwNotConnectedFn = () => {
throw new Error('Session not connected');
};
/** @type {LH.Gatherer.FRProtocolSession} */
const throwingSession = {
setTargetInfo: throwNotConnectedFn,
hasNextProtocolTimeout: throwNotConnectedFn,
getNextProtocolTimeout: throwNotConnectedFn,
setNextProtocolTimeout: throwNotConnectedFn,
on: throwNotConnectedFn,
once: throwNotConnectedFn,
off: throwNotConnectedFn,
sendCommand: throwNotConnectedFn,
dispose: throwNotConnectedFn,
};
/** @implements {LH.Gatherer.FRTransitionalDriver} */
class Driver {
/**
* @param {LH.Puppeteer.Page} page
*/
constructor(page) {
this._page = page;
/** @type {TargetManager|undefined} */
this._targetManager = undefined;
/** @type {ExecutionContext|undefined} */
this._executionContext = undefined;
/** @type {Fetcher|undefined} */
this._fetcher = undefined;
this.defaultSession = throwingSession;
}
/** @return {LH.Gatherer.FRTransitionalDriver['executionContext']} */
get executionContext() {
if (!this._executionContext) return throwNotConnectedFn();
return this._executionContext;
}
/** @return {Fetcher} */
get fetcher() {
if (!this._fetcher) return throwNotConnectedFn();
return this._fetcher;
}
get targetManager() {
if (!this._targetManager) return throwNotConnectedFn();
return this._targetManager;
}
/** @return {Promise<string>} */
async url() {
return this._page.url();
}
/** @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.defaultSession = this._targetManager.rootSession();
this._executionContext = new ExecutionContext(this.defaultSession);
this._fetcher = new Fetcher(this.defaultSession);
log.timeEnd(status);
}
/** @return {Promise<void>} */
async disconnect() {
if (this.defaultSession === throwingSession) return;
await this._targetManager?.disable();
await this.defaultSession.dispose();
}
}
export {Driver};

20
node_modules/lighthouse/core/gather/driver/dom.d.ts generated vendored Normal file
View File

@@ -0,0 +1,20 @@
/**
* Resolves a backend node ID (from a trace event, protocol, etc) to the object ID for use with
* `Runtime.callFunctionOn`. `undefined` means the node could not be found.
*
* @param {LH.Gatherer.FRProtocolSession} session
* @param {number} backendNodeId
* @return {Promise<string|undefined>}
*/
export function resolveNodeIdToObjectId(session: LH.Gatherer.FRProtocolSession, backendNodeId: number): Promise<string | undefined>;
/**
* Resolves a proprietary devtools node path (created from page-function.js) to the object ID for use
* with `Runtime.callFunctionOn`. `undefined` means the node could not be found.
* Requires `DOM.getDocument` to have been called since the object's creation or it will always be `undefined`.
*
* @param {LH.Gatherer.FRProtocolSession} session
* @param {string} path
* @return {Promise<string|undefined>}
*/
export function resolveDevtoolsNodePathToObjectId(session: LH.Gatherer.FRProtocolSession, path: string): Promise<string | undefined>;
//# sourceMappingURL=dom.d.ts.map

57
node_modules/lighthouse/core/gather/driver/dom.js generated vendored Normal file
View File

@@ -0,0 +1,57 @@
/**
* @license Copyright 2021 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/**
* @param {Error} err
* @return {undefined}
*/
function handlePotentialMissingNodeError(err) {
if (
/No node.*found/.test(err.message) ||
/Node.*does not belong to the document/.test(err.message)
) {
return undefined;
}
throw err;
}
/**
* Resolves a backend node ID (from a trace event, protocol, etc) to the object ID for use with
* `Runtime.callFunctionOn`. `undefined` means the node could not be found.
*
* @param {LH.Gatherer.FRProtocolSession} session
* @param {number} backendNodeId
* @return {Promise<string|undefined>}
*/
async function resolveNodeIdToObjectId(session, backendNodeId) {
try {
const resolveNodeResponse = await session.sendCommand('DOM.resolveNode', {backendNodeId});
return resolveNodeResponse.object.objectId;
} catch (err) {
return handlePotentialMissingNodeError(err);
}
}
/**
* Resolves a proprietary devtools node path (created from page-function.js) to the object ID for use
* with `Runtime.callFunctionOn`. `undefined` means the node could not be found.
* Requires `DOM.getDocument` to have been called since the object's creation or it will always be `undefined`.
*
* @param {LH.Gatherer.FRProtocolSession} session
* @param {string} path
* @return {Promise<string|undefined>}
*/
async function resolveDevtoolsNodePathToObjectId(session, path) {
try {
const {nodeId} = await session.sendCommand('DOM.pushNodeByPathToFrontend', {path});
const {object: {objectId}} = await session.sendCommand('DOM.resolveNode', {nodeId});
return objectId;
} catch (err) {
return handlePotentialMissingNodeError(err);
}
}
export {resolveNodeIdToObjectId, resolveDevtoolsNodePathToObjectId};

View File

@@ -0,0 +1,35 @@
export namespace UIStrings {
const warningSlowHostCpu: string;
}
/**
* @param {LH.Gatherer.FRProtocolSession} session
* @return {Promise<LH.Crdp.Browser.GetVersionResponse & {milestone: number}>}
*/
export function getBrowserVersion(session: LH.Gatherer.FRProtocolSession): Promise<import("devtools-protocol").Protocol.Browser.GetVersionResponse & {
milestone: number;
}>;
/**
* Computes the benchmark index to get a rough estimate of device class.
* @param {LH.Gatherer.FRTransitionalDriver['executionContext']} executionContext
* @return {Promise<number>}
*/
export function getBenchmarkIndex(executionContext: LH.Gatherer.FRTransitionalDriver['executionContext']): Promise<number>;
/**
* Returns a warning if the host device appeared to be underpowered according to BenchmarkIndex.
*
* @param {{settings: LH.Config.Settings; baseArtifacts: Pick<LH.Artifacts, 'BenchmarkIndex'>}} context
* @return {LH.IcuMessage | undefined}
*/
export function getSlowHostCpuWarning(context: {
settings: LH.Config.Settings;
baseArtifacts: Pick<LH.Artifacts, 'BenchmarkIndex'>;
}): LH.IcuMessage | undefined;
/**
* @param {{settings: LH.Config.Settings, baseArtifacts: Pick<LH.Artifacts, 'BenchmarkIndex'>}} context
* @return {Array<LH.IcuMessage>}
*/
export function getEnvironmentWarnings(context: {
settings: LH.Config.Settings;
baseArtifacts: Pick<LH.Artifacts, 'BenchmarkIndex'>;
}): Array<LH.IcuMessage>;
//# sourceMappingURL=environment.d.ts.map

View File

@@ -0,0 +1,105 @@
/**
* @license Copyright 2021 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
import log from 'lighthouse-logger';
import * as constants from '../../config/constants.js';
import {pageFunctions} from '../../lib/page-functions.js';
import * as i18n from '../../lib/i18n/i18n.js';
const UIStrings = {
/**
* @description Warning that the host device where Lighthouse is running appears to have a slower
* CPU than the expected Lighthouse baseline.
*/
warningSlowHostCpu: 'The tested device appears to have a slower CPU than ' +
'Lighthouse expects. This can negatively affect your performance score. Learn more about ' +
'[calibrating an appropriate CPU slowdown multiplier](https://github.com/GoogleChrome/lighthouse/blob/main/docs/throttling.md#cpu-throttling).',
};
/**
* We want to warn when the CPU seemed to be at least ~2x weaker than our regular target device.
* We're starting with a more conservative value that will increase over time to our true target threshold.
* @see https://github.com/GoogleChrome/lighthouse/blob/ccbc8002fd058770d14e372a8301cc4f7d256414/docs/throttling.md#calibrating-multipliers
*/
const SLOW_CPU_BENCHMARK_INDEX_THRESHOLD = 1000;
const str_ = i18n.createIcuMessageFn(import.meta.url, UIStrings);
/**
* @param {LH.Gatherer.FRProtocolSession} session
* @return {Promise<LH.Crdp.Browser.GetVersionResponse & {milestone: number}>}
*/
async function getBrowserVersion(session) {
const status = {msg: 'Getting browser version', id: 'lh:gather:getVersion'};
log.time(status, 'verbose');
const version = await session.sendCommand('Browser.getVersion');
const match = version.product.match(/\/(\d+)/); // eg 'Chrome/71.0.3577.0'
const milestone = match ? parseInt(match[1]) : 0;
log.timeEnd(status);
return Object.assign(version, {milestone});
}
/**
* Computes the benchmark index to get a rough estimate of device class.
* @param {LH.Gatherer.FRTransitionalDriver['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;
}
/**
* Returns a warning if the host device appeared to be underpowered according to BenchmarkIndex.
*
* @param {{settings: LH.Config.Settings; baseArtifacts: Pick<LH.Artifacts, 'BenchmarkIndex'>}} context
* @return {LH.IcuMessage | undefined}
*/
function getSlowHostCpuWarning(context) {
const {settings, baseArtifacts} = context;
const {throttling, throttlingMethod} = settings;
const defaultThrottling = constants.defaultSettings.throttling;
// We only want to warn when the user can take an action to fix it.
// Eventually, this should expand to cover DevTools.
if (settings.channel !== 'cli') return;
// Only warn if they are using the default throttling settings.
const isThrottledMethod = throttlingMethod === 'simulate' || throttlingMethod === 'devtools';
const isDefaultMultiplier =
throttling.cpuSlowdownMultiplier === defaultThrottling.cpuSlowdownMultiplier;
if (!isThrottledMethod || !isDefaultMultiplier) return;
// Only warn if the device didn't meet the threshold.
// See https://github.com/GoogleChrome/lighthouse/blob/main/docs/throttling.md#cpu-throttling
if (baseArtifacts.BenchmarkIndex > SLOW_CPU_BENCHMARK_INDEX_THRESHOLD) return;
return str_(UIStrings.warningSlowHostCpu);
}
/**
* @param {{settings: LH.Config.Settings, baseArtifacts: Pick<LH.Artifacts, 'BenchmarkIndex'>}} context
* @return {Array<LH.IcuMessage>}
*/
function getEnvironmentWarnings(context) {
return [
getSlowHostCpuWarning(context),
].filter(/** @return {s is LH.IcuMessage} */ s => !!s);
}
export {
UIStrings,
getBrowserVersion,
getBenchmarkIndex,
getSlowHostCpuWarning,
getEnvironmentWarnings,
};

View File

@@ -0,0 +1,101 @@
export class ExecutionContext {
/**
* 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: string;
/**
* Serializes an array of arguments for use in an `eval` string across the protocol.
* @param {unknown[]} args
* @return {string}
*/
static serializeArguments(args: unknown[]): string;
/** @param {LH.Gatherer.FRProtocolSession} session */
constructor(session: LH.Gatherer.FRProtocolSession);
_session: LH.Gatherer.FRProtocolSession;
/** @type {number|undefined} */
_executionContextId: number | 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}
*/
_executionContextIdentifiersCreated: number;
/**
* Returns the isolated context ID currently in use.
*/
getContextId(): number | undefined;
/**
* Clears the remembered context ID. Use this method when we have knowledge that the runtime context
* we were using has been destroyed by the browser and is no longer available.
*/
clearContextId(): void;
/**
* Returns the cached isolated execution context ID or creates a new execution context for the main
* frame. The cached execution context is cleared on every gotoURL invocation, so a new one will
* always be created on the first call on a new page.
* @return {Promise<number>}
*/
_getOrCreateIsolatedContextId(): Promise<number>;
/**
* Evaluate an expression in the given execution context; an undefined contextId implies the main
* page without isolation.
* @param {string} expression
* @param {number|undefined} contextId
* @return {Promise<*>}
*/
_evaluateInContext(expression: string, contextId: number | undefined): Promise<any>;
/**
* Note: Prefer `evaluate` instead.
* Evaluate an expression in the context of the current page. If useIsolation is true, the expression
* 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 the expression's value.
* @param {string} expression
* @param {{useIsolation?: boolean}=} options
* @return {Promise<*>}
*/
evaluateAsync(expression: string, options?: {
useIsolation?: boolean;
} | undefined): Promise<any>;
/**
* Evaluate a function in the context of the current page.
* 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 {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
* defined for `mainFn` to work.
* @return {Promise<Awaited<R>>}
*/
evaluate<T extends unknown[], R>(mainFn: (...args: T) => R, options: {
args: T;
useIsolation?: boolean | undefined;
deps?: (string | Function)[] | undefined;
}): Promise<Awaited<R>>;
/**
* 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>}
*/
evaluateOnNewDocument<T_1 extends unknown[]>(mainFn: (...args: T_1) => void, options: {
args: T_1;
deps?: (string | Function)[] | undefined;
}): Promise<void>;
/**
* Cache native functions/objects inside window so we are sure polyfills do not overwrite the
* native implementations when the page loads.
* @return {Promise<void>}
*/
cacheNativesOnNewDocument(): Promise<void>;
}
import * as LH from '../../../types/lh.js';
//# sourceMappingURL=execution-context.d.ts.map

View File

@@ -0,0 +1,270 @@
/**
* @license Copyright 2020 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/* global window */
import * as LH from '../../../types/lh.js';
import {pageFunctions} from '../../lib/page-functions.js';
class ExecutionContext {
/** @param {LH.Gatherer.FRProtocolSession} 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();
}
});
}
/**
* Returns the isolated context ID currently in use.
*/
getContextId() {
return this._executionContextId;
}
/**
* Clears the remembered context ID. Use this method when we have knowledge that the runtime context
* we were using has been destroyed by the browser and is no longer available.
*/
clearContextId() {
this._executionContextId = undefined;
}
/**
* Returns the cached isolated execution context ID or creates a new execution context for the main
* frame. The cached execution context is cleared on every gotoURL invocation, so a new one will
* always be created on the first call on a new page.
* @return {Promise<number>}
*/
async _getOrCreateIsolatedContextId() {
if (typeof this._executionContextId === 'number') return this._executionContextId;
await this._session.sendCommand('Page.enable');
await this._session.sendCommand('Runtime.enable');
const frameTreeResponse = await this._session.sendCommand('Page.getFrameTree');
const mainFrameId = frameTreeResponse.frameTree.frame.id;
const isolatedWorldResponse = await this._session.sendCommand('Page.createIsolatedWorld', {
frameId: mainFrameId,
worldName: 'lighthouse_isolated_context',
});
this._executionContextId = isolatedWorldResponse.executionContextId;
this._executionContextIdentifiersCreated++;
return isolatedWorldResponse.executionContextId;
}
/**
* Evaluate an expression in the given execution context; an undefined contextId implies the main
* page without isolation.
* @param {string} expression
* @param {number|undefined} contextId
* @return {Promise<*>}
*/
async _evaluateInContext(expression, contextId) {
// Use a higher than default timeout if the user hasn't specified a specific timeout.
// Otherwise, use whatever was requested.
const timeout = this._session.hasNextProtocolTimeout() ?
this._session.getNextProtocolTimeout() :
60000;
// `__lighthouseExecutionContextUniqueIdentifier` is only used by the FullPageScreenshot gatherer.
// See `getNodeDetails` in page-functions.
const uniqueExecutionContextIdentifier = contextId === undefined ?
undefined :
this._executionContextIdentifiersCreated;
const evaluationParams = {
// We need to explicitly wrap the raw expression for several purposes:
// 1. Ensure that the expression will be a native Promise and not a polyfill/non-Promise.
// 2. Ensure that errors in the expression are captured by the Promise.
// 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')) === '{}'
//
// `__lighthouseExecutionContextUniqueIdentifier` is only used by the FullPageScreenshot gatherer.
// See `getNodeDetails` in page-functions.
expression: `(function wrapInNativePromise() {
${ExecutionContext._cachedNativesPreamble};
globalThis.__lighthouseExecutionContextUniqueIdentifier =
${uniqueExecutionContextIdentifier};
return new Promise(function (resolve) {
return Promise.resolve()
.then(_ => ${expression})
.catch(${pageFunctions.wrapRuntimeEvalErrorInBrowser})
.then(resolve);
});
}())
//# sourceURL=_lighthouse-eval.js`,
includeCommandLineAPI: true,
awaitPromise: true,
returnByValue: true,
timeout,
contextId,
};
this._session.setNextProtocolTimeout(timeout);
const response = await this._session.sendCommand('Runtime.evaluate', evaluationParams);
const ex = response.exceptionDetails;
if (ex) {
// An error occurred before we could even create a Promise, should be *very* rare.
// Also occurs when the expression is not valid JavaScript.
const elidedExpression = expression.replace(/\s+/g, ' ').substring(0, 100);
const messageLines = [
'Runtime.evaluate exception',
`Expression: ${elidedExpression}\n---- (elided)`,
!ex.stackTrace ? `Parse error at: ${ex.lineNumber + 1}:${ex.columnNumber + 1}` : null,
ex.exception?.description || ex.text,
].filter(Boolean);
const evaluationError = new Error(messageLines.join('\n'));
return Promise.reject(evaluationError);
}
// Protocol should always return a 'result' object, but it is sometimes undefined. See #6026.
if (response.result === undefined) {
return Promise.reject(
new Error('Runtime.evaluate response did not contain a "result" object'));
}
const value = response.result.value;
if (value?.__failedInBrowser) {
return Promise.reject(Object.assign(new Error(), value));
} else {
return value;
}
}
/**
* Note: Prefer `evaluate` instead.
* Evaluate an expression in the context of the current page. If useIsolation is true, the expression
* 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 the expression's value.
* @param {string} expression
* @param {{useIsolation?: boolean}=} options
* @return {Promise<*>}
*/
async evaluateAsync(expression, options = {}) {
const contextId = options.useIsolation ? await this._getOrCreateIsolatedContextId() : undefined;
try {
// `await` is not redundant here because we want to `catch` the async errors
return await this._evaluateInContext(expression, contextId);
} catch (err) {
// If we were using isolation and the context disappeared on us, retry one more time.
if (contextId && err.message.includes('Cannot find context')) {
this.clearContextId();
const freshContextId = await this._getOrCreateIsolatedContextId();
return this._evaluateInContext(expression, freshContextId);
}
throw err;
}
}
/**
* Evaluate a function in the context of the current page.
* 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 {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
* defined for `mainFn` to work.
* @return {Promise<Awaited<R>>}
*/
evaluate(mainFn, options) {
const argsSerialized = ExecutionContext.serializeArguments(options.args);
const depsSerialized = options.deps ? options.deps.join('\n') : '';
const expression = `(() => {
${depsSerialized}
return (${mainFn})(${argsSerialized});
})()`;
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});
})()
//# sourceURL=_lighthouse-eval.js`;
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.__nativeFetch = window.fetch;
window.__ElementMatches = window.Element.prototype.matches;
window.__HTMLElementBoundingClientRect = window.HTMLElement.prototype.getBoundingClientRect;
/* 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',
'const fetch = globalThis.__nativeFetch || globalThis.fetch',
].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(',');
}
}
export {ExecutionContext};

View File

@@ -0,0 +1,33 @@
export type NavigationOptions = {
waitUntil: Array<'fcp' | 'load' | 'navigated'>;
} & LH.Config.SharedPassNavigationJson & Partial<Pick<LH.Config.Settings, 'maxWaitForFcp' | 'maxWaitForLoad' | 'debugNavigation'>>;
/**
* Navigates to the given URL, assuming that the page is not already on this URL.
* Resolves on the url of the loaded page, taking into account any redirects.
* Typical use of this method involves navigating to a neutral page such as `about:blank` in between
* navigations.
*
* @param {LH.Gatherer.FRTransitionalDriver} driver
* @param {LH.NavigationRequestor} requestor
* @param {NavigationOptions} options
* @return {Promise<{requestedUrl: string, mainDocumentUrl: string, warnings: Array<LH.IcuMessage>}>}
*/
export function gotoURL(driver: LH.Gatherer.FRTransitionalDriver, requestor: LH.NavigationRequestor, options: NavigationOptions): Promise<{
requestedUrl: string;
mainDocumentUrl: string;
warnings: Array<LH.IcuMessage>;
}>;
/**
* @param {{timedOut: boolean, requestedUrl: string, mainDocumentUrl: string; }} navigation
* @return {Array<LH.IcuMessage>}
*/
export function getNavigationWarnings(navigation: {
timedOut: boolean;
requestedUrl: string;
mainDocumentUrl: string;
}): Array<LH.IcuMessage>;
export namespace UIStrings {
const warningRedirected: string;
const warningTimeout: string;
}
//# sourceMappingURL=navigation.d.ts.map

View File

@@ -0,0 +1,182 @@
/**
* @license Copyright 2021 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
import log from 'lighthouse-logger';
import {NetworkMonitor} from './network-monitor.js';
import {waitForFullyLoaded, waitForFrameNavigated, waitForUserToContinue} from './wait-for-condition.js'; // eslint-disable-line max-len
import * as constants from '../../config/constants.js';
import * as i18n from '../../lib/i18n/i18n.js';
import UrlUtils from '../../lib/url-utils.js';
const UIStrings = {
/**
* @description Warning that the web page redirected during testing and that may have affected the load.
* @example {https://example.com/requested/page} requested
* @example {https://example.com/final/resolved/page} final
*/
warningRedirected: 'The page may not be loading as expected because your test URL ' +
`({requested}) was redirected to {final}. ` +
'Try testing the second URL directly.',
/**
* @description Warning that Lighthouse timed out while waiting for the page to load.
*/
warningTimeout: 'The page loaded too slowly to finish within the time limit. ' +
'Results may be incomplete.',
};
const str_ = i18n.createIcuMessageFn(import.meta.url, UIStrings);
// Controls how long to wait after FCP before continuing
const DEFAULT_PAUSE_AFTER_FCP = 0;
// Controls how long to wait after onLoad before continuing
const DEFAULT_PAUSE_AFTER_LOAD = 0;
// Controls how long to wait between network requests before determining the network is quiet
const DEFAULT_NETWORK_QUIET_THRESHOLD = 5000;
// Controls how long to wait between longtasks before determining the CPU is idle, off by default
const DEFAULT_CPU_QUIET_THRESHOLD = 0;
/** @typedef {{waitUntil: Array<'fcp'|'load'|'navigated'>} & LH.Config.SharedPassNavigationJson & Partial<Pick<LH.Config.Settings, 'maxWaitForFcp'|'maxWaitForLoad'|'debugNavigation'>>} NavigationOptions */
/** @param {NavigationOptions} options */
function resolveWaitForFullyLoadedOptions(options) {
/* eslint-disable max-len */
let {pauseAfterFcpMs, pauseAfterLoadMs, networkQuietThresholdMs, cpuQuietThresholdMs} = options;
let maxWaitMs = options.maxWaitForLoad;
let maxFCPMs = options.maxWaitForFcp;
if (typeof pauseAfterFcpMs !== 'number') pauseAfterFcpMs = DEFAULT_PAUSE_AFTER_FCP;
if (typeof pauseAfterLoadMs !== 'number') pauseAfterLoadMs = DEFAULT_PAUSE_AFTER_LOAD;
if (typeof networkQuietThresholdMs !== 'number') {
networkQuietThresholdMs = DEFAULT_NETWORK_QUIET_THRESHOLD;
}
if (typeof cpuQuietThresholdMs !== 'number') cpuQuietThresholdMs = DEFAULT_CPU_QUIET_THRESHOLD;
if (typeof maxWaitMs !== 'number') maxWaitMs = constants.defaultSettings.maxWaitForLoad;
if (typeof maxFCPMs !== 'number') maxFCPMs = constants.defaultSettings.maxWaitForFcp;
/* eslint-enable max-len */
if (!options.waitUntil.includes('fcp')) maxFCPMs = undefined;
return {
pauseAfterFcpMs,
pauseAfterLoadMs,
networkQuietThresholdMs,
cpuQuietThresholdMs,
maxWaitForLoadedMs: maxWaitMs,
maxWaitForFcpMs: maxFCPMs,
};
}
/**
* Navigates to the given URL, assuming that the page is not already on this URL.
* Resolves on the url of the loaded page, taking into account any redirects.
* Typical use of this method involves navigating to a neutral page such as `about:blank` in between
* navigations.
*
* @param {LH.Gatherer.FRTransitionalDriver} driver
* @param {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 = new NetworkMonitor(driver.targetManager);
// Enable the events and network monitor needed to track navigation progress.
await networkMonitor.enable();
await session.sendCommand('Page.enable');
await session.sendCommand('Page.setLifecycleEventsEnabled', {enabled: true});
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.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;
await networkMonitor.disable();
if (options.debugNavigation) {
await waitForUserToContinue(driver);
}
log.timeEnd(status);
return {
requestedUrl,
mainDocumentUrl,
warnings: getNavigationWarnings({timedOut, mainDocumentUrl, requestedUrl}),
};
}
/**
* @param {{timedOut: boolean, requestedUrl: string, mainDocumentUrl: string; }} navigation
* @return {Array<LH.IcuMessage>}
*/
function getNavigationWarnings(navigation) {
const {requestedUrl, mainDocumentUrl} = navigation;
/** @type {Array<LH.IcuMessage>} */
const warnings = [];
if (navigation.timedOut) warnings.push(str_(UIStrings.warningTimeout));
if (!UrlUtils.equalWithExcludedFragments(requestedUrl, mainDocumentUrl)) {
warnings.push(str_(UIStrings.warningRedirected, {
requested: requestedUrl,
final: mainDocumentUrl,
}));
}
return warnings;
}
export {gotoURL, getNavigationWarnings, UIStrings};

View File

@@ -0,0 +1,82 @@
export type NetworkRecorderEventMap = import('../../lib/network-recorder.js').NetworkRecorderEventMap;
export type NetworkMonitorEvent_ = 'network-2-idle' | 'network-critical-idle' | 'networkidle' | 'networkbusy' | 'network-critical-busy' | 'network-2-busy';
export type NetworkMonitorEventMap = Record<NetworkMonitorEvent_, []> & NetworkRecorderEventMap;
export type NetworkMonitorEvent = keyof NetworkMonitorEventMap;
export type NetworkMonitorEmitter = LH.Protocol.StrictEventEmitterClass<NetworkMonitorEventMap>;
declare const NetworkMonitor_base: NetworkMonitorEmitter;
export class NetworkMonitor extends NetworkMonitor_base {
/**
* Finds all time periods where the number of inflight requests is less than or equal to the
* number of allowed concurrent requests.
* @param {Array<LH.Artifacts.NetworkRequest>} requests
* @param {number} allowedConcurrentRequests
* @param {number=} endTime
* @return {Array<{start: number, end: number}>}
*/
static findNetworkQuietPeriods(requests: Array<LH.Artifacts.NetworkRequest>, allowedConcurrentRequests: number, endTime?: number | undefined): Array<{
start: number;
end: number;
}>;
/** @param {LH.Gatherer.FRTransitionalDriver['targetManager']} targetManager */
constructor(targetManager: LH.Gatherer.FRTransitionalDriver['targetManager']);
/** @type {NetworkRecorder|undefined} */
_networkRecorder: NetworkRecorder | undefined;
/** @type {Array<LH.Crdp.Page.Frame>} */
_frameNavigations: Array<LH.Crdp.Page.Frame>;
/** @type {LH.Gatherer.FRTransitionalDriver['targetManager']} */
_targetManager: LH.Gatherer.FRTransitionalDriver['targetManager'];
/** @type {LH.Gatherer.FRProtocolSession} */
_session: LH.Gatherer.FRProtocolSession;
/** @param {LH.Crdp.Page.FrameNavigatedEvent} event */
_onFrameNavigated: (event: LH.Crdp.Page.FrameNavigatedEvent) => number;
/** @param {LH.Protocol.RawEventMessage} event */
_onProtocolMessage: (event: LH.Protocol.RawEventMessage) => void;
/**
* @return {Promise<void>}
*/
enable(): Promise<void>;
/**
* @return {Promise<void>}
*/
disable(): Promise<void>;
/** @return {Promise<{requestedUrl?: string, mainDocumentUrl?: string}>} */
getNavigationUrls(): Promise<{
requestedUrl?: string | undefined;
mainDocumentUrl?: string | undefined;
}>;
/**
* @return {Array<NetworkRequest>}
*/
getInflightRequests(): Array<NetworkRequest>;
/**
* Returns whether the network is completely idle (i.e. there are 0 inflight network requests).
*/
isIdle(): boolean;
/**
* Returns whether any important resources for the page are in progress.
* Above-the-fold images and XHRs should be included.
* Tracking pixels, low priority images, and cross frame requests should be excluded.
* @return {boolean}
*/
isCriticalIdle(): boolean;
/**
* Returns whether the network is semi-idle (i.e. there are 2 or fewer inflight network requests).
*/
is2Idle(): boolean;
/**
* Returns whether the number of currently inflight requests is less than or
* equal to the number of allowed concurrent requests.
* @param {number} allowedRequests
* @param {(request: NetworkRequest) => boolean} [requestFilter]
* @return {boolean}
*/
_isActiveIdlePeriod(allowedRequests: number, requestFilter?: ((request: NetworkRequest) => boolean) | undefined): boolean;
/**
* Emits the appropriate network status event.
*/
_emitNetworkStatus(): void;
}
import { NetworkRecorder } from '../../lib/network-recorder.js';
import { NetworkRequest } from '../../lib/network-request.js';
export {};
//# sourceMappingURL=network-monitor.d.ts.map

View File

@@ -0,0 +1,258 @@
/**
* @license Copyright 2021 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/**
* @fileoverview This class wires up the procotol to a network recorder and provides overall
* status inspection state.
*/
import {EventEmitter} from 'events';
import log from 'lighthouse-logger';
import {NetworkRecorder} from '../../lib/network-recorder.js';
import {NetworkRequest} from '../../lib/network-request.js';
import UrlUtils from '../../lib/url-utils.js';
/** @typedef {import('../../lib/network-recorder.js').NetworkRecorderEventMap} NetworkRecorderEventMap */
/** @typedef {'network-2-idle'|'network-critical-idle'|'networkidle'|'networkbusy'|'network-critical-busy'|'network-2-busy'} NetworkMonitorEvent_ */
/** @typedef {Record<NetworkMonitorEvent_, []> & NetworkRecorderEventMap} NetworkMonitorEventMap */
/** @typedef {keyof NetworkMonitorEventMap} NetworkMonitorEvent */
/** @typedef {LH.Protocol.StrictEventEmitterClass<NetworkMonitorEventMap>} NetworkMonitorEmitter */
const NetworkMonitorEventEmitter = /** @type {NetworkMonitorEmitter} */ (EventEmitter);
class NetworkMonitor extends NetworkMonitorEventEmitter {
/** @type {NetworkRecorder|undefined} */
_networkRecorder = undefined;
/** @type {Array<LH.Crdp.Page.Frame>} */
_frameNavigations = [];
// TODO(FR-COMPAT): switch to real TargetManager when legacy removed.
/** @param {LH.Gatherer.FRTransitionalDriver['targetManager']} targetManager */
constructor(targetManager) {
super();
/** @type {LH.Gatherer.FRTransitionalDriver['targetManager']} */
this._targetManager = targetManager;
/** @type {LH.Gatherer.FRProtocolSession} */
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);
}
/**
* @return {Promise<void>}
*/
async disable() {
if (!this._networkRecorder) return;
this._session.off('Page.frameNavigated', this._onFrameNavigated);
this._targetManager.off('protocolevent', this._onProtocolMessage);
this._frameNavigations = [];
this._networkRecorder = undefined;
}
/** @return {Promise<{requestedUrl?: string, mainDocumentUrl?: string}>} */
async getNavigationUrls() {
const frameNavigations = this._frameNavigations;
if (!frameNavigations.length) return {};
const mainFrameNavigations = frameNavigations.filter(frame => !frame.parentId);
if (!mainFrameNavigations.length) log.warn('NetworkMonitor', 'No detected navigations');
// The requested URL is the initiator request for the first frame navigation.
/** @type {string|undefined} */
let requestedUrl = mainFrameNavigations[0]?.url;
if (this._networkRecorder) {
const records = this._networkRecorder.getRawRecords();
let initialUrlRequest = records.find(record => record.url === requestedUrl);
while (initialUrlRequest?.redirectSource) {
initialUrlRequest = initialUrlRequest.redirectSource;
requestedUrl = initialUrlRequest.url;
}
}
return {
requestedUrl,
mainDocumentUrl: mainFrameNavigations[mainFrameNavigations.length - 1]?.url,
};
}
/**
* @return {Array<NetworkRequest>}
*/
getInflightRequests() {
if (!this._networkRecorder) return [];
return this._networkRecorder.getRawRecords().filter(request => !request.finished);
}
/**
* Returns whether the network is completely idle (i.e. there are 0 inflight network requests).
*/
isIdle() {
return this._isActiveIdlePeriod(0);
}
/**
* Returns whether any important resources for the page are in progress.
* Above-the-fold images and XHRs should be included.
* Tracking pixels, low priority images, and cross frame requests should be excluded.
* @return {boolean}
*/
isCriticalIdle() {
if (!this._networkRecorder) return false;
const requests = this._networkRecorder.getRawRecords();
const rootFrameRequest = requests.find(r => r.resourceType === 'Document');
const rootFrameId = rootFrameRequest?.frameId;
return this._isActiveIdlePeriod(
0,
request =>
request.frameId === rootFrameId &&
(request.priority === 'VeryHigh' || request.priority === 'High')
);
}
/**
* Returns whether the network is semi-idle (i.e. there are 2 or fewer inflight network requests).
*/
is2Idle() {
return this._isActiveIdlePeriod(2);
}
/**
* Returns whether the number of currently inflight requests is less than or
* equal to the number of allowed concurrent requests.
* @param {number} allowedRequests
* @param {(request: NetworkRequest) => boolean} [requestFilter]
* @return {boolean}
*/
_isActiveIdlePeriod(allowedRequests, requestFilter) {
if (!this._networkRecorder) return false;
const requests = this._networkRecorder.getRawRecords();
let inflightRequests = 0;
for (let i = 0; i < requests.length; i++) {
const request = requests[i];
if (request.finished) continue;
if (requestFilter && !requestFilter(request)) continue;
if (NetworkRequest.isNonNetworkRequest(request)) continue;
inflightRequests++;
}
return inflightRequests <= allowedRequests;
}
/**
* Emits the appropriate network status event.
*/
_emitNetworkStatus() {
const zeroQuiet = this.isIdle();
const twoQuiet = this.is2Idle();
const criticalQuiet = this.isCriticalIdle();
this.emit(zeroQuiet ? 'networkidle' : 'networkbusy');
this.emit(twoQuiet ? 'network-2-idle' : 'network-2-busy');
this.emit(criticalQuiet ? 'network-critical-idle' : 'network-critical-busy');
if (twoQuiet && zeroQuiet) log.verbose('NetworkRecorder', 'network fully-quiet');
else if (twoQuiet && !zeroQuiet) log.verbose('NetworkRecorder', 'network semi-quiet');
else log.verbose('NetworkRecorder', 'network busy');
}
/**
* Finds all time periods where the number of inflight requests is less than or equal to the
* number of allowed concurrent requests.
* @param {Array<LH.Artifacts.NetworkRequest>} requests
* @param {number} allowedConcurrentRequests
* @param {number=} endTime
* @return {Array<{start: number, end: number}>}
*/
static findNetworkQuietPeriods(requests, allowedConcurrentRequests, endTime = Infinity) {
// First collect the timestamps of when requests start and end
/** @type {Array<{time: number, isStart: boolean}>} */
let timeBoundaries = [];
requests.forEach(request => {
if (UrlUtils.isNonNetworkProtocol(request.protocol)) return;
if (request.protocol === 'ws' || request.protocol === 'wss') return;
// convert the network timestamp to ms
timeBoundaries.push({time: request.networkRequestTime * 1000, isStart: true});
if (request.finished) {
timeBoundaries.push({time: request.networkEndTime * 1000, isStart: false});
}
});
timeBoundaries = timeBoundaries
.filter(boundary => boundary.time <= endTime)
.sort((a, b) => a.time - b.time);
let numInflightRequests = 0;
let quietPeriodStart = 0;
/** @type {Array<{start: number, end: number}>} */
const quietPeriods = [];
timeBoundaries.forEach(boundary => {
if (boundary.isStart) {
// we've just started a new request. are we exiting a quiet period?
if (numInflightRequests === allowedConcurrentRequests) {
quietPeriods.push({start: quietPeriodStart, end: boundary.time});
}
numInflightRequests++;
} else {
numInflightRequests--;
// we've just completed a request. are we entering a quiet period?
if (numInflightRequests === allowedConcurrentRequests) {
quietPeriodStart = boundary.time;
}
}
});
// Check we ended in a quiet period
if (numInflightRequests <= allowedConcurrentRequests) {
quietPeriods.push({start: quietPeriodStart, end: endTime});
}
return quietPeriods.filter(period => period.start !== period.end);
}
}
export {NetworkMonitor};

View File

@@ -0,0 +1,10 @@
/**
* Return the body of the response with the given ID. Rejects if getting the
* body times out.
* @param {LH.Gatherer.FRProtocolSession} session
* @param {string} requestId
* @param {number} [timeout]
* @return {Promise<string>}
*/
export function fetchResponseBodyFromCache(session: LH.Gatherer.FRProtocolSession, requestId: string, timeout?: number | undefined): Promise<string>;
//# sourceMappingURL=network.d.ts.map

27
node_modules/lighthouse/core/gather/driver/network.js generated vendored Normal file
View File

@@ -0,0 +1,27 @@
/**
* @license Copyright 2021 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
import {NetworkRequest} from '../../lib/network-request.js';
/**
* Return the body of the response with the given ID. Rejects if getting the
* body times out.
* @param {LH.Gatherer.FRProtocolSession} session
* @param {string} requestId
* @param {number} [timeout]
* @return {Promise<string>}
*/
async function fetchResponseBodyFromCache(session, requestId, timeout = 1000) {
requestId = NetworkRequest.getRequestIdForBackend(requestId);
// Encoding issues may lead to hanging getResponseBody calls: https://github.com/GoogleChrome/lighthouse/pull/4718
// session.sendCommand will handle timeout after 1s.
session.setNextProtocolTimeout(timeout);
const result = await session.sendCommand('Network.getResponseBody', {requestId});
return result.body;
}
export {fetchResponseBodyFromCache};

View File

@@ -0,0 +1,52 @@
/**
* Prepares a target for observational analysis by setting throttling and network headers/blocked patterns.
*
* This method assumes `prepareTargetForNavigationMode` or `prepareTargetForTimespanMode` has already been invoked.
*
* @param {LH.Gatherer.FRProtocolSession} session
* @param {LH.Config.Settings} settings
* @param {{disableThrottling: boolean, blockedUrlPatterns?: string[]}} options
*/
export function prepareThrottlingAndNetwork(session: LH.Gatherer.FRProtocolSession, settings: LH.Config.Settings, options: {
disableThrottling: boolean;
blockedUrlPatterns?: string[];
}): Promise<void>;
/**
* Prepares a target to be analyzed in timespan mode by enabling protocol domains, emulation, and throttling.
*
* @param {LH.Gatherer.FRTransitionalDriver} driver
* @param {LH.Config.Settings} settings
*/
export function prepareTargetForTimespanMode(driver: LH.Gatherer.FRTransitionalDriver, settings: LH.Config.Settings): Promise<void>;
/**
* 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.FRTransitionalDriver} driver
* @param {LH.Config.Settings} settings
*/
export function prepareTargetForNavigationMode(driver: LH.Gatherer.FRTransitionalDriver, settings: LH.Config.Settings): Promise<void>;
/**
* Prepares a target for a particular navigation by resetting storage and setting network.
*
* This method assumes `prepareTargetForNavigationMode` has already been invoked.
*
* @param {LH.Gatherer.FRProtocolSession} session
* @param {LH.Config.Settings} settings
* @param {Pick<LH.Config.NavigationDefn, 'disableThrottling'|'disableStorageReset'|'blockedUrlPatterns'> & {requestor: LH.NavigationRequestor}} navigation
* @return {Promise<{warnings: Array<LH.IcuMessage>}>}
*/
export function prepareTargetForIndividualNavigation(session: LH.Gatherer.FRProtocolSession, settings: LH.Config.Settings, navigation: Pick<import("../../../types/config.js").default.NavigationDefn, "blockedUrlPatterns" | "disableStorageReset" | "disableThrottling"> & {
requestor: LH.NavigationRequestor;
}): Promise<{
warnings: Array<LH.IcuMessage>;
}>;
/**
* Enables `Debugger` domain to receive async stacktrace information on network request initiators.
* This is critical for tracking attribution of tasks and performance simulation accuracy.
* @param {LH.Gatherer.FRProtocolSession} session
*/
export function enableAsyncStacks(session: LH.Gatherer.FRProtocolSession): Promise<() => Promise<void>>;
//# sourceMappingURL=prepare.d.ts.map

259
node_modules/lighthouse/core/gather/driver/prepare.js generated vendored Normal file
View File

@@ -0,0 +1,259 @@
/**
* @license Copyright 2021 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
import log from 'lighthouse-logger';
import * as storage from './storage.js';
import * as emulation from '../../lib/emulation.js';
import {pageFunctions} from '../../lib/page-functions.js';
/**
* Enables `Debugger` domain to receive async stacktrace information on network request initiators.
* This is critical for tracking attribution of tasks and performance simulation accuracy.
* @param {LH.Gatherer.FRProtocolSession} session
*/
async function enableAsyncStacks(session) {
const enable = async () => {
await session.sendCommand('Debugger.enable');
await session.sendCommand('Debugger.setSkipAllPauses', {skip: true});
await session.sendCommand('Debugger.setAsyncCallStackDepth', {maxDepth: 8});
};
/**
* Resume any pauses that make it through `setSkipAllPauses`
*/
function onDebuggerPaused() {
session.sendCommand('Debugger.resume');
}
/**
* `Debugger.setSkipAllPauses` is reset after every navigation, so retrigger it on main frame navigations.
* See https://bugs.chromium.org/p/chromium/issues/detail?id=990945&q=setSkipAllPauses&can=2
* @param {LH.Crdp.Page.FrameNavigatedEvent} event
*/
function onFrameNavigated(event) {
if (event.frame.parentId) return;
enable().catch(err => log.error('Driver', err));
}
session.on('Debugger.paused', onDebuggerPaused);
session.on('Page.frameNavigated', onFrameNavigated);
await enable();
return async () => {
await session.sendCommand('Debugger.disable');
session.off('Debugger.paused', onDebuggerPaused);
session.off('Page.frameNavigated', onFrameNavigated);
};
}
/**
* Use a RequestIdleCallback shim for tests run with simulated throttling, so that the deadline can be used without
* a penalty.
* @param {LH.Gatherer.FRTransitionalDriver} driver
* @param {LH.Config.Settings} settings
* @return {Promise<void>}
*/
async function shimRequestIdleCallbackOnNewDocument(driver, settings) {
await driver.executionContext.evaluateOnNewDocument(pageFunctions.wrapRequestIdleCallback, {
args: [settings.throttling.cpuSlowdownMultiplier],
});
}
/**
* Dismiss JavaScript dialogs (alert, confirm, prompt), providing a
* generic promptText in case the dialog is a prompt.
* @param {LH.Gatherer.FRProtocolSession} 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');
}
/**
* Reset the storage and warn if any stored data could be affecting the scores.
* @param {LH.Gatherer.FRProtocolSession} session
* @param {string} url
* @return {Promise<{warnings: Array<LH.IcuMessage>}>}
*/
async function resetStorageForUrl(session, url) {
/** @type {Array<LH.IcuMessage>} */
const warnings = [];
const importantStorageWarning = await storage.getImportantStorageWarning(session, url);
if (importantStorageWarning) warnings.push(importantStorageWarning);
const clearDataWarnings = await storage.clearDataForOrigin(session, url);
warnings.push(...clearDataWarnings);
const clearCacheWarnings = await storage.clearBrowserCaches(session);
warnings.push(...clearCacheWarnings);
return {warnings};
}
/**
* Prepares a target for observational analysis by setting throttling and network headers/blocked patterns.
*
* This method assumes `prepareTargetForNavigationMode` or `prepareTargetForTimespanMode` has already been invoked.
*
* @param {LH.Gatherer.FRProtocolSession} session
* @param {LH.Config.Settings} settings
* @param {{disableThrottling: boolean, blockedUrlPatterns?: string[]}} options
*/
async function prepareThrottlingAndNetwork(session, settings, options) {
const status = {msg: 'Preparing network conditions', id: `lh:gather:prepareThrottlingAndNetwork`};
log.time(status);
if (options.disableThrottling) await emulation.clearThrottling(session);
else await emulation.throttle(session, settings);
// Set request blocking before any network activity.
// No "clearing" is done at the end of the recording since Network.setBlockedURLs([]) will unset all if
// neccessary at the beginning of the next section.
const blockedUrls = (options.blockedUrlPatterns || []).concat(
settings.blockedUrlPatterns || []
);
await session.sendCommand('Network.setBlockedURLs', {urls: blockedUrls});
const headers = settings.extraHeaders;
if (headers) await session.sendCommand('Network.setExtraHTTPHeaders', {headers});
log.timeEnd(status);
}
/**
* Prepares a target to be analyzed by setting up device emulation (screen/UA, not throttling) and
* async stack traces for network initiators.
*
* @param {LH.Gatherer.FRTransitionalDriver} driver
* @param {LH.Config.Settings} settings
*/
async function prepareDeviceEmulation(driver, settings) {
// Enable network domain here so future calls to `emulate()` don't clear cache (https://github.com/GoogleChrome/lighthouse/issues/12631)
await driver.defaultSession.sendCommand('Network.enable');
// Emulate our target device screen and user agent.
await emulation.emulate(driver.defaultSession, settings);
}
/**
* Prepares a target to be analyzed in timespan mode by enabling protocol domains, emulation, and throttling.
*
* @param {LH.Gatherer.FRTransitionalDriver} driver
* @param {LH.Config.Settings} settings
*/
async function prepareTargetForTimespanMode(driver, settings) {
const status = {msg: 'Preparing target for timespan mode', id: 'lh:prepare:timespanMode'};
log.time(status);
await prepareDeviceEmulation(driver, settings);
await prepareThrottlingAndNetwork(driver.defaultSession, settings, {
disableThrottling: false,
blockedUrlPatterns: undefined,
});
await warmUpIntlSegmenter(driver);
log.timeEnd(status);
}
/**
* Ensure the `Intl.Segmenter` created in `pageFunctions.truncate` is cached by v8 before
* recording the trace begins.
*
* @param {LH.Gatherer.FRTransitionalDriver} driver
*/
async function warmUpIntlSegmenter(driver) {
await driver.executionContext.evaluate(pageFunctions.truncate, {
args: ['aaa', 2],
});
}
/**
* 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.FRTransitionalDriver} driver
* @param {LH.Config.Settings} settings
*/
async function prepareTargetForNavigationMode(driver, settings) {
const status = {msg: 'Preparing target for navigation mode', id: 'lh:prepare:navigationMode'};
log.time(status);
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);
log.timeEnd(status);
}
/**
* Prepares a target for a particular navigation by resetting storage and setting network.
*
* This method assumes `prepareTargetForNavigationMode` has already been invoked.
*
* @param {LH.Gatherer.FRProtocolSession} session
* @param {LH.Config.Settings} settings
* @param {Pick<LH.Config.NavigationDefn, 'disableThrottling'|'disableStorageReset'|'blockedUrlPatterns'> & {requestor: LH.NavigationRequestor}} navigation
* @return {Promise<{warnings: Array<LH.IcuMessage>}>}
*/
async function prepareTargetForIndividualNavigation(session, settings, navigation) {
const status = {msg: 'Preparing target for navigation', id: 'lh:prepare:navigation'};
log.time(status);
/** @type {Array<LH.IcuMessage>} */
const warnings = [];
const {requestor} = navigation;
const shouldResetStorage =
!settings.disableStorageReset &&
!navigation.disableStorageReset &&
// Without prior knowledge of the destination, we cannot know which URL to clear storage for.
typeof requestor === 'string';
if (shouldResetStorage) {
const requestedUrl = requestor;
const {warnings: storageWarnings} = await resetStorageForUrl(session, requestedUrl);
warnings.push(...storageWarnings);
}
await prepareThrottlingAndNetwork(session, settings, navigation);
log.timeEnd(status);
return {warnings};
}
export {
prepareThrottlingAndNetwork,
prepareTargetForTimespanMode,
prepareTargetForNavigationMode,
prepareTargetForIndividualNavigation,
enableAsyncStacks,
};

View File

@@ -0,0 +1,16 @@
/**
* @license Copyright 2021 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/**
* @param {LH.Gatherer.FRProtocolSession} session
* @return {Promise<LH.Crdp.ServiceWorker.WorkerVersionUpdatedEvent>}
*/
export function getServiceWorkerVersions(session: LH.Gatherer.FRProtocolSession): Promise<LH.Crdp.ServiceWorker.WorkerVersionUpdatedEvent>;
/**
* @param {LH.Gatherer.FRProtocolSession} session
* @return {Promise<LH.Crdp.ServiceWorker.WorkerRegistrationUpdatedEvent>}
*/
export function getServiceWorkerRegistrations(session: LH.Gatherer.FRProtocolSession): Promise<LH.Crdp.ServiceWorker.WorkerRegistrationUpdatedEvent>;
//# sourceMappingURL=service-workers.d.ts.map

View File

@@ -0,0 +1,52 @@
/**
* @license Copyright 2021 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/**
* @param {LH.Gatherer.FRProtocolSession} session
* @return {Promise<LH.Crdp.ServiceWorker.WorkerVersionUpdatedEvent>}
*/
function getServiceWorkerVersions(session) {
return new Promise((resolve, reject) => {
/**
* @param {LH.Crdp.ServiceWorker.WorkerVersionUpdatedEvent} data
*/
const versionUpdatedListener = data => {
// find a service worker with runningStatus that looks like active
// on slow connections the serviceworker might still be installing
const activateCandidates = data.versions.filter(sw => {
return sw.status !== 'redundant';
});
const hasActiveServiceWorker = activateCandidates.find(sw => {
return sw.status === 'activated';
});
if (!activateCandidates.length || hasActiveServiceWorker) {
session.off('ServiceWorker.workerVersionUpdated', versionUpdatedListener);
session.sendCommand('ServiceWorker.disable').then(_ => resolve(data), reject);
}
};
session.on('ServiceWorker.workerVersionUpdated', versionUpdatedListener);
session.sendCommand('ServiceWorker.enable').catch(reject);
});
}
/**
* @param {LH.Gatherer.FRProtocolSession} session
* @return {Promise<LH.Crdp.ServiceWorker.WorkerRegistrationUpdatedEvent>}
*/
function getServiceWorkerRegistrations(session) {
return new Promise((resolve, reject) => {
session.once('ServiceWorker.workerRegistrationUpdated', data => {
session.sendCommand('ServiceWorker.disable').then(_ => resolve(data), reject);
});
session.sendCommand('ServiceWorker.enable').catch(reject);
});
}
export {getServiceWorkerVersions, getServiceWorkerRegistrations};

View File

@@ -0,0 +1,24 @@
/**
* @param {LH.Gatherer.FRProtocolSession} session
* @param {string} url
* @return {Promise<LH.IcuMessage[]>}
*/
export function clearDataForOrigin(session: LH.Gatherer.FRProtocolSession, url: string): Promise<LH.IcuMessage[]>;
/**
* Clear the network cache on disk and in memory.
* @param {LH.Gatherer.FRProtocolSession} session
* @return {Promise<LH.IcuMessage[]>}
*/
export function clearBrowserCaches(session: LH.Gatherer.FRProtocolSession): Promise<LH.IcuMessage[]>;
/**
* @param {LH.Gatherer.FRProtocolSession} session
* @param {string} url
* @return {Promise<LH.IcuMessage | undefined>}
*/
export function getImportantStorageWarning(session: LH.Gatherer.FRProtocolSession, url: string): Promise<LH.IcuMessage | undefined>;
export namespace UIStrings {
const warningData: string;
const warningCacheTimeout: string;
const warningOriginDataTimeout: string;
}
//# sourceMappingURL=storage.d.ts.map

149
node_modules/lighthouse/core/gather/driver/storage.js generated vendored Normal file
View File

@@ -0,0 +1,149 @@
/**
* @license Copyright 2021 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
import log from 'lighthouse-logger';
import * as i18n from '../../lib/i18n/i18n.js';
/* eslint-disable max-len */
const UIStrings = {
/**
* @description A warning that previously-saved data may have affected the measured performance and instructions on how to avoid the problem. "locations" will be a list of possible types of data storage locations, e.g. "IndexedDB", "Local Storage", or "Web SQL".
* @example {IndexedDB, Local Storage} locations
*/
warningData: `{locationCount, plural,
=1 {There may be stored data affecting loading performance in this location: {locations}. ` +
`Audit this page in an incognito window to prevent those resources ` +
`from affecting your scores.}
other {There may be stored data affecting loading ` +
`performance in these locations: {locations}. ` +
`Audit this page in an incognito window to prevent those resources ` +
`from affecting your scores.}
}`,
/** A warning that the data in the browser cache may have affected the measured performance because the operation to clear the browser cache timed out. */
warningCacheTimeout: 'Clearing the browser cache timed out. Try auditing this page again and file a bug if the issue persists.',
/** A warning that the data on the page's origin may have affected the measured performance because the operation to clear the origin data timed out. */
warningOriginDataTimeout: 'Clearing the origin data timed out. Try auditing this page again and file a bug if the issue persists.',
};
/* eslint-enable max-len */
const str_ = i18n.createIcuMessageFn(import.meta.url, UIStrings);
/**
* @param {LH.Gatherer.FRProtocolSession} session
* @param {string} url
* @return {Promise<LH.IcuMessage[]>}
*/
async function clearDataForOrigin(session, url) {
const status = {msg: 'Cleaning origin data', id: 'lh:storage:clearDataForOrigin'};
log.time(status);
const warnings = [];
const origin = new URL(url).origin;
// Clear some types of storage.
// Cookies are not cleared, so the user isn't logged out.
// indexeddb, websql, and localstorage are not cleared to prevent loss of potentially important data.
// https://chromedevtools.github.io/debugger-protocol-viewer/tot/Storage/#type-StorageType
const typesToClear = [
// 'cookies',
'file_systems',
'shader_cache',
'service_workers',
'cache_storage',
].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;
}
/**
* @param {LH.Gatherer.FRProtocolSession} session
* @param {string} url
* @return {Promise<LH.IcuMessage | undefined>}
*/
async function getImportantStorageWarning(session, url) {
const usageData = await session.sendCommand('Storage.getUsageAndQuota', {
origin: url,
});
/** @type {Record<string, string>} */
const storageTypeNames = {
local_storage: 'Local Storage',
indexeddb: 'IndexedDB',
websql: 'Web SQL',
};
const locations = usageData.usageBreakdown
.filter(usage => usage.usage)
.map(usage => storageTypeNames[usage.storageType] || '')
.filter(Boolean);
if (locations.length) {
// TODO(#11495): Use Intl.ListFormat with Node 12
return str_(UIStrings.warningData, {
locations: locations.join(', '),
locationCount: locations.length,
});
}
}
/**
* Clear the network cache on disk and in memory.
* @param {LH.Gatherer.FRProtocolSession} session
* @return {Promise<LH.IcuMessage[]>}
*/
async function clearBrowserCaches(session) {
const status = {msg: 'Cleaning browser cache', id: 'lh:storage:clearBrowserCaches'};
log.time(status);
const warnings = [];
try {
// Wipe entire disk cache
await session.sendCommand('Network.clearBrowserCache');
// Toggle 'Disable Cache' to evict the memory cache
await session.sendCommand('Network.setCacheDisabled', {cacheDisabled: true});
await session.sendCommand('Network.setCacheDisabled', {cacheDisabled: false});
} catch (err) {
if (/** @type {LH.LighthouseError} */ (err).code === 'PROTOCOL_TIMEOUT') {
log.warn('Driver', 'clearBrowserCaches timed out');
warnings.push(str_(UIStrings.warningCacheTimeout));
} else {
throw err;
}
} finally {
log.timeEnd(status);
}
return warnings;
}
export {
clearDataForOrigin,
clearBrowserCaches,
getImportantStorageWarning,
UIStrings,
};

View File

@@ -0,0 +1,75 @@
export type TargetWithSession = {
target: LH.Crdp.Target.TargetInfo;
cdpSession: LH.Puppeteer.CDPSession;
session: LH.Gatherer.FRProtocolSession;
protocolListener: (event: unknown) => void;
};
export type ProtocolEventMap = {
'protocolevent': [LH.Protocol.RawEventMessage];
};
export type ProtocolEventMessageEmitter = LH.Protocol.StrictEventEmitterClass<ProtocolEventMap>;
declare const TargetManager_base: ProtocolEventMessageEmitter;
/**
* Tracks targets (the page itself, its iframes, their iframes, etc) as they
* appear and allows listeners to the flattened protocol events from all targets.
*/
export class TargetManager extends TargetManager_base {
/** @param {LH.Puppeteer.CDPSession} cdpSession */
constructor(cdpSession: LH.Puppeteer.CDPSession);
_enabled: boolean;
_rootCdpSession: import("../../../types/puppeteer.js").default.CDPSession;
_mainFrameId: string;
/**
* A map of target id to target/session information. Used to ensure unique
* attached targets.
* @type {Map<string, TargetWithSession>}
*/
_targetIdToTargets: Map<string, TargetWithSession>;
/** @type {Map<string, LH.Crdp.Runtime.ExecutionContextDescription>} */
_executionContextIdToDescriptions: Map<string, LH.Crdp.Runtime.ExecutionContextDescription>;
/**
* @param {LH.Puppeteer.CDPSession} cdpSession
*/
_onSessionAttached(cdpSession: LH.Puppeteer.CDPSession): Promise<void>;
/**
* @param {LH.Crdp.Page.FrameNavigatedEvent} frameNavigatedEvent
*/
_onFrameNavigated(frameNavigatedEvent: LH.Crdp.Page.FrameNavigatedEvent): Promise<void>;
/**
* @param {LH.Crdp.Runtime.ExecutionContextCreatedEvent} event
*/
_onExecutionContextCreated(event: LH.Crdp.Runtime.ExecutionContextCreatedEvent): void;
/**
* @param {LH.Crdp.Runtime.ExecutionContextDestroyedEvent} event
*/
_onExecutionContextDestroyed(event: LH.Crdp.Runtime.ExecutionContextDestroyedEvent): void;
_onExecutionContextsCleared(): void;
/**
* @param {string} sessionId
* @return {LH.Gatherer.FRProtocolSession}
*/
_findSession(sessionId: string): LH.Gatherer.FRProtocolSession;
/**
* Returns the root session.
* @return {LH.Gatherer.FRProtocolSession}
*/
rootSession(): LH.Gatherer.FRProtocolSession;
mainFrameExecutionContexts(): import("devtools-protocol").Protocol.Runtime.ExecutionContextDescription[];
/**
* 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: LH.Protocol.TargetType, sessionId: string): <EventName extends keyof import("puppeteer-core").ProtocolMapping.Events>(method: EventName, params: import("../../../types/protocol.js").default.RawEventMessageRecord[EventName]["params"]) => void;
/**
* @return {Promise<void>}
*/
enable(): Promise<void>;
/**
* @return {Promise<void>}
*/
disable(): Promise<void>;
}
export {};
//# sourceMappingURL=target-manager.d.ts.map

View File

@@ -0,0 +1,260 @@
/**
* @license Copyright 2021 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/**
* @fileoverview This class tracks multiple targets (the page itself and its OOPIFs) and allows consumers to
* listen for protocol events before each target is resumed.
*/
import EventEmitter from 'events';
import log from 'lighthouse-logger';
import {ProtocolSession} from '../session.js';
/**
* @typedef {{
* target: LH.Crdp.Target.TargetInfo,
* cdpSession: LH.Puppeteer.CDPSession,
* session: LH.Gatherer.FRProtocolSession,
* protocolListener: (event: unknown) => void,
* }} TargetWithSession
*/
// Add protocol event types to EventEmitter.
/** @typedef {{'protocolevent': [LH.Protocol.RawEventMessage]}} ProtocolEventMap */
/** @typedef {LH.Protocol.StrictEventEmitterClass<ProtocolEventMap>} ProtocolEventMessageEmitter */
const ProtocolEventEmitter = /** @type {ProtocolEventMessageEmitter} */ (EventEmitter);
/**
* 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);
}
/**
* @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;
}
}
/**
* @param {string} sessionId
* @return {LH.Gatherer.FRProtocolSession}
*/
_findSession(sessionId) {
for (const {session, cdpSession} of this._targetIdToTargets.values()) {
if (cdpSession.id() === sessionId) return session;
}
throw new Error(`session ${sessionId} not found`);
}
/**
* Returns the root session.
* @return {LH.Gatherer.FRProtocolSession}
*/
rootSession() {
const rootSessionId = this._rootCdpSession.id();
return this._findSession(rootSessionId);
}
mainFrameExecutionContexts() {
return [...this._executionContextIdToDescriptions.values()].filter(executionContext => {
return executionContext.auxData.frameId === this._mainFrameId;
});
}
/**
* @param {LH.Puppeteer.CDPSession} cdpSession
*/
async _onSessionAttached(cdpSession) {
const newSession = new ProtocolSession(cdpSession);
try {
const target = await newSession.sendCommand('Target.getTargetInfo').catch(() => null);
const targetType = target?.targetInfo?.type;
const hasValidTargetType = targetType === 'page' || targetType === 'iframe';
// TODO: should detach from target in this case?
// See pptr: https://github.com/puppeteer/puppeteer/blob/733cbecf487c71483bee8350e37030edb24bc021/src/common/Page.ts#L495-L526
if (!target || !hasValidTargetType) return;
// No need to continue if target has already been seen.
const targetId = target.targetInfo.targetId;
if (this._targetIdToTargets.has(targetId)) return;
newSession.setTargetInfo(target.targetInfo);
const targetName = target.targetInfo.url || target.targetInfo.targetId;
log.verbose('target-manager', `target ${targetName} attached`);
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);
const targetWithSession = {
target: target.targetInfo,
cdpSession,
session: newSession,
protocolListener,
};
this._targetIdToTargets.set(targetId, targetWithSession);
// We want to receive information about network requests from iframes, so enable the Network domain.
await newSession.sendCommand('Network.enable');
// We also want to receive information about subtargets of subtargets, so make sure we autoattach recursively.
await newSession.sendCommand('Target.setAutoAttach', {
autoAttach: true,
flatten: true,
waitForDebuggerOnStart: true,
});
} catch (err) {
// Sometimes targets can be closed before we even have a chance to listen to their network activity.
if (/Target closed/.test(err.message)) return;
throw err;
} finally {
// Resume the target if it was paused, but if it's unnecessary, we don't care about the error.
await newSession.sendCommand('Runtime.runIfWaitingForDebugger').catch(() => {});
}
}
/**
* @param {LH.Crdp.Runtime.ExecutionContextCreatedEvent} event
*/
_onExecutionContextCreated(event) {
if (event.context.name === '__puppeteer_utility_world__') return;
if (event.context.name === 'lighthouse_isolated_context') return;
this._executionContextIdToDescriptions.set(event.context.uniqueId, event.context);
}
/**
* @param {LH.Crdp.Runtime.ExecutionContextDestroyedEvent} event
*/
_onExecutionContextDestroyed(event) {
this._executionContextIdToDescriptions.delete(event.executionContextUniqueId);
}
_onExecutionContextsCleared() {
this._executionContextIdToDescriptions.clear();
}
/**
* 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;
}
/**
* @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);
}
/**
* @return {Promise<void>}
*/
async disable() {
this._rootCdpSession.off('Page.frameNavigated', this._onFrameNavigated);
this._rootCdpSession.off('Runtime.executionContextCreated', this._onExecutionContextCreated);
this._rootCdpSession.off('Runtime.executionContextDestroyed',
this._onExecutionContextDestroyed);
this._rootCdpSession.off('Runtime.executionContextsCleared', this._onExecutionContextsCleared);
for (const {cdpSession, protocolListener} of this._targetIdToTargets.values()) {
cdpSession.off('*', protocolListener);
cdpSession.off('sessionattached', this._onSessionAttached);
}
await this._rootCdpSession.send('Page.disable');
await this._rootCdpSession.send('Runtime.disable');
this._enabled = false;
this._targetIdToTargets = new Map();
this._executionContextIdToDescriptions = new Map();
this._mainFrameId = '';
}
}
export {TargetManager};

View File

@@ -0,0 +1,116 @@
export type NetworkMonitor = InstanceType<typeof import("./network-monitor.js")['NetworkMonitor']>;
export type NetworkMonitorEvent = import('./network-monitor.js').NetworkMonitorEvent;
export type CancellableWait<T = void> = {
promise: Promise<T>;
cancel: () => void;
};
export type WaitOptions = {
pauseAfterFcpMs: number;
pauseAfterLoadMs: number;
networkQuietThresholdMs: number;
cpuQuietThresholdMs: number;
maxWaitForLoadedMs: number;
maxWaitForFcpMs: number | undefined;
_waitForTestOverrides?: {
waitForFcp: typeof waitForFcp;
waitForLoadEvent: typeof waitForLoadEvent;
waitForNetworkIdle: typeof waitForNetworkIdle;
waitForCPUIdle: typeof waitForCPUIdle;
} | undefined;
};
/** @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}}
*/
export function waitForNothing(): {
promise: Promise<void>;
cancel: () => void;
};
/**
* 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.FRProtocolSession} session
* @return {CancellableWait<LH.Crdp.Page.FrameNavigatedEvent>}
*/
export function waitForFrameNavigated(session: LH.Gatherer.FRProtocolSession): CancellableWait<LH.Crdp.Page.FrameNavigatedEvent>;
/**
* Returns a promise that resolve when a frame has a FCP.
* @param {LH.Gatherer.FRProtocolSession} session
* @param {number} pauseAfterFcpMs
* @param {number} maxWaitForFcpMs
* @return {CancellableWait}
*/
export function waitForFcp(session: LH.Gatherer.FRProtocolSession, pauseAfterFcpMs: number, maxWaitForFcpMs: number): CancellableWait;
/**
* Return a promise that resolves `pauseAfterLoadMs` after the load event
* fires and a method to cancel internal listeners and timeout.
* @param {LH.Gatherer.FRProtocolSession} session
* @param {number} pauseAfterLoadMs
* @return {CancellableWait}
*/
export function waitForLoadEvent(session: LH.Gatherer.FRProtocolSession, pauseAfterLoadMs: number): CancellableWait;
/**
* 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.FRProtocolSession} session
* @param {NetworkMonitor} networkMonitor
* @param {{networkQuietThresholdMs: number, busyEvent: NetworkMonitorEvent, idleEvent: NetworkMonitorEvent, isIdle(recorder: NetworkMonitor): boolean, pretendDCLAlreadyFired?: boolean}} networkQuietOptions
* @return {CancellableWait}
*/
export function waitForNetworkIdle(session: LH.Gatherer.FRProtocolSession, networkMonitor: NetworkMonitor, networkQuietOptions: {
networkQuietThresholdMs: number;
busyEvent: NetworkMonitorEvent;
idleEvent: NetworkMonitorEvent;
isIdle(recorder: NetworkMonitor): boolean;
pretendDCLAlreadyFired?: boolean | undefined;
}): CancellableWait;
/**
* Resolves when there have been no long tasks for at least waitForCPUQuiet ms.
* @param {LH.Gatherer.FRProtocolSession} session
* @param {number} waitForCPUQuiet
* @return {CancellableWait}
*/
export function waitForCPUIdle(session: LH.Gatherer.FRProtocolSession, waitForCPUQuiet: number): CancellableWait;
/**
* 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.FRProtocolSession} session
* @param {NetworkMonitor} networkMonitor
* @param {WaitOptions} options
* @return {Promise<{timedOut: boolean}>}
*/
export function waitForFullyLoaded(session: LH.Gatherer.FRProtocolSession, networkMonitor: NetworkMonitor, options: WaitOptions): Promise<{
timedOut: boolean;
}>;
/**
* @param {LH.Gatherer.FRTransitionalDriver} driver
*/
export function waitForUserToContinue(driver: LH.Gatherer.FRTransitionalDriver): Promise<void>;
//# sourceMappingURL=wait-for-condition.d.ts.map

View File

@@ -0,0 +1,556 @@
/**
* @license Copyright 2020 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/* 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.FRProtocolSession} 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.FRProtocolSession} 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.FRProtocolSession} 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.Timer|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 = () => {
// eslint-disable-next-line no-use-before-define
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.FRProtocolSession} session
* @param {number} waitForCPUQuiet
* @return {CancellableWait}
*/
function waitForCPUIdle(session, waitForCPUQuiet) {
if (!waitForCPUQuiet) {
return {
promise: Promise.resolve(),
cancel: () => undefined,
};
}
/** @type {NodeJS.Timer|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.FRProtocolSession} 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.Timer|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.FRProtocolSession} 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.FRProtocolSession} 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.Timer|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();
// 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...');
await session.sendCommand('Emulation.setScriptExecutionDisabled', {value: true});
await session.sendCommand('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.FRTransitionalDriver} 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(2 ** 31 - 1);
return driver.executionContext.evaluate(createInPagePromise, {args: []});
}
export {
waitForNothing,
waitForFrameNavigated,
waitForFcp,
waitForLoadEvent,
waitForNetworkIdle,
waitForCPUIdle,
waitForFullyLoaded,
waitForUserToContinue,
};

63
node_modules/lighthouse/core/gather/fetcher.d.ts generated vendored Normal file
View File

@@ -0,0 +1,63 @@
export type FetchResponse = {
content: string | null;
status: number | null;
};
/**
* @fileoverview Fetcher is a utility for making requests to any arbitrary resource,
* ignoring normal browser constraints such as CORS.
*/
/** @typedef {{content: string|null, status: number|null}} FetchResponse */
export class Fetcher {
/**
* @param {LH.Gatherer.FRProtocolSession} session
*/
constructor(session: LH.Gatherer.FRProtocolSession);
session: LH.Gatherer.FRProtocolSession;
/**
* Fetches any resource using the network directly.
*
* @param {string} url
* @param {{timeout: number}=} options timeout is in ms
* @return {Promise<FetchResponse>}
*/
fetchResource(url: string, options?: {
timeout: number;
} | undefined): Promise<FetchResponse>;
/**
* @param {string} url
* @return {Promise<FetchResponse>}
*/
_fetchWithFetchApi(url: string): Promise<FetchResponse>;
/**
* @param {string} handle
* @param {{timeout: number}=} options,
* @return {Promise<string>}
*/
_readIOStream(handle: string, options?: {
timeout: number;
} | undefined): Promise<string>;
/**
* @param {string} url
* @return {Promise<{stream: LH.Crdp.IO.StreamHandle|null, status: number|null}>}
*/
_loadNetworkResource(url: string): Promise<{
stream: LH.Crdp.IO.StreamHandle | null;
status: number | null;
}>;
/**
* @param {string} url
* @param {{timeout: number}} options timeout is in ms
* @return {Promise<FetchResponse>}
*/
_fetchResourceOverProtocol(url: string, options: {
timeout: number;
}): Promise<FetchResponse>;
/**
* @template T
* @param {Promise<T>} promise
* @param {number} ms
*/
_wrapWithTimeout<T>(promise: Promise<T>, ms: number): Promise<T>;
}
import * as LH from '../../types/lh.js';
//# sourceMappingURL=fetcher.d.ts.map

143
node_modules/lighthouse/core/gather/fetcher.js generated vendored Normal file
View File

@@ -0,0 +1,143 @@
/**
* @license Copyright 2020 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
import * as LH from '../../types/lh.js';
/**
* @fileoverview Fetcher is a utility for making requests to any arbitrary resource,
* ignoring normal browser constraints such as CORS.
*/
/* global fetch */
/** @typedef {{content: string|null, status: number|null}} FetchResponse */
class Fetcher {
/**
* @param {LH.Gatherer.FRProtocolSession} 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
* @return {Promise<FetchResponse>}
*/
async _fetchWithFetchApi(url) {
const response = await fetch(url);
let content = null;
try {
content = await response.text();
} catch {}
return {
content,
status: response.status,
};
}
/**
* @param {string} handle
* @param {{timeout: number}=} options,
* @return {Promise<string>}
*/
async _readIOStream(handle, options = {timeout: 2_000}) {
const startTime = Date.now();
let ioResponse;
let data = '';
while (!ioResponse || !ioResponse.eof) {
const elapsedTime = Date.now() - startTime;
if (elapsedTime > options.timeout) {
throw new Error('Waiting for the end of the IO stream exceeded the allotted time.');
}
ioResponse = await this.session.sendCommand('IO.read', {handle});
const responseData = ioResponse.base64Encoded ?
Buffer.from(ioResponse.data, 'base64').toString('utf-8') :
ioResponse.data;
data = data.concat(responseData);
}
return data;
}
/**
* @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,
};
}
/**
* @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};
}
/**
* @template T
* @param {Promise<T>} promise
* @param {number} ms
*/
async _wrapWithTimeout(promise, ms) {
/** @type {NodeJS.Timeout} */
let timeoutHandle;
const timeoutPromise = new Promise((_, reject) => {
timeoutHandle = setTimeout(reject, ms, new Error('Timed out fetching resource'));
});
/** @type {Promise<T>} */
const wrappedPromise = await Promise.race([promise, timeoutPromise])
.finally(() => clearTimeout(timeoutHandle));
return wrappedPromise;
}
}
export {Fetcher};

View File

@@ -0,0 +1,23 @@
export default Accessibility;
declare class Accessibility extends FRGatherer {
static pageFns: {
runA11yChecks: typeof runA11yChecks;
createAxeRuleResultArtifact: typeof createAxeRuleResultArtifact;
};
/**
* @param {LH.Gatherer.FRTransitionalContext} passContext
* @return {Promise<LH.Artifacts.Accessibility>}
*/
getArtifact(passContext: LH.Gatherer.FRTransitionalContext): Promise<LH.Artifacts.Accessibility>;
}
import FRGatherer from '../base-gatherer.js';
/**
* @return {Promise<LH.Artifacts.Accessibility>}
*/
declare function runA11yChecks(): Promise<LH.Artifacts.Accessibility>;
/**
* @param {import('axe-core/axe').Result} result
* @return {LH.Artifacts.AxeRuleResult}
*/
declare function createAxeRuleResultArtifact(result: import('axe-core/axe').Result): LH.Artifacts.AxeRuleResult;
//# sourceMappingURL=accessibility.d.ts.map

View File

@@ -0,0 +1,203 @@
/**
* @license Copyright 2016 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/* global window, document, getNodeDetails */
import FRGatherer from '../base-gatherer.js';
import {axeSource} from '../../lib/axe.js';
import {pageFunctions} from '../../lib/page-functions.js';
/**
* @return {Promise<LH.Artifacts.Accessibility>}
*/
/* c8 ignore start */
async function runA11yChecks() {
/** @type {import('axe-core/axe')} */
// @ts-expect-error - axe defined by axeLibSource
const axe = window.axe;
const application = `lighthouse-${Math.random()}`;
axe.configure({
branding: {
application,
},
noHtml: true,
});
const axeResults = await axe.run(document, {
elementRef: true,
runOnly: {
type: 'tag',
values: [
'wcag2a',
'wcag2aa',
],
},
// resultTypes doesn't limit the output of the axeResults object. Instead, if it's defined,
// some expensive element identification is done only for the respective types. https://github.com/dequelabs/axe-core/blob/f62f0cf18f7b69b247b0b6362cf1ae71ffbf3a1b/lib/core/reporters/helpers/process-aggregate.js#L61-L97
resultTypes: ['violations', 'inapplicable'],
rules: {
// Consider http://go/prcpg for expert review of the aXe rules.
'accesskeys': {enabled: true},
'area-alt': {enabled: false},
'aria-dialog-name': {enabled: true},
'aria-roledescription': {enabled: false},
'aria-treeitem-name': {enabled: true},
'aria-text': {enabled: true},
'audio-caption': {enabled: false},
'blink': {enabled: false},
'duplicate-id': {enabled: false},
'empty-heading': {enabled: true},
'frame-focusable-content': {enabled: false},
'frame-title-unique': {enabled: false},
'heading-order': {enabled: true},
'html-xml-lang-mismatch': {enabled: true},
'identical-links-same-purpose': {enabled: true},
'input-button-name': {enabled: true},
'landmark-one-main': {enabled: true},
'link-in-text-block': {enabled: true},
'marquee': {enabled: false},
'meta-viewport': {enabled: true},
// https://github.com/dequelabs/axe-core/issues/2958
'nested-interactive': {enabled: false},
'no-autoplay-audio': {enabled: false},
'role-img-alt': {enabled: false},
'scrollable-region-focusable': {enabled: false},
'select-name': {enabled: true},
'server-side-image-map': {enabled: false},
'svg-img-alt': {enabled: false},
'tabindex': {enabled: true},
'table-fake-caption': {enabled: true},
'target-size': {enabled: true},
'td-has-header': {enabled: true},
},
});
// axe just scrolled the page, scroll back to the top of the page so that element positions
// are relative to the top of the page
document.documentElement.scrollTop = 0;
return {
violations: axeResults.violations.map(createAxeRuleResultArtifact),
incomplete: axeResults.incomplete.map(createAxeRuleResultArtifact),
notApplicable: axeResults.inapplicable.map(result => ({id: result.id})), // FYI: inapplicable => notApplicable!
passes: axeResults.passes.map(result => ({id: result.id})),
version: axeResults.testEngine.version,
};
}
async function runA11yChecksAndResetScroll() {
const originalScrollPosition = {
x: window.scrollX,
y: window.scrollY,
};
try {
return await runA11yChecks();
} finally {
window.scrollTo(originalScrollPosition.x, originalScrollPosition.y);
}
}
/**
* @param {import('axe-core/axe').Result} result
* @return {LH.Artifacts.AxeRuleResult}
*/
function createAxeRuleResultArtifact(result) {
// Simplify `nodes` and collect nodeDetails for each.
const nodes = result.nodes.map(node => {
const {target, failureSummary, element} = node;
// TODO: with `elementRef: true`, `element` _should_ always be defined, but need to verify.
// @ts-expect-error - getNodeDetails put into scope via stringification
const nodeDetails = getNodeDetails(/** @type {HTMLElement} */ (element));
/** @type {Set<HTMLElement>} */
const relatedNodeElements = new Set();
/** @param {import('axe-core/axe').ImpactValue} impact */
const impactToNumber =
(impact) => [null, 'minor', 'moderate', 'serious', 'critical'].indexOf(impact);
const checkResults = [...node.any, ...node.all, ...node.none]
// @ts-expect-error CheckResult.impact is a string, even though ImpactValue is a thing.
.sort((a, b) => impactToNumber(b.impact) - impactToNumber(a.impact));
for (const checkResult of checkResults) {
for (const relatedNode of checkResult.relatedNodes || []) {
/** @type {HTMLElement} */
// @ts-expect-error - should always exist, just being cautious.
const relatedElement = relatedNode.element;
// Prevent overloading the report with way too many nodes.
if (relatedNodeElements.size >= 3) break;
// Should always exist, just being cautious.
if (!relatedElement) continue;
if (element === relatedElement) continue;
relatedNodeElements.add(relatedElement);
}
}
// @ts-expect-error - getNodeDetails put into scope via stringification
const relatedNodeDetails = [...relatedNodeElements].map(getNodeDetails);
return {
target,
failureSummary,
node: nodeDetails,
relatedNodes: relatedNodeDetails,
};
});
// Ensure errors can be serialized over the protocol.
/** @type {Error | undefined} */
// @ts-expect-error - when rules throw an error, axe saves it here.
// see https://github.com/dequelabs/axe-core/blob/eeff122c2de11dd690fbad0e50ba2fdb244b50e8/lib/core/base/audit.js#L684-L693
const resultError = result.error;
let error;
if (resultError instanceof Error) {
error = {
name: resultError.name,
message: resultError.message,
};
}
return {
id: result.id,
impact: result.impact || undefined,
tags: result.tags,
nodes,
error,
};
}
/* c8 ignore stop */
class Accessibility extends FRGatherer {
/** @type {LH.Gatherer.GathererMeta} */
meta = {
supportedModes: ['snapshot', 'navigation'],
};
static pageFns = {
runA11yChecks,
createAxeRuleResultArtifact,
};
/**
* @param {LH.Gatherer.FRTransitionalContext} passContext
* @return {Promise<LH.Artifacts.Accessibility>}
*/
getArtifact(passContext) {
const driver = passContext.driver;
return driver.executionContext.evaluate(runA11yChecksAndResetScroll, {
args: [],
useIsolation: true,
deps: [
axeSource,
pageFunctions.getNodeDetails,
createAxeRuleResultArtifact,
runA11yChecks,
],
});
}
}
export default Accessibility;

View File

@@ -0,0 +1,10 @@
export default AnchorElements;
declare class AnchorElements extends FRGatherer {
/**
* @param {LH.Gatherer.FRTransitionalContext} passContext
* @return {Promise<LH.Artifacts['AnchorElements']>}
*/
getArtifact(passContext: LH.Gatherer.FRTransitionalContext): Promise<LH.Artifacts['AnchorElements']>;
}
import FRGatherer from '../base-gatherer.js';
//# sourceMappingURL=anchor-elements.d.ts.map

View File

@@ -0,0 +1,134 @@
/**
* @license Copyright 2019 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/* global getNodeDetails */
import FRGatherer from '../base-gatherer.js';
import {pageFunctions} from '../../lib/page-functions.js';
import {resolveDevtoolsNodePathToObjectId} from '../driver/dom.js';
/* eslint-env browser, node */
/**
* Function that is stringified and run in the page to collect anchor elements.
* Additional complexity is introduced because anchors can be HTML or SVG elements.
*
* We use this evaluateAsync method because the `node.getAttribute` method doesn't actually normalize
* the values like access from JavaScript in-page does.
*
* @return {LH.Artifacts['AnchorElements']}
*/
/* c8 ignore start */
function collectAnchorElements() {
/** @param {string} url */
const resolveURLOrEmpty = url => {
try {
return new URL(url, window.location.href).href;
} catch (_) {
return '';
}
};
/** @param {HTMLAnchorElement|SVGAElement} node */
function getTruncatedOnclick(node) {
const onclick = node.getAttribute('onclick') || '';
return onclick.slice(0, 1024);
}
/** @type {Array<HTMLAnchorElement|SVGAElement>} */
// @ts-expect-error - put into scope via stringification
const anchorElements = getElementsInDocument('a'); // eslint-disable-line no-undef
return anchorElements.map(node => {
if (node instanceof HTMLAnchorElement) {
return {
href: node.href,
rawHref: node.getAttribute('href') || '',
onclick: getTruncatedOnclick(node),
role: node.getAttribute('role') || '',
name: node.name,
text: node.innerText, // we don't want to return hidden text, so use innerText
rel: node.rel,
target: node.target,
id: node.getAttribute('id') || '',
// @ts-expect-error - getNodeDetails put into scope via stringification
node: getNodeDetails(node),
};
}
return {
href: resolveURLOrEmpty(node.href.baseVal),
rawHref: node.getAttribute('href') || '',
onclick: getTruncatedOnclick(node),
role: node.getAttribute('role') || '',
text: node.textContent || '',
rel: '',
target: node.target.baseVal || '',
id: node.getAttribute('id') || '',
// @ts-expect-error - getNodeDetails put into scope via stringification
node: getNodeDetails(node),
};
});
}
/* c8 ignore stop */
/**
* @param {LH.Gatherer.FRProtocolSession} session
* @param {string} devtoolsNodePath
* @return {Promise<Array<{type: string}>>}
*/
async function getEventListeners(session, devtoolsNodePath) {
const objectId = await resolveDevtoolsNodePathToObjectId(session, devtoolsNodePath);
if (!objectId) return [];
const response = await session.sendCommand('DOMDebugger.getEventListeners', {
objectId,
});
return response.listeners.map(({type}) => ({type}));
}
class AnchorElements extends FRGatherer {
/** @type {LH.Gatherer.GathererMeta} */
meta = {
supportedModes: ['snapshot', 'navigation'],
};
/**
* @param {LH.Gatherer.FRTransitionalContext} passContext
* @return {Promise<LH.Artifacts['AnchorElements']>}
*/
async getArtifact(passContext) {
const session = passContext.driver.defaultSession;
const anchors = await passContext.driver.executionContext.evaluate(collectAnchorElements, {
args: [],
useIsolation: true,
deps: [
pageFunctions.getElementsInDocument,
pageFunctions.getNodeDetails,
],
});
await session.sendCommand('DOM.enable');
// DOM.getDocument is necessary for pushNodesByBackendIdsToFrontend to properly retrieve nodeIds if the `DOM` domain was enabled before this gatherer, invoke it to be safe.
await session.sendCommand('DOM.getDocument', {depth: -1, pierce: true});
const anchorsWithEventListeners = anchors.map(async anchor => {
const listeners = await getEventListeners(session, anchor.node.devtoolsNodePath);
return {
...anchor,
listeners,
};
});
const result = await Promise.all(anchorsWithEventListeners);
await session.sendCommand('DOM.disable');
return result;
}
}
export default AnchorElements;

View File

@@ -0,0 +1,43 @@
export default BFCacheFailures;
declare class BFCacheFailures extends FRGatherer {
/**
* @param {LH.Crdp.Page.BackForwardCacheNotRestoredExplanation[]} errorList
* @return {LH.Artifacts.BFCacheFailure}
*/
static processBFCacheEventList(errorList: LH.Crdp.Page.BackForwardCacheNotRestoredExplanation[]): LH.Artifacts.BFCacheFailure;
/**
* @param {LH.Crdp.Page.BackForwardCacheNotRestoredExplanationTree} errorTree
* @return {LH.Artifacts.BFCacheFailure}
*/
static processBFCacheEventTree(errorTree: LH.Crdp.Page.BackForwardCacheNotRestoredExplanationTree): LH.Artifacts.BFCacheFailure;
/**
* @param {LH.Crdp.Page.BackForwardCacheNotUsedEvent|undefined} event
* @return {LH.Artifacts.BFCacheFailure}
*/
static processBFCacheEvent(event: LH.Crdp.Page.BackForwardCacheNotUsedEvent | undefined): LH.Artifacts.BFCacheFailure;
/** @type {LH.Gatherer.GathererMeta<'DevtoolsLog'>} */
meta: LH.Gatherer.GathererMeta<'DevtoolsLog'>;
/**
* @param {LH.Gatherer.FRTransitionalContext} context
* @return {Promise<LH.Crdp.Page.BackForwardCacheNotUsedEvent|undefined>}
*/
activelyCollectBFCacheEvent(context: LH.Gatherer.FRTransitionalContext): Promise<LH.Crdp.Page.BackForwardCacheNotUsedEvent | undefined>;
/**
* @param {LH.Gatherer.FRTransitionalContext<'DevtoolsLog'>} context
* @return {LH.Crdp.Page.BackForwardCacheNotUsedEvent[]}
*/
passivelyCollectBFCacheEvents(context: LH.Gatherer.FRTransitionalContext<'DevtoolsLog'>): LH.Crdp.Page.BackForwardCacheNotUsedEvent[];
/**
* @param {LH.Gatherer.FRTransitionalContext<'DevtoolsLog'>} context
* @return {Promise<LH.Artifacts['BFCacheFailures']>}
*/
getArtifact(context: LH.Gatherer.FRTransitionalContext<'DevtoolsLog'>): Promise<LH.Artifacts['BFCacheFailures']>;
/**
* @param {LH.Gatherer.PassContext} passContext
* @param {LH.Gatherer.LoadData} loadData
* @return {Promise<LH.Artifacts['BFCacheFailures']>}
*/
afterPass(passContext: LH.Gatherer.PassContext, loadData: LH.Gatherer.LoadData): Promise<LH.Artifacts['BFCacheFailures']>;
}
import FRGatherer from '../base-gatherer.js';
//# sourceMappingURL=bf-cache-failures.d.ts.map

View File

@@ -0,0 +1,178 @@
/**
* @license Copyright 2022 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
import FRGatherer from '../base-gatherer.js';
import {waitForFrameNavigated, waitForLoadEvent} from '../driver/wait-for-condition.js';
import DevtoolsLog from './devtools-log.js';
const AFTER_RETURN_TIMEOUT = 100;
const TEMP_PAGE_PAUSE_TIMEOUT = 100;
class BFCacheFailures extends FRGatherer {
/** @type {LH.Gatherer.GathererMeta<'DevtoolsLog'>} */
meta = {
supportedModes: ['navigation', 'timespan'],
dependencies: {DevtoolsLog: DevtoolsLog.symbol},
};
/**
* @param {LH.Crdp.Page.BackForwardCacheNotRestoredExplanation[]} errorList
* @return {LH.Artifacts.BFCacheFailure}
*/
static processBFCacheEventList(errorList) {
/** @type {LH.Artifacts.BFCacheNotRestoredReasonsTree} */
const notRestoredReasonsTree = {
Circumstantial: {},
PageSupportNeeded: {},
SupportPending: {},
};
for (const err of errorList) {
const bfCacheErrorsMap = notRestoredReasonsTree[err.type];
bfCacheErrorsMap[err.reason] = [];
}
return {notRestoredReasonsTree};
}
/**
* @param {LH.Crdp.Page.BackForwardCacheNotRestoredExplanationTree} errorTree
* @return {LH.Artifacts.BFCacheFailure}
*/
static processBFCacheEventTree(errorTree) {
/** @type {LH.Artifacts.BFCacheNotRestoredReasonsTree} */
const notRestoredReasonsTree = {
Circumstantial: {},
PageSupportNeeded: {},
SupportPending: {},
};
/**
* @param {LH.Crdp.Page.BackForwardCacheNotRestoredExplanationTree} node
*/
function traverse(node) {
for (const error of node.explanations) {
const bfCacheErrorsMap = notRestoredReasonsTree[error.type];
const frameUrls = bfCacheErrorsMap[error.reason] || [];
frameUrls.push(node.url);
bfCacheErrorsMap[error.reason] = frameUrls;
}
for (const child of node.children) {
traverse(child);
}
}
traverse(errorTree);
return {notRestoredReasonsTree};
}
/**
* @param {LH.Crdp.Page.BackForwardCacheNotUsedEvent|undefined} event
* @return {LH.Artifacts.BFCacheFailure}
*/
static processBFCacheEvent(event) {
if (event?.notRestoredExplanationsTree) {
return BFCacheFailures.processBFCacheEventTree(event.notRestoredExplanationsTree);
}
return BFCacheFailures.processBFCacheEventList(event?.notRestoredExplanations || []);
}
/**
* @param {LH.Gatherer.FRTransitionalContext} context
* @return {Promise<LH.Crdp.Page.BackForwardCacheNotUsedEvent|undefined>}
*/
async activelyCollectBFCacheEvent(context) {
const session = context.driver.defaultSession;
/** @type {LH.Crdp.Page.BackForwardCacheNotUsedEvent|undefined} */
let bfCacheEvent = undefined;
/**
* @param {LH.Crdp.Page.BackForwardCacheNotUsedEvent} event
*/
function onBfCacheNotUsed(event) {
bfCacheEvent = event;
}
session.on('Page.backForwardCacheNotUsed', onBfCacheNotUsed);
const history = await session.sendCommand('Page.getNavigationHistory');
const entry = history.entries[history.currentIndex];
// In theory, we should be able to use about:blank here
// but that sometimes produces BrowsingInstanceNotSwapped failures.
// DevTools uses chrome://terms as it's temporary page so we should stick with that.
// https://github.com/GoogleChrome/lighthouse/issues/14665
await Promise.all([
session.sendCommand('Page.navigate', {url: 'chrome://terms'}),
// DevTools e2e tests can sometimes fail on the next command if we progress too fast.
// The only reliable way to prevent this is to wait for an arbitrary period of time after load.
waitForLoadEvent(session, TEMP_PAGE_PAUSE_TIMEOUT).promise,
]);
const [, frameNavigatedEvent] = await Promise.all([
session.sendCommand('Page.navigateToHistoryEntry', {entryId: entry.id}),
waitForFrameNavigated(session).promise,
]);
// The bfcache failure event is not necessarily emitted by this point.
// If we are expecting a bfcache failure event but haven't seen one, we should wait for it.
// This timeout also allows the environment to "settle" before gathering enters it's cleanup phase.
await new Promise(resolve => setTimeout(resolve, AFTER_RETURN_TIMEOUT));
// If we still can't get the failure reasons after the timeout we should fail loudly,
// otherwise this gatherer will return no failures when there should be failures.
if (frameNavigatedEvent.type !== 'BackForwardCacheRestore' && !bfCacheEvent) {
throw new Error('bfcache failed but the failure reasons were not emitted in time');
}
session.off('Page.backForwardCacheNotUsed', onBfCacheNotUsed);
return bfCacheEvent;
}
/**
* @param {LH.Gatherer.FRTransitionalContext<'DevtoolsLog'>} context
* @return {LH.Crdp.Page.BackForwardCacheNotUsedEvent[]}
*/
passivelyCollectBFCacheEvents(context) {
const events = [];
for (const event of context.dependencies.DevtoolsLog) {
if (event.method === 'Page.backForwardCacheNotUsed') {
events.push(event.params);
}
}
return events;
}
/**
* @param {LH.Gatherer.FRTransitionalContext<'DevtoolsLog'>} context
* @return {Promise<LH.Artifacts['BFCacheFailures']>}
*/
async getArtifact(context) {
const events = this.passivelyCollectBFCacheEvents(context);
if (context.gatherMode === 'navigation' && !context.settings.usePassiveGathering) {
const activelyCollectedEvent = await this.activelyCollectBFCacheEvent(context);
if (activelyCollectedEvent) events.push(activelyCollectedEvent);
}
return events.map(BFCacheFailures.processBFCacheEvent);
}
/**
* @param {LH.Gatherer.PassContext} passContext
* @param {LH.Gatherer.LoadData} loadData
* @return {Promise<LH.Artifacts['BFCacheFailures']>}
*/
async afterPass(passContext, loadData) {
return this.getArtifact({...passContext, dependencies: {DevtoolsLog: loadData.devtoolsLog}});
}
}
export default BFCacheFailures;

View File

@@ -0,0 +1,11 @@
export default CacheContents;
declare class CacheContents extends FRGatherer {
/**
* Creates an array of cached URLs.
* @param {LH.Gatherer.FRTransitionalContext} passContext
* @return {Promise<LH.Artifacts['CacheContents']>}
*/
getArtifact(passContext: LH.Gatherer.FRTransitionalContext): Promise<LH.Artifacts['CacheContents']>;
}
import FRGatherer from '../base-gatherer.js';
//# sourceMappingURL=cache-contents.d.ts.map

View File

@@ -0,0 +1,58 @@
/**
* @license Copyright 2016 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/* global caches */
import FRGatherer from '../base-gatherer.js';
/**
* @return {Promise<Array<string>>}
*/
/* c8 ignore start */
function getCacheContents() {
// Get every cache by name.
return caches.keys()
// Open each one.
.then(cacheNames => Promise.all(cacheNames.map(cacheName => caches.open(cacheName))))
.then(caches => {
/** @type {Array<string>} */
const requests = [];
// Take each cache and get any requests is contains, and bounce each one down to its URL.
return Promise.all(caches.map(cache => {
return cache.keys()
.then(reqs => {
requests.push(...reqs.map(r => r.url));
});
})).then(_ => {
return requests;
});
});
}
/* c8 ignore stop */
class CacheContents extends FRGatherer {
/** @type {LH.Gatherer.GathererMeta} */
meta = {
supportedModes: ['snapshot', 'navigation'],
};
/**
* Creates an array of cached URLs.
* @param {LH.Gatherer.FRTransitionalContext} passContext
* @return {Promise<LH.Artifacts['CacheContents']>}
*/
async getArtifact(passContext) {
const driver = passContext.driver;
const cacheUrls = await driver.executionContext.evaluate(getCacheContents, {args: []});
return cacheUrls;
}
}
export default CacheContents;

View File

@@ -0,0 +1,39 @@
export default ConsoleMessages;
declare class ConsoleMessages extends FRGatherer {
/** @type {LH.Artifacts.ConsoleMessage[]} */
_logEntries: LH.Artifacts.ConsoleMessage[];
_onConsoleAPICalled: (event: LH.Crdp.Runtime.ConsoleAPICalledEvent) => void;
_onExceptionThrown: (event: LH.Crdp.Runtime.ExceptionThrownEvent) => void;
_onLogEntryAdded: (event: LH.Crdp.Log.EntryAddedEvent) => void;
/**
* Handles events for when a script invokes a console API.
* @param {LH.Crdp.Runtime.ConsoleAPICalledEvent} event
*/
onConsoleAPICalled(event: LH.Crdp.Runtime.ConsoleAPICalledEvent): void;
/**
* Handles exception thrown events.
* @param {LH.Crdp.Runtime.ExceptionThrownEvent} event
*/
onExceptionThrown(event: LH.Crdp.Runtime.ExceptionThrownEvent): void;
/**
* Handles browser reports logged to the console, including interventions,
* deprecations, violations, and more.
* @param {LH.Crdp.Log.EntryAddedEvent} event
*/
onLogEntry(event: LH.Crdp.Log.EntryAddedEvent): void;
/**
* @param {LH.Gatherer.FRTransitionalContext} passContext
*/
startInstrumentation(passContext: LH.Gatherer.FRTransitionalContext): Promise<void>;
/**
* @param {LH.Gatherer.FRTransitionalContext} passContext
* @return {Promise<void>}
*/
stopInstrumentation({ driver }: LH.Gatherer.FRTransitionalContext): Promise<void>;
/**
* @return {Promise<LH.Artifacts['ConsoleMessages']>}
*/
getArtifact(): Promise<LH.Artifacts['ConsoleMessages']>;
}
import FRGatherer from '../base-gatherer.js';
//# sourceMappingURL=console-messages.d.ts.map

View File

@@ -0,0 +1,174 @@
/**
* @license Copyright 2020 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/**
* @fileoverview Gathers all entries logged to the console, including console API calls,
* exceptions, and browser reports (on violations, interventions, deprecations, etc.).
*/
import FRGatherer from '../base-gatherer.js';
/**
* @param {LH.Crdp.Runtime.RemoteObject} obj
* @return {string}
*/
function remoteObjectToString(obj) {
if (typeof obj.value !== 'undefined' || obj.type === 'undefined') {
return String(obj.value);
}
if (typeof obj.description === 'string' && obj.description !== obj.className) {
return obj.description;
}
const type = obj.subtype || obj.type;
const className = obj.className || 'Object';
// Simulate calling String() on the object.
return `[${type} ${className}]`;
}
class ConsoleMessages extends FRGatherer {
/** @type {LH.Gatherer.GathererMeta} */
meta = {
supportedModes: ['timespan', 'navigation'],
};
constructor() {
super();
/** @type {LH.Artifacts.ConsoleMessage[]} */
this._logEntries = [];
this._onConsoleAPICalled = this.onConsoleAPICalled.bind(this);
this._onExceptionThrown = this.onExceptionThrown.bind(this);
this._onLogEntryAdded = this.onLogEntry.bind(this);
}
/**
* Handles events for when a script invokes a console API.
* @param {LH.Crdp.Runtime.ConsoleAPICalledEvent} event
*/
onConsoleAPICalled(event) {
const {type} = event;
if (type !== 'warning' && type !== 'error') {
// Only gather warnings and errors for brevity.
return;
}
/** @type {LH.Crdp.Runtime.RemoteObject[]} */
const args = event.args || [];
const text = args.map(remoteObjectToString).join(' ');
if (!text && !event.stackTrace) {
// No useful information from Chrome. Skip.
return;
}
const {url, lineNumber, columnNumber} =
event.stackTrace?.callFrames[0] || {};
/** @type {LH.Artifacts.ConsoleMessage} */
const consoleMessage = {
eventType: 'consoleAPI',
source: type === 'warning' ? 'console.warn' : 'console.error',
level: type,
text,
stackTrace: event.stackTrace,
timestamp: event.timestamp,
url,
lineNumber,
columnNumber,
};
this._logEntries.push(consoleMessage);
}
/**
* Handles exception thrown events.
* @param {LH.Crdp.Runtime.ExceptionThrownEvent} event
*/
onExceptionThrown(event) {
const text = event.exceptionDetails.exception ?
event.exceptionDetails.exception.description : event.exceptionDetails.text;
if (!text) {
return;
}
/** @type {LH.Artifacts.ConsoleMessage} */
const consoleMessage = {
eventType: 'exception',
source: 'exception',
level: 'error',
text,
stackTrace: event.exceptionDetails.stackTrace,
timestamp: event.timestamp,
url: event.exceptionDetails.url,
scriptId: event.exceptionDetails.scriptId,
lineNumber: event.exceptionDetails.lineNumber,
columnNumber: event.exceptionDetails.columnNumber,
};
this._logEntries.push(consoleMessage);
}
/**
* Handles browser reports logged to the console, including interventions,
* deprecations, violations, and more.
* @param {LH.Crdp.Log.EntryAddedEvent} event
*/
onLogEntry(event) {
const {source, level, text, stackTrace, timestamp, url, lineNumber} = event.entry;
// JS events have a stack trace, which we use to get the column.
// CSS/HTML events only expose a line number.
const firstStackFrame = event.entry.stackTrace?.callFrames[0];
this._logEntries.push({
eventType: 'protocolLog',
source,
level,
text,
stackTrace,
timestamp,
url,
scriptId: firstStackFrame?.scriptId,
lineNumber,
columnNumber: firstStackFrame?.columnNumber,
});
}
/**
* @param {LH.Gatherer.FRTransitionalContext} passContext
*/
async startInstrumentation(passContext) {
const session = passContext.driver.defaultSession;
session.on('Log.entryAdded', this._onLogEntryAdded);
await session.sendCommand('Log.enable');
await session.sendCommand('Log.startViolationsReport', {
config: [{name: 'discouragedAPIUse', threshold: -1}],
});
session.on('Runtime.consoleAPICalled', this._onConsoleAPICalled);
session.on('Runtime.exceptionThrown', this._onExceptionThrown);
await session.sendCommand('Runtime.enable');
}
/**
* @param {LH.Gatherer.FRTransitionalContext} passContext
* @return {Promise<void>}
*/
async stopInstrumentation({driver}) {
await driver.defaultSession.sendCommand('Log.stopViolationsReport');
await driver.defaultSession.off('Log.entryAdded', this._onLogEntryAdded);
await driver.defaultSession.sendCommand('Log.disable');
await driver.defaultSession.off('Runtime.consoleAPICalled', this._onConsoleAPICalled);
await driver.defaultSession.off('Runtime.exceptionThrown', this._onExceptionThrown);
await driver.defaultSession.sendCommand('Runtime.disable');
}
/**
* @return {Promise<LH.Artifacts['ConsoleMessages']>}
*/
async getArtifact() {
return this._logEntries;
}
}
export default ConsoleMessages;

View File

@@ -0,0 +1,39 @@
export default CSSUsage;
declare class CSSUsage extends FRGatherer {
/** @type {LH.Gatherer.FRProtocolSession|undefined} */
_session: LH.Gatherer.FRProtocolSession | undefined;
/** @type {Map<string, Promise<LH.Artifacts.CSSStyleSheetInfo|Error>>} */
_sheetPromises: Map<string, Promise<LH.Artifacts.CSSStyleSheetInfo | Error>>;
/**
* Initialize as undefined so we can assert results are fetched.
* @type {LH.Crdp.CSS.RuleUsage[]|undefined}
*/
_ruleUsage: LH.Crdp.CSS.RuleUsage[] | undefined;
/**
* @param {LH.Crdp.CSS.StyleSheetAddedEvent} event
*/
_onStylesheetAdded(event: LH.Crdp.CSS.StyleSheetAddedEvent): Promise<void>;
/**
* @param {LH.Gatherer.FRTransitionalContext} context
*/
startCSSUsageTracking(context: LH.Gatherer.FRTransitionalContext): Promise<void>;
/**
* @param {LH.Gatherer.FRTransitionalContext} context
*/
stopCSSUsageTracking(context: LH.Gatherer.FRTransitionalContext): Promise<void>;
/**
* @param {LH.Gatherer.FRTransitionalContext} context
*/
startInstrumentation(context: LH.Gatherer.FRTransitionalContext): Promise<void>;
/**
* @param {LH.Gatherer.FRTransitionalContext} context
*/
stopInstrumentation(context: LH.Gatherer.FRTransitionalContext): Promise<void>;
/**
* @param {LH.Gatherer.FRTransitionalContext} context
* @return {Promise<LH.Artifacts['CSSUsage']>}
*/
getArtifact(context: LH.Gatherer.FRTransitionalContext): Promise<LH.Artifacts['CSSUsage']>;
}
import FRGatherer from '../base-gatherer.js';
//# sourceMappingURL=css-usage.d.ts.map

View File

@@ -0,0 +1,150 @@
/**
* @license Copyright 2017 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/**
* @fileoverview Tracks unused CSS rules.
*/
import log from 'lighthouse-logger';
import FRGatherer from '../base-gatherer.js';
import {Sentry} from '../../lib/sentry.js';
class CSSUsage extends FRGatherer {
constructor() {
super();
/** @type {LH.Gatherer.FRProtocolSession|undefined} */
this._session = undefined;
/** @type {Map<string, Promise<LH.Artifacts.CSSStyleSheetInfo|Error>>} */
this._sheetPromises = new Map();
/**
* Initialize as undefined so we can assert results are fetched.
* @type {LH.Crdp.CSS.RuleUsage[]|undefined}
*/
this._ruleUsage = undefined;
this._onStylesheetAdded = this._onStylesheetAdded.bind(this);
}
/** @type {LH.Gatherer.GathererMeta} */
meta = {
supportedModes: ['snapshot', 'timespan', 'navigation'],
};
/**
* @param {LH.Crdp.CSS.StyleSheetAddedEvent} event
*/
async _onStylesheetAdded(event) {
if (!this._session) throw new Error('Session not initialized');
const styleSheetId = event.header.styleSheetId;
const sheetPromise = this._session.sendCommand('CSS.getStyleSheetText', {styleSheetId})
.then(content => ({
header: event.header,
content: content.text,
}))
.catch(/** @param {Error} err */ (err) => {
log.warn(
'CSSUsage',
`Error fetching content of stylesheet with URL "${event.header.sourceURL}"`
);
Sentry.captureException(err, {
tags: {
gatherer: this.name,
},
extra: {
url: event.header.sourceURL,
},
level: 'error',
});
return err;
});
this._sheetPromises.set(styleSheetId, sheetPromise);
}
/**
* @param {LH.Gatherer.FRTransitionalContext} context
*/
async startCSSUsageTracking(context) {
const session = context.driver.defaultSession;
this._session = session;
session.on('CSS.styleSheetAdded', this._onStylesheetAdded);
await session.sendCommand('DOM.enable');
await session.sendCommand('CSS.enable');
await session.sendCommand('CSS.startRuleUsageTracking');
}
/**
* @param {LH.Gatherer.FRTransitionalContext} context
*/
async stopCSSUsageTracking(context) {
const session = context.driver.defaultSession;
const coverageResponse = await session.sendCommand('CSS.stopRuleUsageTracking');
this._ruleUsage = coverageResponse.ruleUsage;
session.off('CSS.styleSheetAdded', this._onStylesheetAdded);
}
/**
* @param {LH.Gatherer.FRTransitionalContext} context
*/
async startInstrumentation(context) {
if (context.gatherMode !== 'timespan') return;
await this.startCSSUsageTracking(context);
}
/**
* @param {LH.Gatherer.FRTransitionalContext} context
*/
async stopInstrumentation(context) {
if (context.gatherMode !== 'timespan') return;
await this.stopCSSUsageTracking(context);
}
/**
* @param {LH.Gatherer.FRTransitionalContext} context
* @return {Promise<LH.Artifacts['CSSUsage']>}
*/
async getArtifact(context) {
const session = context.driver.defaultSession;
const executionContext = context.driver.executionContext;
if (context.gatherMode !== 'timespan') {
await this.startCSSUsageTracking(context);
// Force style to recompute.
// Doesn't appear to be necessary in newer versions of Chrome.
await executionContext.evaluateAsync('getComputedStyle(document.body)');
await this.stopCSSUsageTracking(context);
}
/** @type {Map<string, LH.Artifacts.CSSStyleSheetInfo>} */
const dedupedStylesheets = new Map();
const sheets = await Promise.all(this._sheetPromises.values());
for (const sheet of sheets) {
// Erroneous sheets will be reported via sentry and the log.
// We can ignore them here without throwing a fatal error.
if (sheet instanceof Error) {
continue;
}
dedupedStylesheets.set(sheet.content, sheet);
}
await session.sendCommand('CSS.disable');
await session.sendCommand('DOM.disable');
if (!this._ruleUsage) throw new Error('Issue collecting rule usages');
return {
rules: this._ruleUsage,
stylesheets: Array.from(dedupedStylesheets.values()),
};
}
}
export default CSSUsage;

View File

@@ -0,0 +1,13 @@
export default DevtoolsLogCompat;
/** @implements {LH.Gatherer.FRGathererInstance<'DevtoolsLog'>} */
declare class DevtoolsLogCompat extends FRGatherer implements LH.Gatherer.FRGathererInstance<'DevtoolsLog'> {
/** @type {LH.Gatherer.GathererMeta<'DevtoolsLog'>} */
meta: LH.Gatherer.GathererMeta<'DevtoolsLog'>;
/**
* @param {LH.Gatherer.FRTransitionalContext<'DevtoolsLog'>} passContext
* @return {Promise<LH.Artifacts['devtoolsLogs']>}
*/
getArtifact(passContext: LH.Gatherer.FRTransitionalContext<'DevtoolsLog'>): Promise<LH.Artifacts['devtoolsLogs']>;
}
import FRGatherer from '../base-gatherer.js';
//# sourceMappingURL=devtools-log-compat.d.ts.map

View File

@@ -0,0 +1,35 @@
/**
* @license Copyright 2021 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/**
* @fileoverview
* This gatherer remaps the result of the DevtoolsLog gatherer for compatibility with legacy Lighthouse
* when devtools logs and traces were special-cased.
*/
import DevtoolsLogGatherer from './devtools-log.js';
import FRGatherer from '../base-gatherer.js';
/** @implements {LH.Gatherer.FRGathererInstance<'DevtoolsLog'>} */
class DevtoolsLogCompat extends FRGatherer {
/** @type {LH.Gatherer.GathererMeta<'DevtoolsLog'>} */
meta = {
supportedModes: ['timespan', 'navigation'],
dependencies: {DevtoolsLog: DevtoolsLogGatherer.symbol},
};
/**
* @param {LH.Gatherer.FRTransitionalContext<'DevtoolsLog'>} passContext
* @return {Promise<LH.Artifacts['devtoolsLogs']>}
*/
async getArtifact(passContext) {
return {
defaultPass: passContext.dependencies.DevtoolsLog,
};
}
}
export default DevtoolsLogCompat;

View File

@@ -0,0 +1,48 @@
export default DevtoolsLog;
declare class DevtoolsLog extends FRGatherer {
static symbol: symbol;
_messageLog: DevtoolsMessageLog;
/** @param {LH.Protocol.RawEventMessage} e */
_onProtocolMessage: (e: LH.Protocol.RawEventMessage) => void;
/**
* @param {LH.Gatherer.FRTransitionalContext} passContext
*/
startSensitiveInstrumentation({ driver }: LH.Gatherer.FRTransitionalContext): Promise<void>;
/**
* @param {LH.Gatherer.FRTransitionalContext} passContext
*/
stopSensitiveInstrumentation({ driver }: LH.Gatherer.FRTransitionalContext): Promise<void>;
/**
* @return {Promise<LH.Artifacts['DevtoolsLog']>}
*/
getArtifact(): Promise<LH.Artifacts['DevtoolsLog']>;
}
/**
* This class saves all protocol messages whose method match a particular
* regex filter. Used when saving assets for later analysis by another tool such as
* Webpagetest.
*/
export class DevtoolsMessageLog {
/**
* @param {RegExp=} regexFilter
*/
constructor(regexFilter?: RegExp | undefined);
_filter: RegExp | undefined;
/** @type {LH.DevtoolsLog} */
_messages: import("../../index.js").DevtoolsLog;
_isRecording: boolean;
/**
* @return {LH.DevtoolsLog}
*/
get messages(): import("../../index.js").DevtoolsLog;
reset(): void;
beginRecording(): void;
endRecording(): void;
/**
* Records a message if method matches filter and recording has been started.
* @param {LH.Protocol.RawEventMessage} message
*/
record(message: LH.Protocol.RawEventMessage): void;
}
import FRGatherer from '../base-gatherer.js';
//# sourceMappingURL=devtools-log.d.ts.map

View File

@@ -0,0 +1,115 @@
/**
* @license Copyright 2021 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/**
* @fileoverview
* This gatherer collects all network and page devtools protocol traffic during the timespan/navigation.
* This protocol log can be used to recreate the network records using lib/network-recorder.js.
*/
import FRGatherer from '../base-gatherer.js';
class DevtoolsLog extends FRGatherer {
static symbol = Symbol('DevtoolsLog');
/** @type {LH.Gatherer.GathererMeta} */
meta = {
symbol: DevtoolsLog.symbol,
supportedModes: ['timespan', 'navigation'],
};
constructor() {
super();
this._messageLog = new DevtoolsMessageLog(/^(Page|Network|Target|Runtime)\./);
/** @param {LH.Protocol.RawEventMessage} e */
this._onProtocolMessage = e => this._messageLog.record(e);
}
/**
* @param {LH.Gatherer.FRTransitionalContext} passContext
*/
async startSensitiveInstrumentation({driver}) {
this._messageLog.reset();
this._messageLog.beginRecording();
driver.targetManager.on('protocolevent', this._onProtocolMessage);
await driver.defaultSession.sendCommand('Page.enable');
}
/**
* @param {LH.Gatherer.FRTransitionalContext} passContext
*/
async stopSensitiveInstrumentation({driver}) {
this._messageLog.endRecording();
driver.targetManager.off('protocolevent', this._onProtocolMessage);
}
/**
* @return {Promise<LH.Artifacts['DevtoolsLog']>}
*/
async getArtifact() {
return this._messageLog.messages;
}
}
/**
* This class saves all protocol messages whose method match a particular
* regex filter. Used when saving assets for later analysis by another tool such as
* Webpagetest.
*/
class DevtoolsMessageLog {
/**
* @param {RegExp=} regexFilter
*/
constructor(regexFilter) {
this._filter = regexFilter;
/** @type {LH.DevtoolsLog} */
this._messages = [];
this._isRecording = false;
}
/**
* @return {LH.DevtoolsLog}
*/
get messages() {
return this._messages;
}
reset() {
this._messages = [];
}
beginRecording() {
this._isRecording = true;
}
endRecording() {
this._isRecording = false;
}
/**
* Records a message if method matches filter and recording has been started.
* @param {LH.Protocol.RawEventMessage} message
*/
record(message) {
// We're not recording, skip the rest of the checks.
if (!this._isRecording) return;
// The event was likely an internal puppeteer method that uses Symbols.
if (typeof message.method !== 'string') return;
// The event didn't pass our filter, do not record it.
if (this._filter && !this._filter.test(message.method)) return;
// We passed all the checks, record the message.
this._messages.push(message);
}
}
export default DevtoolsLog;
export {DevtoolsMessageLog};

View File

@@ -0,0 +1,10 @@
export default Doctype;
declare class Doctype extends FRGatherer {
/**
* @param {LH.Gatherer.FRTransitionalContext} passContext
* @return {Promise<LH.Artifacts['Doctype']>}
*/
getArtifact(passContext: LH.Gatherer.FRTransitionalContext): Promise<LH.Artifacts['Doctype']>;
}
import FRGatherer from '../../base-gatherer.js';
//# sourceMappingURL=doctype.d.ts.map

View File

@@ -0,0 +1,47 @@
/**
* @license Copyright 2018 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
import FRGatherer from '../../base-gatherer.js';
/* global document */
/**
* Get and return `name`, `publicId`, `systemId` from
* `document.doctype`
* and `compatMode` from `document` to check `quirks-mode`
* @return {{name: string, publicId: string, systemId: string, documentCompatMode: string} | null}
*/
function getDoctype() {
// An example of this is warnerbros.com/archive/spacejam/movie/jam.htm
if (!document.doctype) {
return null;
}
const documentCompatMode = document.compatMode;
const {name, publicId, systemId} = document.doctype;
return {name, publicId, systemId, documentCompatMode};
}
class Doctype extends FRGatherer {
/** @type {LH.Gatherer.GathererMeta} */
meta = {
supportedModes: ['snapshot', 'navigation'],
};
/**
* @param {LH.Gatherer.FRTransitionalContext} passContext
* @return {Promise<LH.Artifacts['Doctype']>}
*/
getArtifact(passContext) {
const driver = passContext.driver;
return driver.executionContext.evaluate(getDoctype, {
args: [],
useIsolation: true,
});
}
}
export default Doctype;

View File

@@ -0,0 +1,10 @@
export default DOMStats;
declare class DOMStats extends FRGatherer {
/**
* @param {LH.Gatherer.FRTransitionalContext} passContext
* @return {Promise<LH.Artifacts['DOMStats']>}
*/
getArtifact(passContext: LH.Gatherer.FRTransitionalContext): Promise<LH.Artifacts['DOMStats']>;
}
import FRGatherer from '../../base-gatherer.js';
//# sourceMappingURL=domstats.d.ts.map

View File

@@ -0,0 +1,102 @@
/**
* @license Copyright 2017 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/**
* @fileoverview Gathers stats about the max height and width of the DOM tree
* and total number of elements used on the page.
*/
/* global getNodeDetails document */
import FRGatherer from '../../base-gatherer.js';
import {pageFunctions} from '../../../lib/page-functions.js';
/**
* Calculates the maximum tree depth of the DOM.
* @param {HTMLElement} element Root of the tree to look in.
* @param {boolean=} deep True to include shadow roots. Defaults to true.
* @return {LH.Artifacts.DOMStats}
*/
/* c8 ignore start */
function getDOMStats(element = document.body, deep = true) {
let deepestElement = null;
let maxDepth = -1;
let maxWidth = -1;
let numElements = 0;
let parentWithMostChildren = null;
/**
* @param {Element|ShadowRoot} element
* @param {number} depth
*/
const _calcDOMWidthAndHeight = function(element, depth = 1) {
if (depth > maxDepth) {
deepestElement = element;
maxDepth = depth;
}
if (element.children.length > maxWidth) {
parentWithMostChildren = element;
maxWidth = element.children.length;
}
let child = element.firstElementChild;
while (child) {
_calcDOMWidthAndHeight(child, depth + 1);
// If element has shadow dom, traverse into that tree.
if (deep && child.shadowRoot) {
_calcDOMWidthAndHeight(child.shadowRoot, depth + 1);
}
child = child.nextElementSibling;
numElements++;
}
return {maxDepth, maxWidth, numElements};
};
const result = _calcDOMWidthAndHeight(element);
return {
depth: {
max: result.maxDepth,
// @ts-expect-error - getNodeDetails put into scope via stringification
...getNodeDetails(deepestElement),
},
width: {
max: result.maxWidth,
// @ts-expect-error - getNodeDetails put into scope via stringification
...getNodeDetails(parentWithMostChildren),
},
totalBodyElements: result.numElements,
};
}
/* c8 ignore stop */
class DOMStats extends FRGatherer {
/** @type {LH.Gatherer.GathererMeta} */
meta = {
supportedModes: ['snapshot', 'navigation'],
};
/**
* @param {LH.Gatherer.FRTransitionalContext} passContext
* @return {Promise<LH.Artifacts['DOMStats']>}
*/
async getArtifact(passContext) {
const driver = passContext.driver;
await driver.defaultSession.sendCommand('DOM.enable');
const results = await driver.executionContext.evaluate(getDOMStats, {
args: [],
useIsolation: true,
deps: [pageFunctions.getNodeDetails],
});
await driver.defaultSession.sendCommand('DOM.disable');
return results;
}
}
export default DOMStats;

View File

@@ -0,0 +1,61 @@
export default OptimizedImages;
export type SimplifiedNetworkRecord = {
requestId: string;
url: string;
mimeType: string;
resourceSize: number;
};
/** @typedef {{requestId: string, url: string, mimeType: string, resourceSize: number}} SimplifiedNetworkRecord */
declare class OptimizedImages extends FRGatherer {
/**
* @param {Array<LH.Artifacts.NetworkRequest>} networkRecords
* @return {Array<SimplifiedNetworkRecord>}
*/
static filterImageRequests(networkRecords: Array<LH.Artifacts.NetworkRequest>): Array<SimplifiedNetworkRecord>;
/** @type {LH.Gatherer.GathererMeta<'DevtoolsLog'>} */
meta: LH.Gatherer.GathererMeta<'DevtoolsLog'>;
_encodingStartAt: number;
/**
* @param {LH.Gatherer.FRProtocolSession} session
* @param {string} requestId
* @param {'jpeg'|'webp'} encoding Either webp or jpeg.
* @return {Promise<LH.Crdp.Audits.GetEncodedResponseResponse>}
*/
_getEncodedResponse(session: LH.Gatherer.FRProtocolSession, requestId: string, encoding: 'jpeg' | 'webp'): Promise<LH.Crdp.Audits.GetEncodedResponseResponse>;
/**
* @param {LH.Gatherer.FRProtocolSession} session
* @param {SimplifiedNetworkRecord} networkRecord
* @return {Promise<{originalSize: number, jpegSize?: number, webpSize?: number}>}
*/
calculateImageStats(session: LH.Gatherer.FRProtocolSession, networkRecord: SimplifiedNetworkRecord): Promise<{
originalSize: number;
jpegSize?: number | undefined;
webpSize?: number | undefined;
}>;
/**
* @param {LH.Gatherer.FRProtocolSession} session
* @param {Array<SimplifiedNetworkRecord>} imageRecords
* @return {Promise<LH.Artifacts['OptimizedImages']>}
*/
computeOptimizedImages(session: LH.Gatherer.FRProtocolSession, imageRecords: Array<SimplifiedNetworkRecord>): Promise<LH.Artifacts['OptimizedImages']>;
/**
* @param {LH.Gatherer.FRTransitionalContext} context
* @param {LH.Artifacts.NetworkRequest[]} networkRecords
* @return {Promise<LH.Artifacts['OptimizedImages']>}
*/
_getArtifact(context: LH.Gatherer.FRTransitionalContext, networkRecords: LH.Artifacts.NetworkRequest[]): Promise<LH.Artifacts['OptimizedImages']>;
/**
* @param {LH.Gatherer.FRTransitionalContext<'DevtoolsLog'>} context
* @return {Promise<LH.Artifacts['OptimizedImages']>}
*/
getArtifact(context: LH.Gatherer.FRTransitionalContext<'DevtoolsLog'>): Promise<LH.Artifacts['OptimizedImages']>;
/**
* @param {LH.Gatherer.PassContext} passContext
* @param {LH.Gatherer.LoadData} loadData
* @return {Promise<LH.Artifacts['OptimizedImages']>}
*/
afterPass(passContext: LH.Gatherer.PassContext, loadData: LH.Gatherer.LoadData): Promise<LH.Artifacts['OptimizedImages']>;
}
import FRGatherer from '../../base-gatherer.js';
import { NetworkRequest } from '../../../lib/network-request.js';
//# sourceMappingURL=optimized-images.d.ts.map

View File

@@ -0,0 +1,193 @@
/**
* @license Copyright 2017 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/**
* @fileoverview Determines optimized jpeg/webp filesizes for all same-origin and dataURI images by
* running the images through canvas in the browser context.
*/
import log from 'lighthouse-logger';
import FRGatherer from '../../base-gatherer.js';
import UrlUtils from '../../../lib/url-utils.js';
import {NetworkRequest} from '../../../lib/network-request.js';
import {Sentry} from '../../../lib/sentry.js';
import {NetworkRecords} from '../../../computed/network-records.js';
import DevtoolsLog from '../devtools-log.js';
// Image encoding can be slow and we don't want to spend forever on it.
// Cap our encoding to 5 seconds, anything after that will be estimated.
const MAX_TIME_TO_SPEND_ENCODING = 5000;
// Cap our image file size at 2MB, anything bigger than that will be estimated.
const MAX_RESOURCE_SIZE_TO_ENCODE = 2000 * 1024;
const JPEG_QUALITY = 0.92;
const WEBP_QUALITY = 0.85;
const MINIMUM_IMAGE_SIZE = 4096; // savings of <4 KiB will be ignored in the audit anyway
const IMAGE_REGEX = /^image\/((x|ms|x-ms)-)?(png|bmp|jpeg)$/;
/** @typedef {{requestId: string, url: string, mimeType: string, resourceSize: number}} SimplifiedNetworkRecord */
class OptimizedImages extends FRGatherer {
/** @type {LH.Gatherer.GathererMeta<'DevtoolsLog'>} */
meta = {
supportedModes: ['timespan', 'navigation'],
dependencies: {DevtoolsLog: DevtoolsLog.symbol},
};
constructor() {
super();
this._encodingStartAt = 0;
}
/**
* @param {Array<LH.Artifacts.NetworkRequest>} networkRecords
* @return {Array<SimplifiedNetworkRecord>}
*/
static filterImageRequests(networkRecords) {
/** @type {Set<string>} */
const seenUrls = new Set();
return networkRecords.reduce((prev, record) => {
// Skip records that we've seen before, never finished, or came from OOPIFs.
if (seenUrls.has(record.url) || !record.finished || record.isOutOfProcessIframe) {
return prev;
}
seenUrls.add(record.url);
const isOptimizableImage = record.resourceType === NetworkRequest.TYPES.Image &&
IMAGE_REGEX.test(record.mimeType);
const actualResourceSize = NetworkRequest.getResourceSizeOnNetwork(record);
if (isOptimizableImage && actualResourceSize > MINIMUM_IMAGE_SIZE) {
prev.push({
requestId: record.requestId,
url: record.url,
mimeType: record.mimeType,
resourceSize: actualResourceSize,
});
}
return prev;
}, /** @type {Array<SimplifiedNetworkRecord>} */ ([]));
}
/**
* @param {LH.Gatherer.FRProtocolSession} session
* @param {string} requestId
* @param {'jpeg'|'webp'} encoding Either webp or jpeg.
* @return {Promise<LH.Crdp.Audits.GetEncodedResponseResponse>}
*/
_getEncodedResponse(session, requestId, encoding) {
requestId = NetworkRequest.getRequestIdForBackend(requestId);
const quality = encoding === 'jpeg' ? JPEG_QUALITY : WEBP_QUALITY;
const params = {requestId, encoding, quality, sizeOnly: true};
return session.sendCommand('Audits.getEncodedResponse', params);
}
/**
* @param {LH.Gatherer.FRProtocolSession} session
* @param {SimplifiedNetworkRecord} networkRecord
* @return {Promise<{originalSize: number, jpegSize?: number, webpSize?: number}>}
*/
async calculateImageStats(session, networkRecord) {
const originalSize = networkRecord.resourceSize;
// Once we've hit our execution time limit or when the image is too big, don't try to re-encode it.
// Images in this execution path will fallback to byte-per-pixel heuristics on the audit side.
if (Date.now() - this._encodingStartAt > MAX_TIME_TO_SPEND_ENCODING ||
originalSize > MAX_RESOURCE_SIZE_TO_ENCODE) {
return {originalSize, jpegSize: undefined, webpSize: undefined};
}
const jpegData = await this._getEncodedResponse(session, networkRecord.requestId, 'jpeg');
const webpData = await this._getEncodedResponse(session, networkRecord.requestId, 'webp');
return {
originalSize,
jpegSize: jpegData.encodedSize,
webpSize: webpData.encodedSize,
};
}
/**
* @param {LH.Gatherer.FRProtocolSession} session
* @param {Array<SimplifiedNetworkRecord>} imageRecords
* @return {Promise<LH.Artifacts['OptimizedImages']>}
*/
async computeOptimizedImages(session, imageRecords) {
this._encodingStartAt = Date.now();
/** @type {LH.Artifacts['OptimizedImages']} */
const results = [];
for (const record of imageRecords) {
try {
const stats = await this.calculateImageStats(session, record);
/** @type {LH.Artifacts.OptimizedImage} */
const image = {failed: false, ...stats, ...record};
results.push(image);
} catch (err) {
log.warn('optimized-images', err.message);
// Track this with Sentry since these errors aren't surfaced anywhere else, but we don't
// want to tank the entire run due to a single image.
Sentry.captureException(err, {
tags: {gatherer: 'OptimizedImages'},
extra: {imageUrl: UrlUtils.elideDataURI(record.url)},
level: 'warning',
});
/** @type {LH.Artifacts.OptimizedImageError} */
const imageError = {failed: true, errMsg: err.message, ...record};
results.push(imageError);
}
}
return results;
}
/**
* @param {LH.Gatherer.FRTransitionalContext} context
* @param {LH.Artifacts.NetworkRequest[]} networkRecords
* @return {Promise<LH.Artifacts['OptimizedImages']>}
*/
async _getArtifact(context, networkRecords) {
const imageRecords = OptimizedImages
.filterImageRequests(networkRecords)
.sort((a, b) => b.resourceSize - a.resourceSize);
const results = await this.computeOptimizedImages(context.driver.defaultSession, imageRecords);
const successfulResults = results.filter(result => !result.failed);
if (results.length && !successfulResults.length) {
throw new Error('All image optimizations failed');
}
return results;
}
/**
* @param {LH.Gatherer.FRTransitionalContext<'DevtoolsLog'>} context
* @return {Promise<LH.Artifacts['OptimizedImages']>}
*/
async getArtifact(context) {
const devtoolsLog = context.dependencies.DevtoolsLog;
const networkRecords = await NetworkRecords.request(devtoolsLog, context);
return this._getArtifact(context, networkRecords);
}
/**
* @param {LH.Gatherer.PassContext} passContext
* @param {LH.Gatherer.LoadData} loadData
* @return {Promise<LH.Artifacts['OptimizedImages']>}
*/
async afterPass(passContext, loadData) {
return this._getArtifact({...passContext, dependencies: {}}, loadData.networkRecords);
}
}
export default OptimizedImages;

View File

@@ -0,0 +1,30 @@
export default ResponseCompression;
declare class ResponseCompression extends FRGatherer {
/**
* @param {LH.Artifacts.NetworkRequest[]} networkRecords
* @return {LH.Artifacts['ResponseCompression']}
*/
static filterUnoptimizedResponses(networkRecords: LH.Artifacts.NetworkRequest[]): LH.Artifacts['ResponseCompression'];
/** @type {LH.Gatherer.GathererMeta<'DevtoolsLog'>} */
meta: LH.Gatherer.GathererMeta<'DevtoolsLog'>;
/**
* @param {LH.Gatherer.FRTransitionalContext} context
* @param {LH.Artifacts.NetworkRequest[]} networkRecords
* @return {Promise<LH.Artifacts['ResponseCompression']>}
*/
_getArtifact(context: LH.Gatherer.FRTransitionalContext, networkRecords: LH.Artifacts.NetworkRequest[]): Promise<LH.Artifacts['ResponseCompression']>;
/**
* @param {LH.Gatherer.FRTransitionalContext<'DevtoolsLog'>} context
* @return {Promise<LH.Artifacts['ResponseCompression']>}
*/
getArtifact(context: LH.Gatherer.FRTransitionalContext<'DevtoolsLog'>): Promise<LH.Artifacts['ResponseCompression']>;
/**
* @param {LH.Gatherer.PassContext} passContext
* @param {LH.Gatherer.LoadData} loadData
* @return {Promise<LH.Artifacts['ResponseCompression']>}
*/
afterPass(passContext: LH.Gatherer.PassContext, loadData: LH.Gatherer.LoadData): Promise<LH.Artifacts['ResponseCompression']>;
}
import FRGatherer from '../../base-gatherer.js';
import { NetworkRequest } from '../../../lib/network-request.js';
//# sourceMappingURL=response-compression.d.ts.map

View File

@@ -0,0 +1,154 @@
/**
* @license Copyright 2017 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/**
* @fileoverview Determines optimized gzip/br/deflate filesizes for all responses by
* checking the content-encoding header.
*/
import {Buffer} from 'buffer';
import {gzip} from 'zlib';
import FRGatherer from '../../base-gatherer.js';
import UrlUtils from '../../../lib/url-utils.js';
import {Sentry} from '../../../lib/sentry.js';
import {NetworkRequest} from '../../../lib/network-request.js';
import DevtoolsLog from '../devtools-log.js';
import {fetchResponseBodyFromCache} from '../../driver/network.js';
import {NetworkRecords} from '../../../computed/network-records.js';
const CHROME_EXTENSION_PROTOCOL = 'chrome-extension:';
const compressionHeaders = [
'content-encoding',
'x-original-content-encoding',
'x-content-encoding-over-network',
];
const compressionTypes = ['gzip', 'br', 'deflate'];
const binaryMimeTypes = ['image', 'audio', 'video'];
/** @type {LH.Crdp.Network.ResourceType[]} */
const textResourceTypes = [
NetworkRequest.TYPES.Document,
NetworkRequest.TYPES.Script,
NetworkRequest.TYPES.Stylesheet,
NetworkRequest.TYPES.XHR,
NetworkRequest.TYPES.Fetch,
NetworkRequest.TYPES.EventSource,
];
class ResponseCompression extends FRGatherer {
/** @type {LH.Gatherer.GathererMeta<'DevtoolsLog'>} */
meta = {
supportedModes: ['timespan', 'navigation'],
dependencies: {DevtoolsLog: DevtoolsLog.symbol},
};
/**
* @param {LH.Artifacts.NetworkRequest[]} networkRecords
* @return {LH.Artifacts['ResponseCompression']}
*/
static filterUnoptimizedResponses(networkRecords) {
/** @type {LH.Artifacts['ResponseCompression']} */
const unoptimizedResponses = [];
networkRecords.forEach(record => {
if (record.isOutOfProcessIframe) return;
const mimeType = record.mimeType;
const resourceType = record.resourceType || NetworkRequest.TYPES.Other;
const resourceSize = record.resourceSize;
const isBinaryResource = mimeType && binaryMimeTypes.some(type => mimeType.startsWith(type));
const isTextResource = !isBinaryResource && textResourceTypes.includes(resourceType);
const isChromeExtensionResource = record.url.startsWith(CHROME_EXTENSION_PROTOCOL);
if (!isTextResource || !resourceSize || !record.finished ||
isChromeExtensionResource || !record.transferSize || record.statusCode === 304) {
return;
}
const isContentEncoded = (record.responseHeaders || []).find(header =>
compressionHeaders.includes(header.name.toLowerCase()) &&
compressionTypes.includes(header.value)
);
if (!isContentEncoded) {
unoptimizedResponses.push({
requestId: record.requestId,
url: record.url,
mimeType: mimeType,
transferSize: record.transferSize,
resourceSize: resourceSize,
gzipSize: 0,
});
}
});
return unoptimizedResponses;
}
/**
* @param {LH.Gatherer.FRTransitionalContext} context
* @param {LH.Artifacts.NetworkRequest[]} networkRecords
* @return {Promise<LH.Artifacts['ResponseCompression']>}
*/
async _getArtifact(context, networkRecords) {
const session = context.driver.defaultSession;
const textRecords = ResponseCompression.filterUnoptimizedResponses(networkRecords);
return Promise.all(textRecords.map(record => {
return fetchResponseBodyFromCache(session, record.requestId).then(content => {
// if we don't have any content, gzipSize is already set to 0
if (!content) {
return record;
}
return new Promise((resolve, reject) => {
return gzip(content, (err, res) => {
if (err) {
return reject(err);
}
// get gzip size
record.gzipSize = Buffer.byteLength(res, 'utf8');
resolve(record);
});
});
}).catch(err => {
Sentry.captureException(err, {
tags: {gatherer: 'ResponseCompression'},
extra: {url: UrlUtils.elideDataURI(record.url)},
level: 'warning',
});
record.gzipSize = undefined;
return record;
});
}));
}
/**
* @param {LH.Gatherer.FRTransitionalContext<'DevtoolsLog'>} context
* @return {Promise<LH.Artifacts['ResponseCompression']>}
*/
async getArtifact(context) {
const devtoolsLog = context.dependencies.DevtoolsLog;
const networkRecords = await NetworkRecords.request(devtoolsLog, context);
return this._getArtifact(context, networkRecords);
}
/**
* @param {LH.Gatherer.PassContext} passContext
* @param {LH.Gatherer.LoadData} loadData
* @return {Promise<LH.Artifacts['ResponseCompression']>}
*/
async afterPass(passContext, loadData) {
return this._getArtifact({...passContext, dependencies: {}}, loadData.networkRecords);
}
}
export default ResponseCompression;

View File

@@ -0,0 +1,53 @@
export default TagsBlockingFirstPaint;
export type MediaChange = {
href: string;
media: string;
msSinceHTMLEnd: number;
matches: boolean;
};
export type LinkTag = {
tagName: 'LINK';
url: string;
href: string;
rel: string;
media: string;
disabled: boolean;
mediaChanges: Array<MediaChange>;
};
export type ScriptTag = {
tagName: 'SCRIPT';
url: string;
src: string;
};
declare class TagsBlockingFirstPaint extends FRGatherer {
/**
* @param {Array<LH.Artifacts.NetworkRequest>} networkRecords
* @return {Map<string, LH.Artifacts.NetworkRequest>}
*/
static _filteredAndIndexedByUrl(networkRecords: Array<LH.Artifacts.NetworkRequest>): Map<string, LH.Artifacts.NetworkRequest>;
/**
* @param {LH.Gatherer.FRTransitionalDriver} driver
* @param {Array<LH.Artifacts.NetworkRequest>} networkRecords
* @return {Promise<Array<LH.Artifacts.TagBlockingFirstPaint>>}
*/
static findBlockingTags(driver: LH.Gatherer.FRTransitionalDriver, networkRecords: Array<LH.Artifacts.NetworkRequest>): Promise<Array<LH.Artifacts.TagBlockingFirstPaint>>;
/** @type {LH.Gatherer.GathererMeta<'DevtoolsLog'>} */
meta: LH.Gatherer.GathererMeta<'DevtoolsLog'>;
/**
* @param {LH.Gatherer.FRTransitionalContext} context
*/
startSensitiveInstrumentation(context: LH.Gatherer.FRTransitionalContext): Promise<void>;
/**
* @param {LH.Gatherer.FRTransitionalContext<'DevtoolsLog'>} context
* @return {Promise<LH.Artifacts['TagsBlockingFirstPaint']>}
*/
getArtifact(context: LH.Gatherer.FRTransitionalContext<'DevtoolsLog'>): Promise<LH.Artifacts['TagsBlockingFirstPaint']>;
/**
* @param {LH.Gatherer.PassContext} passContext
* @param {LH.Gatherer.LoadData} loadData
* @return {Promise<LH.Artifacts['TagsBlockingFirstPaint']>}
*/
afterPass(passContext: LH.Gatherer.PassContext, loadData: LH.Gatherer.LoadData): Promise<LH.Artifacts['TagsBlockingFirstPaint']>;
}
import FRGatherer from '../../base-gatherer.js';
//# sourceMappingURL=tags-blocking-first-paint.d.ts.map

View File

@@ -0,0 +1,237 @@
/**
* @license Copyright 2016 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/**
* @fileoverview
* Identifies stylesheets, HTML Imports, and scripts that potentially block
* the first paint of the page by running several scripts in the page context.
* Candidate blocking tags are collected by querying for all script tags in
* the head of the page and all link tags that are either matching media
* stylesheets or non-async HTML imports. These are then compared to the
* network requests to ensure they were initiated by the parser and not
* injected with script. To avoid false positives from strategies like
* (http://filamentgroup.github.io/loadCSS/test/preload.html), a separate
* script is run to flag all links that at one point were rel=preload.
*/
import {NetworkRecords} from '../../../computed/network-records.js';
import DevtoolsLog from '../devtools-log.js';
import FRGatherer from '../../base-gatherer.js';
/* global document, window, 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 */
/** @typedef {{tagName: 'SCRIPT', url: string, src: string}} ScriptTag */
/* c8 ignore start */
function installMediaListener() {
// @ts-expect-error - inserted in page to track media changes.
window.___linkMediaChanges = [];
Object.defineProperty(HTMLLinkElement.prototype, 'media', {
set: function(val) {
/** @type {MediaChange} */
const mediaChange = {
href: this.href,
media: val,
msSinceHTMLEnd: Date.now() - performance.timing.responseEnd,
matches: window.matchMedia(val).matches,
};
// @ts-expect-error - `___linkMediaChanges` created above.
window.___linkMediaChanges.push(mediaChange);
this.setAttribute('media', val);
},
});
}
/* c8 ignore stop */
/**
* @return {Promise<Array<LinkTag | ScriptTag>>}
*/
/* c8 ignore start */
async function collectTagsThatBlockFirstPaint() {
/** @type {Array<MediaChange>} */
// @ts-expect-error - `___linkMediaChanges` created in `installMediaListener`.
const linkMediaChanges = window.___linkMediaChanges;
try {
/** @type {Array<LinkTag>} */
const linkTags = [...document.querySelectorAll('link')]
.filter(linkTag => {
// Filter stylesheet/HTML imports that block rendering.
// https://www.igvita.com/2012/06/14/debunking-responsive-css-performance-myths/
// https://www.w3.org/TR/html-imports/#dfn-import-async-attribute
const blockingStylesheet = linkTag.rel === 'stylesheet' &&
window.matchMedia(linkTag.media).matches && !linkTag.disabled;
const blockingImport = linkTag.rel === 'import' && !linkTag.hasAttribute('async');
return blockingStylesheet || blockingImport;
})
.map(tag => {
return {
tagName: 'LINK',
url: tag.href,
href: tag.href,
rel: tag.rel,
media: tag.media,
disabled: tag.disabled,
mediaChanges: linkMediaChanges.filter(item => item.href === tag.href),
};
});
/** @type {Array<ScriptTag>} */
const scriptTags = [...document.querySelectorAll('head script[src]')]
.filter(/** @return {scriptTag is HTMLScriptElement} */ scriptTag => {
// SVGScriptElement can't appear in <head> (it'll be kicked to <body>), but keep tsc happy.
// https://html.spec.whatwg.org/multipage/semantics.html#the-head-element
if (scriptTag instanceof SVGScriptElement) return false;
return (
!scriptTag.hasAttribute('async') &&
!scriptTag.hasAttribute('defer') &&
!/^data:/.test(scriptTag.src) &&
!/^blob:/.test(scriptTag.src) &&
scriptTag.getAttribute('type') !== 'module'
);
})
.map(tag => {
return {
tagName: 'SCRIPT',
url: tag.src,
src: tag.src,
};
});
return [...linkTags, ...scriptTags];
} catch (e) {
const friendly = 'Unable to gather Scripts/Stylesheets/HTML Imports on the page';
throw new Error(`${friendly}: ${e.message}`);
}
}
/* c8 ignore stop */
class TagsBlockingFirstPaint extends FRGatherer {
/** @type {LH.Gatherer.GathererMeta<'DevtoolsLog'>} */
meta = {
supportedModes: ['navigation'],
dependencies: {DevtoolsLog: DevtoolsLog.symbol},
};
/**
* @param {Array<LH.Artifacts.NetworkRequest>} networkRecords
* @return {Map<string, LH.Artifacts.NetworkRequest>}
*/
static _filteredAndIndexedByUrl(networkRecords) {
/** @type {Map<string, LH.Artifacts.NetworkRequest>} */
const result = new Map();
for (const record of networkRecords) {
if (!record.finished) continue;
const isParserGenerated = record.initiator.type === 'parser';
// A stylesheet only blocks script if it was initiated by the parser
// https://html.spec.whatwg.org/multipage/semantics.html#interactions-of-styling-and-scripting
const isParserScriptOrStyle = /(css|script)/.test(record.mimeType) && isParserGenerated;
const isFailedRequest = record.failed;
const isHtml = record.mimeType && record.mimeType.includes('html');
// Filter stylesheet, javascript, and html import mimetypes.
// Include 404 scripts/links generated by the parser because they are likely blocking.
if (isHtml || isParserScriptOrStyle || (isFailedRequest && isParserGenerated)) {
result.set(record.url, record);
}
}
return result;
}
/**
* @param {LH.Gatherer.FRTransitionalDriver} driver
* @param {Array<LH.Artifacts.NetworkRequest>} networkRecords
* @return {Promise<Array<LH.Artifacts.TagBlockingFirstPaint>>}
*/
static async findBlockingTags(driver, networkRecords) {
const firstRequestEndTime = networkRecords.reduce(
(min, record) => Math.min(min, record.networkEndTime),
Infinity
);
const tags = await driver.executionContext.evaluate(collectTagsThatBlockFirstPaint, {args: []});
const requests = TagsBlockingFirstPaint._filteredAndIndexedByUrl(networkRecords);
/** @type {Array<LH.Artifacts.TagBlockingFirstPaint>} */
const result = [];
for (const tag of tags) {
const request = requests.get(tag.url);
if (!request || request.isLinkPreload) continue;
let endTime = request.networkEndTime;
let mediaChanges;
if (tag.tagName === 'LINK') {
// Even if the request was initially blocking or appeared to be blocking once the
// page was loaded, the media attribute could have been changed during load, capping the
// amount of time it was render blocking. See https://github.com/GoogleChrome/lighthouse/issues/2832.
const timesResourceBecameNonBlocking = tag.mediaChanges
.filter(change => !change.matches)
.map(change => change.msSinceHTMLEnd);
if (timesResourceBecameNonBlocking.length > 0) {
const earliestNonBlockingTime = Math.min(...timesResourceBecameNonBlocking);
const lastTimeResourceWasBlocking = Math.max(
request.networkRequestTime,
firstRequestEndTime + earliestNonBlockingTime / 1000
);
endTime = Math.min(endTime, lastTimeResourceWasBlocking);
}
mediaChanges = tag.mediaChanges;
}
const {tagName, url} = tag;
result.push({
tag: {tagName, url, mediaChanges},
transferSize: request.transferSize,
startTime: request.networkRequestTime,
endTime,
});
// Prevent duplicates from showing up again
requests.delete(tag.url);
}
return result;
}
/**
* @param {LH.Gatherer.FRTransitionalContext} context
*/
async startSensitiveInstrumentation(context) {
const {executionContext} = context.driver;
// Don't return return value of `evaluateOnNewDocument`.
await executionContext.evaluateOnNewDocument(installMediaListener, {args: []});
}
/**
* @param {LH.Gatherer.FRTransitionalContext<'DevtoolsLog'>} context
* @return {Promise<LH.Artifacts['TagsBlockingFirstPaint']>}
*/
async getArtifact(context) {
const devtoolsLog = context.dependencies.DevtoolsLog;
const networkRecords = await NetworkRecords.request(devtoolsLog, context);
return TagsBlockingFirstPaint.findBlockingTags(context.driver, networkRecords);
}
/**
* @param {LH.Gatherer.PassContext} passContext
* @param {LH.Gatherer.LoadData} loadData
* @return {Promise<LH.Artifacts['TagsBlockingFirstPaint']>}
*/
afterPass(passContext, loadData) {
return TagsBlockingFirstPaint.findBlockingTags(passContext.driver, loadData.networkRecords);
}
}
export default TagsBlockingFirstPaint;

View File

@@ -0,0 +1,35 @@
export default FullPageScreenshot;
declare class FullPageScreenshot extends FRGatherer {
/**
* @param {LH.Gatherer.FRTransitionalContext} context
* @param {{height: number, width: number, mobile: boolean}} deviceMetrics
*/
_resizeViewport(context: LH.Gatherer.FRTransitionalContext, deviceMetrics: {
height: number;
width: number;
mobile: boolean;
}): Promise<void>;
/**
* @param {LH.Gatherer.FRTransitionalContext} context
* @return {Promise<LH.Result.FullPageScreenshot['screenshot']>}
*/
_takeScreenshot(context: LH.Gatherer.FRTransitionalContext): Promise<LH.Result.FullPageScreenshot['screenshot']>;
/**
* Gatherers can collect details about DOM nodes, including their position on the page.
* Layout shifts occuring after a gatherer runs can cause these positions to be incorrect,
* resulting in a poor experience for element screenshots.
* `getNodeDetails` maintains a collection of DOM objects in the page, which we can iterate
* to re-collect the bounding client rectangle.
* @see pageFunctions.getNodeDetails
* @param {LH.Gatherer.FRTransitionalContext} context
* @return {Promise<LH.Result.FullPageScreenshot['nodes']>}
*/
_resolveNodes(context: LH.Gatherer.FRTransitionalContext): Promise<LH.Result.FullPageScreenshot['nodes']>;
/**
* @param {LH.Gatherer.FRTransitionalContext} context
* @return {Promise<LH.Artifacts['FullPageScreenshot']>}
*/
getArtifact(context: LH.Gatherer.FRTransitionalContext): Promise<LH.Artifacts['FullPageScreenshot']>;
}
import FRGatherer from '../base-gatherer.js';
//# sourceMappingURL=full-page-screenshot.d.ts.map

View File

@@ -0,0 +1,258 @@
/**
* @license Copyright 2020 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/* globals window getBoundingClientRect requestAnimationFrame */
import FRGatherer from '../base-gatherer.js';
import * as emulation from '../../lib/emulation.js';
import {pageFunctions} from '../../lib/page-functions.js';
import {NetworkMonitor} from '../driver/network-monitor.js';
import {waitForNetworkIdle} from '../driver/wait-for-condition.js';
// JPEG quality setting
// Exploration and examples of reports using different quality settings: https://docs.google.com/document/d/1ZSffucIca9XDW2eEwfoevrk-OTl7WQFeMf0CgeJAA8M/edit#
// Note: this analysis was done for JPEG, but now we use WEBP.
const FULL_PAGE_SCREENSHOT_QUALITY = 30;
// https://developers.google.com/speed/webp/faq#what_is_the_maximum_size_a_webp_image_can_be
const MAX_WEBP_SIZE = 16383;
/**
* @template {string} S
* @param {S} str
*/
function kebabCaseToCamelCase(str) {
return /** @type {LH.Util.KebabToCamelCase<S>} */ (
str.replace(/(-\w)/g, m => m[1].toUpperCase())
);
}
/* c8 ignore start */
function getObservedDeviceMetrics() {
// Convert the Web API's kebab case (landscape-primary) to camel case (landscapePrimary).
const screenOrientationType = kebabCaseToCamelCase(window.screen.orientation.type);
return {
width: window.outerWidth,
height: window.outerHeight,
screenOrientation: {
type: screenOrientationType,
angle: window.screen.orientation.angle,
},
deviceScaleFactor: window.devicePixelRatio,
};
}
/**
* The screenshot dimensions are sized to `window.outerHeight` / `window.outerWidth`,
* however the bounding boxes of the elements are relative to `window.innerHeight` / `window.innerWidth`.
*/
function getScreenshotAreaSize() {
return {
width: window.innerWidth,
height: window.innerHeight,
};
}
function waitForDoubleRaf() {
return new Promise((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(resolve));
});
}
/* c8 ignore stop */
class FullPageScreenshot extends FRGatherer {
/** @type {LH.Gatherer.GathererMeta} */
meta = {
supportedModes: ['snapshot', 'timespan', 'navigation'],
};
/**
* @param {LH.Gatherer.FRTransitionalContext} context
* @param {{height: number, width: number, mobile: boolean}} deviceMetrics
*/
async _resizeViewport(context, deviceMetrics) {
const session = context.driver.defaultSession;
const metrics = await session.sendCommand('Page.getLayoutMetrics');
// Height should be as tall as the content.
// Scale the emulated height to reach the content height.
const fullHeight = Math.round(
deviceMetrics.height *
metrics.cssContentSize.height /
metrics.cssLayoutViewport.clientHeight
);
const height = Math.min(fullHeight, MAX_WEBP_SIZE);
// Setup network monitor before we change the viewport.
const networkMonitor = new NetworkMonitor(context.driver.targetManager);
const waitForNetworkIdleResult = waitForNetworkIdle(session, networkMonitor, {
pretendDCLAlreadyFired: true,
networkQuietThresholdMs: 1000,
busyEvent: 'network-critical-busy',
idleEvent: 'network-critical-idle',
isIdle: recorder => recorder.isCriticalIdle(),
});
await networkMonitor.enable();
await session.sendCommand('Emulation.setDeviceMetricsOverride', {
mobile: deviceMetrics.mobile,
deviceScaleFactor: 1,
height,
width: 0, // Leave width unchanged
});
// Now that the viewport is taller, give the page some time to fetch new resources that
// are now in view.
await Promise.race([
new Promise(resolve => setTimeout(resolve, 1000 * 5)),
waitForNetworkIdleResult.promise,
]);
waitForNetworkIdleResult.cancel();
await networkMonitor.disable();
// Now that new resources are (probably) fetched, wait long enough for a layout.
await context.driver.executionContext.evaluate(waitForDoubleRaf, {args: []});
}
/**
* @param {LH.Gatherer.FRTransitionalContext} context
* @return {Promise<LH.Result.FullPageScreenshot['screenshot']>}
*/
async _takeScreenshot(context) {
const result = await context.driver.defaultSession.sendCommand('Page.captureScreenshot', {
format: 'webp',
quality: FULL_PAGE_SCREENSHOT_QUALITY,
});
const data = 'data:image/webp;base64,' + result.data;
const screenshotAreaSize =
await context.driver.executionContext.evaluate(getScreenshotAreaSize, {
args: [],
useIsolation: true,
});
return {
data,
width: screenshotAreaSize.width,
height: screenshotAreaSize.height,
};
}
/**
* Gatherers can collect details about DOM nodes, including their position on the page.
* Layout shifts occuring after a gatherer runs can cause these positions to be incorrect,
* resulting in a poor experience for element screenshots.
* `getNodeDetails` maintains a collection of DOM objects in the page, which we can iterate
* to re-collect the bounding client rectangle.
* @see pageFunctions.getNodeDetails
* @param {LH.Gatherer.FRTransitionalContext} context
* @return {Promise<LH.Result.FullPageScreenshot['nodes']>}
*/
async _resolveNodes(context) {
function resolveNodes() {
/** @type {LH.Result.FullPageScreenshot['nodes']} */
const nodes = {};
if (!window.__lighthouseNodesDontTouchOrAllVarianceGoesAway) return nodes;
const lhIdToElements = window.__lighthouseNodesDontTouchOrAllVarianceGoesAway;
for (const [node, id] of lhIdToElements.entries()) {
// @ts-expect-error - getBoundingClientRect put into scope via stringification
const rect = getBoundingClientRect(node);
nodes[id] = rect;
}
return nodes;
}
/**
* @param {{useIsolation: boolean}} _
*/
function resolveNodesInPage({useIsolation}) {
return context.driver.executionContext.evaluate(resolveNodes, {
args: [],
useIsolation,
deps: [pageFunctions.getBoundingClientRect],
});
}
// Collect nodes with the page context (`useIsolation: false`) and with our own, reused
// context (`useIsolation: true`). Gatherers use both modes when collecting node details,
// so we must do the same here too.
const pageContextResult = await resolveNodesInPage({useIsolation: false});
const isolatedContextResult = await resolveNodesInPage({useIsolation: true});
return {...pageContextResult, ...isolatedContextResult};
}
/**
* @param {LH.Gatherer.FRTransitionalContext} context
* @return {Promise<LH.Artifacts['FullPageScreenshot']>}
*/
async getArtifact(context) {
const session = context.driver.defaultSession;
const executionContext = context.driver.executionContext;
const settings = context.settings;
const lighthouseControlsEmulation = !settings.screenEmulation.disabled;
// Make a copy so we don't modify the config settings.
/** @type {{width: number, height: number, deviceScaleFactor: number, mobile: boolean}} */
const deviceMetrics = {...settings.screenEmulation};
try {
if (!settings.usePassiveGathering) {
// In case some other program is controlling emulation, remember what the device looks like now and reset after gatherer is done.
// If we're gathering with mobile screenEmulation on (overlay scrollbars, etc), continue to use that for this screenshot.
if (!lighthouseControlsEmulation) {
const observedDeviceMetrics = await executionContext.evaluate(getObservedDeviceMetrics, {
args: [],
useIsolation: true,
deps: [kebabCaseToCamelCase],
});
deviceMetrics.height = observedDeviceMetrics.height;
deviceMetrics.width = observedDeviceMetrics.width;
deviceMetrics.deviceScaleFactor = observedDeviceMetrics.deviceScaleFactor;
// If screen emulation is disabled, use formFactor to determine if we are on mobile.
deviceMetrics.mobile = settings.formFactor === 'mobile';
}
await this._resizeViewport(context, deviceMetrics);
}
// Issue both commands at once, to reduce the chance that the page changes between capturing
// a screenshot and resolving the nodes. https://github.com/GoogleChrome/lighthouse/pull/14763
const [screenshot, nodes] =
await Promise.all([this._takeScreenshot(context), this._resolveNodes(context)]);
return {
screenshot,
nodes,
};
} finally {
if (!settings.usePassiveGathering) {
// Revert resized page.
if (lighthouseControlsEmulation) {
await emulation.emulate(session, settings);
} else {
// Best effort to reset emulation to what it was.
// https://github.com/GoogleChrome/lighthouse/pull/10716#discussion_r428970681
// TODO: seems like this would be brittle. Should at least work for devtools, but what
// about scripted puppeteer usages? Better to introduce a "setEmulation" callback
// in the LH runner api, which for ex. puppeteer consumers would setup puppeteer emulation,
// and then just call that to reset?
// https://github.com/GoogleChrome/lighthouse/issues/11122
await session.sendCommand('Emulation.setDeviceMetricsOverride', {
mobile: deviceMetrics.mobile,
deviceScaleFactor: deviceMetrics.deviceScaleFactor,
height: deviceMetrics.height,
width: 0, // Leave width unchanged
});
}
}
}
}
}
export default FullPageScreenshot;

View File

@@ -0,0 +1,46 @@
/**
* @license Copyright 2016 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/**
* Base class for all gatherers; defines pass lifecycle methods. The artifact
* from the gatherer is the last not-undefined value returned by a lifecycle
* method. All methods can return the artifact value directly or return a
* Promise that resolves to that value.
*
* If an Error is thrown (or a Promise that rejects on an Error),
* the runner will treat it as an error internal to the gatherer and
* continue execution of any remaining gatherers.
*
* @implements {LH.Gatherer.GathererInstance}
*/
export class Gatherer implements LH.Gatherer.GathererInstance {
/**
* @return {keyof LH.GathererArtifacts}
*/
get name(): keyof import("../..").GathererArtifacts;
/**
* Called before navigation to target url.
* @param {LH.Gatherer.PassContext} passContext
* @return {LH.Gatherer.PhaseResult}
*/
beforePass(passContext: LH.Gatherer.PassContext): LH.Gatherer.PhaseResult;
/**
* Called after target page is loaded. If a trace is enabled for this pass,
* the trace is still being recorded.
* @param {LH.Gatherer.PassContext} passContext
* @return {LH.Gatherer.PhaseResult}
*/
pass(passContext: LH.Gatherer.PassContext): LH.Gatherer.PhaseResult;
/**
* Called after target page is loaded, all gatherer `pass` methods have been
* executed, and — if generated in this pass — the trace is ended. The trace
* and record of network activity are provided in `loadData`.
* @param {LH.Gatherer.PassContext} passContext
* @param {LH.Gatherer.LoadData} loadData
* @return {LH.Gatherer.PhaseResult}
*/
afterPass(passContext: LH.Gatherer.PassContext, loadData: LH.Gatherer.LoadData): LH.Gatherer.PhaseResult;
}
//# sourceMappingURL=gatherer.d.ts.map

View File

@@ -0,0 +1,58 @@
/**
* @license Copyright 2016 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/**
* Base class for all gatherers; defines pass lifecycle methods. The artifact
* from the gatherer is the last not-undefined value returned by a lifecycle
* method. All methods can return the artifact value directly or return a
* Promise that resolves to that value.
*
* If an Error is thrown (or a Promise that rejects on an Error),
* the runner will treat it as an error internal to the gatherer and
* continue execution of any remaining gatherers.
*
* @implements {LH.Gatherer.GathererInstance}
*/
class Gatherer {
/**
* @return {keyof LH.GathererArtifacts}
*/
get name() {
// @ts-expect-error - assume that class name has been added to LH.GathererArtifacts.
return this.constructor.name;
}
/* eslint-disable no-unused-vars */
/**
* Called before navigation to target url.
* @param {LH.Gatherer.PassContext} passContext
* @return {LH.Gatherer.PhaseResult}
*/
beforePass(passContext) { }
/**
* Called after target page is loaded. If a trace is enabled for this pass,
* the trace is still being recorded.
* @param {LH.Gatherer.PassContext} passContext
* @return {LH.Gatherer.PhaseResult}
*/
pass(passContext) { }
/**
* Called after target page is loaded, all gatherer `pass` methods have been
* executed, and — if generated in this pass — the trace is ended. The trace
* and record of network activity are provided in `loadData`.
* @param {LH.Gatherer.PassContext} passContext
* @param {LH.Gatherer.LoadData} loadData
* @return {LH.Gatherer.PhaseResult}
*/
afterPass(passContext, loadData) { }
/* eslint-enable no-unused-vars */
}
export {Gatherer};

View File

@@ -0,0 +1,27 @@
export default GlobalListeners;
declare class GlobalListeners extends FRGatherer {
/**
* @param {LH.Crdp.DOMDebugger.EventListener} listener
* @return {listener is {type: 'pagehide'|'unload'|'visibilitychange'} & LH.Crdp.DOMDebugger.EventListener}
*/
static _filterForAllowlistedTypes(listener: LH.Crdp.DOMDebugger.EventListener): listener is {
type: 'pagehide' | 'unload' | 'visibilitychange';
} & import("devtools-protocol").Protocol.DOMDebugger.EventListener;
/**
* @param { LH.Artifacts.GlobalListener } listener
* @return { string }
*/
getListenerIndentifier(listener: LH.Artifacts.GlobalListener): string;
/**
* @param { LH.Artifacts['GlobalListeners'] } listeners
* @return { LH.Artifacts['GlobalListeners'] }
*/
dedupeListeners(listeners: LH.Artifacts['GlobalListeners']): LH.Artifacts['GlobalListeners'];
/**
* @param {LH.Gatherer.FRTransitionalContext} passContext
* @return {Promise<LH.Artifacts['GlobalListeners']>}
*/
getArtifact(passContext: LH.Gatherer.FRTransitionalContext): Promise<LH.Artifacts['GlobalListeners']>;
}
import FRGatherer from '../base-gatherer.js';
//# sourceMappingURL=global-listeners.d.ts.map

View File

@@ -0,0 +1,108 @@
/**
* @license Copyright 2020 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/**
* @fileoverview
* A gatherer to collect information about the event listeners registered on the
* global object. For now, the scope is narrowed to events that occur on and
* around page unload, but this can be expanded in the future.
*/
import log from 'lighthouse-logger';
import FRGatherer from '../base-gatherer.js';
class GlobalListeners extends FRGatherer {
/** @type {LH.Gatherer.GathererMeta} */
meta = {
supportedModes: ['snapshot', 'timespan', 'navigation'],
};
/**
* @param {LH.Crdp.DOMDebugger.EventListener} listener
* @return {listener is {type: 'pagehide'|'unload'|'visibilitychange'} & LH.Crdp.DOMDebugger.EventListener}
*/
static _filterForAllowlistedTypes(listener) {
return listener.type === 'pagehide' ||
listener.type === 'unload' ||
listener.type === 'visibilitychange';
}
/**
* @param { LH.Artifacts.GlobalListener } listener
* @return { string }
*/
getListenerIndentifier(listener) {
return `${listener.type}:${listener.scriptId}:${listener.columnNumber}:${listener.lineNumber}`;
}
/**
* @param { LH.Artifacts['GlobalListeners'] } listeners
* @return { LH.Artifacts['GlobalListeners'] }
*/
dedupeListeners(listeners) {
const seenListeners = new Set();
return listeners.filter(listener => {
const id = this.getListenerIndentifier(listener);
if (!seenListeners.has(id)) {
seenListeners.add(id);
return true;
} else {
return false;
}
});
}
/**
* @param {LH.Gatherer.FRTransitionalContext} passContext
* @return {Promise<LH.Artifacts['GlobalListeners']>}
*/
async getArtifact(passContext) {
const session = passContext.driver.defaultSession;
/** @type {Array<LH.Artifacts.GlobalListener>} */
const listeners = [];
for (const executionContext of passContext.driver.targetManager.mainFrameExecutionContexts()) {
// Get a RemoteObject handle to `window`.
let objectId;
try {
const {result} = await session.sendCommand('Runtime.evaluate', {
expression: 'window',
returnByValue: false,
uniqueContextId: executionContext.uniqueId,
});
if (!result.objectId) {
throw new Error('Error fetching information about the global object');
}
objectId = result.objectId;
} catch (err) {
// Execution context is no longer valid, but don't let that fail the gatherer.
log.warn('Execution context is no longer valid', executionContext, err);
continue;
}
// And get all its listeners of interest.
const response = await session.sendCommand('DOMDebugger.getEventListeners', {objectId});
for (const listener of response.listeners) {
if (GlobalListeners._filterForAllowlistedTypes(listener)) {
const {type, scriptId, lineNumber, columnNumber} = listener;
listeners.push({
type,
scriptId,
lineNumber,
columnNumber,
});
}
}
}
// Dedupe listeners with same underlying data.
return this.dedupeListeners(listeners);
}
}
export default GlobalListeners;

View File

@@ -0,0 +1,11 @@
export default IFrameElements;
declare class IFrameElements extends FRGatherer {
/**
* @param {LH.Gatherer.FRTransitionalContext} passContext
* @return {Promise<LH.Artifacts['IFrameElements']>}
* @override
*/
override getArtifact(passContext: LH.Gatherer.FRTransitionalContext): Promise<LH.Artifacts['IFrameElements']>;
}
import FRGatherer from '../base-gatherer.js';
//# sourceMappingURL=iframe-elements.d.ts.map

View File

@@ -0,0 +1,67 @@
/**
* @license Copyright 2019 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/* global getNodeDetails */
import FRGatherer from '../base-gatherer.js';
import {pageFunctions} from '../../lib/page-functions.js';
/* eslint-env browser, node */
/**
* @return {LH.Artifacts['IFrameElements']}
*/
/* c8 ignore start */
function collectIFrameElements() {
const realBoundingClientRect = window.__HTMLElementBoundingClientRect ||
window.HTMLElement.prototype.getBoundingClientRect;
// @ts-expect-error - put into scope via stringification
const iFrameElements = getElementsInDocument('iframe'); // eslint-disable-line no-undef
return iFrameElements.map(/** @param {HTMLIFrameElement} node */ (node) => {
const clientRect = realBoundingClientRect.call(node);
const {top, bottom, left, right, width, height} = clientRect;
return {
id: node.id,
src: node.src,
clientRect: {top, bottom, left, right, width, height},
// @ts-expect-error - put into scope via stringification
isPositionFixed: isPositionFixed(node), // eslint-disable-line no-undef
// @ts-expect-error - getNodeDetails put into scope via stringification
node: getNodeDetails(node),
};
});
}
/* c8 ignore stop */
class IFrameElements extends FRGatherer {
/** @type {LH.Gatherer.GathererMeta} */
meta = {
supportedModes: ['snapshot', 'navigation'],
};
/**
* @param {LH.Gatherer.FRTransitionalContext} passContext
* @return {Promise<LH.Artifacts['IFrameElements']>}
* @override
*/
async getArtifact(passContext) {
const driver = passContext.driver;
const iframeElements = await driver.executionContext.evaluate(collectIFrameElements, {
args: [],
useIsolation: true,
deps: [
pageFunctions.getElementsInDocument,
pageFunctions.isPositionFixed,
pageFunctions.getNodeDetails,
],
});
return iframeElements;
}
}
export default IFrameElements;

View File

@@ -0,0 +1,35 @@
export default ImageElements;
declare class ImageElements extends FRGatherer {
/** @type {Map<string, {naturalWidth: number, naturalHeight: number}>} */
_naturalSizeCache: Map<string, {
naturalWidth: number;
naturalHeight: number;
}>;
/**
* @param {LH.Gatherer.FRTransitionalDriver} driver
* @param {LH.Artifacts.ImageElement} element
*/
fetchElementWithSizeInformation(driver: LH.Gatherer.FRTransitionalDriver, element: LH.Artifacts.ImageElement): Promise<void>;
/**
* Images might be sized via CSS. In order to compute unsized-images failures, we need to collect
* matched CSS rules to see if this is the case.
* @url http://go/dwoqq (googlers only)
* @param {LH.Gatherer.FRProtocolSession} session
* @param {string} devtoolsNodePath
* @param {LH.Artifacts.ImageElement} element
*/
fetchSourceRules(session: LH.Gatherer.FRProtocolSession, devtoolsNodePath: string, element: LH.Artifacts.ImageElement): Promise<void>;
/**
*
* @param {LH.Gatherer.FRTransitionalDriver} driver
* @param {LH.Artifacts.ImageElement[]} elements
*/
collectExtraDetails(driver: LH.Gatherer.FRTransitionalDriver, elements: LH.Artifacts.ImageElement[]): Promise<void>;
/**
* @param {LH.Gatherer.FRTransitionalContext} context
* @return {Promise<LH.Artifacts['ImageElements']>}
*/
getArtifact(context: LH.Gatherer.FRTransitionalContext): Promise<LH.Artifacts['ImageElements']>;
}
import FRGatherer from '../base-gatherer.js';
//# sourceMappingURL=image-elements.d.ts.map

View File

@@ -0,0 +1,379 @@
/**
* @license Copyright 2017 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/**
* @fileoverview Gathers all images used on the page with their src, size,
* and attribute information. Executes script in the context of the page.
*/
import log from 'lighthouse-logger';
import FRGatherer from '../base-gatherer.js';
import {pageFunctions} from '../../lib/page-functions.js';
import * as FontSize from './seo/font-size.js';
/* global window, getElementsInDocument, Image, getNodeDetails, ShadowRoot */
/** @param {Element} element */
/* c8 ignore start */
function getClientRect(element) {
const clientRect = element.getBoundingClientRect();
return {
// Just grab the DOMRect properties we want, excluding x/y/width/height
top: clientRect.top,
bottom: clientRect.bottom,
left: clientRect.left,
right: clientRect.right,
};
}
/* c8 ignore stop */
/**
* If an image is within `picture`, the `picture` element's css position
* is what we want to collect, since that position is relevant to CLS.
* @param {Element} element
* @param {CSSStyleDeclaration} computedStyle
*/
/* c8 ignore start */
function getPosition(element, computedStyle) {
if (element.parentElement && element.parentElement.tagName === 'PICTURE') {
const parentStyle = window.getComputedStyle(element.parentElement);
return parentStyle.getPropertyValue('position');
}
return computedStyle.getPropertyValue('position');
}
/* c8 ignore stop */
/**
* @param {Array<Element>} allElements
* @return {Array<LH.Artifacts.ImageElement>}
*/
/* c8 ignore start */
function getHTMLImages(allElements) {
const allImageElements = /** @type {Array<HTMLImageElement>} */ (allElements.filter(element => {
return element.localName === 'img';
}));
return allImageElements.map(element => {
const computedStyle = window.getComputedStyle(element);
const isPicture = !!element.parentElement && element.parentElement.tagName === 'PICTURE';
const canTrustNaturalDimensions = !isPicture && !element.srcset;
return {
// currentSrc used over src to get the url as determined by the browser
// after taking into account srcset/media/sizes/etc.
src: element.currentSrc,
srcset: element.srcset,
displayedWidth: element.width,
displayedHeight: element.height,
clientRect: getClientRect(element),
attributeWidth: element.getAttribute('width'),
attributeHeight: element.getAttribute('height'),
naturalDimensions: canTrustNaturalDimensions ?
{width: element.naturalWidth, height: element.naturalHeight} :
undefined,
cssRules: undefined, // this will get overwritten below
computedStyles: {
position: getPosition(element, computedStyle),
objectFit: computedStyle.getPropertyValue('object-fit'),
imageRendering: computedStyle.getPropertyValue('image-rendering'),
},
isCss: false,
isPicture,
loading: element.loading,
isInShadowDOM: element.getRootNode() instanceof ShadowRoot,
fetchPriority: element.fetchPriority,
// @ts-expect-error - getNodeDetails put into scope via stringification
node: getNodeDetails(element),
};
});
}
/* c8 ignore stop */
/**
* @param {Array<Element>} allElements
* @return {Array<LH.Artifacts.ImageElement>}
*/
/* c8 ignore start */
function getCSSImages(allElements) {
// Chrome normalizes background image style from getComputedStyle to be an absolute URL in quotes.
// Only match basic background-image: url("http://host/image.jpeg") declarations
const CSS_URL_REGEX = /^url\("([^"]+)"\)$/;
/** @type {Array<LH.Artifacts.ImageElement>} */
const images = [];
for (const element of allElements) {
const style = window.getComputedStyle(element);
// If the element didn't have a CSS background image, we're not interested.
if (!style.backgroundImage || !CSS_URL_REGEX.test(style.backgroundImage)) continue;
const imageMatch = style.backgroundImage.match(CSS_URL_REGEX);
// @ts-expect-error test() above ensures that there is a match.
const url = imageMatch[1];
images.push({
src: url,
srcset: '',
displayedWidth: element.clientWidth,
displayedHeight: element.clientHeight,
clientRect: getClientRect(element),
attributeWidth: null,
attributeHeight: null,
naturalDimensions: undefined,
cssEffectiveRules: undefined,
computedStyles: {
position: getPosition(element, style),
objectFit: '',
imageRendering: style.getPropertyValue('image-rendering'),
},
isCss: true,
isPicture: false,
isInShadowDOM: element.getRootNode() instanceof ShadowRoot,
// @ts-expect-error - getNodeDetails put into scope via stringification
node: getNodeDetails(element),
});
}
return images;
}
/* c8 ignore stop */
/** @return {Array<LH.Artifacts.ImageElement>} */
/* c8 ignore start */
function collectImageElementInfo() {
/** @type {Array<Element>} */
// @ts-expect-error - added by getElementsInDocumentFnString
const allElements = getElementsInDocument();
return getHTMLImages(allElements).concat(getCSSImages(allElements));
}
/* c8 ignore stop */
/**
* @param {string} url
* @return {Promise<{naturalWidth: number, naturalHeight: number}>}
*/
/* c8 ignore start */
function determineNaturalSize(url) {
return new Promise((resolve, reject) => {
const img = new Image();
img.addEventListener('error', _ => reject(new Error('determineNaturalSize failed img load')));
img.addEventListener('load', () => {
resolve({
naturalWidth: img.naturalWidth,
naturalHeight: img.naturalHeight,
});
});
img.src = url;
});
}
/* c8 ignore stop */
/**
* @param {Partial<Pick<LH.Crdp.CSS.CSSStyle, 'cssProperties'>>|undefined} rule
* @param {string} property
* @return {string | undefined}
*/
function findSizeDeclaration(rule, property) {
if (!rule || !rule.cssProperties) return;
const definedProp = rule.cssProperties.find(({name}) => name === property);
if (!definedProp) return;
return definedProp.value;
}
/**
* Finds the most specific directly matched CSS font-size rule from the list.
*
* @param {Array<LH.Crdp.CSS.RuleMatch>|undefined} matchedCSSRules
* @param {string} property
* @return {string | undefined}
*/
function findMostSpecificCSSRule(matchedCSSRules, property) {
/** @param {LH.Crdp.CSS.CSSStyle} declaration */
const isDeclarationofInterest = (declaration) => findSizeDeclaration(declaration, property);
const rule = FontSize.findMostSpecificMatchedCSSRule(matchedCSSRules, isDeclarationofInterest);
if (!rule) return;
return findSizeDeclaration(rule, property);
}
/**
* @param {LH.Crdp.CSS.GetMatchedStylesForNodeResponse} matched CSS rules}
* @param {string} property
* @return {string | null}
*/
function getEffectiveSizingRule({attributesStyle, inlineStyle, matchedCSSRules}, property) {
// CSS sizing can't be inherited.
// We only need to check inline & matched styles.
// Inline styles have highest priority.
const inlineRule = findSizeDeclaration(inlineStyle, property);
if (inlineRule) return inlineRule;
const attributeRule = findSizeDeclaration(attributesStyle, property);
if (attributeRule) return attributeRule;
// Rules directly referencing the node come next.
const matchedRule = findMostSpecificCSSRule(matchedCSSRules, property);
if (matchedRule) return matchedRule;
return null;
}
/**
* @param {LH.Artifacts.ImageElement} element
* @return {number}
*/
function getPixelArea(element) {
if (element.naturalDimensions) {
return element.naturalDimensions.height * element.naturalDimensions.width;
}
return element.displayedHeight * element.displayedWidth;
}
class ImageElements extends FRGatherer {
/** @type {LH.Gatherer.GathererMeta} */
meta = {
supportedModes: ['snapshot', 'timespan', 'navigation'],
};
constructor() {
super();
/** @type {Map<string, {naturalWidth: number, naturalHeight: number}>} */
this._naturalSizeCache = new Map();
}
/**
* @param {LH.Gatherer.FRTransitionalDriver} driver
* @param {LH.Artifacts.ImageElement} element
*/
async fetchElementWithSizeInformation(driver, element) {
const url = element.src;
let size = this._naturalSizeCache.get(url);
if (!size) {
try {
// We don't want this to take forever, 250ms should be enough for images that are cached
driver.defaultSession.setNextProtocolTimeout(250);
size = await driver.executionContext.evaluate(determineNaturalSize, {
args: [url],
useIsolation: true,
});
this._naturalSizeCache.set(url, size);
} catch (_) {
// determineNaturalSize fails on invalid images, which we treat as non-visible
}
}
if (!size) return;
element.naturalDimensions = {width: size.naturalWidth, height: size.naturalHeight};
}
/**
* Images might be sized via CSS. In order to compute unsized-images failures, we need to collect
* matched CSS rules to see if this is the case.
* @url http://go/dwoqq (googlers only)
* @param {LH.Gatherer.FRProtocolSession} session
* @param {string} devtoolsNodePath
* @param {LH.Artifacts.ImageElement} element
*/
async fetchSourceRules(session, devtoolsNodePath, element) {
try {
const {nodeId} = await session.sendCommand('DOM.pushNodeByPathToFrontend', {
path: devtoolsNodePath,
});
if (!nodeId) return;
const matchedRules = await session.sendCommand('CSS.getMatchedStylesForNode', {
nodeId: nodeId,
});
const width = getEffectiveSizingRule(matchedRules, 'width');
const height = getEffectiveSizingRule(matchedRules, 'height');
const aspectRatio = getEffectiveSizingRule(matchedRules, 'aspect-ratio');
element.cssEffectiveRules = {width, height, aspectRatio};
} catch (err) {
if (/No node.*found/.test(err.message)) return;
throw err;
}
}
/**
*
* @param {LH.Gatherer.FRTransitionalDriver} driver
* @param {LH.Artifacts.ImageElement[]} elements
*/
async collectExtraDetails(driver, elements) {
// Don't do more than 5s of this expensive devtools protocol work. See #11289
let reachedGatheringBudget = false;
setTimeout(_ => (reachedGatheringBudget = true), 5000);
let skippedCount = 0;
for (const element of elements) {
if (reachedGatheringBudget) {
skippedCount++;
continue;
}
// Need source rules to determine if sized via CSS (for unsized-images).
if (!element.isInShadowDOM && !element.isCss) {
await this.fetchSourceRules(driver.defaultSession, element.node.devtoolsNodePath, element);
}
// Images within `picture` behave strangely and natural size information isn't accurate,
// CSS images have no natural size information at all. Try to get the actual size if we can.
if (element.isPicture || element.isCss || element.srcset) {
await this.fetchElementWithSizeInformation(driver, element);
}
}
if (reachedGatheringBudget) {
log.warn('ImageElements', `Reached gathering budget of 5s. Skipped extra details for ${skippedCount}/${elements.length}`); // eslint-disable-line max-len
}
}
/**
* @param {LH.Gatherer.FRTransitionalContext} context
* @return {Promise<LH.Artifacts['ImageElements']>}
*/
async getArtifact(context) {
const session = context.driver.defaultSession;
const executionContext = context.driver.executionContext;
const elements = await executionContext.evaluate(collectImageElementInfo, {
args: [],
useIsolation: true,
deps: [
pageFunctions.getElementsInDocument,
pageFunctions.getBoundingClientRect,
pageFunctions.getNodeDetails,
getClientRect,
getPosition,
getHTMLImages,
getCSSImages,
],
});
await Promise.all([
session.sendCommand('DOM.enable'),
session.sendCommand('CSS.enable'),
session.sendCommand('DOM.getDocument', {depth: -1, pierce: true}),
]);
// Spend our extra details budget on highest impact images.
// Our best approximation of impact without network records is to use pixel area.
elements.sort((a, b) => getPixelArea(b) - getPixelArea(a));
await this.collectExtraDetails(context.driver, elements);
await Promise.all([
session.sendCommand('DOM.disable'),
session.sendCommand('CSS.disable'),
]);
return elements;
}
}
export default ImageElements;

View File

@@ -0,0 +1,10 @@
export default Inputs;
declare class Inputs extends FRGatherer {
/**
* @param {LH.Gatherer.FRTransitionalContext} passContext
* @return {Promise<LH.Artifacts['Inputs']>}
*/
getArtifact(passContext: LH.Gatherer.FRTransitionalContext): Promise<LH.Artifacts['Inputs']>;
}
import FRGatherer from '../base-gatherer.js';
//# sourceMappingURL=inputs.d.ts.map

118
node_modules/lighthouse/core/gather/gatherers/inputs.js generated vendored Normal file
View File

@@ -0,0 +1,118 @@
/**
* @license Copyright 2020 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/* global getNodeDetails */
import FRGatherer from '../base-gatherer.js';
import {pageFunctions} from '../../lib/page-functions.js';
/* eslint-env browser, node */
/**
* @return {LH.Artifacts['Inputs']}
*/
/* c8 ignore start */
function collectElements() {
/** @type {LH.Artifacts.InputElement[]} */
const inputArtifacts = [];
/** @type {Map<HTMLFormElement, LH.Artifacts.FormElement>} */
const formElToArtifact = new Map();
/** @type {Map<HTMLLabelElement, LH.Artifacts.LabelElement>} */
const labelElToArtifact = new Map();
/** @type {HTMLFormElement[]} */
// @ts-expect-error - put into scope via stringification
const formEls = getElementsInDocument('form'); // eslint-disable-line no-undef
for (const formEl of formEls) {
formElToArtifact.set(formEl, {
id: formEl.id,
name: formEl.name,
autocomplete: formEl.autocomplete,
// @ts-expect-error - getNodeDetails put into scope via stringification
node: getNodeDetails(formEl),
});
}
/** @type {HTMLLabelElement[]} */
// @ts-expect-error - put into scope via stringification
const labelEls = getElementsInDocument('label'); // eslint-disable-line no-undef
for (const labelEl of labelEls) {
labelElToArtifact.set(labelEl, {
for: labelEl.htmlFor,
// @ts-expect-error - getNodeDetails put into scope via stringification
node: getNodeDetails(labelEl),
});
}
/** @type {HTMLInputElement[]} */
// @ts-expect-error - put into scope via stringification
const inputEls = getElementsInDocument('textarea, input, select'); // eslint-disable-line no-undef
for (const inputEl of inputEls) {
// If the input element is in a form (either because an ancestor element is <form> or the
// form= attribute is associated with a <form> element's id), this will be set.
const parentFormEl = inputEl.form;
const parentFormIndex = parentFormEl ?
[...formElToArtifact.keys()].indexOf(parentFormEl) :
undefined;
const labelIndices = [...(inputEl.labels || [])].map((labelEl) => {
return [...labelElToArtifact.keys()].indexOf(labelEl);
});
let preventsPaste;
if (!inputEl.readOnly) {
preventsPaste = !inputEl.dispatchEvent(new ClipboardEvent('paste', {cancelable: true}));
}
inputArtifacts.push({
parentFormIndex,
labelIndices,
id: inputEl.id,
name: inputEl.name,
type: inputEl.type,
placeholder: inputEl instanceof HTMLSelectElement ? undefined : inputEl.placeholder,
autocomplete: {
property: inputEl.autocomplete,
attribute: inputEl.getAttribute('autocomplete'),
// Requires `--enable-features=AutofillShowTypePredictions`.
prediction: inputEl.getAttribute('autofill-prediction'),
},
preventsPaste,
// @ts-expect-error - getNodeDetails put into scope via stringification
node: getNodeDetails(inputEl),
});
}
return {
inputs: inputArtifacts,
forms: [...formElToArtifact.values()],
labels: [...labelElToArtifact.values()],
};
}
/* c8 ignore stop */
class Inputs extends FRGatherer {
/** @type {LH.Gatherer.GathererMeta} */
meta = {
supportedModes: ['snapshot', 'navigation'],
};
/**
* @param {LH.Gatherer.FRTransitionalContext} passContext
* @return {Promise<LH.Artifacts['Inputs']>}
*/
async getArtifact(passContext) {
return passContext.driver.executionContext.evaluate(collectElements, {
args: [],
useIsolation: true,
deps: [
pageFunctions.getElementsInDocument,
pageFunctions.getNodeDetails,
],
});
}
}
export default Inputs;

View File

@@ -0,0 +1,38 @@
export default InspectorIssues;
declare class InspectorIssues extends FRGatherer {
/** @type {LH.Gatherer.GathererMeta<'DevtoolsLog'>} */
meta: LH.Gatherer.GathererMeta<'DevtoolsLog'>;
/** @type {Array<LH.Crdp.Audits.InspectorIssue>} */
_issues: Array<LH.Crdp.Audits.InspectorIssue>;
_onIssueAdded: (entry: LH.Crdp.Audits.IssueAddedEvent) => void;
/**
* @param {LH.Crdp.Audits.IssueAddedEvent} entry
*/
onIssueAdded(entry: LH.Crdp.Audits.IssueAddedEvent): void;
/**
* @param {LH.Gatherer.FRTransitionalContext} context
*/
startInstrumentation(context: LH.Gatherer.FRTransitionalContext): Promise<void>;
/**
* @param {LH.Gatherer.FRTransitionalContext} context
*/
stopInstrumentation(context: LH.Gatherer.FRTransitionalContext): Promise<void>;
/**
* @param {Array<LH.Artifacts.NetworkRequest>} networkRecords
* @return {Promise<LH.Artifacts['InspectorIssues']>}
*/
_getArtifact(networkRecords: Array<LH.Artifacts.NetworkRequest>): Promise<LH.Artifacts['InspectorIssues']>;
/**
* @param {LH.Gatherer.FRTransitionalContext<'DevtoolsLog'>} context
* @return {Promise<LH.Artifacts['InspectorIssues']>}
*/
getArtifact(context: LH.Gatherer.FRTransitionalContext<'DevtoolsLog'>): Promise<LH.Artifacts['InspectorIssues']>;
/**
* @param {LH.Gatherer.PassContext} passContext
* @param {LH.Gatherer.LoadData} loadData
* @return {Promise<LH.Artifacts['InspectorIssues']>}
*/
afterPass(passContext: LH.Gatherer.PassContext, loadData: LH.Gatherer.LoadData): Promise<LH.Artifacts['InspectorIssues']>;
}
import FRGatherer from '../base-gatherer.js';
//# sourceMappingURL=inspector-issues.d.ts.map

View File

@@ -0,0 +1,128 @@
/**
* @license Copyright 2020 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/**
* @fileoverview Capture IssueAdded events
*/
import FRGatherer from '../base-gatherer.js';
import {NetworkRecords} from '../../computed/network-records.js';
import DevtoolsLog from './devtools-log.js';
class InspectorIssues extends FRGatherer {
/** @type {LH.Gatherer.GathererMeta<'DevtoolsLog'>} */
meta = {
supportedModes: ['timespan', 'navigation'],
dependencies: {DevtoolsLog: DevtoolsLog.symbol},
};
constructor() {
super();
/** @type {Array<LH.Crdp.Audits.InspectorIssue>} */
this._issues = [];
this._onIssueAdded = this.onIssueAdded.bind(this);
}
/**
* @param {LH.Crdp.Audits.IssueAddedEvent} entry
*/
onIssueAdded(entry) {
this._issues.push(entry.issue);
}
/**
* @param {LH.Gatherer.FRTransitionalContext} context
*/
async startInstrumentation(context) {
const session = context.driver.defaultSession;
session.on('Audits.issueAdded', this._onIssueAdded);
await session.sendCommand('Audits.enable');
}
/**
* @param {LH.Gatherer.FRTransitionalContext} context
*/
async stopInstrumentation(context) {
const session = context.driver.defaultSession;
session.off('Audits.issueAdded', this._onIssueAdded);
await session.sendCommand('Audits.disable');
}
/**
* @param {Array<LH.Artifacts.NetworkRequest>} networkRecords
* @return {Promise<LH.Artifacts['InspectorIssues']>}
*/
async _getArtifact(networkRecords) {
/** @type {LH.Artifacts.InspectorIssues} */
const artifact = {
attributionReportingIssue: [],
blockedByResponseIssue: [],
bounceTrackingIssue: [],
clientHintIssue: [],
contentSecurityPolicyIssue: [],
corsIssue: [],
deprecationIssue: [],
federatedAuthRequestIssue: [],
genericIssue: [],
heavyAdIssue: [],
lowTextContrastIssue: [],
mixedContentIssue: [],
navigatorUserAgentIssue: [],
quirksModeIssue: [],
cookieIssue: [],
sharedArrayBufferIssue: [],
stylesheetLoadingIssue: [],
federatedAuthUserInfoRequestIssue: [],
};
const keys = /** @type {Array<keyof LH.Artifacts['InspectorIssues']>} */(Object.keys(artifact));
for (const key of keys) {
/** @type {`${key}Details`} */
const detailsKey = `${key}Details`;
const allDetails = this._issues.map(issue => issue.details[detailsKey]);
for (const detail of allDetails) {
if (!detail) {
continue;
}
// Duplicate issues can occur for the same request; only use the one with a matching networkRequest.
const requestId = 'request' in detail && detail.request && detail.request.requestId;
if (requestId) {
if (networkRecords.find(req => req.requestId === requestId)) {
// @ts-expect-error - detail types are not all compatible
artifact[key].push(detail);
}
} else {
// @ts-expect-error - detail types are not all compatible
artifact[key].push(detail);
}
}
}
return artifact;
}
/**
* @param {LH.Gatherer.FRTransitionalContext<'DevtoolsLog'>} context
* @return {Promise<LH.Artifacts['InspectorIssues']>}
*/
async getArtifact(context) {
const devtoolsLog = context.dependencies.DevtoolsLog;
const networkRecords = await NetworkRecords.request(devtoolsLog, context);
return this._getArtifact(networkRecords);
}
/**
* @param {LH.Gatherer.PassContext} passContext
* @param {LH.Gatherer.LoadData} loadData
* @return {Promise<LH.Artifacts['InspectorIssues']>}
*/
async afterPass(passContext, loadData) {
await this.stopInstrumentation({...passContext, dependencies: {}});
return this._getArtifact(loadData.networkRecords);
}
}
export default InspectorIssues;

View File

@@ -0,0 +1,17 @@
export default InstallabilityErrors;
declare class InstallabilityErrors extends FRGatherer {
/**
* Creates an Artifacts.InstallabilityErrors, tranforming data from the protocol
* for old versions of Chrome.
* @param {LH.Gatherer.FRProtocolSession} session
* @return {Promise<LH.Artifacts['InstallabilityErrors']>}
*/
static getInstallabilityErrors(session: LH.Gatherer.FRProtocolSession): Promise<LH.Artifacts['InstallabilityErrors']>;
/**
* @param {LH.Gatherer.FRTransitionalContext} context
* @return {Promise<LH.Artifacts['InstallabilityErrors']>}
*/
getArtifact(context: LH.Gatherer.FRTransitionalContext): Promise<LH.Artifacts['InstallabilityErrors']>;
}
import FRGatherer from '../base-gatherer.js';
//# sourceMappingURL=installability-errors.d.ts.map

View File

@@ -0,0 +1,56 @@
/**
* @license Copyright 2021 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
import log from 'lighthouse-logger';
import FRGatherer from '../base-gatherer.js';
class InstallabilityErrors extends FRGatherer {
/** @type {LH.Gatherer.GathererMeta} */
meta = {
supportedModes: ['snapshot', 'navigation'],
};
/**
* Creates an Artifacts.InstallabilityErrors, tranforming data from the protocol
* for old versions of Chrome.
* @param {LH.Gatherer.FRProtocolSession} session
* @return {Promise<LH.Artifacts['InstallabilityErrors']>}
*/
static async getInstallabilityErrors(session) {
const status = {
msg: 'Get webapp installability errors',
id: 'lh:gather:getInstallabilityErrors',
};
log.time(status);
const response = await session.sendCommand('Page.getInstallabilityErrors');
const errors = response.installabilityErrors;
log.timeEnd(status);
return {errors};
}
/**
* @param {LH.Gatherer.FRTransitionalContext} context
* @return {Promise<LH.Artifacts['InstallabilityErrors']>}
*/
async getArtifact(context) {
const driver = context.driver;
try {
return await InstallabilityErrors.getInstallabilityErrors(driver.defaultSession);
} catch {
return {
errors: [
{errorId: 'protocol-timeout', errorArguments: []},
],
};
}
}
}
export default InstallabilityErrors;

View File

@@ -0,0 +1,22 @@
export default JsUsage;
/**
* @fileoverview Tracks unused JavaScript
*/
declare class JsUsage extends FRGatherer {
/** @type {LH.Crdp.Profiler.ScriptCoverage[]} */
_scriptUsages: LH.Crdp.Profiler.ScriptCoverage[];
/**
* @param {LH.Gatherer.FRTransitionalContext} context
*/
startInstrumentation(context: LH.Gatherer.FRTransitionalContext): Promise<void>;
/**
* @param {LH.Gatherer.FRTransitionalContext} context
*/
stopInstrumentation(context: LH.Gatherer.FRTransitionalContext): Promise<void>;
/**
* @return {Promise<LH.Artifacts['JsUsage']>}
*/
getArtifact(): Promise<LH.Artifacts['JsUsage']>;
}
import FRGatherer from '../base-gatherer.js';
//# sourceMappingURL=js-usage.d.ts.map

View File

@@ -0,0 +1,74 @@
/**
* @license Copyright 2017 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
import FRGatherer from '../base-gatherer.js';
/**
* @fileoverview Tracks unused JavaScript
*/
class JsUsage extends FRGatherer {
/** @type {LH.Gatherer.GathererMeta} */
meta = {
supportedModes: ['snapshot', 'timespan', 'navigation'],
};
constructor() {
super();
/** @type {LH.Crdp.Profiler.ScriptCoverage[]} */
this._scriptUsages = [];
}
/**
* @param {LH.Gatherer.FRTransitionalContext} context
*/
async startInstrumentation(context) {
const session = context.driver.defaultSession;
await session.sendCommand('Profiler.enable');
await session.sendCommand('Profiler.startPreciseCoverage', {detailed: false});
}
/**
* @param {LH.Gatherer.FRTransitionalContext} context
*/
async stopInstrumentation(context) {
const session = context.driver.defaultSession;
const coverageResponse = await session.sendCommand('Profiler.takePreciseCoverage');
this._scriptUsages = coverageResponse.result;
await session.sendCommand('Profiler.stopPreciseCoverage');
await session.sendCommand('Profiler.disable');
}
/**
* @return {Promise<LH.Artifacts['JsUsage']>}
*/
async getArtifact() {
/** @type {Record<string, LH.Crdp.Profiler.ScriptCoverage>} */
const usageByScriptId = {};
for (const scriptUsage of this._scriptUsages) {
// If `url` is blank, that means the script was dynamically
// created (eval, new Function, onload, ...)
if (scriptUsage.url === '' || scriptUsage.url === '_lighthouse-eval.js') {
// We currently don't consider coverage of dynamic scripts, and we definitely don't want
// coverage of code Lighthouse ran to inspect the page, so we ignore this ScriptCoverage.
// Audits would work the same without this, it is only an optimization (not tracking coverage
// for scripts we don't care about).
continue;
}
// Scripts run via puppeteer's evaluate interface will have this url.
if (scriptUsage.url === '__puppeteer_evaluation_script__') {
continue;
}
usageByScriptId[scriptUsage.scriptId] = scriptUsage;
}
return usageByScriptId;
}
}
export default JsUsage;

View File

@@ -0,0 +1,42 @@
export default LinkElements;
declare class LinkElements extends FRGatherer {
/**
* @param {LH.Gatherer.FRTransitionalContext} context
* @return {Promise<LH.Artifacts['LinkElements']>}
*/
static getLinkElementsInDOM(context: LH.Gatherer.FRTransitionalContext): Promise<LH.Artifacts['LinkElements']>;
/**
* @param {LH.Gatherer.FRTransitionalContext} context
* @param {LH.Artifacts['DevtoolsLog']} devtoolsLog
* @return {Promise<LH.Artifacts['LinkElements']>}
*/
static getLinkElementsInHeaders(context: LH.Gatherer.FRTransitionalContext, devtoolsLog: LH.Artifacts['DevtoolsLog']): Promise<LH.Artifacts['LinkElements']>;
/**
* This needs to be in the constructor.
* https://github.com/GoogleChrome/lighthouse/issues/12134
* @type {LH.Gatherer.GathererMeta<'DevtoolsLog'>}
*/
meta: LH.Gatherer.GathererMeta<'DevtoolsLog'>;
/**
* @param {LH.Gatherer.FRTransitionalContext} context
* @param {LH.Artifacts['DevtoolsLog']} devtoolsLog
* @return {Promise<LH.Artifacts['LinkElements']>}
*/
_getArtifact(context: LH.Gatherer.FRTransitionalContext, devtoolsLog: LH.Artifacts['DevtoolsLog']): Promise<LH.Artifacts['LinkElements']>;
/**
* @param {LH.Gatherer.PassContext} context
* @param {LH.Gatherer.LoadData} loadData
* @return {Promise<LH.Artifacts['LinkElements']>}
*/
afterPass(context: LH.Gatherer.PassContext, loadData: LH.Gatherer.LoadData): Promise<LH.Artifacts['LinkElements']>;
/**
* @param {LH.Gatherer.FRTransitionalContext<'DevtoolsLog'>} context
* @return {Promise<LH.Artifacts['LinkElements']>}
*/
getArtifact(context: LH.Gatherer.FRTransitionalContext<'DevtoolsLog'>): Promise<LH.Artifacts['LinkElements']>;
}
export namespace UIStrings {
const headerParseWarning: string;
}
import FRGatherer from '../base-gatherer.js';
//# sourceMappingURL=link-elements.d.ts.map

View File

@@ -0,0 +1,211 @@
/**
* @license Copyright 2018 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
import LinkHeader from 'http-link-header';
import FRGatherer from '../base-gatherer.js';
import {pageFunctions} from '../../lib/page-functions.js';
import DevtoolsLog from './devtools-log.js';
import {MainResource} from '../../computed/main-resource.js';
import {Util} from '../../../shared/util.js';
import * as i18n from '../../lib/i18n/i18n.js';
/* globals HTMLLinkElement getNodeDetails */
/**
* @fileoverview
* This gatherer collects all the effect `link` elements, both in the page and declared in the
* headers of the main resource.
*/
const UIStrings = {
/**
* @description Warning message explaining that there was an error parsing a link header in an HTTP response. `error` will be an english string with more details on the error. `header` will be the value of the header that caused the error. `link` is a type of HTTP header and should not be translated.
* @example {Expected attribute delimiter at offset 94} error
* @example {<https://assets.calendly.com/assets/booking/css/booking-d0ac32b1.css>; rel=preload; as=style; nopush} error
*/
headerParseWarning: 'Error parsing `link` header ({error}): `{header}`',
};
const str_ = i18n.createIcuMessageFn(import.meta.url, UIStrings);
/**
*
* @param {string} url
* @param {string} finalDisplayedUrl
* @return {string|null}
*/
function normalizeUrlOrNull(url, finalDisplayedUrl) {
try {
return new URL(url, finalDisplayedUrl).href;
} catch (_) {
return null;
}
}
/**
* @param {string|undefined} value
* @return {LH.Artifacts.LinkElement['crossOrigin']}
*/
function getCrossoriginFromHeader(value) {
if (value === 'anonymous') return 'anonymous';
if (value === 'use-credentials') return 'use-credentials';
return null;
}
/**
* @return {LH.Artifacts['LinkElements']}
*/
/* c8 ignore start */
function getLinkElementsInDOM() {
/** @type {Array<HTMLOrSVGElement>} */
// @ts-expect-error - getElementsInDocument put into scope via stringification
const browserElements = getElementsInDocument('link'); // eslint-disable-line no-undef
/** @type {LH.Artifacts['LinkElements']} */
const linkElements = [];
for (const link of browserElements) {
// We're only interested in actual LinkElements, not `<link>` tagName elements inside SVGs.
// https://github.com/GoogleChrome/lighthouse/issues/9764
if (!(link instanceof HTMLLinkElement)) continue;
const hrefRaw = link.getAttribute('href') || '';
const source = link.closest('head') ? 'head' : 'body';
linkElements.push({
rel: link.rel,
href: link.href,
hreflang: link.hreflang,
as: link.as,
crossOrigin: link.crossOrigin,
hrefRaw,
source,
fetchPriority: link.fetchPriority,
// @ts-expect-error - put into scope via stringification
node: getNodeDetails(link),
});
}
return linkElements;
}
/* c8 ignore stop */
class LinkElements extends FRGatherer {
constructor() {
super();
/**
* This needs to be in the constructor.
* https://github.com/GoogleChrome/lighthouse/issues/12134
* @type {LH.Gatherer.GathererMeta<'DevtoolsLog'>}
*/
this.meta = {
supportedModes: ['timespan', 'navigation'],
dependencies: {DevtoolsLog: DevtoolsLog.symbol},
};
}
/**
* @param {LH.Gatherer.FRTransitionalContext} context
* @return {Promise<LH.Artifacts['LinkElements']>}
*/
static getLinkElementsInDOM(context) {
// We'll use evaluateAsync because the `node.getAttribute` method doesn't actually normalize
// the values like access from JavaScript does.
return context.driver.executionContext.evaluate(getLinkElementsInDOM, {
args: [],
useIsolation: true,
deps: [
pageFunctions.getNodeDetails,
pageFunctions.getElementsInDocument,
],
});
}
/**
* @param {LH.Gatherer.FRTransitionalContext} context
* @param {LH.Artifacts['DevtoolsLog']} devtoolsLog
* @return {Promise<LH.Artifacts['LinkElements']>}
*/
static async getLinkElementsInHeaders(context, devtoolsLog) {
const mainDocument =
await MainResource.request({devtoolsLog, URL: context.baseArtifacts.URL}, context);
/** @type {LH.Artifacts['LinkElements']} */
const linkElements = [];
for (const header of mainDocument.responseHeaders) {
if (header.name.toLowerCase() !== 'link') continue;
/** @type {LinkHeader.Reference[]} */
let parsedRefs = [];
try {
parsedRefs = LinkHeader.parse(header.value).refs;
} catch (err) {
const truncatedHeader = Util.truncate(header.value, 100);
const warning = str_(UIStrings.headerParseWarning, {
error: err.message,
header: truncatedHeader,
});
context.baseArtifacts.LighthouseRunWarnings.push(warning);
}
for (const link of parsedRefs) {
linkElements.push({
rel: link.rel || '',
href: normalizeUrlOrNull(link.uri, context.baseArtifacts.URL.finalDisplayedUrl),
hrefRaw: link.uri || '',
hreflang: link.hreflang || '',
as: link.as || '',
crossOrigin: getCrossoriginFromHeader(link.crossorigin),
source: 'headers',
fetchPriority: link.fetchpriority,
node: null,
});
}
}
return linkElements;
}
/**
* @param {LH.Gatherer.FRTransitionalContext} context
* @param {LH.Artifacts['DevtoolsLog']} devtoolsLog
* @return {Promise<LH.Artifacts['LinkElements']>}
*/
async _getArtifact(context, devtoolsLog) {
const fromDOM = await LinkElements.getLinkElementsInDOM(context);
const fromHeaders = await LinkElements.getLinkElementsInHeaders(context, devtoolsLog);
const linkElements = fromDOM.concat(fromHeaders);
for (const link of linkElements) {
// Normalize the rel for easy consumption/filtering
link.rel = link.rel.toLowerCase();
}
return linkElements;
}
/**
* @param {LH.Gatherer.PassContext} context
* @param {LH.Gatherer.LoadData} loadData
* @return {Promise<LH.Artifacts['LinkElements']>}
*/
async afterPass(context, loadData) {
return this._getArtifact({...context, dependencies: {}}, loadData.devtoolsLog);
}
/**
* @param {LH.Gatherer.FRTransitionalContext<'DevtoolsLog'>} context
* @return {Promise<LH.Artifacts['LinkElements']>}
*/
async getArtifact(context) {
return this._getArtifact(context, context.dependencies.DevtoolsLog);
}
}
export default LinkElements;
export {UIStrings};

View File

@@ -0,0 +1,27 @@
export default MainDocumentContent;
/**
* Collects the content of the main html document.
*/
declare class MainDocumentContent extends FRGatherer {
/** @type {LH.Gatherer.GathererMeta<'DevtoolsLog'>} */
meta: LH.Gatherer.GathererMeta<'DevtoolsLog'>;
/**
* @param {LH.Gatherer.FRTransitionalContext} context
* @param {LH.Artifacts['DevtoolsLog']} devtoolsLog
* @return {Promise<LH.Artifacts['MainDocumentContent']>}
*/
_getArtifact(context: LH.Gatherer.FRTransitionalContext, devtoolsLog: LH.Artifacts['DevtoolsLog']): Promise<LH.Artifacts['MainDocumentContent']>;
/**
* @param {LH.Gatherer.FRTransitionalContext<'DevtoolsLog'>} context
* @return {Promise<LH.Artifacts['MainDocumentContent']>}
*/
getArtifact(context: LH.Gatherer.FRTransitionalContext<'DevtoolsLog'>): Promise<LH.Artifacts['MainDocumentContent']>;
/**
* @param {LH.Gatherer.PassContext} passContext
* @param {LH.Gatherer.LoadData} loadData
* @return {Promise<LH.Artifacts['MainDocumentContent']>}
*/
afterPass(passContext: LH.Gatherer.PassContext, loadData: LH.Gatherer.LoadData): Promise<LH.Artifacts['MainDocumentContent']>;
}
import FRGatherer from '../base-gatherer.js';
//# sourceMappingURL=main-document-content.d.ts.map

View File

@@ -0,0 +1,53 @@
/**
* @license Copyright 2019 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
import FRGatherer from '../base-gatherer.js';
import DevtoolsLog from './devtools-log.js';
import {fetchResponseBodyFromCache} from '../driver/network.js';
import {MainResource} from '../../computed/main-resource.js';
/**
* Collects the content of the main html document.
*/
class MainDocumentContent extends FRGatherer {
/** @type {LH.Gatherer.GathererMeta<'DevtoolsLog'>} */
meta = {
supportedModes: ['navigation'],
dependencies: {DevtoolsLog: DevtoolsLog.symbol},
};
/**
* @param {LH.Gatherer.FRTransitionalContext} context
* @param {LH.Artifacts['DevtoolsLog']} devtoolsLog
* @return {Promise<LH.Artifacts['MainDocumentContent']>}
*/
async _getArtifact(context, devtoolsLog) {
const mainResource =
await MainResource.request({devtoolsLog, URL: context.baseArtifacts.URL}, context);
const session = context.driver.defaultSession;
return fetchResponseBodyFromCache(session, mainResource.requestId);
}
/**
* @param {LH.Gatherer.FRTransitionalContext<'DevtoolsLog'>} context
* @return {Promise<LH.Artifacts['MainDocumentContent']>}
*/
async getArtifact(context) {
const devtoolsLog = context.dependencies.DevtoolsLog;
return this._getArtifact(context, devtoolsLog);
}
/**
* @param {LH.Gatherer.PassContext} passContext
* @param {LH.Gatherer.LoadData} loadData
* @return {Promise<LH.Artifacts['MainDocumentContent']>}
*/
async afterPass(passContext, loadData) {
return this._getArtifact({...passContext, dependencies: {}}, loadData.devtoolsLog);
}
}
export default MainDocumentContent;

View File

@@ -0,0 +1,10 @@
export default MetaElements;
declare class MetaElements extends FRGatherer {
/**
* @param {LH.Gatherer.FRTransitionalContext} passContext
* @return {Promise<LH.Artifacts['MetaElements']>}
*/
getArtifact(passContext: LH.Gatherer.FRTransitionalContext): Promise<LH.Artifacts['MetaElements']>;
}
import FRGatherer from '../base-gatherer.js';
//# sourceMappingURL=meta-elements.d.ts.map

View File

@@ -0,0 +1,67 @@
/**
* @license Copyright 2019 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
import FRGatherer from '../base-gatherer.js';
import {pageFunctions} from '../../lib/page-functions.js';
/* globals getElementsInDocument getNodeDetails */
/* c8 ignore start */
function collectMetaElements() {
const functions = /** @type {typeof pageFunctions} */({
// @ts-expect-error - getElementsInDocument put into scope via stringification
getElementsInDocument,
// @ts-expect-error - getNodeDetails put into scope via stringification
getNodeDetails,
});
const metas = functions.getElementsInDocument('head meta');
return metas.map(meta => {
/** @param {string} name */
const getAttribute = name => {
const attr = meta.attributes.getNamedItem(name);
if (!attr) return;
return attr.value;
};
return {
name: meta.name.toLowerCase(),
content: meta.content,
property: getAttribute('property'),
httpEquiv: meta.httpEquiv ? meta.httpEquiv.toLowerCase() : undefined,
charset: getAttribute('charset'),
node: functions.getNodeDetails(meta),
};
});
}
/* c8 ignore stop */
class MetaElements extends FRGatherer {
/** @type {LH.Gatherer.GathererMeta} */
meta = {
supportedModes: ['snapshot', 'navigation'],
};
/**
* @param {LH.Gatherer.FRTransitionalContext} passContext
* @return {Promise<LH.Artifacts['MetaElements']>}
*/
getArtifact(passContext) {
const driver = passContext.driver;
// We'll use evaluateAsync because the `node.getAttribute` method doesn't actually normalize
// the values like access from JavaScript does.
return driver.executionContext.evaluate(collectMetaElements, {
args: [],
useIsolation: true,
deps: [
pageFunctions.getElementsInDocument,
pageFunctions.getNodeDetails,
],
});
}
}
export default MetaElements;

View File

@@ -0,0 +1,18 @@
export default NetworkUserAgent;
/** @implements {LH.Gatherer.FRGathererInstance<'DevtoolsLog'>} */
declare class NetworkUserAgent extends FRGatherer implements LH.Gatherer.FRGathererInstance<'DevtoolsLog'> {
/**
* @param {LH.Artifacts['DevtoolsLog']} devtoolsLog
* @return {string}
*/
static getNetworkUserAgent(devtoolsLog: LH.Artifacts['DevtoolsLog']): string;
/** @type {LH.Gatherer.GathererMeta<'DevtoolsLog'>} */
meta: LH.Gatherer.GathererMeta<'DevtoolsLog'>;
/**
* @param {LH.Gatherer.FRTransitionalContext<'DevtoolsLog'>} context
* @return {Promise<LH.Artifacts['NetworkUserAgent']>}
*/
getArtifact(context: LH.Gatherer.FRTransitionalContext<'DevtoolsLog'>): Promise<LH.Artifacts['NetworkUserAgent']>;
}
import FRGatherer from '../base-gatherer.js';
//# sourceMappingURL=network-user-agent.d.ts.map

View File

@@ -0,0 +1,41 @@
/**
* @license Copyright 2021 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
import FRGatherer from '../base-gatherer.js';
import DevtoolsLogGatherer from './devtools-log.js';
/** @implements {LH.Gatherer.FRGathererInstance<'DevtoolsLog'>} */
class NetworkUserAgent extends FRGatherer {
/** @type {LH.Gatherer.GathererMeta<'DevtoolsLog'>} */
meta = {
supportedModes: ['timespan', 'navigation'],
dependencies: {DevtoolsLog: DevtoolsLogGatherer.symbol},
};
/**
* @param {LH.Artifacts['DevtoolsLog']} devtoolsLog
* @return {string}
*/
static getNetworkUserAgent(devtoolsLog) {
for (const entry of devtoolsLog) {
if (entry.method !== 'Network.requestWillBeSent') continue;
const userAgent = entry.params.request.headers['User-Agent'];
if (userAgent) return userAgent;
}
return '';
}
/**
* @param {LH.Gatherer.FRTransitionalContext<'DevtoolsLog'>} context
* @return {Promise<LH.Artifacts['NetworkUserAgent']>}
*/
async getArtifact(context) {
return NetworkUserAgent.getNetworkUserAgent(context.dependencies.DevtoolsLog);
}
}
export default NetworkUserAgent;

View File

@@ -0,0 +1,26 @@
export default ScriptElements;
/**
* @fileoverview Gets JavaScript file contents.
*/
declare class ScriptElements extends FRGatherer {
/** @type {LH.Gatherer.GathererMeta<'DevtoolsLog'>} */
meta: LH.Gatherer.GathererMeta<'DevtoolsLog'>;
/**
* @param {LH.Gatherer.FRTransitionalContext} context
* @param {LH.Artifacts.NetworkRequest[]} networkRecords
* @return {Promise<LH.Artifacts['ScriptElements']>}
*/
_getArtifact(context: LH.Gatherer.FRTransitionalContext, networkRecords: LH.Artifacts.NetworkRequest[]): Promise<LH.Artifacts['ScriptElements']>;
/**
* @param {LH.Gatherer.FRTransitionalContext<'DevtoolsLog'>} context
*/
getArtifact(context: LH.Gatherer.FRTransitionalContext<'DevtoolsLog'>): Promise<import("../../index.js").Artifacts.ScriptElement[]>;
/**
* @param {LH.Gatherer.PassContext} passContext
* @param {LH.Gatherer.LoadData} loadData
*/
afterPass(passContext: LH.Gatherer.PassContext, loadData: LH.Gatherer.LoadData): Promise<import("../../index.js").Artifacts.ScriptElement[]>;
}
import FRGatherer from '../base-gatherer.js';
import { NetworkRequest } from '../../lib/network-request.js';
//# sourceMappingURL=script-elements.d.ts.map

View File

@@ -0,0 +1,109 @@
/**
* @license Copyright 2017 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
import FRGatherer from '../base-gatherer.js';
import {NetworkRecords} from '../../computed/network-records.js';
import {NetworkRequest} from '../../lib/network-request.js';
import {pageFunctions} from '../../lib/page-functions.js';
import DevtoolsLog from './devtools-log.js';
/* global getNodeDetails */
/**
* @return {LH.Artifacts['ScriptElements']}
*/
/* c8 ignore start */
function collectAllScriptElements() {
/** @type {HTMLScriptElement[]} */
// @ts-expect-error - getElementsInDocument put into scope via stringification
const scripts = getElementsInDocument('script'); // eslint-disable-line no-undef
return scripts.map(script => {
return {
type: script.type || null,
src: script.src || null,
id: script.id || null,
async: script.async,
defer: script.defer,
source: script.closest('head') ? 'head' : 'body',
// @ts-expect-error - getNodeDetails put into scope via stringification
node: getNodeDetails(script),
};
});
}
/* c8 ignore stop */
/**
* @fileoverview Gets JavaScript file contents.
*/
class ScriptElements extends FRGatherer {
/** @type {LH.Gatherer.GathererMeta<'DevtoolsLog'>} */
meta = {
supportedModes: ['timespan', 'navigation'],
dependencies: {DevtoolsLog: DevtoolsLog.symbol},
};
/**
* @param {LH.Gatherer.FRTransitionalContext} context
* @param {LH.Artifacts.NetworkRequest[]} networkRecords
* @return {Promise<LH.Artifacts['ScriptElements']>}
*/
async _getArtifact(context, networkRecords) {
const executionContext = context.driver.executionContext;
const scripts = await executionContext.evaluate(collectAllScriptElements, {
args: [],
useIsolation: true,
deps: [
pageFunctions.getNodeDetails,
pageFunctions.getElementsInDocument,
],
});
const scriptRecords = networkRecords
.filter(record => record.resourceType === NetworkRequest.TYPES.Script)
.filter(record => !record.isOutOfProcessIframe);
for (let i = 0; i < scriptRecords.length; i++) {
const record = scriptRecords[i];
const matchedScriptElement = scripts.find(script => script.src === record.url);
if (!matchedScriptElement) {
scripts.push({
type: null,
src: record.url,
id: null,
async: false,
defer: false,
source: 'network',
node: null,
});
}
}
return scripts;
}
/**
* @param {LH.Gatherer.FRTransitionalContext<'DevtoolsLog'>} context
*/
async getArtifact(context) {
const devtoolsLog = context.dependencies.DevtoolsLog;
const networkRecords = await NetworkRecords.request(devtoolsLog, context);
return this._getArtifact(context, networkRecords);
}
/**
* @param {LH.Gatherer.PassContext} passContext
* @param {LH.Gatherer.LoadData} loadData
*/
async afterPass(passContext, loadData) {
const networkRecords = loadData.networkRecords;
return this._getArtifact({...passContext, dependencies: {}}, networkRecords);
}
}
export default ScriptElements;

View File

@@ -0,0 +1,25 @@
export default Scripts;
/**
* @fileoverview Gets JavaScript file contents.
*/
declare class Scripts extends FRGatherer {
/** @type {LH.Crdp.Debugger.ScriptParsedEvent[]} */
_scriptParsedEvents: LH.Crdp.Debugger.ScriptParsedEvent[];
/** @type {Array<string | undefined>} */
_scriptContents: Array<string | undefined>;
/**
* @param {LH.Crdp.Debugger.ScriptParsedEvent} params
*/
onScriptParsed(params: LH.Crdp.Debugger.ScriptParsedEvent): void;
/**
* @param {LH.Gatherer.FRTransitionalContext} context
*/
startInstrumentation(context: LH.Gatherer.FRTransitionalContext): Promise<void>;
/**
* @param {LH.Gatherer.FRTransitionalContext} context
*/
stopInstrumentation(context: LH.Gatherer.FRTransitionalContext): Promise<void>;
getArtifact(): Promise<import("../../index.js").Artifacts.Script[]>;
}
import FRGatherer from '../base-gatherer.js';
//# sourceMappingURL=scripts.d.ts.map

View File

@@ -0,0 +1,136 @@
/**
* @license Copyright 2022 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
import FRGatherer from '../base-gatherer.js';
/**
* @template T, U
* @param {Array<T>} values
* @param {(value: T) => Promise<U>} promiseMapper
* @param {boolean} runInSeries
* @return {Promise<Array<U>>}
*/
async function runInSeriesOrParallel(values, promiseMapper, runInSeries) {
if (runInSeries) {
const results = [];
for (const value of values) {
const result = await promiseMapper(value);
results.push(result);
}
return results;
} else {
const promises = values.map(promiseMapper);
return await Promise.all(promises);
}
}
/**
* Returns true if the script was created via our own calls
* to Runtime.evaluate.
* @param {LH.Crdp.Debugger.ScriptParsedEvent} script
*/
function isLighthouseRuntimeEvaluateScript(script) {
// Scripts created by Runtime.evaluate that run on the main session/frame
// result in an empty string for the embedderName.
// Or, it means the script was dynamically created (eval, new Function, onload, ...)
if (!script.embedderName) return true;
// Otherwise, when running our own code inside other frames, the embedderName
// is set to the frame's url. In that case, we rely on the special sourceURL that
// we set.
return script.hasSourceURL && script.url === '_lighthouse-eval.js';
}
/**
* @fileoverview Gets JavaScript file contents.
*/
class Scripts extends FRGatherer {
/** @type {LH.Gatherer.GathererMeta} */
meta = {
supportedModes: ['timespan', 'navigation'],
};
/** @type {LH.Crdp.Debugger.ScriptParsedEvent[]} */
_scriptParsedEvents = [];
/** @type {Array<string | undefined>} */
_scriptContents = [];
constructor() {
super();
this.onScriptParsed = this.onScriptParsed.bind(this);
}
/**
* @param {LH.Crdp.Debugger.ScriptParsedEvent} params
*/
onScriptParsed(params) {
if (!isLighthouseRuntimeEvaluateScript(params)) {
this._scriptParsedEvents.push(params);
}
}
/**
* @param {LH.Gatherer.FRTransitionalContext} context
*/
async startInstrumentation(context) {
const session = context.driver.defaultSession;
session.on('Debugger.scriptParsed', this.onScriptParsed);
await session.sendCommand('Debugger.enable');
}
/**
* @param {LH.Gatherer.FRTransitionalContext} context
*/
async stopInstrumentation(context) {
const session = context.driver.defaultSession;
const formFactor = context.baseArtifacts.HostFormFactor;
session.off('Debugger.scriptParsed', this.onScriptParsed);
// Without this line the Debugger domain will be off in FR runner,
// because only the legacy gatherer has special handling for multiple,
// overlapped enabled/disable calls.
await session.sendCommand('Debugger.enable');
// If run on a mobile device, be sensitive to memory limitations and only
// request one at a time.
this._scriptContents = await runInSeriesOrParallel(
this._scriptParsedEvents,
({scriptId}) => {
return session.sendCommand('Debugger.getScriptSource', {scriptId})
.then((resp) => resp.scriptSource)
.catch(() => undefined);
},
formFactor === 'mobile' /* runInSeries */
);
await session.sendCommand('Debugger.disable');
}
async getArtifact() {
/** @type {LH.Artifacts['Scripts']} */
const scripts = this._scriptParsedEvents.map((event, i) => {
// 'embedderName' and 'url' are confusingly named, so we rewrite them here.
// On the protocol, 'embedderName' always refers to the URL of the script (or HTML if inline).
// Same for 'url' ... except, magic "sourceURL=" comments will override the value.
// It's nice to display the user-provided value in Lighthouse, so we add a field 'name'
// to make it clear this is for presentational purposes.
// See https://chromium-review.googlesource.com/c/v8/v8/+/2317310
return {
name: event.url,
...event,
// embedderName is optional on the protocol because backends like Node may not set it.
// For our purposes, it is always set. But just in case it isn't... fallback to the url.
url: event.embedderName || event.url,
content: this._scriptContents[i],
};
});
return scripts;
}
}
export default Scripts;

View File

@@ -0,0 +1,10 @@
export default EmbeddedContent;
declare class EmbeddedContent extends FRGatherer {
/**
* @param {LH.Gatherer.FRTransitionalContext} passContext
* @return {Promise<LH.Artifacts['EmbeddedContent']>}
*/
getArtifact(passContext: LH.Gatherer.FRTransitionalContext): Promise<LH.Artifacts['EmbeddedContent']>;
}
import FRGatherer from '../../base-gatherer.js';
//# sourceMappingURL=embedded-content.d.ts.map

View File

@@ -0,0 +1,63 @@
/**
* @license Copyright 2018 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/* globals getElementsInDocument getNodeDetails */
import FRGatherer from '../../base-gatherer.js';
import {pageFunctions} from '../../../lib/page-functions.js';
/**
* @return {LH.Artifacts.EmbeddedContentInfo[]}
*/
function getEmbeddedContent() {
const functions = /** @type {typeof pageFunctions} */ ({
// @ts-expect-error - getElementsInDocument put into scope via stringification
getElementsInDocument,
// @ts-expect-error - getNodeDetails put into scope via stringification
getNodeDetails,
});
const selector = 'object, embed, applet';
const elements = functions.getElementsInDocument(selector);
return elements
.map(node => ({
tagName: node.tagName,
type: node.getAttribute('type'),
src: node.getAttribute('src'),
data: node.getAttribute('data'),
code: node.getAttribute('code'),
params: Array.from(node.children)
.filter(el => el.tagName === 'PARAM')
.map(el => ({
name: el.getAttribute('name') || '',
value: el.getAttribute('value') || '',
})),
node: functions.getNodeDetails(node),
}));
}
class EmbeddedContent extends FRGatherer {
/** @type {LH.Gatherer.GathererMeta} */
meta = {
supportedModes: ['snapshot', 'navigation'],
};
/**
* @param {LH.Gatherer.FRTransitionalContext} passContext
* @return {Promise<LH.Artifacts['EmbeddedContent']>}
*/
getArtifact(passContext) {
return passContext.driver.executionContext.evaluate(getEmbeddedContent, {
args: [],
deps: [
pageFunctions.getElementsInDocument,
pageFunctions.getNodeDetails,
],
});
}
}
export default EmbeddedContent;

View File

@@ -0,0 +1,130 @@
export default FontSize;
export type NodeFontData = LH.Artifacts.FontSize['analyzedFailingNodesData'][0];
export type BackendIdsToFontData = Map<number, {
fontSize: number;
textLength: number;
}>;
declare class FontSize extends FRGatherer {
/**
* @param {LH.Gatherer.FRProtocolSession} session
* @param {Array<NodeFontData>} failingNodes
*/
static fetchFailingNodeSourceRules(session: LH.Gatherer.FRProtocolSession, failingNodes: Array<NodeFontData>): Promise<{
analyzedFailingNodesData: {
nodeId: number;
fontSize: number;
textLength: number;
parentNode: {
backendNodeId: number;
attributes: string[];
nodeName: string;
parentNode?: {
backendNodeId: number;
attributes: string[];
nodeName: string;
} | undefined;
};
cssRule?: {
type: "Inline" | "Regular" | "Attributes";
range?: {
startLine: number;
startColumn: number;
} | undefined;
parentRule?: {
origin: import("devtools-protocol").Protocol.CSS.StyleSheetOrigin;
selectors: {
text: string;
}[];
} | undefined;
styleSheetId?: string | undefined;
stylesheet?: import("devtools-protocol").Protocol.CSS.CSSStyleSheetHeader | undefined;
cssProperties?: import("devtools-protocol").Protocol.CSS.CSSProperty[] | undefined;
} | undefined;
}[];
analyzedFailingTextLength: number;
}>;
/**
* Returns the TextNodes in a DOM Snapshot.
* Every entry is associated with a TextNode in the layout tree (not display: none).
* @param {LH.Crdp.DOMSnapshot.CaptureSnapshotResponse} snapshot
*/
getTextNodesInLayoutFromSnapshot(snapshot: LH.Crdp.DOMSnapshot.CaptureSnapshotResponse): {
nodeIndex: number;
backendNodeId: number;
fontSize: number;
textLength: number;
parentNode: {
parentNode: {
backendNodeId: number;
attributes: string[];
nodeName: string;
} | undefined;
backendNodeId: number;
attributes: string[];
nodeName: string;
};
}[];
/**
* Get all the failing text nodes that don't meet the legible text threshold.
* @param {LH.Crdp.DOMSnapshot.CaptureSnapshotResponse} snapshot
*/
findFailingNodes(snapshot: LH.Crdp.DOMSnapshot.CaptureSnapshotResponse): {
totalTextLength: number;
failingTextLength: number;
failingNodes: {
nodeId: number;
fontSize: number;
textLength: number;
parentNode: {
backendNodeId: number;
attributes: string[];
nodeName: string;
parentNode?: {
backendNodeId: number;
attributes: string[];
nodeName: string;
} | undefined;
};
cssRule?: {
type: "Inline" | "Regular" | "Attributes";
range?: {
startLine: number;
startColumn: number;
} | undefined;
parentRule?: {
origin: import("devtools-protocol").Protocol.CSS.StyleSheetOrigin;
selectors: {
text: string;
}[];
} | undefined;
styleSheetId?: string | undefined;
stylesheet?: import("devtools-protocol").Protocol.CSS.CSSStyleSheetHeader | undefined;
cssProperties?: import("devtools-protocol").Protocol.CSS.CSSProperty[] | undefined;
} | undefined;
}[];
};
/**
* @param {LH.Gatherer.FRTransitionalContext} passContext
* @return {Promise<LH.Artifacts.FontSize>} font-size analysis
*/
getArtifact(passContext: LH.Gatherer.FRTransitionalContext): Promise<LH.Artifacts.FontSize>;
}
/**
* Returns the governing/winning CSS font-size rule for the set of styles given.
* This is roughly a stripped down version of the CSSMatchedStyle class in DevTools.
*
* @see https://cs.chromium.org/chromium/src/third_party/blink/renderer/devtools/front_end/sdk/CSSMatchedStyles.js?q=CSSMatchedStyles+f:devtools+-f:out&sq=package:chromium&dr=C&l=59-134
* @param {LH.Crdp.CSS.GetMatchedStylesForNodeResponse} matched CSS rules
* @return {NodeFontData['cssRule']|undefined}
*/
export function getEffectiveFontRule({ attributesStyle, inlineStyle, matchedCSSRules, inherited }: LH.Crdp.CSS.GetMatchedStylesForNodeResponse): NodeFontData['cssRule'] | undefined;
/**
* Finds the most specific directly matched CSS font-size rule from the list.
*
* @param {Array<LH.Crdp.CSS.RuleMatch>} matchedCSSRules
* @param {function(LH.Crdp.CSS.CSSStyle):boolean|string|undefined} isDeclarationOfInterest
* @return {NodeFontData['cssRule']|undefined}
*/
export function findMostSpecificMatchedCSSRule(matchedCSSRules: import("devtools-protocol").Protocol.CSS.RuleMatch[] | undefined, isDeclarationOfInterest: (arg0: LH.Crdp.CSS.CSSStyle) => boolean | string | undefined): NodeFontData['cssRule'] | undefined;
import FRGatherer from '../../base-gatherer.js';
//# sourceMappingURL=font-size.d.ts.map

View File

@@ -0,0 +1,338 @@
/**
* @license Copyright 2017 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/**
* @fileoverview Extracts information about illegible text from the page.
*
* In effort to keep this audit's execution time around 1s, maximum number of protocol calls was limited.
* Firstly, number of visited nodes (text nodes for which font size was checked) is capped.
* Secondly, number of failing nodes that are analyzed (for which detailed CSS information is extracted) is also limited.
*
* The applicable CSS rule is also determined by the code here with some simplifications (namely !important is ignored).
* This gatherer collects stylesheet metadata by itself, instead of relying on the styles gatherer which is slow (because it parses the stylesheet content).
*/
import FRGatherer from '../../base-gatherer.js';
const FONT_SIZE_PROPERTY_NAME = 'font-size';
const MINIMAL_LEGIBLE_FONT_SIZE_PX = 12;
// limit number of protocol calls to make sure that gatherer doesn't take more than 1-2s
const MAX_NODES_SOURCE_RULE_FETCHED = 50; // number of nodes to fetch the source font-size rule
/** @typedef {LH.Artifacts.FontSize['analyzedFailingNodesData'][0]} NodeFontData */
/** @typedef {Map<number, {fontSize: number, textLength: number}>} BackendIdsToFontData */
/**
* @param {LH.Crdp.CSS.CSSStyle} [style]
* @return {boolean}
*/
function hasFontSizeDeclaration(style) {
return !!style && !!style.cssProperties.find(({name}) => name === FONT_SIZE_PROPERTY_NAME);
}
/**
* Finds the most specific directly matched CSS font-size rule from the list.
*
* @param {Array<LH.Crdp.CSS.RuleMatch>} matchedCSSRules
* @param {function(LH.Crdp.CSS.CSSStyle):boolean|string|undefined} isDeclarationOfInterest
* @return {NodeFontData['cssRule']|undefined}
*/
function findMostSpecificMatchedCSSRule(matchedCSSRules = [], isDeclarationOfInterest) {
let mostSpecificRule;
for (let i = matchedCSSRules.length - 1; i >= 0; i--) {
if (isDeclarationOfInterest(matchedCSSRules[i].rule.style)) {
mostSpecificRule = matchedCSSRules[i].rule;
break;
}
}
if (mostSpecificRule) {
return {
type: 'Regular',
...mostSpecificRule.style,
parentRule: {
origin: mostSpecificRule.origin,
selectors: mostSpecificRule.selectorList.selectors,
},
};
}
}
/**
* Finds the most specific directly matched CSS font-size rule from the list.
*
* @param {Array<LH.Crdp.CSS.InheritedStyleEntry>} [inheritedEntries]
* @return {NodeFontData['cssRule']|undefined}
*/
function findInheritedCSSRule(inheritedEntries = []) {
// The inherited array contains the array of matched rules for all parents in ascending tree order.
// i.e. for an element whose path is `html > body > #main > #nav > p`
// `inherited` will be an array of styles like `[#nav, #main, body, html]`
// The closest parent with font-size will win
for (const {inlineStyle, matchedCSSRules} of inheritedEntries) {
if (hasFontSizeDeclaration(inlineStyle)) return {type: 'Inline', ...inlineStyle};
const directRule = findMostSpecificMatchedCSSRule(matchedCSSRules, hasFontSizeDeclaration);
if (directRule) return directRule;
}
}
/**
* Returns the governing/winning CSS font-size rule for the set of styles given.
* This is roughly a stripped down version of the CSSMatchedStyle class in DevTools.
*
* @see https://cs.chromium.org/chromium/src/third_party/blink/renderer/devtools/front_end/sdk/CSSMatchedStyles.js?q=CSSMatchedStyles+f:devtools+-f:out&sq=package:chromium&dr=C&l=59-134
* @param {LH.Crdp.CSS.GetMatchedStylesForNodeResponse} matched CSS rules
* @return {NodeFontData['cssRule']|undefined}
*/
function getEffectiveFontRule({attributesStyle, inlineStyle, matchedCSSRules, inherited}) {
// Inline styles have highest priority
if (hasFontSizeDeclaration(inlineStyle)) return {type: 'Inline', ...inlineStyle};
// Rules directly referencing the node come next
const matchedRule = findMostSpecificMatchedCSSRule(matchedCSSRules, hasFontSizeDeclaration);
if (matchedRule) return matchedRule;
// Then comes attributes styles (<font size="1">)
if (hasFontSizeDeclaration(attributesStyle)) return {type: 'Attributes', ...attributesStyle};
// Finally, find an inherited property if there is one
const inheritedRule = findInheritedCSSRule(inherited);
if (inheritedRule) return inheritedRule;
return undefined;
}
/**
* @param {string} text
* @return {number}
*/
function getTextLength(text) {
// Array.from to count symbols not unicode code points. See: #6973
return !text ? 0 : Array.from(text.trim()).length;
}
/**
* @param {LH.Gatherer.FRProtocolSession} session
* @param {number} nodeId text node
* @return {Promise<NodeFontData['cssRule']|undefined>}
*/
async function fetchSourceRule(session, nodeId) {
const matchedRules = await session.sendCommand('CSS.getMatchedStylesForNode', {
nodeId,
});
const sourceRule = getEffectiveFontRule(matchedRules);
if (!sourceRule) return undefined;
return {
type: sourceRule.type,
range: sourceRule.range,
styleSheetId: sourceRule.styleSheetId,
parentRule: sourceRule.parentRule && {
origin: sourceRule.parentRule.origin,
selectors: sourceRule.parentRule.selectors,
},
};
}
class FontSize extends FRGatherer {
/** @type {LH.Gatherer.GathererMeta} */
meta = {
supportedModes: ['snapshot', 'navigation'],
};
/**
* @param {LH.Gatherer.FRProtocolSession} session
* @param {Array<NodeFontData>} failingNodes
*/
static async fetchFailingNodeSourceRules(session, failingNodes) {
const nodesToAnalyze = failingNodes
.sort((a, b) => b.textLength - a.textLength)
.slice(0, MAX_NODES_SOURCE_RULE_FETCHED);
// DOM.getDocument is necessary for pushNodesByBackendIdsToFrontend to properly retrieve nodeIds if the `DOM` domain was enabled before this gatherer, invoke it to be safe.
await session.sendCommand('DOM.getDocument', {depth: -1, pierce: true});
const {nodeIds} = await session.sendCommand('DOM.pushNodesByBackendIdsToFrontend', {
backendNodeIds: nodesToAnalyze.map(node => node.parentNode.backendNodeId),
});
const analysisPromises = nodesToAnalyze
.map(async (failingNode, i) => {
failingNode.nodeId = nodeIds[i];
try {
const cssRule = await fetchSourceRule(session, nodeIds[i]);
failingNode.cssRule = cssRule;
} catch (err) {
// The node was deleted. We don't need to distinguish between lack-of-rule
// due to a deleted node vs due to failed attribution, so just set to undefined.
failingNode.cssRule = undefined;
}
return failingNode;
});
const analyzedFailingNodesData = await Promise.all(analysisPromises);
const analyzedFailingTextLength = analyzedFailingNodesData.reduce(
(sum, {textLength}) => (sum += textLength),
0
);
return {analyzedFailingNodesData, analyzedFailingTextLength};
}
/**
* Returns the TextNodes in a DOM Snapshot.
* Every entry is associated with a TextNode in the layout tree (not display: none).
* @param {LH.Crdp.DOMSnapshot.CaptureSnapshotResponse} snapshot
*/
getTextNodesInLayoutFromSnapshot(snapshot) {
const strings = snapshot.strings;
/** @param {number} index */
const getString = (index) => strings[index];
/** @param {number} index */
const getFloat = (index) => parseFloat(strings[index]);
const textNodesData = [];
for (let j = 0; j < snapshot.documents.length; j++) {
// `doc` is a flattened property list describing all the Nodes in a document, with all string
// values deduped in the `strings` array.
const doc = snapshot.documents[j];
if (!doc.nodes.backendNodeId || !doc.nodes.parentIndex ||
!doc.nodes.attributes || !doc.nodes.nodeName) {
throw new Error('Unexpected response from DOMSnapshot.captureSnapshot.');
}
const nodes = /** @type {Required<typeof doc['nodes']>} */ (doc.nodes);
/** @param {number} parentIndex */
const getParentData = (parentIndex) => ({
backendNodeId: nodes.backendNodeId[parentIndex],
attributes: nodes.attributes[parentIndex].map(getString),
nodeName: getString(nodes.nodeName[parentIndex]),
});
for (const layoutIndex of doc.textBoxes.layoutIndex) {
const text = strings[doc.layout.text[layoutIndex]];
if (!text) continue;
const nodeIndex = doc.layout.nodeIndex[layoutIndex];
const styles = doc.layout.styles[layoutIndex];
const [fontSizeStringId] = styles;
const fontSize = getFloat(fontSizeStringId);
const parentIndex = nodes.parentIndex[nodeIndex];
const grandParentIndex = nodes.parentIndex[parentIndex];
const parentNode = getParentData(parentIndex);
const grandParentNode =
grandParentIndex !== undefined ? getParentData(grandParentIndex) : undefined;
textNodesData.push({
nodeIndex,
backendNodeId: nodes.backendNodeId[nodeIndex],
fontSize,
textLength: getTextLength(text),
parentNode: {
...parentNode,
parentNode: grandParentNode,
},
});
}
}
return textNodesData;
}
/**
* Get all the failing text nodes that don't meet the legible text threshold.
* @param {LH.Crdp.DOMSnapshot.CaptureSnapshotResponse} snapshot
*/
findFailingNodes(snapshot) {
/** @type {NodeFontData[]} */
const failingNodes = [];
let totalTextLength = 0;
let failingTextLength = 0;
for (const textNodeData of this.getTextNodesInLayoutFromSnapshot(snapshot)) {
totalTextLength += textNodeData.textLength;
if (textNodeData.fontSize < MINIMAL_LEGIBLE_FONT_SIZE_PX) {
// Once a bad TextNode is identified, its parent Node is needed.
failingTextLength += textNodeData.textLength;
failingNodes.push({
nodeId: 0, // Set later in fetchFailingNodeSourceRules.
parentNode: textNodeData.parentNode,
textLength: textNodeData.textLength,
fontSize: textNodeData.fontSize,
});
}
}
return {totalTextLength, failingTextLength, failingNodes};
}
/**
* @param {LH.Gatherer.FRTransitionalContext} passContext
* @return {Promise<LH.Artifacts.FontSize>} font-size analysis
*/
async getArtifact(passContext) {
const session = passContext.driver.defaultSession;
/** @type {Map<string, LH.Crdp.CSS.CSSStyleSheetHeader>} */
const stylesheets = new Map();
/** @param {LH.Crdp.CSS.StyleSheetAddedEvent} sheet */
const onStylesheetAdded = sheet => stylesheets.set(sheet.header.styleSheetId, sheet.header);
session.on('CSS.styleSheetAdded', onStylesheetAdded);
await Promise.all([
session.sendCommand('DOMSnapshot.enable'),
session.sendCommand('DOM.enable'),
session.sendCommand('CSS.enable'),
]);
// Get the computed font-size style of every node.
const snapshot = await session.sendCommand('DOMSnapshot.captureSnapshot', {
computedStyles: ['font-size'],
});
const {
totalTextLength,
failingTextLength,
failingNodes,
} = this.findFailingNodes(snapshot);
const {
analyzedFailingNodesData,
analyzedFailingTextLength,
} = await FontSize.fetchFailingNodeSourceRules(session, failingNodes);
session.off('CSS.styleSheetAdded', onStylesheetAdded);
// For the nodes whose computed style we could attribute to a stylesheet, assign
// the stylsheet to the data.
analyzedFailingNodesData
.filter(data => data.cssRule?.styleSheetId)
// @ts-expect-error - guaranteed to exist from the filter immediately above
.forEach(data => (data.cssRule.stylesheet = stylesheets.get(data.cssRule.styleSheetId)));
await Promise.all([
session.sendCommand('DOMSnapshot.disable'),
session.sendCommand('DOM.disable'),
session.sendCommand('CSS.disable'),
]);
return {
analyzedFailingNodesData,
analyzedFailingTextLength,
failingTextLength,
totalTextLength,
};
}
}
export default FontSize;
export {
getEffectiveFontRule,
findMostSpecificMatchedCSSRule,
};

View File

@@ -0,0 +1,10 @@
export default RobotsTxt;
declare class RobotsTxt extends FRGatherer {
/**
* @param {LH.Gatherer.FRTransitionalContext} passContext
* @return {Promise<LH.Artifacts['RobotsTxt']>}
*/
getArtifact(passContext: LH.Gatherer.FRTransitionalContext): Promise<LH.Artifacts['RobotsTxt']>;
}
import FRGatherer from '../../base-gatherer.js';
//# sourceMappingURL=robots-txt.d.ts.map

View File

@@ -0,0 +1,27 @@
/**
* @license Copyright 2018 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
import FRGatherer from '../../base-gatherer.js';
class RobotsTxt extends FRGatherer {
/** @type {LH.Gatherer.GathererMeta} */
meta = {
supportedModes: ['snapshot', 'navigation'],
};
/**
* @param {LH.Gatherer.FRTransitionalContext} passContext
* @return {Promise<LH.Artifacts['RobotsTxt']>}
*/
async getArtifact(passContext) {
const {finalDisplayedUrl} = passContext.baseArtifacts.URL;
const robotsUrl = new URL('/robots.txt', finalDisplayedUrl).href;
return passContext.driver.fetcher.fetchResource(robotsUrl)
.catch(err => ({status: null, content: null, errorMessage: err.message}));
}
}
export default RobotsTxt;

View File

@@ -0,0 +1,21 @@
export default TapTargets;
declare class TapTargets extends FRGatherer {
/**
* @param {LH.Gatherer.FRProtocolSession} session
* @param {string} className
* @return {Promise<string>}
*/
addStyleRule(session: LH.Gatherer.FRProtocolSession, className: string): Promise<string>;
/**
* @param {LH.Gatherer.FRProtocolSession} session
* @param {string} styleSheetId
*/
removeStyleRule(session: LH.Gatherer.FRProtocolSession, styleSheetId: string): Promise<void>;
/**
* @param {LH.Gatherer.FRTransitionalContext} passContext
* @return {Promise<LH.Artifacts.TapTarget[]>} All visible tap targets with their positions and sizes
*/
getArtifact(passContext: LH.Gatherer.FRTransitionalContext): Promise<LH.Artifacts.TapTarget[]>;
}
import FRGatherer from '../../base-gatherer.js';
//# sourceMappingURL=tap-targets.d.ts.map

View File

@@ -0,0 +1,389 @@
/**
* @license Copyright 2018 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/* global document, window, getComputedStyle, getElementsInDocument, Node, getNodeDetails, getRectCenterPoint */
import FRGatherer from '../../base-gatherer.js';
import {pageFunctions} from '../../../lib/page-functions.js';
import * as RectHelpers from '../../../lib/rect-helpers.js';
const TARGET_SELECTORS = [
'button',
'a',
'input',
'textarea',
'select',
'option',
'[role=button]',
'[role=checkbox]',
'[role=link]',
'[role=menuitem]',
'[role=menuitemcheckbox]',
'[role=menuitemradio]',
'[role=option]',
'[role=scrollbar]',
'[role=slider]',
'[role=spinbutton]',
];
const tapTargetsSelector = TARGET_SELECTORS.join(',');
/**
* @param {HTMLElement} element
* @return {boolean}
*/
/* c8 ignore start */
function elementIsVisible(element) {
return !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
}
/* c8 ignore stop */
/**
* @param {Element} element
* @return {LH.Artifacts.Rect[]}
*/
/* c8 ignore start */
function getClientRects(element) {
const clientRects = Array.from(
element.getClientRects()
).map(clientRect => {
// Contents of DOMRect get lost when returned from Runtime.evaluate call,
// so we convert them to plain objects.
const {width, height, left, top, right, bottom} = clientRect;
return {width, height, left, top, right, bottom};
});
for (const child of element.children) {
clientRects.push(...getClientRects(child));
}
return clientRects;
}
/* c8 ignore stop */
/**
* @param {Element} element
* @param {string} tapTargetsSelector
* @return {boolean}
*/
/* c8 ignore start */
function elementHasAncestorTapTarget(element, tapTargetsSelector) {
if (!element.parentElement) {
return false;
}
if (element.parentElement.matches(tapTargetsSelector)) {
return true;
}
return elementHasAncestorTapTarget(element.parentElement, tapTargetsSelector);
}
/* c8 ignore stop */
/**
* @param {Element} element
*/
/* c8 ignore start */
function hasTextNodeSiblingsFormingTextBlock(element) {
if (!element.parentElement) {
return false;
}
const parentElement = element.parentElement;
const nodeText = element.textContent || '';
const parentText = parentElement.textContent || '';
if (parentText.length - nodeText.length < 5) {
// Parent text mostly consists of this node, so the parent
// is not a text block container
return false;
}
for (const sibling of element.parentElement.childNodes) {
if (sibling === element) {
continue;
}
const siblingTextContent = (sibling.textContent || '').trim();
// Only count text in text nodes so that a series of e.g. buttons isn't counted
// as a text block.
// This works reasonably well, but means we miss text blocks where all text is e.g.
// wrapped in spans
if (sibling.nodeType === Node.TEXT_NODE && siblingTextContent.length > 0) {
return true;
}
}
return false;
}
/* c8 ignore stop */
/**
* Check if element is in a block of text, such as paragraph with a bunch of links in it.
* Makes a reasonable guess, but for example gets it wrong if the element is surrounded by other
* HTML elements instead of direct text nodes.
* @param {Element} element
* @return {boolean}
*/
/* c8 ignore start */
function elementIsInTextBlock(element) {
const {display} = getComputedStyle(element);
if (display !== 'inline' && display !== 'inline-block') {
return false;
}
if (hasTextNodeSiblingsFormingTextBlock(element)) {
return true;
} else if (element.parentElement) {
return elementIsInTextBlock(element.parentElement);
} else {
return false;
}
}
/* c8 ignore stop */
/**
* @param {Element} el
* @param {{x: number, y: number}} elCenterPoint
*/
/* c8 ignore start */
function elementCenterIsAtZAxisTop(el, elCenterPoint) {
const viewportHeight = window.innerHeight;
const targetScrollY = Math.floor(elCenterPoint.y / viewportHeight) * viewportHeight;
if (window.scrollY !== targetScrollY) {
window.scrollTo(0, targetScrollY);
}
const topEl = document.elementFromPoint(
elCenterPoint.x,
elCenterPoint.y - window.scrollY
);
return topEl === el || el.contains(topEl);
}
/* c8 ignore stop */
/**
* Finds all position sticky/absolute elements on the page and adds a class
* that disables pointer events on them.
* @param {string} className
* @return {() => void} - undo function to re-enable pointer events
*/
/* c8 ignore start */
function disableFixedAndStickyElementPointerEvents(className) {
document.querySelectorAll('*').forEach(el => {
const position = getComputedStyle(el).position;
if (position === 'fixed' || position === 'sticky') {
el.classList.add(className);
}
});
return function undo() {
Array.from(document.getElementsByClassName(className)).forEach(el => {
el.classList.remove(className);
});
};
}
/* c8 ignore stop */
/**
* @param {string} tapTargetsSelector
* @param {string} className
* @return {LH.Artifacts.TapTarget[]}
*/
/* c8 ignore start */
function gatherTapTargets(tapTargetsSelector, className) {
/** @type {LH.Artifacts.TapTarget[]} */
const targets = [];
// Capture element positions relative to the top of the page
window.scrollTo(0, 0);
/** @type {HTMLElement[]} */
// @ts-expect-error - getElementsInDocument put into scope via stringification
const tapTargetElements = getElementsInDocument(tapTargetsSelector);
/** @type {{
tapTargetElement: Element,
clientRects: LH.Artifacts.Rect[]
}[]} */
const tapTargetsWithClientRects = [];
tapTargetElements.forEach(tapTargetElement => {
// Filter out tap targets that are likely to cause false failures:
if (elementHasAncestorTapTarget(tapTargetElement, tapTargetsSelector)) {
// This is usually intentional, either the tap targets trigger the same action
// or there's a child with a related action (like a delete button for an item)
return;
}
if (elementIsInTextBlock(tapTargetElement)) {
// Links inside text blocks cause a lot of failures, and there's also an exception for them
// in the Web Content Accessibility Guidelines https://www.w3.org/TR/WCAG21/#target-size
return;
}
if (!elementIsVisible(tapTargetElement)) {
return;
}
tapTargetsWithClientRects.push({
tapTargetElement,
clientRects: getClientRects(tapTargetElement),
});
});
// Disable pointer events so that tap targets below them don't get
// detected as non-tappable (they are tappable, just not while the viewport
// is at the current scroll position)
const reenableFixedAndStickyElementPointerEvents =
disableFixedAndStickyElementPointerEvents(className);
/** @type {{
tapTargetElement: Element,
visibleClientRects: LH.Artifacts.Rect[]
}[]} */
const tapTargetsWithVisibleClientRects = [];
// We use separate loop here to get visible client rects because that involves
// scrolling around the page for elementCenterIsAtZAxisTop, which would affect the
// client rect positions.
tapTargetsWithClientRects.forEach(({tapTargetElement, clientRects}) => {
// Filter out empty client rects
let visibleClientRects = clientRects.filter(cr => cr.width !== 0 && cr.height !== 0);
// Filter out client rects that are invisible, e.g because they are in a position absolute element
// with a lower z-index than the main content.
// This will also filter out all position fixed or sticky tap targets elements because we disable pointer
// events on them before running this. That's the correct behavior because whether a position fixed/stick
// element overlaps with another tap target depends on the scroll position.
visibleClientRects = visibleClientRects.filter(rect => {
// Just checking the center can cause false failures for large partially hidden tap targets,
// but that should be a rare edge case
// @ts-expect-error - put into scope via stringification
const rectCenterPoint = getRectCenterPoint(rect);
return elementCenterIsAtZAxisTop(tapTargetElement, rectCenterPoint);
});
if (visibleClientRects.length > 0) {
tapTargetsWithVisibleClientRects.push({
tapTargetElement,
visibleClientRects,
});
}
});
for (const {tapTargetElement, visibleClientRects} of tapTargetsWithVisibleClientRects) {
targets.push({
clientRects: visibleClientRects,
href: /** @type {HTMLAnchorElement} */(tapTargetElement)['href'] || '',
// @ts-expect-error - getNodeDetails put into scope via stringification
node: getNodeDetails(tapTargetElement),
});
}
reenableFixedAndStickyElementPointerEvents();
return targets;
}
/**
* @param {string} tapTargetsSelector
* @param {string} className
* @return {LH.Artifacts.TapTarget[]}
*/
function gatherTapTargetsAndResetScroll(tapTargetsSelector, className) {
const originalScrollPosition = {
x: window.scrollX,
y: window.scrollY,
};
try {
return gatherTapTargets(tapTargetsSelector, className);
} finally {
window.scrollTo(originalScrollPosition.x, originalScrollPosition.y);
}
}
/* c8 ignore stop */
class TapTargets extends FRGatherer {
constructor() {
super();
/**
* This needs to be in the constructor.
* https://github.com/GoogleChrome/lighthouse/issues/12134
* @type {LH.Gatherer.GathererMeta}
*/
this.meta = {
supportedModes: ['snapshot', 'navigation'],
};
}
/**
* @param {LH.Gatherer.FRProtocolSession} session
* @param {string} className
* @return {Promise<string>}
*/
async addStyleRule(session, className) {
const frameTreeResponse = await session.sendCommand('Page.getFrameTree');
const {styleSheetId} = await session.sendCommand('CSS.createStyleSheet', {
frameId: frameTreeResponse.frameTree.frame.id,
});
const ruleText = `.${className} { pointer-events: none !important }`;
await session.sendCommand('CSS.setStyleSheetText', {
styleSheetId,
text: ruleText,
});
return styleSheetId;
}
/**
* @param {LH.Gatherer.FRProtocolSession} session
* @param {string} styleSheetId
*/
async removeStyleRule(session, styleSheetId) {
await session.sendCommand('CSS.setStyleSheetText', {
styleSheetId,
text: '',
});
}
/**
* @param {LH.Gatherer.FRTransitionalContext} passContext
* @return {Promise<LH.Artifacts.TapTarget[]>} All visible tap targets with their positions and sizes
*/
async getArtifact(passContext) {
const session = passContext.driver.defaultSession;
await session.sendCommand('DOM.enable');
await session.sendCommand('CSS.enable');
const className = 'lighthouse-disable-pointer-events';
const styleSheetId = await this.addStyleRule(session, className);
const tapTargets =
await passContext.driver.executionContext.evaluate(gatherTapTargetsAndResetScroll, {
args: [tapTargetsSelector, className],
useIsolation: true,
deps: [
pageFunctions.getNodeDetails,
pageFunctions.getElementsInDocument,
disableFixedAndStickyElementPointerEvents,
elementIsVisible,
elementHasAncestorTapTarget,
elementCenterIsAtZAxisTop,
getClientRects,
hasTextNodeSiblingsFormingTextBlock,
elementIsInTextBlock,
RectHelpers.getRectCenterPoint,
pageFunctions.getNodePath,
pageFunctions.getNodeSelector,
pageFunctions.getNodeLabel,
gatherTapTargets,
],
});
await this.removeStyleRule(session, styleSheetId);
await session.sendCommand('CSS.disable');
await session.sendCommand('DOM.disable');
return tapTargets;
}
}
export default TapTargets;

View File

@@ -0,0 +1,16 @@
export default ServiceWorker;
declare class ServiceWorker extends FRGatherer {
/**
* @param {LH.Gatherer.PassContext} passContext
* @return {Promise<LH.Artifacts['ServiceWorker']>}
*/
beforePass(passContext: LH.Gatherer.PassContext): Promise<LH.Artifacts['ServiceWorker']>;
afterPass(): Promise<void>;
/**
* @param {LH.Gatherer.FRTransitionalContext} context
* @return {Promise<LH.Artifacts['ServiceWorker']>}
*/
getArtifact(context: LH.Gatherer.FRTransitionalContext): Promise<LH.Artifacts['ServiceWorker']>;
}
import FRGatherer from '../base-gatherer.js';
//# sourceMappingURL=service-worker.d.ts.map

View File

@@ -0,0 +1,44 @@
/**
* @license Copyright 2016 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
import FRGatherer from '../base-gatherer.js';
import * as serviceWorkers from '../driver/service-workers.js';
class ServiceWorker extends FRGatherer {
/** @type {LH.Gatherer.GathererMeta} */
meta = {
supportedModes: ['navigation'],
};
/**
* @param {LH.Gatherer.PassContext} passContext
* @return {Promise<LH.Artifacts['ServiceWorker']>}
*/
async beforePass(passContext) {
return this.getArtifact({...passContext, dependencies: {}});
}
// This gatherer is run in a separate pass for legacy mode.
// Legacy compat code is in `beforePass`.
async afterPass() { }
/**
* @param {LH.Gatherer.FRTransitionalContext} context
* @return {Promise<LH.Artifacts['ServiceWorker']>}
*/
async getArtifact(context) {
const session = context.driver.defaultSession;
const {versions} = await serviceWorkers.getServiceWorkerVersions(session);
const {registrations} = await serviceWorkers.getServiceWorkerRegistrations(session);
return {
versions,
registrations,
};
}
}
export default ServiceWorker;

View File

@@ -0,0 +1,50 @@
export default SourceMaps;
/**
* @fileoverview Gets JavaScript source maps.
*/
declare class SourceMaps extends FRGatherer {
/** @type {LH.Crdp.Debugger.ScriptParsedEvent[]} */
_scriptParsedEvents: LH.Crdp.Debugger.ScriptParsedEvent[];
/**
* @param {LH.Crdp.Debugger.ScriptParsedEvent} event
*/
onScriptParsed(event: LH.Crdp.Debugger.ScriptParsedEvent): void;
/**
* @param {LH.Gatherer.FRTransitionalDriver} driver
* @param {string} sourceMapUrl
* @return {Promise<LH.Artifacts.RawSourceMap>}
*/
fetchSourceMap(driver: LH.Gatherer.FRTransitionalDriver, sourceMapUrl: string): Promise<LH.Artifacts.RawSourceMap>;
/**
* @param {string} sourceMapURL
* @return {LH.Artifacts.RawSourceMap}
*/
parseSourceMapFromDataUrl(sourceMapURL: string): LH.Artifacts.RawSourceMap;
/**
* @param {string} url
* @param {string} base
* @return {string|undefined}
*/
_resolveUrl(url: string, base: string): string | undefined;
/**
* @param {LH.Gatherer.FRTransitionalDriver} driver
* @param {LH.Crdp.Debugger.ScriptParsedEvent} event
* @return {Promise<LH.Artifacts.SourceMap>}
*/
_retrieveMapFromScriptParsedEvent(driver: LH.Gatherer.FRTransitionalDriver, event: LH.Crdp.Debugger.ScriptParsedEvent): Promise<LH.Artifacts.SourceMap>;
/**
* @param {LH.Gatherer.FRTransitionalContext} context
*/
startSensitiveInstrumentation(context: LH.Gatherer.FRTransitionalContext): Promise<void>;
/**
* @param {LH.Gatherer.FRTransitionalContext} context
*/
stopSensitiveInstrumentation(context: LH.Gatherer.FRTransitionalContext): Promise<void>;
/**
* @param {LH.Gatherer.FRTransitionalContext} context
* @return {Promise<LH.Artifacts['SourceMaps']>}
*/
getArtifact(context: LH.Gatherer.FRTransitionalContext): Promise<LH.Artifacts['SourceMaps']>;
}
import FRGatherer from '../base-gatherer.js';
//# sourceMappingURL=source-maps.d.ts.map

View File

@@ -0,0 +1,156 @@
/**
* @license Copyright 2019 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
import SDK from '../../lib/cdt/SDK.js';
import FRGatherer from '../base-gatherer.js';
/**
* @fileoverview Gets JavaScript source maps.
*/
class SourceMaps extends FRGatherer {
/** @type {LH.Gatherer.GathererMeta} */
meta = {
supportedModes: ['timespan', 'navigation'],
};
constructor() {
super();
/** @type {LH.Crdp.Debugger.ScriptParsedEvent[]} */
this._scriptParsedEvents = [];
this.onScriptParsed = this.onScriptParsed.bind(this);
}
/**
* @param {LH.Gatherer.FRTransitionalDriver} driver
* @param {string} sourceMapUrl
* @return {Promise<LH.Artifacts.RawSourceMap>}
*/
async fetchSourceMap(driver, sourceMapUrl) {
const response = await driver.fetcher.fetchResource(sourceMapUrl, {timeout: 1500});
if (response.content === null) {
throw new Error(`Failed fetching source map (${response.status})`);
}
return SDK.SourceMap.parseSourceMap(response.content);
}
/**
* @param {string} sourceMapURL
* @return {LH.Artifacts.RawSourceMap}
*/
parseSourceMapFromDataUrl(sourceMapURL) {
const buffer = Buffer.from(sourceMapURL.split(',')[1], 'base64');
return SDK.SourceMap.parseSourceMap(buffer.toString());
}
/**
* @param {LH.Crdp.Debugger.ScriptParsedEvent} event
*/
onScriptParsed(event) {
if (event.sourceMapURL) {
this._scriptParsedEvents.push(event);
}
}
/**
* @param {string} url
* @param {string} base
* @return {string|undefined}
*/
_resolveUrl(url, base) {
try {
return new URL(url, base).href;
} catch (e) {
return;
}
}
/**
* @param {LH.Gatherer.FRTransitionalDriver} driver
* @param {LH.Crdp.Debugger.ScriptParsedEvent} event
* @return {Promise<LH.Artifacts.SourceMap>}
*/
async _retrieveMapFromScriptParsedEvent(driver, event) {
if (!event.sourceMapURL) {
throw new Error('precondition failed: event.sourceMapURL should exist');
}
// `sourceMapURL` is simply the URL found in either a magic comment or an x-sourcemap header.
// It has not been resolved to a base url.
const isSourceMapADataUri = event.sourceMapURL.startsWith('data:');
const scriptUrl = event.url;
const rawSourceMapUrl = isSourceMapADataUri ?
event.sourceMapURL :
this._resolveUrl(event.sourceMapURL, event.url);
if (!rawSourceMapUrl) {
return {
scriptId: event.scriptId,
scriptUrl,
errorMessage: `Could not resolve map url: ${event.sourceMapURL}`,
};
}
// sourceMapUrl isn't included in the the artifact if it was a data URL.
const sourceMapUrl = isSourceMapADataUri ? undefined : rawSourceMapUrl;
try {
const map = isSourceMapADataUri ?
this.parseSourceMapFromDataUrl(rawSourceMapUrl) :
await this.fetchSourceMap(driver, rawSourceMapUrl);
if (typeof map.version !== 'number') throw new Error('Map has no numeric `version` field');
if (!Array.isArray(map.sources)) throw new Error('Map has no `sources` list');
if (typeof map.mappings !== 'string') throw new Error('Map has no `mappings` field');
if (map.sections) {
map.sections = map.sections.filter(section => section.map);
}
return {
scriptId: event.scriptId,
scriptUrl,
sourceMapUrl,
map,
};
} catch (err) {
return {
scriptId: event.scriptId,
scriptUrl,
sourceMapUrl,
errorMessage: err.toString(),
};
}
}
/**
* @param {LH.Gatherer.FRTransitionalContext} context
*/
async startSensitiveInstrumentation(context) {
const session = context.driver.defaultSession;
session.on('Debugger.scriptParsed', this.onScriptParsed);
await session.sendCommand('Debugger.enable');
}
/**
* @param {LH.Gatherer.FRTransitionalContext} context
*/
async stopSensitiveInstrumentation(context) {
const session = context.driver.defaultSession;
await session.sendCommand('Debugger.disable');
session.off('Debugger.scriptParsed', this.onScriptParsed);
}
/**
* @param {LH.Gatherer.FRTransitionalContext} context
* @return {Promise<LH.Artifacts['SourceMaps']>}
*/
async getArtifact(context) {
const eventProcessPromises = this._scriptParsedEvents
.map((event) => this._retrieveMapFromScriptParsedEvent(context.driver, event));
return Promise.all(eventProcessPromises);
}
}
export default SourceMaps;

View File

@@ -0,0 +1,38 @@
export default Stacks;
export type JSLibraryDetectorTestResult = false | {
version: string | number | null;
};
export type JSLibraryDetectorTest = {
id: string;
icon: string;
url: string;
/**
* npm module name, if applicable to library.
*/
npm: string | null;
/**
* Returns false if library is not present, otherwise returns an object that contains the library version (set to null if the version is not detected).
*/
test: (arg0: Window) => JSLibraryDetectorTestResult | Promise<JSLibraryDetectorTestResult>;
};
export type JSLibrary = {
id: string;
name: string;
version: string | number | null;
npm: string | null;
};
/** @implements {LH.Gatherer.FRGathererInstance} */
declare class Stacks extends FRGatherer implements LH.Gatherer.FRGathererInstance {
/**
* @param {LH.Gatherer.FRTransitionalDriver['executionContext']} executionContext
* @return {Promise<LH.Artifacts['Stacks']>}
*/
static collectStacks(executionContext: LH.Gatherer.FRTransitionalDriver['executionContext']): Promise<LH.Artifacts['Stacks']>;
/**
* @param {LH.Gatherer.FRTransitionalContext} context
* @return {Promise<LH.Artifacts['Stacks']>}
*/
getArtifact(context: LH.Gatherer.FRTransitionalContext): Promise<LH.Artifacts['Stacks']>;
}
import FRGatherer from '../base-gatherer.js';
//# sourceMappingURL=stacks.d.ts.map

137
node_modules/lighthouse/core/gather/gatherers/stacks.js generated vendored Normal file
View File

@@ -0,0 +1,137 @@
/**
* @license Copyright 2019 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/**
* @fileoverview Gathers a list of detected JS libraries and their versions.
*/
/* global window */
/* global d41d8cd98f00b204e9800998ecf8427e_LibraryDetectorTests */
import fs from 'fs';
import {createRequire} from 'module';
import log from 'lighthouse-logger';
import FRGatherer from '../base-gatherer.js';
// This is removed by rollup, because the only usage is to resolve a module path
// but that is replaced by the inline-fs plugin, leaving `require` unused.
const require = /* #__PURE__ */ createRequire(import.meta.url);
const libDetectorSource = fs.readFileSync(
require.resolve('js-library-detector/library/libraries.js'), 'utf8');
/** @typedef {false | {version: string|number|null}} JSLibraryDetectorTestResult */
/**
* @typedef JSLibraryDetectorTest
* @property {string} id
* @property {string} icon
* @property {string} url
* @property {string|null} npm npm module name, if applicable to library.
* @property {function(Window): JSLibraryDetectorTestResult | Promise<JSLibraryDetectorTestResult>} test Returns false if library is not present, otherwise returns an object that contains the library version (set to null if the version is not detected).
*/
/**
* @typedef JSLibrary
* @property {string} id
* @property {string} name
* @property {string|number|null} version
* @property {string|null} npm
*/
/**
* Obtains a list of detected JS libraries and their versions.
*/
/* c8 ignore start */
async function detectLibraries() {
/** @type {JSLibrary[]} */
const libraries = [];
// d41d8cd98f00b204e9800998ecf8427e_ is a consistent prefix used by the detect libraries
// see https://github.com/HTTPArchive/httparchive/issues/77#issuecomment-291320900
/** @type {Record<string, JSLibraryDetectorTest>} */
// @ts-expect-error - injected libDetectorSource var
const libraryDetectorTests = d41d8cd98f00b204e9800998ecf8427e_LibraryDetectorTests; // eslint-disable-line
for (const [name, lib] of Object.entries(libraryDetectorTests)) {
try {
/** @type {NodeJS.Timeout|undefined} */
let timeout;
// Some library detections are async that can never return.
// Guard ourselves from PROTOCL_TIMEOUT by limiting each detection to a max of 1s.
// See https://github.com/GoogleChrome/lighthouse/issues/11124.
const timeoutPromise = new Promise(r => timeout = setTimeout(() => r(false), 1000));
const result = await Promise.race([lib.test(window), timeoutPromise]);
if (timeout) clearTimeout(timeout);
if (result) {
libraries.push({
id: lib.id,
name: name,
version: result.version,
npm: lib.npm,
});
}
} catch (e) {}
}
return libraries;
}
/* c8 ignore stop */
/** @implements {LH.Gatherer.FRGathererInstance} */
class Stacks extends FRGatherer {
constructor() {
super();
/** @type {LH.Gatherer.GathererMeta} */
this.meta = {
supportedModes: ['snapshot', 'navigation'],
};
}
/**
* @param {LH.Gatherer.FRTransitionalDriver['executionContext']} executionContext
* @return {Promise<LH.Artifacts['Stacks']>}
*/
static async collectStacks(executionContext) {
const status = {msg: 'Collect stacks', id: 'lh:gather:collectStacks'};
log.time(status);
const jsLibraries = await executionContext.evaluate(detectLibraries, {
args: [],
deps: [libDetectorSource],
});
/** @type {LH.Artifacts['Stacks']} */
const stacks = jsLibraries.map(lib => ({
detector: 'js',
id: lib.id,
name: lib.name,
version: typeof lib.version === 'number' ? String(lib.version) : (lib.version || undefined),
npm: lib.npm || undefined,
}));
log.timeEnd(status);
return stacks;
}
/**
* @param {LH.Gatherer.FRTransitionalContext} context
* @return {Promise<LH.Artifacts['Stacks']>}
*/
async getArtifact(context) {
try {
return await Stacks.collectStacks(context.driver.executionContext);
} catch {
return [];
}
}
}
export default Stacks;

Some files were not shown because too many files have changed in this diff Show More