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

107
node_modules/lighthouse/core/legacy/config/config.d.ts generated vendored Normal file
View File

@@ -0,0 +1,107 @@
export type GathererConstructor = typeof import('../../gather/gatherers/gatherer.js').Gatherer;
export type Gatherer = InstanceType<GathererConstructor>;
/**
* @implements {LH.Config.LegacyResolvedConfig}
*/
export class LegacyResolvedConfig implements LH.Config.LegacyResolvedConfig {
/**
* Resolves the provided config (inherits from extended config, if set), resolves
* all referenced modules, and validates.
* @param {LH.Config=} config If not provided, uses the default config.
* @param {LH.Flags=} flags
* @return {Promise<LegacyResolvedConfig>}
*/
static fromJson(config?: LH.Config | undefined, flags?: LH.Flags | undefined): Promise<LegacyResolvedConfig>;
/**
* @param {LH.Config} baseJSON The JSON of the configuration to extend
* @param {LH.Config} extendJSON The JSON of the extensions
* @return {LH.Config}
*/
static extendConfigJSON(baseJSON: LH.Config, extendJSON: LH.Config): LH.Config;
/**
* @param {LH.Config['passes']} passes
* @return {?Array<Required<LH.Config.PassJson>>}
*/
static augmentPassesWithDefaults(passes: LH.Config['passes']): Array<Required<LH.Config.PassJson>> | null;
/**
* Observed throttling methods (devtools/provided) require at least 5s of quiet for the metrics to
* be computed. This method adjusts the quiet thresholds to the required minimums if necessary.
* @param {LH.Config.Settings} settings
* @param {?Array<Required<LH.Config.PassJson>>} passes
*/
static adjustDefaultPassForThrottling(settings: LH.Config.Settings, passes: Array<Required<LH.Config.PassJson>> | null): void;
/**
* Filter out any unrequested items from the config, based on requested categories or audits.
* @param {LegacyResolvedConfig} config
*/
static filterConfigIfNeeded(config: LegacyResolvedConfig): void;
/**
* Filter out any unrequested categories or audits from the categories object.
* @param {LegacyResolvedConfig['categories']} oldCategories
* @param {LH.Config.Settings} settings
* @return {{categories: LegacyResolvedConfig['categories'], requestedAuditNames: Set<string>}}
*/
static filterCategoriesAndAudits(oldCategories: LegacyResolvedConfig['categories'], settings: LH.Config.Settings): {
categories: LegacyResolvedConfig['categories'];
requestedAuditNames: Set<string>;
};
/**
* From some requested audits, return names of all required and optional artifacts
* @param {LegacyResolvedConfig['audits']} audits
* @return {Set<string>}
*/
static getGatherersRequestedByAudits(audits: LegacyResolvedConfig['audits']): Set<string>;
/**
* Filters to only requested passes and gatherers, returning a new passes array.
* @param {LegacyResolvedConfig['passes']} passes
* @param {Set<string>} requestedGatherers
* @return {LegacyResolvedConfig['passes']}
*/
static generatePassesNeededByGatherers(passes: LegacyResolvedConfig['passes'], requestedGatherers: Set<string>): LegacyResolvedConfig['passes'];
/**
* Take an array of audits and audit paths and require any paths (possibly
* relative to the optional `configDir`) using `resolveModulePath`,
* leaving only an array of AuditDefns.
* @param {LH.Config['audits']} audits
* @param {string=} configDir
* @return {Promise<LegacyResolvedConfig['audits']>}
*/
static requireAudits(audits: LH.Config['audits'], configDir?: string | undefined): Promise<LegacyResolvedConfig['audits']>;
/**
* Takes an array of passes with every property now initialized except the
* gatherers and requires them, (relative to the optional `configDir` if
* provided) using `resolveModulePath`, returning an array of full Passes.
* @param {?Array<Required<LH.Config.PassJson>>} passes
* @param {string=} configDir
* @return {Promise<LegacyResolvedConfig['passes']>}
*/
static requireGatherers(passes: Array<Required<LH.Config.PassJson>> | null, configDir?: string | undefined): Promise<LegacyResolvedConfig['passes']>;
/**
* @deprecated `LegacyResolvedConfig.fromJson` should be used instead.
* @constructor
* @param {LH.Config} config
* @param {{settings: LH.Config.Settings, passes: ?LH.Config.Pass[], audits: ?LH.Config.AuditDefn[]}} opts
*/
constructor(config: LH.Config, opts: {
settings: LH.Config.Settings;
passes: LH.Config.Pass[] | null;
audits: LH.Config.AuditDefn[] | null;
});
/** @type {LH.Config.Settings} */
settings: LH.Config.Settings;
/** @type {?Array<LH.Config.Pass>} */
passes: Array<LH.Config.Pass> | null;
/** @type {?Array<LH.Config.AuditDefn>} */
audits: Array<LH.Config.AuditDefn> | null;
/** @type {?Record<string, LH.Config.Category>} */
categories: Record<string, LH.Config.Category> | null;
/** @type {?Record<string, LH.Config.Group>} */
groups: Record<string, LH.Config.Group> | null;
/**
* Provides a cleaned-up, stringified version of this config. Gatherer and
* Audit `implementation` and `instance` do not survive this process.
* @return {string}
*/
getPrintString(): string;
}
//# sourceMappingURL=config.d.ts.map

566
node_modules/lighthouse/core/legacy/config/config.js generated vendored Normal file
View File

@@ -0,0 +1,566 @@
/**
* @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 path from 'path';
import log from 'lighthouse-logger';
import legacyDefaultConfig from './legacy-default-config.js';
import * as constants from '../../config/constants.js';
import * as format from '../../../shared/localization/format.js';
import * as validation from '../../config/validation.js';
import {Runner} from '../../runner.js';
import {
mergePlugins,
mergeConfigFragment,
resolveSettings,
resolveAuditsToDefns,
resolveGathererToDefn,
deepClone,
deepCloneConfigJson,
} from '../../config/config-helpers.js';
import {getModuleDirectory} from '../../../esm-utils.js';
const defaultConfigPath = './legacy-default-config.js';
/** @typedef {typeof import('../../gather/gatherers/gatherer.js').Gatherer} GathererConstructor */
/** @typedef {InstanceType<GathererConstructor>} Gatherer */
/**
* Define with object literal so that tsc will require it to stay updated.
* @type {Record<keyof LH.BaseArtifacts, ''>}
*/
const BASE_ARTIFACT_BLANKS = {
fetchTime: '',
LighthouseRunWarnings: '',
HostFormFactor: '',
HostUserAgent: '',
NetworkUserAgent: '',
BenchmarkIndex: '',
BenchmarkIndexes: '',
GatherContext: '',
traces: '',
devtoolsLogs: '',
settings: '',
URL: '',
Timing: '',
PageLoadError: '',
};
const BASE_ARTIFACT_NAMES = Object.keys(BASE_ARTIFACT_BLANKS);
// These were legacy base artifacts, but we need certain gatherers (e.g. bfcache) to run after them.
// The order is controlled by the config, but still need to force them to run every time.
const alwaysRunArtifactIds = [
'WebAppManifest',
'InstallabilityErrors',
'Stacks',
'FullPageScreenshot',
];
/**
* Certain gatherers are destructive to the page state.
* We should ensure that these gatherers run after any custom gatherers.
* The default priority should be 0.
* TODO: Make this an official part of the config or design a different solution.
* @type {Record<string, number|undefined>}
*/
const internalGathererPriorities = {
FullPageScreenshot: 1,
BFCacheFailures: 1,
};
/**
* @param {LegacyResolvedConfig['passes']} passes
* @param {LegacyResolvedConfig['audits']} audits
*/
function assertValidPasses(passes, audits) {
if (!Array.isArray(passes)) {
return;
}
const requestedGatherers = LegacyResolvedConfig.getGatherersRequestedByAudits(audits);
// Base artifacts are provided by GatherRunner, so start foundGatherers with them.
const foundGatherers = new Set(BASE_ARTIFACT_NAMES);
// Log if we are running gathers that are not needed by the audits listed in the config
passes.forEach((pass, passIndex) => {
if (passIndex === 0 && pass.loadFailureMode !== 'fatal') {
log.warn(`"${pass.passName}" is the first pass but was marked as non-fatal. ` +
`The first pass will always be treated as loadFailureMode=fatal.`);
pass.loadFailureMode = 'fatal';
}
pass.gatherers.forEach(gathererDefn => {
const gatherer = gathererDefn.instance;
foundGatherers.add(gatherer.name);
const isGatherRequiredByAudits = requestedGatherers.has(gatherer.name);
const isAlwaysRunArtifact = alwaysRunArtifactIds.includes(gatherer.name);
if (!isGatherRequiredByAudits && !isAlwaysRunArtifact) {
const msg = `${gatherer.name} gatherer requested, however no audit requires it.`;
log.warn('config', msg);
}
});
});
// All required gatherers must be found in the config. Throw otherwise.
for (const auditDefn of audits || []) {
const auditMeta = auditDefn.implementation.meta;
for (const requiredArtifact of auditMeta.requiredArtifacts) {
if (!foundGatherers.has(requiredArtifact)) {
throw new Error(`${requiredArtifact} gatherer, required by audit ${auditMeta.id}, ` +
'was not found in config.');
}
}
}
// Passes must have unique `passName`s. Throw otherwise.
const usedNames = new Set();
passes.forEach(pass => {
const passName = pass.passName;
if (usedNames.has(passName)) {
throw new Error(`Passes must have unique names (repeated passName: ${passName}.`);
}
usedNames.add(passName);
});
}
/**
* @param {Gatherer} gathererInstance
* @param {string=} gathererName
*/
function assertValidGatherer(gathererInstance, gathererName) {
gathererName = gathererName || gathererInstance.name || 'gatherer';
if (typeof gathererInstance.beforePass !== 'function') {
throw new Error(`${gathererName} has no beforePass() method.`);
}
if (typeof gathererInstance.pass !== 'function') {
throw new Error(`${gathererName} has no pass() method.`);
}
if (typeof gathererInstance.afterPass !== 'function') {
throw new Error(`${gathererName} has no afterPass() method.`);
}
}
/**
* @implements {LH.Config.LegacyResolvedConfig}
*/
class LegacyResolvedConfig {
/**
* Resolves the provided config (inherits from extended config, if set), resolves
* all referenced modules, and validates.
* @param {LH.Config=} config If not provided, uses the default config.
* @param {LH.Flags=} flags
* @return {Promise<LegacyResolvedConfig>}
*/
static async fromJson(config, flags) {
const status = {msg: 'Create config', id: 'lh:init:config'};
log.time(status, 'verbose');
let configPath = flags?.configPath;
if (!config) {
config = legacyDefaultConfig;
configPath = path.resolve(getModuleDirectory(import.meta), defaultConfigPath);
}
if (configPath && !path.isAbsolute(configPath)) {
throw new Error('configPath must be an absolute path.');
}
// We don't want to mutate the original config object
config = deepCloneConfigJson(config);
// Extend the default config if specified
if (config.extends) {
if (config.extends !== 'lighthouse:default') {
throw new Error('`lighthouse:default` is the only valid extension method.');
}
config = LegacyResolvedConfig.extendConfigJSON(
deepCloneConfigJson(legacyDefaultConfig), config);
}
// The directory of the config path, if one was provided.
const configDir = configPath ? path.dirname(configPath) : undefined;
// Validate and merge in plugins (if any).
config = await mergePlugins(config, configDir, flags);
const settings = resolveSettings(config.settings || {}, flags);
// Augment passes with necessary defaults and require gatherers.
const passesWithDefaults = LegacyResolvedConfig.augmentPassesWithDefaults(config.passes);
LegacyResolvedConfig.adjustDefaultPassForThrottling(settings, passesWithDefaults);
const passes = await LegacyResolvedConfig.requireGatherers(passesWithDefaults, configDir);
const audits = await LegacyResolvedConfig.requireAudits(config.audits, configDir);
const resolvedConfig = new LegacyResolvedConfig(config, {settings, passes, audits});
log.timeEnd(status);
return resolvedConfig;
}
/**
* @deprecated `LegacyResolvedConfig.fromJson` should be used instead.
* @constructor
* @param {LH.Config} config
* @param {{settings: LH.Config.Settings, passes: ?LH.Config.Pass[], audits: ?LH.Config.AuditDefn[]}} opts
*/
constructor(config, opts) {
/** @type {LH.Config.Settings} */
this.settings = opts.settings;
/** @type {?Array<LH.Config.Pass>} */
this.passes = opts.passes;
/** @type {?Array<LH.Config.AuditDefn>} */
this.audits = opts.audits;
/** @type {?Record<string, LH.Config.Category>} */
this.categories = config.categories || null;
/** @type {?Record<string, LH.Config.Group>} */
this.groups = config.groups || null;
LegacyResolvedConfig.filterConfigIfNeeded(this);
assertValidPasses(this.passes, this.audits);
validation.assertValidCategories(this.categories, this.audits, this.groups);
}
/**
* Provides a cleaned-up, stringified version of this config. Gatherer and
* Audit `implementation` and `instance` do not survive this process.
* @return {string}
*/
getPrintString() {
const jsonConfig = deepClone(this);
if (jsonConfig.passes) {
for (const pass of jsonConfig.passes) {
for (const gathererDefn of pass.gatherers) {
gathererDefn.implementation = undefined;
// @ts-expect-error Breaking the Config.GathererDefn type.
gathererDefn.instance = undefined;
}
}
}
if (jsonConfig.audits) {
for (const auditDefn of jsonConfig.audits) {
// @ts-expect-error Breaking the Config.AuditDefn type.
auditDefn.implementation = undefined;
if (Object.keys(auditDefn.options).length === 0) {
// @ts-expect-error Breaking the Config.AuditDefn type.
auditDefn.options = undefined;
}
}
}
// Printed config is more useful with localized strings.
format.replaceIcuMessages(jsonConfig, jsonConfig.settings.locale);
return JSON.stringify(jsonConfig, null, 2);
}
/**
* @param {LH.Config} baseJSON The JSON of the configuration to extend
* @param {LH.Config} extendJSON The JSON of the extensions
* @return {LH.Config}
*/
static extendConfigJSON(baseJSON, extendJSON) {
if (extendJSON.passes && baseJSON.passes) {
for (const pass of extendJSON.passes) {
// use the default pass name if one is not specified
const passName = pass.passName || constants.defaultPassConfig.passName;
const basePass = baseJSON.passes.find(candidate => candidate.passName === passName);
if (!basePass) {
baseJSON.passes.push(pass);
} else {
mergeConfigFragment(basePass, pass);
}
}
delete extendJSON.passes;
}
return mergeConfigFragment(baseJSON, extendJSON);
}
/**
* @param {LH.Config['passes']} passes
* @return {?Array<Required<LH.Config.PassJson>>}
*/
static augmentPassesWithDefaults(passes) {
if (!passes) {
return null;
}
const {defaultPassConfig} = constants;
return passes.map(pass => mergeConfigFragment(deepClone(defaultPassConfig), pass));
}
/**
* Observed throttling methods (devtools/provided) require at least 5s of quiet for the metrics to
* be computed. This method adjusts the quiet thresholds to the required minimums if necessary.
* @param {LH.Config.Settings} settings
* @param {?Array<Required<LH.Config.PassJson>>} passes
*/
static adjustDefaultPassForThrottling(settings, passes) {
if (!passes ||
(settings.throttlingMethod !== 'devtools' && settings.throttlingMethod !== 'provided')) {
return;
}
const defaultPass = passes.find(pass => pass.passName === 'defaultPass');
if (!defaultPass) return;
const overrides = constants.nonSimulatedPassConfigOverrides;
defaultPass.pauseAfterFcpMs =
Math.max(overrides.pauseAfterFcpMs, defaultPass.pauseAfterFcpMs);
defaultPass.pauseAfterLoadMs =
Math.max(overrides.pauseAfterLoadMs, defaultPass.pauseAfterLoadMs);
defaultPass.cpuQuietThresholdMs =
Math.max(overrides.cpuQuietThresholdMs, defaultPass.cpuQuietThresholdMs);
defaultPass.networkQuietThresholdMs =
Math.max(overrides.networkQuietThresholdMs, defaultPass.networkQuietThresholdMs);
}
/**
* Filter out any unrequested items from the config, based on requested categories or audits.
* @param {LegacyResolvedConfig} config
*/
static filterConfigIfNeeded(config) {
const settings = config.settings;
// eslint-disable-next-line max-len
if (!settings.onlyCategories && !settings.onlyAudits && !settings.skipAudits && !settings.disableFullPageScreenshot) {
return;
}
// 1. Filter to just the chosen categories/audits
const {categories, requestedAuditNames} =
LegacyResolvedConfig.filterCategoriesAndAudits(config.categories, settings);
// 2. Resolve which audits will need to run
const audits = config.audits && config.audits.filter(auditDefn =>
requestedAuditNames.has(auditDefn.implementation.meta.id));
// 3. Resolve which gatherers will need to run
const requestedGathererIds = LegacyResolvedConfig.getGatherersRequestedByAudits(audits);
for (const gathererId of alwaysRunArtifactIds) {
requestedGathererIds.add(gathererId);
}
// Remove FullPageScreenshot if we explicitly exclude it.
if (settings.disableFullPageScreenshot) {
requestedGathererIds.delete('FullPageScreenshot');
}
// 4. Filter to only the neccessary passes
const passes =
LegacyResolvedConfig.generatePassesNeededByGatherers(config.passes, requestedGathererIds);
config.categories = categories;
config.audits = audits;
config.passes = passes;
}
/**
* Filter out any unrequested categories or audits from the categories object.
* @param {LegacyResolvedConfig['categories']} oldCategories
* @param {LH.Config.Settings} settings
* @return {{categories: LegacyResolvedConfig['categories'], requestedAuditNames: Set<string>}}
*/
static filterCategoriesAndAudits(oldCategories, settings) {
if (!oldCategories) {
return {categories: null, requestedAuditNames: new Set()};
}
if (settings.onlyAudits && settings.skipAudits) {
throw new Error('Cannot set both skipAudits and onlyAudits');
}
/** @type {NonNullable<LegacyResolvedConfig['categories']>} */
const categories = {};
const filterByIncludedCategory = !!settings.onlyCategories;
const filterByIncludedAudit = !!settings.onlyAudits;
const categoryIds = settings.onlyCategories || [];
const auditIds = settings.onlyAudits || [];
const skipAuditIds = settings.skipAudits || [];
// warn if the category is not found
categoryIds.forEach(categoryId => {
if (!oldCategories[categoryId]) {
log.warn('config', `unrecognized category in 'onlyCategories': ${categoryId}`);
}
});
// warn if the audit is not found in a category or there are overlaps
const auditsToValidate = new Set(auditIds.concat(skipAuditIds));
for (const auditId of auditsToValidate) {
const foundCategory = Object.keys(oldCategories).find(categoryId => {
const auditRefs = oldCategories[categoryId].auditRefs;
return !!auditRefs.find(candidate => candidate.id === auditId);
});
if (!foundCategory) {
const parentKeyName = skipAuditIds.includes(auditId) ? 'skipAudits' : 'onlyAudits';
log.warn('config', `unrecognized audit in '${parentKeyName}': ${auditId}`);
} else if (auditIds.includes(auditId) && categoryIds.includes(foundCategory)) {
log.warn('config', `${auditId} in 'onlyAudits' is already included by ` +
`${foundCategory} in 'onlyCategories'`);
}
}
const includedAudits = new Set(auditIds);
skipAuditIds.forEach(id => includedAudits.delete(id));
Object.keys(oldCategories).forEach(categoryId => {
const category = deepClone(oldCategories[categoryId]);
if (filterByIncludedCategory && filterByIncludedAudit) {
// If we're filtering by category and audit, include the union of the two
if (!categoryIds.includes(categoryId)) {
category.auditRefs = category.auditRefs.filter(audit => auditIds.includes(audit.id));
}
} else if (filterByIncludedCategory) {
// If we're filtering by just category, and the category is not included, skip it
if (!categoryIds.includes(categoryId)) {
return;
}
} else if (filterByIncludedAudit) {
category.auditRefs = category.auditRefs.filter(audit => auditIds.includes(audit.id));
}
// always filter based on skipAuditIds
category.auditRefs = category.auditRefs.filter(audit => !skipAuditIds.includes(audit.id));
if (category.auditRefs.length) {
categories[categoryId] = category;
category.auditRefs.forEach(audit => includedAudits.add(audit.id));
}
});
return {categories, requestedAuditNames: includedAudits};
}
/**
* From some requested audits, return names of all required and optional artifacts
* @param {LegacyResolvedConfig['audits']} audits
* @return {Set<string>}
*/
static getGatherersRequestedByAudits(audits) {
// It's possible we weren't given any audits (but existing audit results), in which case
// there is no need to do any work here.
if (!audits) {
return new Set();
}
const gatherers = new Set();
for (const auditDefn of audits) {
const {requiredArtifacts, __internalOptionalArtifacts} = auditDefn.implementation.meta;
requiredArtifacts.forEach(artifact => gatherers.add(artifact));
if (__internalOptionalArtifacts) {
__internalOptionalArtifacts.forEach(artifact => gatherers.add(artifact));
}
}
return gatherers;
}
/**
* Filters to only requested passes and gatherers, returning a new passes array.
* @param {LegacyResolvedConfig['passes']} passes
* @param {Set<string>} requestedGatherers
* @return {LegacyResolvedConfig['passes']}
*/
static generatePassesNeededByGatherers(passes, requestedGatherers) {
if (!passes) {
return null;
}
const auditsNeedTrace = requestedGatherers.has('traces');
const filteredPasses = passes.map(pass => {
// remove any unncessary gatherers from within the passes
pass.gatherers = pass.gatherers.filter(gathererDefn => {
const gatherer = gathererDefn.instance;
return requestedGatherers.has(gatherer.name);
});
// disable the trace if no audit requires a trace
if (pass.recordTrace && !auditsNeedTrace) {
const passName = pass.passName || 'unknown pass';
log.warn('config', `Trace not requested by an audit, dropping trace in ${passName}`);
pass.recordTrace = false;
}
return pass;
}).filter(pass => {
// remove any passes lacking concrete gatherers, unless they are dependent on the trace
if (pass.recordTrace) return true;
// Always keep defaultPass
if (pass.passName === 'defaultPass') return true;
return pass.gatherers.length > 0;
});
return filteredPasses;
}
/**
* Take an array of audits and audit paths and require any paths (possibly
* relative to the optional `configDir`) using `resolveModulePath`,
* leaving only an array of AuditDefns.
* @param {LH.Config['audits']} audits
* @param {string=} configDir
* @return {Promise<LegacyResolvedConfig['audits']>}
*/
static async requireAudits(audits, configDir) {
const status = {msg: 'Requiring audits', id: 'lh:config:requireAudits'};
log.time(status, 'verbose');
const auditDefns = await resolveAuditsToDefns(audits, configDir);
log.timeEnd(status);
return auditDefns;
}
/**
* Takes an array of passes with every property now initialized except the
* gatherers and requires them, (relative to the optional `configDir` if
* provided) using `resolveModulePath`, returning an array of full Passes.
* @param {?Array<Required<LH.Config.PassJson>>} passes
* @param {string=} configDir
* @return {Promise<LegacyResolvedConfig['passes']>}
*/
static async requireGatherers(passes, configDir) {
if (!passes) {
return null;
}
const status = {msg: 'Requiring gatherers', id: 'lh:config:requireGatherers'};
log.time(status, 'verbose');
const coreList = Runner.getGathererList();
const fullPassesPromises = passes.map(async (pass) => {
const gathererDefns = await Promise.all(
pass.gatherers
.map(gatherer => resolveGathererToDefn(gatherer, coreList, configDir))
);
// De-dupe gatherers by artifact name because artifact IDs must be unique at runtime.
const uniqueDefns = Array.from(
new Map(gathererDefns.map(defn => [defn.instance.name, defn])).values()
);
uniqueDefns.forEach(gatherer => assertValidGatherer(gatherer.instance, gatherer.path));
uniqueDefns.sort((a, b) => {
const aPriority = internalGathererPriorities[a.instance.name] || 0;
const bPriority = internalGathererPriorities[b.instance.name] || 0;
return aPriority - bPriority;
});
return Object.assign(pass, {gatherers: uniqueDefns});
});
const fullPasses = await Promise.all(fullPassesPromises);
log.timeEnd(status);
return fullPasses;
}
}
export {LegacyResolvedConfig};

View File

@@ -0,0 +1,4 @@
export default legacyDefaultConfig;
/** @type {LH.Config} */
declare const legacyDefaultConfig: LH.Config;
//# sourceMappingURL=legacy-default-config.d.ts.map

View File

@@ -0,0 +1,87 @@
/**
* @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.
*/
/**
* @fileoverview Construct the legacy default config from the standard default config.
*/
import defaultConfig from '../../config/default-config.js';
/** @type {LH.Config} */
const legacyDefaultConfig = JSON.parse(JSON.stringify(defaultConfig));
if (!legacyDefaultConfig.categories) {
throw new Error('Default config should always have categories');
}
// These properties are ignored in Legacy navigations.
delete legacyDefaultConfig.artifacts;
// These audits don't work in Legacy navigation mode so we remove them.
const unsupportedAuditIds = [
'experimental-interaction-to-next-paint',
'uses-responsive-images-snapshot',
'work-during-interaction',
];
legacyDefaultConfig.audits = legacyDefaultConfig.audits?.filter(audit =>
!unsupportedAuditIds.find(auditId => audit.toString().endsWith(auditId)));
legacyDefaultConfig.categories['performance'].auditRefs =
legacyDefaultConfig.categories['performance'].auditRefs.filter(auditRef =>
!unsupportedAuditIds.includes(auditRef.id));
legacyDefaultConfig.passes = [{
passName: 'defaultPass',
recordTrace: true,
useThrottling: true,
pauseAfterFcpMs: 1000,
pauseAfterLoadMs: 1000,
networkQuietThresholdMs: 1000,
cpuQuietThresholdMs: 1000,
gatherers: [
'css-usage',
'js-usage',
'viewport-dimensions',
'console-messages',
'anchor-elements',
'image-elements',
'link-elements',
'meta-elements',
'script-elements',
'scripts',
'iframe-elements',
'inputs',
'main-document-content',
'global-listeners',
'dobetterweb/doctype',
'dobetterweb/domstats',
'dobetterweb/optimized-images',
'dobetterweb/response-compression',
'dobetterweb/tags-blocking-first-paint',
'seo/font-size',
'seo/embedded-content',
'seo/robots-txt',
'seo/tap-targets',
'accessibility',
'trace-elements',
'inspector-issues',
'source-maps',
'web-app-manifest',
'installability-errors',
'stacks',
'full-page-screenshot',
'bf-cache-failures',
],
},
{
passName: 'offlinePass',
loadFailureMode: 'ignore',
gatherers: [
'service-worker',
],
}];
export default legacyDefaultConfig;

View File

@@ -0,0 +1,77 @@
export type ProtocolEventRecord = {
'protocolevent': [LH.Protocol.RawEventMessage];
};
export type CrdpEventMessageEmitter = LH.Protocol.StrictEventEmitter<ProtocolEventRecord>;
export type CommandInfo = LH.CrdpCommands[keyof LH.CrdpCommands];
export type CommandCallback = {
resolve: (arg0: Promise<CommandInfo['returnType']>) => void;
method: keyof LH.CrdpCommands;
};
/**
* @typedef {{'protocolevent': [LH.Protocol.RawEventMessage]}} ProtocolEventRecord
* @typedef {LH.Protocol.StrictEventEmitter<ProtocolEventRecord>} CrdpEventMessageEmitter
* @typedef {LH.CrdpCommands[keyof LH.CrdpCommands]} CommandInfo
* @typedef {{resolve: function(Promise<CommandInfo['returnType']>): void, method: keyof LH.CrdpCommands}} CommandCallback
*/
export class Connection {
_lastCommandId: number;
/** @type {Map<number, CommandCallback>} */
_callbacks: Map<number, CommandCallback>;
/** @type {Map<string, LH.Protocol.TargetType>} */
_sessionIdToTargetType: Map<string, LH.Protocol.TargetType>;
_eventEmitter: CrdpEventMessageEmitter | null;
/**
* @return {Promise<void>}
*/
connect(): Promise<void>;
/**
* @return {Promise<void>}
*/
disconnect(): Promise<void>;
/**
* @return {Promise<string>}
*/
wsEndpoint(): Promise<string>;
/**
* Call protocol methods
* @template {keyof LH.CrdpCommands} C
* @param {C} method
* @param {string|undefined} sessionId
* @param {LH.CrdpCommands[C]['paramsType']} paramArgs,
* @return {Promise<LH.CrdpCommands[C]['returnType']>}
*/
sendCommand<C extends keyof LH.CrdpCommands>(method: C, sessionId: string | undefined, ...paramArgs: LH.CrdpCommands[C]["paramsType"]): Promise<LH.CrdpCommands[C]["returnType"]>;
/**
* Bind listeners for connection events.
* @param {'protocolevent'} eventName
* @param {function(LH.Protocol.RawEventMessage): void} cb
*/
on(eventName: 'protocolevent', cb: (arg0: LH.Protocol.RawEventMessage) => void): void;
/**
* Unbind listeners for connection events.
* @param {'protocolevent'} eventName
* @param {function(LH.Protocol.RawEventMessage): void} cb
*/
off(eventName: 'protocolevent', cb: (arg0: LH.Protocol.RawEventMessage) => void): void;
/**
* @param {string} message
* @protected
*/
protected sendRawMessage(message: string): void;
/**
* @param {string} message
* @return {void}
* @protected
*/
protected handleRawMessage(message: string): void;
/**
* @param {LH.Protocol.RawEventMessage} eventMessage
*/
emitProtocolEvent(eventMessage: LH.Protocol.RawEventMessage): void;
/**
* @protected
*/
protected dispose(): void;
}
import * as LH from '../../../../types/lh.js';
//# sourceMappingURL=connection.d.ts.map

View File

@@ -0,0 +1,196 @@
/**
* @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 {EventEmitter} from 'events';
import log from 'lighthouse-logger';
import * as LH from '../../../../types/lh.js';
import {LighthouseError} from '../../../lib/lh-error.js';
// TODO(bckenny): CommandCallback properties should be tied by command type after
// https://github.com/Microsoft/TypeScript/pull/22348. See driver.js TODO.
/**
* @typedef {{'protocolevent': [LH.Protocol.RawEventMessage]}} ProtocolEventRecord
* @typedef {LH.Protocol.StrictEventEmitter<ProtocolEventRecord>} CrdpEventMessageEmitter
* @typedef {LH.CrdpCommands[keyof LH.CrdpCommands]} CommandInfo
* @typedef {{resolve: function(Promise<CommandInfo['returnType']>): void, method: keyof LH.CrdpCommands}} CommandCallback
*/
class Connection {
constructor() {
this._lastCommandId = 0;
/** @type {Map<number, CommandCallback>} */
this._callbacks = new Map();
/** @type {Map<string, LH.Protocol.TargetType>} */
this._sessionIdToTargetType = new Map();
this._eventEmitter = /** @type {?CrdpEventMessageEmitter} */ (new EventEmitter());
}
/**
* @return {Promise<void>}
*/
connect() {
return Promise.reject(new Error('Not implemented'));
}
/**
* @return {Promise<void>}
*/
disconnect() {
return Promise.reject(new Error('Not implemented'));
}
/**
* @return {Promise<string>}
*/
wsEndpoint() {
return Promise.reject(new Error('Not implemented'));
}
/**
* Call protocol methods
* @template {keyof LH.CrdpCommands} C
* @param {C} method
* @param {string|undefined} sessionId
* @param {LH.CrdpCommands[C]['paramsType']} paramArgs,
* @return {Promise<LH.CrdpCommands[C]['returnType']>}
*/
sendCommand(method, sessionId, ...paramArgs) {
// Reify params since we need it as a property so can't just spread again.
const params = paramArgs.length ? paramArgs[0] : undefined;
log.formatProtocol('method => browser', {method, params}, 'verbose');
const id = ++this._lastCommandId;
const message = JSON.stringify({id, sessionId, method, params});
this.sendRawMessage(message);
return new Promise(resolve => {
this._callbacks.set(id, {method, resolve});
});
}
/**
* Bind listeners for connection events.
* @param {'protocolevent'} eventName
* @param {function(LH.Protocol.RawEventMessage): void} cb
*/
on(eventName, cb) {
if (eventName !== 'protocolevent') {
throw new Error('Only supports "protocolevent" events');
}
if (!this._eventEmitter) {
throw new Error('Attempted to add event listener after connection disposed.');
}
this._eventEmitter.on(eventName, cb);
}
/**
* Unbind listeners for connection events.
* @param {'protocolevent'} eventName
* @param {function(LH.Protocol.RawEventMessage): void} cb
*/
off(eventName, cb) {
if (eventName !== 'protocolevent') {
throw new Error('Only supports "protocolevent" events');
}
if (!this._eventEmitter) {
throw new Error('Attempted to remove event listener after connection disposed.');
}
this._eventEmitter.removeListener(eventName, cb);
}
/* eslint-disable no-unused-vars */
/**
* @param {string} message
* @protected
*/
sendRawMessage(message) {
throw new Error('Not implemented');
}
/* eslint-enable no-unused-vars */
/**
* @param {string} message
* @return {void}
* @protected
*/
handleRawMessage(message) {
/** @type {LH.Protocol.RawMessage} */
const object = JSON.parse(message);
// Responses to commands carry "id" property, while events do not.
if (!('id' in object)) {
log.formatProtocol('<= event',
{method: object.method, params: object.params}, 'verbose');
if (object.method === 'Target.attachedToTarget') {
const type = object.params.targetInfo.type;
if (type === 'page' || type === 'iframe') {
this._sessionIdToTargetType.set(object.params.sessionId, type);
}
}
if (object.sessionId) {
const type = this._sessionIdToTargetType.get(object.sessionId);
if (type) {
object.targetType = type;
}
}
this.emitProtocolEvent(object);
return;
}
const callback = this._callbacks.get(object.id);
if (callback) {
this._callbacks.delete(object.id);
callback.resolve(Promise.resolve().then(_ => {
if (object.error) {
log.formatProtocol('method <= browser ERR', {method: callback.method}, 'error');
throw LighthouseError.fromProtocolMessage(callback.method, object.error);
}
log.formatProtocol('method <= browser OK',
{method: callback.method, params: object.result}, 'verbose');
return object.result;
}));
} else {
// In DevTools we receive responses to commands we did not send which we cannot act on, so we
// just log these occurrences.
const error = object.error?.message;
log.formatProtocol(`disowned method <= browser ${error ? 'ERR' : 'OK'}`,
{method: 'UNKNOWN', params: error || object.result}, 'verbose');
}
}
/**
* @param {LH.Protocol.RawEventMessage} eventMessage
*/
emitProtocolEvent(eventMessage) {
if (!this._eventEmitter) {
throw new Error('Attempted to emit event after connection disposed.');
}
this._eventEmitter.emit('protocolevent', eventMessage);
}
/**
* @protected
*/
dispose() {
if (this._eventEmitter) {
this._eventEmitter.removeAllListeners();
this._eventEmitter = null;
}
this._sessionIdToTargetType.clear();
}
}
export {Connection};

View File

@@ -0,0 +1,27 @@
export class CriConnection extends Connection {
/**
* @param {number=} port Optional port number. Defaults to 9222;
* @param {string=} hostname Optional hostname. Defaults to localhost.
* @constructor
*/
constructor(port?: number | undefined, hostname?: string | undefined);
port: number;
hostname: string;
_ws: WebSocket | null;
_pageId: string | null;
/**
* @param {LH.DevToolsJsonTarget} response
* @return {Promise<void>}
* @private
*/
private _connectToSocket;
/**
* @param {string} command
* @return {Promise<LH.DevToolsJsonTarget | Array<LH.DevToolsJsonTarget> | {message: string}>}
* @private
*/
private _runJsonCommand;
}
import { Connection } from './connection.js';
import WebSocket from 'ws';
//# sourceMappingURL=cri.d.ts.map

View File

@@ -0,0 +1,162 @@
/**
* @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 http from 'http';
import WebSocket from 'ws';
import log from 'lighthouse-logger';
import {Connection} from './connection.js';
import {LighthouseError} from '../../../lib/lh-error.js';
const DEFAULT_HOSTNAME = '127.0.0.1';
const CONNECT_TIMEOUT = 10000;
const DEFAULT_PORT = 9222;
class CriConnection extends Connection {
/**
* @param {number=} port Optional port number. Defaults to 9222;
* @param {string=} hostname Optional hostname. Defaults to localhost.
* @constructor
*/
constructor(port = DEFAULT_PORT, hostname = DEFAULT_HOSTNAME) {
super();
this.port = port;
this.hostname = hostname;
this._ws = null;
this._pageId = null;
}
/**
* @override
* @return {Promise<void>}
*/
async connect() {
const response = await this._runJsonCommand('new');
return this._connectToSocket(/** @type {LH.DevToolsJsonTarget} */(response));
}
/**
* @param {LH.DevToolsJsonTarget} response
* @return {Promise<void>}
* @private
*/
_connectToSocket(response) {
const url = response.webSocketDebuggerUrl;
this._pageId = response.id;
return new Promise((resolve, reject) => {
const ws = new WebSocket(url, {
perMessageDeflate: false,
});
ws.on('open', () => {
this._ws = ws;
resolve();
});
ws.on('message', data => this.handleRawMessage(/** @type {string} */ (data)));
ws.on('close', this.dispose.bind(this));
ws.on('error', reject);
});
}
/**
* @param {string} command
* @return {Promise<LH.DevToolsJsonTarget | Array<LH.DevToolsJsonTarget> | {message: string}>}
* @private
*/
_runJsonCommand(command) {
return new Promise((resolve, reject) => {
const request = http.request({
method: 'PUT', // GET and POST are deprecated: https://crrev.com/c/3595822
hostname: this.hostname,
port: this.port,
path: '/json/' + command,
}, response => {
let data = '';
response.setEncoding('utf8');
response.on('data', chunk => {
data += chunk;
});
response.on('end', () => {
if (response.statusCode === 200) {
try {
resolve(JSON.parse(data));
return;
} catch (e) {
// In the case of 'close' & 'activate' Chromium returns a string rather than JSON: goo.gl/7v27xD
if (data === 'Target is closing' || data === 'Target activated') {
return resolve({message: data});
}
return reject(e);
}
}
reject(new Error(`Protocol JSON API error (${command}), status: ${response.statusCode}`));
});
});
request.end();
// This error handler is critical to ensuring Lighthouse exits cleanly even when Chrome crashes.
// See https://github.com/GoogleChrome/lighthouse/pull/8583.
request.on('error', reject);
request.setTimeout(CONNECT_TIMEOUT, () => {
// Reject on error with code specifically indicating timeout in connection setup.
const err = new LighthouseError(LighthouseError.errors.CRI_TIMEOUT);
log.error('CriConnection', err.friendlyMessage);
reject(err);
request.abort();
});
});
}
/**
* @override
* @return {Promise<void>}
*/
disconnect() {
if (!this._ws) {
log.warn('CriConnection', 'disconnect() was called without an established connection.');
return Promise.resolve();
}
return this._runJsonCommand(`close/${this._pageId}`).then(_ => {
if (this._ws) {
this._ws.removeAllListeners();
this._ws.close();
this._ws = null;
}
this._pageId = null;
});
}
/**
* @override
* @return {Promise<string>}
*/
wsEndpoint() {
return this._runJsonCommand('version').then(response => {
return /** @type {LH.DevToolsJsonTarget} */ (response).webSocketDebuggerUrl;
});
}
/**
* @override
* @param {string} message
* @protected
*/
sendRawMessage(message) {
if (!this._ws) {
log.error('CriConnection', 'sendRawMessage() was called without an established connection.');
throw new Error('sendRawMessage() was called without an established connection.');
}
this._ws.send(message);
}
}
export {CriConnection};

View File

@@ -0,0 +1,20 @@
export type Port = {
on: (eventName: 'message' | 'close', cb: ((arg: string) => void) | (() => void)) => void;
send: (message: string) => void;
close: () => void;
};
/**
* @typedef {object} Port
* @property {(eventName: 'message'|'close', cb: ((arg: string) => void) | (() => void)) => void} on
* @property {(message: string) => void} send
* @property {() => void} close
*/
export class RawConnection extends Connection {
/**
* @param {Port} port
*/
constructor(port: Port);
_port: Port;
}
import { Connection } from './connection.js';
//# sourceMappingURL=raw.d.ts.map

View File

@@ -0,0 +1,57 @@
/**
* @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 {Connection} from './connection.js';
/* eslint-disable no-unused-vars */
/**
* @typedef {object} Port
* @property {(eventName: 'message'|'close', cb: ((arg: string) => void) | (() => void)) => void} on
* @property {(message: string) => void} send
* @property {() => void} close
*/
/* eslint-enable no-unused-vars */
class RawConnection extends Connection {
/**
* @param {Port} port
*/
constructor(port) {
super();
this._port = port;
this._port.on('message', this.handleRawMessage.bind(this));
this._port.on('close', this.dispose.bind(this));
}
/**
* @override
* @return {Promise<void>}
*/
connect() {
return Promise.resolve();
}
/**
* @return {Promise<void>}
*/
disconnect() {
this._port.close();
return Promise.resolve();
}
/**
* @override
* @param {string} message
* @protected
*/
sendRawMessage(message) {
this._port.send(message);
}
}
export {RawConnection};

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

@@ -0,0 +1,216 @@
export type CrdpEventEmitter = LH.Protocol.StrictEventEmitter<LH.CrdpEvents>;
/**
* @typedef {LH.Protocol.StrictEventEmitter<LH.CrdpEvents>} CrdpEventEmitter
*/
/**
* @implements {LH.Gatherer.FRTransitionalDriver}
*/
export class Driver implements LH.Gatherer.FRTransitionalDriver {
/** @deprecated - Not available on Fraggle Rock driver. */
static get traceCategories(): string[];
/**
* @param {import('./connections/connection.js').Connection} connection
*/
constructor(connection: import('./connections/connection.js').Connection);
/**
* @pri_vate (This should be private, but that makes our tests harder).
* An event emitter that enforces mapping between Crdp event names and payload types.
*/
_eventEmitter: CrdpEventEmitter;
/**
* @private
* Used to save network and lifecycle protocol traffic. Just Page and Network are needed.
*/
private _devtoolsLog;
/**
* @private
* @type {Map<string, number>}
*/
private _domainEnabledCounts;
/**
* @type {number}
* @private
*/
private _nextProtocolTimeout;
online: boolean;
executionContext: ExecutionContext;
defaultSession: Driver;
fetcher: Fetcher;
_connection: import("./connections/connection.js").Connection;
/** @private @deprecated Only available for plugin backcompat. */
private evaluate;
/** @private @deprecated Only available for plugin backcompat. */
private evaluateAsync;
targetManager: {
rootSession: () => Driver;
mainFrameExecutionContexts: () => LH.Crdp.Runtime.ExecutionContextDescription[];
/**
* Bind to *any* protocol event.
* @param {'protocolevent'} event
* @param {(payload: LH.Protocol.RawEventMessage) => void} callback
*/
on: (event: 'protocolevent', callback: (payload: LH.Protocol.RawEventMessage) => void) => void;
/**
* Unbind to *any* protocol event.
* @param {'protocolevent'} event
* @param {(payload: LH.Protocol.RawEventMessage) => void} callback
*/
off: (event: 'protocolevent', callback: (payload: LH.Protocol.RawEventMessage) => void) => void;
};
/**
* @return {Promise<LH.Crdp.Browser.GetVersionResponse & {milestone: number}>}
*/
getBrowserVersion(): Promise<LH.Crdp.Browser.GetVersionResponse & {
milestone: number;
}>;
/**
* @return {Promise<void>}
*/
connect(): Promise<void>;
/**
* @return {Promise<void>}
*/
disconnect(): Promise<void>;
/** @return {Promise<void>} */
dispose(): Promise<void>;
/**
* Get the browser WebSocket endpoint for devtools protocol clients like Puppeteer.
* Only works with WebSocket connection, not extension or devtools.
* @return {Promise<string>}
*/
wsEndpoint(): Promise<string>;
/**
* Bind listeners for protocol events.
* @template {keyof LH.CrdpEvents} E
* @param {E} eventName
* @param {(...args: LH.CrdpEvents[E]) => void} cb
*/
on<E extends keyof LH.CrdpEvents>(eventName: E, cb: (...args: LH.CrdpEvents[E]) => void): void;
/**
* Bind a one-time listener for protocol events. Listener is removed once it
* has been called.
* @template {keyof LH.CrdpEvents} E
* @param {E} eventName
* @param {(...args: LH.CrdpEvents[E]) => void} cb
*/
once<E_1 extends keyof LH.CrdpEvents>(eventName: E_1, cb: (...args: LH.CrdpEvents[E_1]) => void): void;
/**
* Unbind event listener.
* @template {keyof LH.CrdpEvents} E
* @param {E} eventName
* @param {Function} cb
*/
off<E_2 extends keyof LH.CrdpEvents>(eventName: E_2, cb: Function): void;
/** @param {LH.Crdp.Target.TargetInfo} targetInfo */
setTargetInfo(targetInfo: LH.Crdp.Target.TargetInfo): void;
/**
* Debounce enabling or disabling domains to prevent driver users from
* stomping on each other. Maintains an internal count of the times a domain
* has been enabled. Returns false if the command would have no effect (domain
* is already enabled or disabled), or if command would interfere with another
* user of that domain (e.g. two gatherers have enabled a domain, both need to
* disable it for it to be disabled). Returns true otherwise.
* @param {string} domain
* @param {string|undefined} sessionId
* @param {boolean} enable
* @return {boolean}
* @private
*/
private _shouldToggleDomain;
/**
* @return {boolean}
*/
hasNextProtocolTimeout(): boolean;
/**
* @return {number}
*/
getNextProtocolTimeout(): number;
/**
* timeout is used for the next call to 'sendCommand'.
* NOTE: This can eventually be replaced when TypeScript
* resolves https://github.com/Microsoft/TypeScript/issues/5453.
* @param {number} timeout
*/
setNextProtocolTimeout(timeout: number): void;
/**
* @param {LH.Protocol.RawEventMessage} event
*/
_handleProtocolEvent(event: LH.Protocol.RawEventMessage): void;
/**
* @param {Error} error
*/
_handleEventError(error: Error): void;
/**
* @param {LH.Crdp.Target.AttachedToTargetEvent} event
*/
_handleTargetAttached(event: LH.Crdp.Target.AttachedToTargetEvent): Promise<void>;
/**
* Call protocol methods, with a timeout.
* To configure the timeout for the next call, use 'setNextProtocolTimeout'.
* If 'sessionId' is undefined, the message is sent to the main session.
* @template {keyof LH.CrdpCommands} C
* @param {C} method
* @param {string|undefined} sessionId
* @param {LH.CrdpCommands[C]['paramsType']} params
* @return {Promise<LH.CrdpCommands[C]['returnType']>}
*/
sendCommandToSession<C extends keyof LH.CrdpCommands>(method: C, sessionId: string | undefined, ...params: LH.CrdpCommands[C]["paramsType"]): Promise<LH.CrdpCommands[C]["returnType"]>;
/**
* Alias for 'sendCommandToSession(method, undefined, ...params)'
* @template {keyof LH.CrdpCommands} C
* @param {C} method
* @param {LH.CrdpCommands[C]['paramsType']} params
* @return {Promise<LH.CrdpCommands[C]['returnType']>}
*/
sendCommand<C_1 extends keyof LH.CrdpCommands>(method: C_1, ...params: LH.CrdpCommands[C_1]["paramsType"]): Promise<LH.CrdpCommands[C_1]["returnType"]>;
/**
* Call protocol methods.
* @private
* @template {keyof LH.CrdpCommands} C
* @param {C} method
* @param {string|undefined} sessionId
* @param {LH.CrdpCommands[C]['paramsType']} params
* @return {Promise<LH.CrdpCommands[C]['returnType']>}
*/
private _innerSendCommand;
/**
* Returns whether a domain is currently enabled.
* @param {string} domain
* @return {boolean}
*/
isDomainEnabled(domain: string): boolean;
/**
* Return the body of the response with the given ID. Rejects if getting the
* body times out.
* @param {string} requestId
* @param {number} [timeout]
* @return {Promise<string>}
*/
getRequestContent(requestId: string, timeout?: number | undefined): Promise<string>;
/**
* @param {{additionalTraceCategories?: string|null}=} settings
* @return {Promise<void>}
*/
beginTrace(settings?: {
additionalTraceCategories?: string | null;
} | undefined): Promise<void>;
/**
* @return {Promise<LH.Trace>}
*/
endTrace(): Promise<LH.Trace>;
/**
* Begin recording devtools protocol messages.
*/
beginDevtoolsLog(): Promise<void>;
_disableAsyncStacks: (() => Promise<void>) | undefined;
/**
* Stop recording to devtoolsLog and return log contents.
* @return {Promise<LH.DevtoolsLog>}
*/
endDevtoolsLog(): Promise<LH.DevtoolsLog>;
url(): Promise<string>;
}
import * as LH from '../../../types/lh.js';
import { ExecutionContext } from '../../gather/driver/execution-context.js';
import { Fetcher } from '../../gather/fetcher.js';
//# sourceMappingURL=driver.d.ts.map

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

@@ -0,0 +1,480 @@
/**
* @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 {EventEmitter} from 'events';
import log from 'lighthouse-logger';
import * as LH from '../../../types/lh.js';
import {Fetcher} from '../../gather/fetcher.js';
import {ExecutionContext} from '../../gather/driver/execution-context.js';
import {LighthouseError} from '../../lib/lh-error.js';
import {fetchResponseBodyFromCache} from '../../gather/driver/network.js';
import {DevtoolsMessageLog} from '../../gather/gatherers/devtools-log.js';
import TraceGatherer from '../../gather/gatherers/trace.js';
import {getBrowserVersion} from '../../gather/driver/environment.js';
import {enableAsyncStacks} from '../../gather/driver/prepare.js';
// Controls how long to wait for a response after sending a DevTools protocol command.
const DEFAULT_PROTOCOL_TIMEOUT = 30000;
/**
* @typedef {LH.Protocol.StrictEventEmitter<LH.CrdpEvents>} CrdpEventEmitter
*/
/**
* @implements {LH.Gatherer.FRTransitionalDriver}
*/
class Driver {
/**
* @pri_vate (This should be private, but that makes our tests harder).
* An event emitter that enforces mapping between Crdp event names and payload types.
*/
_eventEmitter = /** @type {CrdpEventEmitter} */ (new EventEmitter());
/**
* @private
* Used to save network and lifecycle protocol traffic. Just Page and Network are needed.
*/
_devtoolsLog = new DevtoolsMessageLog(/^(Page|Network|Target|Runtime)\./);
/**
* @private
* @type {Map<string, number>}
*/
_domainEnabledCounts = new Map();
/**
* @type {number}
* @private
*/
_nextProtocolTimeout = DEFAULT_PROTOCOL_TIMEOUT;
online = true;
// eslint-disable-next-line no-invalid-this
executionContext = new ExecutionContext(this);
// eslint-disable-next-line no-invalid-this
defaultSession = this;
// eslint-disable-next-line no-invalid-this
fetcher = new Fetcher(this.defaultSession);
/**
* @param {import('./connections/connection.js').Connection} connection
*/
constructor(connection) {
this._connection = connection;
this.on('Target.attachedToTarget', event => {
this._handleTargetAttached(event).catch(this._handleEventError);
});
this.on('Page.frameNavigated', event => {
// We're only interested in setting autoattach on the root via this method.
// `_handleTargetAttached` takes care of the recursive piece.
if (event.frame.parentId) return;
// Enable auto-attaching to subtargets so we receive iframe information.
this.sendCommand('Target.setAutoAttach', {
flatten: true,
autoAttach: true,
// Pause targets on startup so we don't miss anything
waitForDebuggerOnStart: true,
}).catch(this._handleEventError);
});
connection.on('protocolevent', this._handleProtocolEvent.bind(this));
/** @private @deprecated Only available for plugin backcompat. */
this.evaluate = this.executionContext.evaluate.bind(this.executionContext);
/** @private @deprecated Only available for plugin backcompat. */
this.evaluateAsync = this.executionContext.evaluateAsync.bind(this.executionContext);
// A shim for sufficient coverage of targetManager functionality. Exposes the target
// management that legacy driver already handles (see this._handleTargetAttached).
this.targetManager = {
rootSession: () => {
return this.defaultSession;
},
// For legacy driver, only bother supporting access to the default execution context.
mainFrameExecutionContexts: () => {
// @ts-expect-error - undefined ids are OK for purposes of calling protocol commands like Runtime.evaluate.
return [/** @type {LH.Crdp.Runtime.ExecutionContextDescription} */({
id: undefined,
uniqueId: undefined,
origin: '',
name: '',
auxData: {isDefault: true, type: 'default', frameId: ''},
})];
},
/**
* Bind to *any* protocol event.
* @param {'protocolevent'} event
* @param {(payload: LH.Protocol.RawEventMessage) => void} callback
*/
on: (event, callback) => {
this._connection.on('protocolevent', callback);
},
/**
* Unbind to *any* protocol event.
* @param {'protocolevent'} event
* @param {(payload: LH.Protocol.RawEventMessage) => void} callback
*/
off: (event, callback) => {
this._connection.off('protocolevent', callback);
},
};
}
/** @deprecated - Not available on Fraggle Rock driver. */
static get traceCategories() {
return TraceGatherer.getDefaultTraceCategories();
}
/**
* @return {Promise<LH.Crdp.Browser.GetVersionResponse & {milestone: number}>}
*/
async getBrowserVersion() {
return getBrowserVersion(this);
}
/**
* @return {Promise<void>}
*/
async connect() {
const status = {msg: 'Connecting to browser', id: 'lh:init:connect'};
log.time(status);
await this._connection.connect();
log.timeEnd(status);
}
/**
* @return {Promise<void>}
*/
disconnect() {
return this._connection.disconnect();
}
/** @return {Promise<void>} */
dispose() {
return this.disconnect();
}
/**
* Get the browser WebSocket endpoint for devtools protocol clients like Puppeteer.
* Only works with WebSocket connection, not extension or devtools.
* @return {Promise<string>}
*/
wsEndpoint() {
return this._connection.wsEndpoint();
}
/**
* Bind listeners for protocol events.
* @template {keyof LH.CrdpEvents} E
* @param {E} eventName
* @param {(...args: LH.CrdpEvents[E]) => void} cb
*/
on(eventName, cb) {
if (this._eventEmitter === null) {
throw new Error('connect() must be called before attempting to listen to events.');
}
// log event listeners being bound
log.formatProtocol('listen for event =>', {method: eventName}, 'verbose');
this._eventEmitter.on(eventName, cb);
}
/**
* Bind a one-time listener for protocol events. Listener is removed once it
* has been called.
* @template {keyof LH.CrdpEvents} E
* @param {E} eventName
* @param {(...args: LH.CrdpEvents[E]) => void} cb
*/
once(eventName, cb) {
if (this._eventEmitter === null) {
throw new Error('connect() must be called before attempting to listen to events.');
}
// log event listeners being bound
log.formatProtocol('listen once for event =>', {method: eventName}, 'verbose');
this._eventEmitter.once(eventName, cb);
}
/**
* Unbind event listener.
* @template {keyof LH.CrdpEvents} E
* @param {E} eventName
* @param {Function} cb
*/
off(eventName, cb) {
if (this._eventEmitter === null) {
throw new Error('connect() must be called before attempting to remove an event listener.');
}
this._eventEmitter.removeListener(eventName, cb);
}
/** @param {LH.Crdp.Target.TargetInfo} targetInfo */
setTargetInfo(targetInfo) { // eslint-disable-line no-unused-vars
// OOPIF handling in legacy driver is implicit.
}
/**
* Debounce enabling or disabling domains to prevent driver users from
* stomping on each other. Maintains an internal count of the times a domain
* has been enabled. Returns false if the command would have no effect (domain
* is already enabled or disabled), or if command would interfere with another
* user of that domain (e.g. two gatherers have enabled a domain, both need to
* disable it for it to be disabled). Returns true otherwise.
* @param {string} domain
* @param {string|undefined} sessionId
* @param {boolean} enable
* @return {boolean}
* @private
*/
_shouldToggleDomain(domain, sessionId, enable) {
const key = domain + (sessionId || '');
const enabledCount = this._domainEnabledCounts.get(key) || 0;
const newCount = enabledCount + (enable ? 1 : -1);
this._domainEnabledCounts.set(key, Math.max(0, newCount));
// Switching to enabled or disabled, respectively.
if ((enable && newCount === 1) || (!enable && newCount === 0)) {
log.verbose('Driver', `${domain}.${enable ? 'enable' : 'disable'}`);
return true;
} else {
if (newCount < 0) {
log.error('Driver', `Attempted to disable domain '${domain}' when already disabled.`);
}
return false;
}
}
/**
* @return {boolean}
*/
hasNextProtocolTimeout() {
return this._nextProtocolTimeout !== DEFAULT_PROTOCOL_TIMEOUT;
}
/**
* @return {number}
*/
getNextProtocolTimeout() {
return this._nextProtocolTimeout;
}
/**
* timeout is used for the next call to 'sendCommand'.
* NOTE: This can eventually be replaced when TypeScript
* resolves https://github.com/Microsoft/TypeScript/issues/5453.
* @param {number} timeout
*/
setNextProtocolTimeout(timeout) {
this._nextProtocolTimeout = timeout;
}
/**
* @param {LH.Protocol.RawEventMessage} event
*/
_handleProtocolEvent(event) {
this._devtoolsLog.record(event);
// @ts-expect-error TODO(bckenny): tsc can't type event.params correctly yet,
// typing as property of union instead of narrowing from union of
// properties. See https://github.com/Microsoft/TypeScript/pull/22348.
this._eventEmitter.emit(event.method, event.params);
}
/**
* @param {Error} error
*/
_handleEventError(error) {
log.error('Driver', 'Unhandled event error', error.message);
}
/**
* @param {LH.Crdp.Target.AttachedToTargetEvent} event
*/
async _handleTargetAttached(event) {
// We're only interested in network requests from iframes for now as those are "part of the page".
// If it's not an iframe, just resume it and move on.
if (event.targetInfo.type !== 'iframe') {
// We suspended the target when we auto-attached, so make sure it goes back to being normal.
await this.sendCommandToSession('Runtime.runIfWaitingForDebugger', event.sessionId);
return;
}
// Note: This is only reached for _out of process_ iframes (OOPIFs).
// If the iframe is in the same process as its embedding document, that means they
// share the same target.
// A target won't acknowledge/respond to protocol methods (or, at least for Network.enable)
// until it is resumed. But also we're paranoid about sending Network.enable _slightly_ too late,
// so we issue that method first. Therefore, we don't await on this serially, but await all at once.
await Promise.all([
// Events from subtargets will be stringified and sent back on `Target.receivedMessageFromTarget`.
// We want to receive information about network requests from iframes, so enable the Network domain.
this.sendCommandToSession('Network.enable', event.sessionId),
// We also want to receive information about subtargets of subtargets, so make sure we autoattach recursively.
this.sendCommandToSession('Target.setAutoAttach', event.sessionId, {
autoAttach: true,
flatten: true,
// Pause targets on startup so we don't miss anything
waitForDebuggerOnStart: true,
}),
// We suspended the target when we auto-attached, so make sure it goes back to being normal.
this.sendCommandToSession('Runtime.runIfWaitingForDebugger', event.sessionId),
]);
}
/**
* Call protocol methods, with a timeout.
* To configure the timeout for the next call, use 'setNextProtocolTimeout'.
* If 'sessionId' is undefined, the message is sent to the main session.
* @template {keyof LH.CrdpCommands} C
* @param {C} method
* @param {string|undefined} sessionId
* @param {LH.CrdpCommands[C]['paramsType']} params
* @return {Promise<LH.CrdpCommands[C]['returnType']>}
*/
sendCommandToSession(method, sessionId, ...params) {
const timeout = this._nextProtocolTimeout;
this._nextProtocolTimeout = DEFAULT_PROTOCOL_TIMEOUT;
/** @type {NodeJS.Timer|undefined} */
let asyncTimeout;
const timeoutPromise = new Promise((resolve, reject) => {
if (timeout === Infinity) return;
// eslint-disable-next-line max-len
asyncTimeout = setTimeout(reject, timeout, new LighthouseError(LighthouseError.errors.PROTOCOL_TIMEOUT, {
protocolMethod: method,
}));
});
return Promise.race([
this._innerSendCommand(method, sessionId, ...params),
timeoutPromise,
]).finally(() => {
asyncTimeout && clearTimeout(asyncTimeout);
});
}
/**
* Alias for 'sendCommandToSession(method, undefined, ...params)'
* @template {keyof LH.CrdpCommands} C
* @param {C} method
* @param {LH.CrdpCommands[C]['paramsType']} params
* @return {Promise<LH.CrdpCommands[C]['returnType']>}
*/
sendCommand(method, ...params) {
return this.sendCommandToSession(method, undefined, ...params);
}
/**
* Call protocol methods.
* @private
* @template {keyof LH.CrdpCommands} C
* @param {C} method
* @param {string|undefined} sessionId
* @param {LH.CrdpCommands[C]['paramsType']} params
* @return {Promise<LH.CrdpCommands[C]['returnType']>}
*/
_innerSendCommand(method, sessionId, ...params) {
const domainCommand = /^(\w+)\.(enable|disable)$/.exec(method);
if (domainCommand) {
const enable = domainCommand[2] === 'enable';
if (!this._shouldToggleDomain(domainCommand[1], sessionId, enable)) {
return Promise.resolve();
}
}
return this._connection.sendCommand(method, sessionId, ...params);
}
/**
* Returns whether a domain is currently enabled.
* @param {string} domain
* @return {boolean}
*/
isDomainEnabled(domain) {
// Defined, non-zero elements of the domains map are enabled.
return !!this._domainEnabledCounts.get(domain);
}
/**
* Return the body of the response with the given ID. Rejects if getting the
* body times out.
* @param {string} requestId
* @param {number} [timeout]
* @return {Promise<string>}
*/
async getRequestContent(requestId, timeout = 1000) {
return fetchResponseBodyFromCache(this.defaultSession, requestId, timeout);
}
/**
* @param {{additionalTraceCategories?: string|null}=} settings
* @return {Promise<void>}
*/
async beginTrace(settings) {
const additionalCategories = (settings?.additionalTraceCategories &&
settings.additionalTraceCategories.split(',')) || [];
const traceCategories = TraceGatherer.getDefaultTraceCategories().concat(additionalCategories);
const uniqueCategories = Array.from(new Set(traceCategories));
// Check any domains that could interfere with or add overhead to the trace.
if (this.isDomainEnabled('CSS')) {
throw new Error('CSS domain enabled when starting trace');
}
if (this.isDomainEnabled('DOM')) {
throw new Error('DOM domain enabled when starting trace');
}
// Enable Page domain to wait for Page.loadEventFired
return this.sendCommand('Page.enable')
.then(_ => this.sendCommand('Tracing.start', {
categories: uniqueCategories.join(','),
options: 'sampling-frequency=10000', // 1000 is default and too slow.
}));
}
/**
* @return {Promise<LH.Trace>}
*/
endTrace() {
return TraceGatherer.endTraceAndCollectEvents(this.defaultSession);
}
/**
* Begin recording devtools protocol messages.
*/
async beginDevtoolsLog() {
this._disableAsyncStacks = await enableAsyncStacks(this);
this._devtoolsLog.reset();
this._devtoolsLog.beginRecording();
}
/**
* Stop recording to devtoolsLog and return log contents.
* @return {Promise<LH.DevtoolsLog>}
*/
async endDevtoolsLog() {
this._devtoolsLog.endRecording();
await this._disableAsyncStacks?.();
return this._devtoolsLog.messages;
}
async url() {
const {frameTree} = await this.sendCommand('Page.getFrameTree');
return `${frameTree.frame.url}${frameTree.frame.urlFragment || ''}`;
}
}
export {Driver};

View File

@@ -0,0 +1,166 @@
export type Driver = import('./driver.js').Driver;
export type ArbitraryEqualityMap = import('../../lib/arbitrary-equality-map.js').ArbitraryEqualityMap;
/**
* Each entry in each gatherer result array is the output of a gatherer phase:
* `beforePass`, `pass`, and `afterPass`. Flattened into an `LH.Artifacts` in
* `collectArtifacts`.
*/
export type GathererResults = Record<keyof LH.GathererArtifacts, Array<LH.Gatherer.PhaseResult>>;
export type GathererResultsEntries = Array<[keyof GathererResults, GathererResults[keyof GathererResults]]>;
/** @typedef {import('./driver.js').Driver} Driver */
/** @typedef {import('../../lib/arbitrary-equality-map.js').ArbitraryEqualityMap} ArbitraryEqualityMap */
/**
* Each entry in each gatherer result array is the output of a gatherer phase:
* `beforePass`, `pass`, and `afterPass`. Flattened into an `LH.Artifacts` in
* `collectArtifacts`.
* @typedef {Record<keyof LH.GathererArtifacts, Array<LH.Gatherer.PhaseResult>>} GathererResults
*/
/** @typedef {Array<[keyof GathererResults, GathererResults[keyof GathererResults]]>} GathererResultsEntries */
/**
* Class that drives browser to load the page and runs gatherer lifecycle hooks.
*/
export class GatherRunner {
/**
* Loads about:blank and waits there briefly. Since a Page.reload command does
* not let a service worker take over, we navigate away and then come back to
* reload. We do not `waitForLoad` on about:blank since a page load event is
* never fired on it.
* @param {Driver} driver
* @param {string=} url
* @return {Promise<void>}
*/
static loadBlank(driver: Driver, url?: string | undefined): Promise<void>;
/**
* Loads options.url with specified options. If the main document URL
* redirects, options.url will be updated accordingly. As such, options.url
* will always represent the post-redirected URL. options.requestedUrl is the
* pre-redirect starting URL. If the navigation errors with "expected" errors such as
* NO_FCP, a `navigationError` is returned.
* @param {Driver} driver
* @param {LH.Gatherer.PassContext} passContext
* @return {Promise<{navigationError?: LH.LighthouseError}>}
*/
static loadPage(driver: Driver, passContext: LH.Gatherer.PassContext): Promise<{
navigationError?: LH.LighthouseError;
}>;
/**
* Rejects if any open tabs would share a service worker with the target URL.
* This includes the target tab, so navigation to something like about:blank
* should be done before calling.
* @param {LH.Gatherer.FRProtocolSession} session
* @param {string} pageUrl
* @return {Promise<void>}
*/
static assertNoSameOriginServiceWorkerClients(session: LH.Gatherer.FRProtocolSession, pageUrl: string): Promise<void>;
/**
* @param {Driver} driver
* @param {{requestedUrl: string, settings: LH.Config.Settings}} options
* @return {Promise<void>}
*/
static setupDriver(driver: Driver, options: {
requestedUrl: string;
settings: LH.Config.Settings;
}): Promise<void>;
/**
* Reset browser state where needed and release the connection.
* @param {Driver} driver
* @param {{requestedUrl: string, settings: LH.Config.Settings}} options
* @return {Promise<void>}
*/
static disposeDriver(driver: Driver, options: {
requestedUrl: string;
settings: LH.Config.Settings;
}): Promise<void>;
/**
* Beging recording devtoolsLog and trace (if requested).
* @param {LH.Gatherer.PassContext} passContext
* @return {Promise<void>}
*/
static beginRecording(passContext: LH.Gatherer.PassContext): Promise<void>;
/**
* End recording devtoolsLog and trace (if requested), returning an
* `LH.Gatherer.LoadData` with the recorded data.
* @param {LH.Gatherer.PassContext} passContext
* @return {Promise<LH.Gatherer.LoadData>}
*/
static endRecording(passContext: LH.Gatherer.PassContext): Promise<LH.Gatherer.LoadData>;
/**
* Run beforePass() on gatherers.
* @param {LH.Gatherer.PassContext} passContext
* @param {Partial<GathererResults>} gathererResults
* @return {Promise<void>}
*/
static beforePass(passContext: LH.Gatherer.PassContext, gathererResults: Partial<GathererResults>): Promise<void>;
/**
* Run pass() on gatherers.
* @param {LH.Gatherer.PassContext} passContext
* @param {Partial<GathererResults>} gathererResults
* @return {Promise<void>}
*/
static pass(passContext: LH.Gatherer.PassContext, gathererResults: Partial<GathererResults>): Promise<void>;
/**
* Run afterPass() on gatherers.
* @param {LH.Gatherer.PassContext} passContext
* @param {LH.Gatherer.LoadData} loadData
* @param {Partial<GathererResults>} gathererResults
* @return {Promise<void>}
*/
static afterPass(passContext: LH.Gatherer.PassContext, loadData: LH.Gatherer.LoadData, gathererResults: Partial<GathererResults>): Promise<void>;
/**
* Takes the results of each gatherer phase for each gatherer and uses the
* last produced value (that's not undefined) as the artifact for that
* gatherer. If an error was rejected from a gatherer phase,
* uses that error object as the artifact instead.
* @param {Partial<GathererResults>} gathererResults
* @return {Promise<{artifacts: Partial<LH.GathererArtifacts>}>}
*/
static collectArtifacts(gathererResults: Partial<GathererResults>): Promise<{
artifacts: Partial<LH.GathererArtifacts>;
}>;
/**
* Return an initialized but mostly empty set of base artifacts, to be
* populated as the run continues.
* @param {{driver: Driver, requestedUrl: string, settings: LH.Config.Settings}} options
* @return {Promise<LH.BaseArtifacts>}
*/
static initializeBaseArtifacts(options: {
driver: Driver;
requestedUrl: string;
settings: LH.Config.Settings;
}): Promise<LH.BaseArtifacts>;
/**
* Populates the important base artifacts from a fully loaded test page.
* Currently must be run before `start-url` gatherer so that `WebAppManifest`
* will be available to it.
* @param {LH.Gatherer.PassContext} passContext
*/
static populateBaseArtifacts(passContext: LH.Gatherer.PassContext): Promise<void>;
/**
* @param {Array<LH.Config.Pass>} passConfigs
* @param {{driver: Driver, requestedUrl: string, settings: LH.Config.Settings, computedCache: Map<string, ArbitraryEqualityMap>}} options
* @return {Promise<LH.Artifacts>}
*/
static run(passConfigs: Array<LH.Config.Pass>, options: {
driver: Driver;
requestedUrl: string;
settings: LH.Config.Settings;
computedCache: Map<string, ArbitraryEqualityMap>;
}): Promise<LH.Artifacts>;
/**
* Save the devtoolsLog and trace (if applicable) to baseArtifacts.
* @param {LH.Gatherer.PassContext} passContext
* @param {LH.Gatherer.LoadData} loadData
* @param {string} passName
*/
static _addLoadDataToBaseArtifacts(passContext: LH.Gatherer.PassContext, loadData: LH.Gatherer.LoadData, passName: string): void;
/**
* Starting from about:blank, load the page and run gatherers for this pass.
* @param {LH.Gatherer.PassContext} passContext
* @return {Promise<{artifacts: Partial<LH.GathererArtifacts>, pageLoadError?: LH.LighthouseError}>}
*/
static runPass(passContext: LH.Gatherer.PassContext): Promise<{
artifacts: Partial<LH.GathererArtifacts>;
pageLoadError?: import("../../lib/lh-error.js").LighthouseError | undefined;
}>;
}
//# sourceMappingURL=gather-runner.d.ts.map

View File

@@ -0,0 +1,590 @@
/**
* @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 log from 'lighthouse-logger';
import {NetworkRecords} from '../../computed/network-records.js';
import {getPageLoadError} from '../../lib/navigation-error.js';
import * as emulation from '../../lib/emulation.js';
import * as constants from '../../config/constants.js';
import * as format from '../../../shared/localization/format.js';
import {getBenchmarkIndex, getEnvironmentWarnings} from '../../gather/driver/environment.js';
import * as prepare from '../../gather/driver/prepare.js';
import * as storage from '../../gather/driver/storage.js';
import * as navigation from '../../gather/driver/navigation.js';
import * as serviceWorkers from '../../gather/driver/service-workers.js';
import NetworkUserAgent from '../../gather/gatherers/network-user-agent.js';
import {finalizeArtifacts} from '../../gather/base-artifacts.js';
import UrlUtils from '../../lib/url-utils.js';
/** @typedef {import('./driver.js').Driver} Driver */
/** @typedef {import('../../lib/arbitrary-equality-map.js').ArbitraryEqualityMap} ArbitraryEqualityMap */
/**
* Each entry in each gatherer result array is the output of a gatherer phase:
* `beforePass`, `pass`, and `afterPass`. Flattened into an `LH.Artifacts` in
* `collectArtifacts`.
* @typedef {Record<keyof LH.GathererArtifacts, Array<LH.Gatherer.PhaseResult>>} GathererResults
*/
/** @typedef {Array<[keyof GathererResults, GathererResults[keyof GathererResults]]>} GathererResultsEntries */
/**
* Class that drives browser to load the page and runs gatherer lifecycle hooks.
*/
class GatherRunner {
/**
* Loads about:blank and waits there briefly. Since a Page.reload command does
* not let a service worker take over, we navigate away and then come back to
* reload. We do not `waitForLoad` on about:blank since a page load event is
* never fired on it.
* @param {Driver} driver
* @param {string=} url
* @return {Promise<void>}
*/
static async loadBlank(driver, url = constants.defaultPassConfig.blankPage) {
const status = {msg: 'Resetting state with about:blank', id: 'lh:gather:loadBlank'};
log.time(status);
await navigation.gotoURL(driver, url, {waitUntil: ['navigated']});
log.timeEnd(status);
}
/**
* Loads options.url with specified options. If the main document URL
* redirects, options.url will be updated accordingly. As such, options.url
* will always represent the post-redirected URL. options.requestedUrl is the
* pre-redirect starting URL. If the navigation errors with "expected" errors such as
* NO_FCP, a `navigationError` is returned.
* @param {Driver} driver
* @param {LH.Gatherer.PassContext} passContext
* @return {Promise<{navigationError?: LH.LighthouseError}>}
*/
static async loadPage(driver, passContext) {
const status = {
msg: 'Loading page & waiting for onload',
id: `lh:gather:loadPage-${passContext.passConfig.passName}`,
};
log.time(status);
try {
const requestedUrl = passContext.url;
const {mainDocumentUrl, warnings} = await navigation.gotoURL(driver, requestedUrl, {
waitUntil: passContext.passConfig.recordTrace ?
['load', 'fcp'] : ['load'],
debugNavigation: passContext.settings.debugNavigation,
maxWaitForFcp: passContext.settings.maxWaitForFcp,
maxWaitForLoad: passContext.settings.maxWaitForLoad,
...passContext.passConfig,
});
passContext.url = mainDocumentUrl;
const {URL} = passContext.baseArtifacts;
if (!URL.finalDisplayedUrl || !URL.mainDocumentUrl) {
URL.mainDocumentUrl = mainDocumentUrl;
URL.finalDisplayedUrl = await passContext.driver.url();
}
if (passContext.passConfig.loadFailureMode === 'fatal') {
passContext.LighthouseRunWarnings.push(...warnings);
}
} catch (err) {
// If it's one of our loading-based LighthouseErrors, we'll treat it as a page load error.
if (err.code === 'NO_FCP' || err.code === 'PAGE_HUNG') {
return {navigationError: err};
}
throw err;
} finally {
log.timeEnd(status);
}
return {};
}
/**
* Rejects if any open tabs would share a service worker with the target URL.
* This includes the target tab, so navigation to something like about:blank
* should be done before calling.
* @param {LH.Gatherer.FRProtocolSession} session
* @param {string} pageUrl
* @return {Promise<void>}
*/
static assertNoSameOriginServiceWorkerClients(session, pageUrl) {
/** @type {Array<LH.Crdp.ServiceWorker.ServiceWorkerRegistration>} */
let registrations;
/** @type {Array<LH.Crdp.ServiceWorker.ServiceWorkerVersion>} */
let versions;
return serviceWorkers.getServiceWorkerRegistrations(session)
.then(data => {
registrations = data.registrations;
})
.then(_ => serviceWorkers.getServiceWorkerVersions(session))
.then(data => {
versions = data.versions;
})
.then(_ => {
const origin = new URL(pageUrl).origin;
registrations
.filter(reg => {
const swOrigin = new URL(reg.scopeURL).origin;
return origin === swOrigin;
})
.forEach(reg => {
versions.forEach(ver => {
// Ignore workers unaffiliated with this registration
if (ver.registrationId !== reg.registrationId) {
return;
}
// Throw if service worker for this origin has active controlledClients.
if (ver.controlledClients && ver.controlledClients.length > 0) {
throw new Error('You probably have multiple tabs open to the same origin.');
}
});
});
});
}
/**
* @param {Driver} driver
* @param {{requestedUrl: string, settings: LH.Config.Settings}} options
* @return {Promise<void>}
*/
static async setupDriver(driver, options) {
const status = {msg: 'Initializing…', id: 'lh:gather:setupDriver'};
log.time(status);
const session = driver.defaultSession;
// Assert no service workers are still installed, so we test that they would actually be installed for a new user.
await GatherRunner.assertNoSameOriginServiceWorkerClients(session, options.requestedUrl);
await prepare.prepareTargetForNavigationMode(driver, options.settings);
log.timeEnd(status);
}
/**
* Reset browser state where needed and release the connection.
* @param {Driver} driver
* @param {{requestedUrl: string, settings: LH.Config.Settings}} options
* @return {Promise<void>}
*/
static async disposeDriver(driver, options) {
const status = {msg: 'Disconnecting from browser...', id: 'lh:gather:disconnect'};
log.time(status);
try {
// If storage was cleared for the run, clear at the end so Lighthouse specifics aren't cached.
const session = driver.defaultSession;
const resetStorage = !options.settings.disableStorageReset;
if (resetStorage) await storage.clearDataForOrigin(session, options.requestedUrl);
await driver.disconnect();
} catch (err) {
// Ignore disconnecting error if browser was already closed.
// See https://github.com/GoogleChrome/lighthouse/issues/1583
if (!(/close\/.*status: (500|404)$/.test(err.message))) {
log.error('GatherRunner disconnect', err.message);
}
}
log.timeEnd(status);
}
/**
* Beging recording devtoolsLog and trace (if requested).
* @param {LH.Gatherer.PassContext} passContext
* @return {Promise<void>}
*/
static async beginRecording(passContext) {
const status = {msg: 'Beginning devtoolsLog and trace', id: 'lh:gather:beginRecording'};
log.time(status);
const {driver, passConfig, settings} = passContext;
// Always record devtoolsLog
await driver.beginDevtoolsLog();
if (passConfig.recordTrace) {
await driver.beginTrace(settings);
}
log.timeEnd(status);
}
/**
* End recording devtoolsLog and trace (if requested), returning an
* `LH.Gatherer.LoadData` with the recorded data.
* @param {LH.Gatherer.PassContext} passContext
* @return {Promise<LH.Gatherer.LoadData>}
*/
static async endRecording(passContext) {
const {driver, passConfig} = passContext;
let trace;
if (passConfig.recordTrace) {
const status = {msg: 'Gathering trace', id: `lh:gather:getTrace`};
log.time(status);
trace = await driver.endTrace();
log.timeEnd(status);
}
const status = {
msg: 'Gathering devtoolsLog & network records',
id: `lh:gather:getDevtoolsLog`,
};
log.time(status);
const devtoolsLog = await driver.endDevtoolsLog();
const networkRecords = await NetworkRecords.request(devtoolsLog, passContext);
log.timeEnd(status);
return {
networkRecords,
devtoolsLog,
trace,
};
}
/**
* Run beforePass() on gatherers.
* @param {LH.Gatherer.PassContext} passContext
* @param {Partial<GathererResults>} gathererResults
* @return {Promise<void>}
*/
static async beforePass(passContext, gathererResults) {
const bpStatus = {msg: `Running beforePass methods`, id: `lh:gather:beforePass`};
log.time(bpStatus, 'verbose');
for (const gathererDefn of passContext.passConfig.gatherers) {
const gatherer = gathererDefn.instance;
const status = {
msg: `Gathering setup: ${gatherer.name}`,
id: `lh:gather:beforePass:${gatherer.name}`,
};
log.time(status, 'verbose');
const artifactPromise = Promise.resolve().then(_ => gatherer.beforePass(passContext));
gathererResults[gatherer.name] = [artifactPromise];
await artifactPromise.catch(() => {});
log.timeEnd(status);
}
log.timeEnd(bpStatus);
}
/**
* Run pass() on gatherers.
* @param {LH.Gatherer.PassContext} passContext
* @param {Partial<GathererResults>} gathererResults
* @return {Promise<void>}
*/
static async pass(passContext, gathererResults) {
const config = passContext.passConfig;
const gatherers = config.gatherers;
const pStatus = {msg: `Running pass methods`, id: `lh:gather:pass`};
log.time(pStatus, 'verbose');
for (const gathererDefn of gatherers) {
const gatherer = gathererDefn.instance;
const status = {
msg: `Gathering in-page: ${gatherer.name}`,
id: `lh:gather:pass:${gatherer.name}`,
};
log.time(status);
const artifactPromise = Promise.resolve().then(_ => gatherer.pass(passContext));
const gathererResult = gathererResults[gatherer.name] || [];
gathererResult.push(artifactPromise);
gathererResults[gatherer.name] = gathererResult;
await artifactPromise.catch(() => {});
}
log.timeEnd(pStatus);
}
/**
* Run afterPass() on gatherers.
* @param {LH.Gatherer.PassContext} passContext
* @param {LH.Gatherer.LoadData} loadData
* @param {Partial<GathererResults>} gathererResults
* @return {Promise<void>}
*/
static async afterPass(passContext, loadData, gathererResults) {
const config = passContext.passConfig;
const gatherers = config.gatherers;
const apStatus = {msg: `Running afterPass methods`, id: `lh:gather:afterPass`};
log.time(apStatus, 'verbose');
for (const gathererDefn of gatherers) {
const gatherer = gathererDefn.instance;
const status = {
msg: `Gathering: ${gatherer.name}`,
id: `lh:gather:afterPass:${gatherer.name}`,
};
log.time(status);
const artifactPromise = Promise.resolve()
.then(_ => gatherer.afterPass(passContext, loadData));
const gathererResult = gathererResults[gatherer.name] || [];
gathererResult.push(artifactPromise);
gathererResults[gatherer.name] = gathererResult;
await artifactPromise.catch(() => {});
log.timeEnd(status);
}
log.timeEnd(apStatus);
}
/**
* Takes the results of each gatherer phase for each gatherer and uses the
* last produced value (that's not undefined) as the artifact for that
* gatherer. If an error was rejected from a gatherer phase,
* uses that error object as the artifact instead.
* @param {Partial<GathererResults>} gathererResults
* @return {Promise<{artifacts: Partial<LH.GathererArtifacts>}>}
*/
static async collectArtifacts(gathererResults) {
/** @type {Partial<LH.GathererArtifacts>} */
const gathererArtifacts = {};
const resultsEntries = /** @type {GathererResultsEntries} */ (Object.entries(gathererResults));
for (const [gathererName, phaseResultsPromises] of resultsEntries) {
try {
const phaseResults = await Promise.all(phaseResultsPromises);
// Take the last defined pass result as artifact. If none are defined, the undefined check below handles it.
const definedResults = phaseResults.filter(element => element !== undefined);
const artifact = definedResults[definedResults.length - 1];
// @ts-expect-error tsc can't yet express that gathererName is only a single type in each iteration, not a union of types.
gathererArtifacts[gathererName] = artifact;
} catch (err) {
// Return error to runner to handle turning it into an error audit.
gathererArtifacts[gathererName] = err;
}
if (gathererArtifacts[gathererName] === undefined) {
throw new Error(`${gathererName} failed to provide an artifact.`);
}
}
return {
artifacts: gathererArtifacts,
};
}
/**
* Return an initialized but mostly empty set of base artifacts, to be
* populated as the run continues.
* @param {{driver: Driver, requestedUrl: string, settings: LH.Config.Settings}} options
* @return {Promise<LH.BaseArtifacts>}
*/
static async initializeBaseArtifacts(options) {
const hostUserAgent = (await options.driver.getBrowserVersion()).userAgent;
// Whether Lighthouse was run on a mobile device (i.e. not on a desktop machine).
const HostFormFactor = hostUserAgent.includes('Android') || hostUserAgent.includes('Mobile') ?
'mobile' : 'desktop';
return {
fetchTime: (new Date()).toJSON(),
LighthouseRunWarnings: [],
HostFormFactor,
HostUserAgent: hostUserAgent,
NetworkUserAgent: '', // updated later
BenchmarkIndex: 0, // updated later
traces: {},
devtoolsLogs: {},
settings: options.settings,
GatherContext: {gatherMode: 'navigation'},
URL: {
requestedUrl: options.requestedUrl,
mainDocumentUrl: '',
finalDisplayedUrl: '',
},
Timing: [],
PageLoadError: null,
};
}
/**
* Populates the important base artifacts from a fully loaded test page.
* Currently must be run before `start-url` gatherer so that `WebAppManifest`
* will be available to it.
* @param {LH.Gatherer.PassContext} passContext
*/
static async populateBaseArtifacts(passContext) {
const status = {msg: 'Populate base artifacts', id: 'lh:gather:populateBaseArtifacts'};
log.time(status);
const baseArtifacts = passContext.baseArtifacts;
// Find the NetworkUserAgent actually used in the devtoolsLogs.
const devtoolsLog = baseArtifacts.devtoolsLogs[passContext.passConfig.passName];
baseArtifacts.NetworkUserAgent = NetworkUserAgent.getNetworkUserAgent(devtoolsLog);
const environmentWarnings = getEnvironmentWarnings(passContext);
baseArtifacts.LighthouseRunWarnings.push(...environmentWarnings);
log.timeEnd(status);
}
/**
* @param {Array<LH.Config.Pass>} passConfigs
* @param {{driver: Driver, requestedUrl: string, settings: LH.Config.Settings, computedCache: Map<string, ArbitraryEqualityMap>}} options
* @return {Promise<LH.Artifacts>}
*/
static async run(passConfigs, options) {
const driver = options.driver;
/** @type {Partial<LH.GathererArtifacts>} */
const artifacts = {};
try {
await driver.connect();
// In the devtools/extension case, we can't still be on the site while trying to clear state
// So we first navigate to about:blank, then apply our emulation & setup
await GatherRunner.loadBlank(driver);
const baseArtifacts = await GatherRunner.initializeBaseArtifacts(options);
baseArtifacts.BenchmarkIndex = await getBenchmarkIndex(driver.executionContext);
// Hack for running benchmarkIndex extra times.
// Add a `bidx=20` query param, eg: https://www.example.com/?bidx=50
const parsedUrl = UrlUtils.isValid(options.requestedUrl) && new URL(options.requestedUrl);
if (options.settings.channel === 'lr' && parsedUrl && parsedUrl.searchParams.has('bidx')) {
const bidxRunCount = Number(parsedUrl.searchParams.get('bidx')) || 0;
// Add the first bidx into the new set
const indexes = [baseArtifacts.BenchmarkIndex];
for (let i = 0; i < bidxRunCount; i++) {
const bidx = await getBenchmarkIndex(driver.executionContext);
indexes.push(bidx);
}
baseArtifacts.BenchmarkIndexes = indexes;
}
await GatherRunner.setupDriver(driver, options);
let isFirstPass = true;
for (const passConfig of passConfigs) {
/** @type {LH.Gatherer.PassContext} */
const passContext = {
gatherMode: 'navigation',
driver,
url: options.requestedUrl,
settings: options.settings,
passConfig,
baseArtifacts,
computedCache: options.computedCache,
LighthouseRunWarnings: baseArtifacts.LighthouseRunWarnings,
};
const passResults = await GatherRunner.runPass(passContext);
Object.assign(artifacts, passResults.artifacts);
// If we encountered a pageLoadError, don't try to keep loading the page in future passes.
if (passResults.pageLoadError && passConfig.loadFailureMode === 'fatal') {
baseArtifacts.PageLoadError = passResults.pageLoadError;
break;
}
if (isFirstPass) {
await GatherRunner.populateBaseArtifacts(passContext);
isFirstPass = false;
}
}
await GatherRunner.disposeDriver(driver, options);
return finalizeArtifacts(baseArtifacts, artifacts);
} catch (err) {
// Clean up on error. Don't await so that the root error, not a disposal error, is shown.
GatherRunner.disposeDriver(driver, options);
throw err;
}
}
/**
* Save the devtoolsLog and trace (if applicable) to baseArtifacts.
* @param {LH.Gatherer.PassContext} passContext
* @param {LH.Gatherer.LoadData} loadData
* @param {string} passName
*/
static _addLoadDataToBaseArtifacts(passContext, loadData, passName) {
const baseArtifacts = passContext.baseArtifacts;
baseArtifacts.devtoolsLogs[passName] = loadData.devtoolsLog;
if (loadData.trace) baseArtifacts.traces[passName] = loadData.trace;
}
/**
* Starting from about:blank, load the page and run gatherers for this pass.
* @param {LH.Gatherer.PassContext} passContext
* @return {Promise<{artifacts: Partial<LH.GathererArtifacts>, pageLoadError?: LH.LighthouseError}>}
*/
static async runPass(passContext) {
const status = {
msg: `Running ${passContext.passConfig.passName} pass`,
id: `lh:gather:runPass-${passContext.passConfig.passName}`,
args: [passContext.passConfig.gatherers.map(g => g.instance.name).join(', ')],
};
log.time(status);
/** @type {Partial<GathererResults>} */
const gathererResults = {};
const {driver, passConfig} = passContext;
// Go to about:blank, set up, and run `beforePass()` on gatherers.
await GatherRunner.loadBlank(driver, passConfig.blankPage);
const {warnings} = await prepare.prepareTargetForIndividualNavigation(
driver.defaultSession,
passContext.settings,
{
requestor: passContext.url,
disableStorageReset: !passConfig.useThrottling,
disableThrottling: !passConfig.useThrottling,
blockedUrlPatterns: passConfig.blockedUrlPatterns,
}
);
passContext.LighthouseRunWarnings.push(...warnings);
await GatherRunner.beforePass(passContext, gathererResults);
// Navigate, start recording, and run `pass()` on gatherers.
await GatherRunner.beginRecording(passContext);
const {navigationError: possibleNavError} = await GatherRunner.loadPage(driver, passContext);
await GatherRunner.pass(passContext, gathererResults);
const loadData = await GatherRunner.endRecording(passContext);
// Disable throttling so the afterPass analysis isn't throttled.
await emulation.clearThrottling(driver.defaultSession);
// In case of load error, save log and trace with an error prefix, return no artifacts for this pass.
const pageLoadError = getPageLoadError(possibleNavError, {
url: passContext.url,
loadFailureMode: passConfig.loadFailureMode,
networkRecords: loadData.networkRecords,
warnings: passContext.LighthouseRunWarnings,
});
if (pageLoadError) {
const localizedMessage = format.getFormatted(pageLoadError.friendlyMessage,
passContext.settings.locale);
log.error('GatherRunner', localizedMessage, passContext.url);
passContext.LighthouseRunWarnings.push(pageLoadError.friendlyMessage);
GatherRunner._addLoadDataToBaseArtifacts(passContext, loadData,
`pageLoadError-${passConfig.passName}`);
log.timeEnd(status);
return {artifacts: {}, pageLoadError};
}
// If no error, save devtoolsLog and trace.
GatherRunner._addLoadDataToBaseArtifacts(passContext, loadData, passConfig.passName);
// Run `afterPass()` on gatherers and return collected artifacts.
await GatherRunner.afterPass(passContext, loadData, gathererResults);
const artifacts = GatherRunner.collectArtifacts(gathererResults);
log.timeEnd(status);
return artifacts;
}
}
export {GatherRunner};