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

5
node_modules/lighthouse/cli/bin.d.ts generated vendored Normal file
View File

@@ -0,0 +1,5 @@
/**
* @return {Promise<LH.RunnerResult|void>}
*/
export function begin(): Promise<LH.RunnerResult | void>;
//# sourceMappingURL=bin.d.ts.map

140
node_modules/lighthouse/cli/bin.js generated vendored Normal file
View File

@@ -0,0 +1,140 @@
/**
* @license Copyright 2016 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/**
* @fileoverview The relationship between these CLI modules:
*
* index.js : only calls bin.js's begin()
* cli-flags.js : leverages yargs to read argv, outputs LH.CliFlags
* bin.js : CLI args processing. cwd, list/print commands
* run.js : chrome-launcher bits, calling core, output to Printer
*
* index ----> bin ----> run ----> printer
* ⭏ ⭎ ⭏ ⭎
* cli-flags lh-core/index
*/
import fs from 'fs';
import path from 'path';
import url from 'url';
import log from 'lighthouse-logger';
import * as commands from './commands/commands.js';
import * as Printer from './printer.js';
import {getFlags} from './cli-flags.js';
import {runLighthouse} from './run.js';
import {askPermission} from './sentry-prompt.js';
import {LH_ROOT} from '../root.js';
import {Sentry} from '../core/lib/sentry.js';
const pkg = JSON.parse(fs.readFileSync(LH_ROOT + '/package.json', 'utf-8'));
/**
* @return {boolean}
*/
function isDev() {
return fs.existsSync(path.join(LH_ROOT, '/.git'));
}
/**
* @return {Promise<LH.RunnerResult|void>}
*/
async function begin() {
const cliFlags = getFlags();
// Process terminating command
if (cliFlags.listAllAudits) {
commands.listAudits();
}
// Process terminating command
if (cliFlags.listLocales) {
commands.listLocales();
}
// Process terminating command
if (cliFlags.listTraceCategories) {
commands.listTraceCategories();
}
const urlUnderTest = cliFlags._[0];
/** @type {LH.Config|undefined} */
let config;
if (cliFlags.configPath) {
// Resolve the config file path relative to where cli was called.
cliFlags.configPath = path.resolve(process.cwd(), cliFlags.configPath);
if (cliFlags.configPath.endsWith('.json')) {
config = JSON.parse(fs.readFileSync(cliFlags.configPath, 'utf-8'));
} else {
const configModuleUrl = url.pathToFileURL(cliFlags.configPath).href;
config = (await import(configModuleUrl)).default;
}
} else if (cliFlags.preset) {
config = (await import(`../core/config/${cliFlags.preset}-config.js`)).default;
}
if (cliFlags.budgetPath) {
cliFlags.budgetPath = path.resolve(process.cwd(), cliFlags.budgetPath);
/** @type {Array<LH.Budget>} */
const parsedBudget = JSON.parse(fs.readFileSync(cliFlags.budgetPath, 'utf8'));
cliFlags.budgets = parsedBudget;
}
// set logging preferences
cliFlags.logLevel = 'info';
if (cliFlags.verbose) {
cliFlags.logLevel = 'verbose';
} else if (cliFlags.quiet) {
cliFlags.logLevel = 'silent';
}
log.setLevel(cliFlags.logLevel);
if (
cliFlags.output.length === 1 &&
cliFlags.output[0] === Printer.OutputMode.json &&
!cliFlags.outputPath
) {
cliFlags.outputPath = 'stdout';
}
if (cliFlags.precomputedLanternDataPath) {
const lanternDataStr = fs.readFileSync(cliFlags.precomputedLanternDataPath, 'utf8');
/** @type {LH.PrecomputedLanternData} */
const data = JSON.parse(lanternDataStr);
if (!data.additionalRttByOrigin || !data.serverResponseTimeByOrigin) {
throw new Error('Invalid precomputed lantern data file');
}
cliFlags.precomputedLanternData = data;
}
// By default, cliFlags.enableErrorReporting is undefined so the user is
// prompted. This can be overridden with an explicit flag or by the cached
// answer returned by askPermission().
if (typeof cliFlags.enableErrorReporting === 'undefined') {
cliFlags.enableErrorReporting = await askPermission();
}
if (cliFlags.enableErrorReporting) {
await Sentry.init({
url: urlUnderTest,
flags: cliFlags,
environmentData: {
serverName: 'redacted', // prevent sentry from using hostname
environment: isDev() ? 'development' : 'production',
release: pkg.version,
},
});
}
return runLighthouse(urlUnderTest, cliFlags, config);
}
export {
begin,
};

258
node_modules/lighthouse/cli/cli-flags.d.ts generated vendored Normal file
View File

@@ -0,0 +1,258 @@
/**
* @param {string=} manualArgv
* @param {{noExitOnFailure?: boolean}=} options
* @return {LH.CliFlags}
*/
export function getFlags(manualArgv?: string | undefined, options?: {
noExitOnFailure?: boolean;
} | undefined): LH.CliFlags;
/**
* @param {string=} manualArgv
*/
export function getYargsParser(manualArgv?: string | undefined): yargs.Argv<yargs.Omit<yargs.Omit<yargs.Omit<yargs.Omit<yargs.Omit<yargs.Omit<yargs.Omit<yargs.Omit<{
_: string[] | undefined;
} & {
"cli-flags-path": unknown;
}, "verbose" | "quiet"> & yargs.InferredOptionTypes<{
verbose: {
type: "boolean";
default: boolean;
describe: string;
};
quiet: {
type: "boolean";
default: boolean;
describe: string;
};
}>, "port" | "screenEmulation" | "emulatedUserAgent" | "hostname" | "preset" | "save-assets" | "list-all-audits" | "list-locales" | "list-trace-categories" | "debug-navigation" | "legacy-navigation" | "additional-trace-categories" | "config-path" | "chrome-flags" | "form-factor" | "max-wait-for-load" | "enable-error-reporting" | "gather-mode" | "audit-mode" | "only-audits" | "only-categories" | "skip-audits" | "budget-path" | "disable-full-page-screenshot"> & yargs.InferredOptionTypes<{
'save-assets': {
type: "boolean";
default: boolean;
describe: string;
};
'list-all-audits': {
type: "boolean";
default: boolean;
describe: string;
};
'list-locales': {
type: "boolean";
default: boolean;
describe: string;
};
'list-trace-categories': {
type: "boolean";
default: boolean;
describe: string;
};
'debug-navigation': {
type: "boolean";
describe: string;
};
'legacy-navigation': {
type: "boolean";
default: boolean;
describe: string;
};
'additional-trace-categories': {
type: "string";
describe: string;
};
'config-path': {
type: "string";
describe: string;
};
preset: {
type: "string";
describe: string;
};
'chrome-flags': {
type: "string";
default: string;
describe: string;
};
port: {
type: "number";
default: number;
describe: string;
};
hostname: {
type: "string";
default: string;
describe: string;
};
'form-factor': {
type: "string";
describe: string;
};
screenEmulation: {
describe: string;
coerce: typeof coerceScreenEmulation;
};
emulatedUserAgent: {
type: "string";
coerce: typeof coerceOptionalStringBoolean;
describe: string;
};
'max-wait-for-load': {
type: "number";
describe: string;
};
'enable-error-reporting': {
type: "boolean";
describe: string;
};
'gather-mode': {
alias: string;
coerce: typeof coerceOptionalStringBoolean;
describe: string;
};
'audit-mode': {
alias: string;
coerce: typeof coerceOptionalStringBoolean;
describe: string;
};
'only-audits': {
array: true;
type: "string";
coerce: typeof splitCommaSeparatedValues;
describe: string;
};
'only-categories': {
array: true;
type: "string";
coerce: typeof splitCommaSeparatedValues;
describe: string;
};
'skip-audits': {
array: true;
type: "string";
coerce: typeof splitCommaSeparatedValues;
describe: string;
};
'budget-path': {
type: "string";
describe: string;
};
'disable-full-page-screenshot': {
type: "boolean";
describe: string;
};
}>, "output" | "view" | "output-path"> & yargs.InferredOptionTypes<{
output: {
type: "array";
default: readonly ["html"];
coerce: typeof coerceOutput;
describe: string;
};
'output-path': {
type: "string";
coerce: typeof coerceOutputPath;
describe: string;
};
view: {
type: "boolean";
default: boolean;
describe: string;
};
}>, "locale" | "blocked-url-patterns" | "disable-storage-reset" | "throttling-method"> & yargs.InferredOptionTypes<{
locale: {
coerce: typeof coerceLocale;
describe: string;
};
'blocked-url-patterns': {
array: true;
type: "string";
describe: string;
};
'disable-storage-reset': {
type: "boolean";
describe: string;
};
'throttling-method': {
type: "string";
describe: string;
};
}> & {
throttling: import("../types/lh.js").ThrottlingSettings | undefined;
}, "channel" | "plugins" | "extra-headers" | "precomputed-lantern-data-path" | "lantern-data-output-path" | "chrome-ignore-default-flags"> & yargs.InferredOptionTypes<{
'extra-headers': {
coerce: typeof coerceExtraHeaders;
describe: string;
};
'precomputed-lantern-data-path': {
type: "string";
describe: string;
};
'lantern-data-output-path': {
type: "string";
describe: string;
};
plugins: {
array: true;
type: "string";
coerce: typeof splitCommaSeparatedValues;
describe: string;
};
channel: {
type: "string";
default: string;
};
'chrome-ignore-default-flags': {
type: "boolean";
default: boolean;
};
}>, "form-factor"> & {
"form-factor": "mobile" | "desktop" | undefined;
}, "throttling-method"> & {
"throttling-method": "devtools" | "simulate" | "provided" | undefined;
}, "preset"> & {
preset: "desktop" | "experimental" | "perf" | undefined;
}>;
import yargs from 'yargs';
/**
* Take yarg's unchecked object value and ensure it is a proper LH.screenEmulationSettings.
* @param {unknown} value
* @return {Partial<LH.ScreenEmulationSettings>|undefined}
*/
declare function coerceScreenEmulation(value: unknown): Partial<LH.ScreenEmulationSettings> | undefined;
/**
* @param {unknown} value
* @return {boolean|string|undefined}
*/
declare function coerceOptionalStringBoolean(value: unknown): boolean | string | undefined;
/**
* Support comma-separated values for some array flags by splitting on any ',' found.
* @param {Array<string>=} strings
* @return {Array<string>=}
*/
declare function splitCommaSeparatedValues(strings?: Array<string> | undefined): Array<string> | undefined;
/**
* Coerce output CLI input to `LH.SharedFlagsSettings['output']` or throw if not possible.
* @param {Array<unknown>} values
* @return {Array<LH.OutputMode>}
*/
declare function coerceOutput(values: Array<unknown>): Array<LH.OutputMode>;
/**
* Verifies outputPath is something we can actually write to.
* @param {unknown=} value
* @return {string=}
*/
declare function coerceOutputPath(value?: unknown | undefined): string | undefined;
/**
* Verifies value is a string, then coerces type to LH.Locale for convenience. However, don't
* allowlist specific locales. Why? So we can support the user who requests 'es-MX' (unsupported)
* and we'll fall back to 'es' (supported).
* @param {unknown} value
* @return {LH.Locale|undefined}
*/
declare function coerceLocale(value: unknown): LH.Locale | undefined;
/**
* `--extra-headers` comes in as a JSON string or a path to a JSON string, but the flag value
* needs to be the parsed object. Load file (if necessary) and returns the parsed object.
* @param {unknown} value
* @return {LH.SharedFlagsSettings['extraHeaders']}
*/
declare function coerceExtraHeaders(value: unknown): LH.SharedFlagsSettings['extraHeaders'];
export {};
//# sourceMappingURL=cli-flags.d.ts.map

553
node_modules/lighthouse/cli/cli-flags.js generated vendored Normal file
View File

@@ -0,0 +1,553 @@
/**
* @license Copyright 2017 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/* eslint-disable max-len */
import fs from 'fs';
import path from 'path';
import yargs from 'yargs';
import * as yargsHelpers from 'yargs/helpers';
import {LH_ROOT} from '../root.js';
import {isObjectOfUnknownValues} from '../shared/type-verifiers.js';
/**
* @param {string=} manualArgv
*/
function getYargsParser(manualArgv) {
const y = manualArgv ?
// @ts-expect-error - undocumented, but yargs() supports parsing a single `string`.
yargs(manualArgv) :
yargs(yargsHelpers.hideBin(process.argv));
return y.help('help')
.version(JSON.parse(fs.readFileSync(`${LH_ROOT}/package.json`, 'utf-8')).version)
.showHelpOnFail(false, 'Specify --help for available options')
.usage('lighthouse <url> <options>')
.example(
'lighthouse <url> --view', 'Opens the HTML report in a browser after the run completes')
.example(
'lighthouse <url> --config-path=./myconfig.js',
'Runs Lighthouse with your own configuration: custom audits, report generation, etc.')
.example(
'lighthouse <url> --output=json --output-path=./report.json --save-assets',
'Save trace, screenshots, and named JSON report.')
.example(
'lighthouse <url> --screenEmulation.disabled --throttling-method=provided --no-emulated-user-agent',
'Disable emulation and all throttling')
.example(
'lighthouse <url> --chrome-flags="--window-size=412,660"',
'Launch Chrome with a specific window size')
.example(
'lighthouse <url> --quiet --chrome-flags="--headless"',
'Launch Headless Chrome, turn off logging')
.example(
'lighthouse <url> --extra-headers "{\\"Cookie\\":\\"monster=blue\\", \\"x-men\\":\\"wolverine\\"}"',
'Stringify\'d JSON HTTP Header key/value pairs to send in requests')
.example(
'lighthouse <url> --extra-headers=./path/to/file.json',
'Path to JSON file of HTTP Header key/value pairs to send in requests')
.example(
'lighthouse <url> --only-categories=performance,pwa',
'Only run the specified categories. Available categories: accessibility, best-practices, performance, pwa, seo')
// We only have the single string positional argument, the url.
.option('_', {
array: true, // Always an array, but this lets the type system know.
type: 'string',
})
/*
* Also accept a file for all of these flags. Yargs will merge in and override the file-based
* flags with the command-line flags.
*
* i.e. when command-line `--throttling-method=provided` and file `throttlingMethod: "devtools"`,
* throttlingMethod will be `provided`.
*
* @see https://github.com/yargs/yargs/blob/a6e67f15a61558d0ba28bfe53385332f0ce5d431/docs/api.md#config
*/
.option('cli-flags-path', {
config: true,
describe: 'The path to a JSON file that contains the desired CLI flags to apply. Flags specified at the command line will still override the file-based ones.',
})
// Logging
.options({
'verbose': {
type: 'boolean',
default: false,
describe: 'Displays verbose logging',
},
'quiet': {
type: 'boolean',
default: false,
describe: 'Displays no progress, debug logs, or errors',
},
})
.group(['verbose', 'quiet'], 'Logging:')
// Configuration
.options({
'save-assets': {
type: 'boolean',
default: false,
describe: 'Save the trace contents & devtools logs to disk',
},
'list-all-audits': {
type: 'boolean',
default: false,
describe: 'Prints a list of all available audits and exits',
},
'list-locales': {
type: 'boolean',
default: false,
describe: 'Prints a list of all supported locales and exits',
},
'list-trace-categories': {
type: 'boolean',
default: false,
describe: 'Prints a list of all required trace categories and exits',
},
'debug-navigation': {
type: 'boolean',
describe: 'Pause after page load to wait for permission to continue the run, evaluate `continueLighthouseRun` in the console to continue.',
},
'legacy-navigation': {
type: 'boolean',
default: false,
describe: '[DEPRECATED] Use the legacy navigation runner to gather results. Only use this if you are using a pre-10.0 custom Lighthouse config, or if Lighthouse unexpectedly fails after updating to 10.0. Please file a bug if you need this flag for Lighthouse to work.',
},
'additional-trace-categories': {
type: 'string',
describe: 'Additional categories to capture with the trace (comma-delimited).',
},
'config-path': {
type: 'string',
describe: `The path to the config JSON.
An example config file: core/config/lr-desktop-config.js`,
},
'preset': {
type: 'string',
describe: `Use a built-in configuration.
WARNING: If the --config-path flag is provided, this preset will be ignored.`,
},
'chrome-flags': {
type: 'string',
default: '',
describe: `Custom flags to pass to Chrome (space-delimited). For a full list of flags, see https://bit.ly/chrome-flags
Additionally, use the CHROME_PATH environment variable to use a specific Chrome binary. Requires Chromium version 66.0 or later. If omitted, any detected Chrome Canary or Chrome stable will be used.`,
},
'port': {
type: 'number',
default: 0,
describe: 'The port to use for the debugging protocol. Use 0 for a random port',
},
'hostname': {
type: 'string',
default: '127.0.0.1',
describe: 'The hostname to use for the debugging protocol.',
},
'form-factor': {
type: 'string',
describe: 'Determines how performance metrics are scored and if mobile-only audits are skipped. For desktop, --preset=desktop instead.',
},
'screenEmulation': {
describe: 'Sets screen emulation parameters. See also --preset. Use --screenEmulation.disabled to disable. Otherwise set these 4 parameters individually: --screenEmulation.mobile --screenEmulation.width=360 --screenEmulation.height=640 --screenEmulation.deviceScaleFactor=2',
coerce: coerceScreenEmulation,
},
'emulatedUserAgent': {
type: 'string',
coerce: coerceOptionalStringBoolean,
describe: 'Sets useragent emulation',
},
'max-wait-for-load': {
type: 'number',
describe: 'The timeout (in milliseconds) to wait before the page is considered done loading and the run should continue. WARNING: Very high values can lead to large traces and instability',
},
'enable-error-reporting': {
type: 'boolean',
describe: 'Enables error reporting, overriding any saved preference. --no-enable-error-reporting will do the opposite. More: https://github.com/GoogleChrome/lighthouse/blob/main/docs/error-reporting.md',
},
'gather-mode': {
alias: 'G',
coerce: coerceOptionalStringBoolean,
describe: 'Collect artifacts from a connected browser and save to disk. (Artifacts folder path may optionally be provided). If audit-mode is not also enabled, the run will quit early.',
},
'audit-mode': {
alias: 'A',
coerce: coerceOptionalStringBoolean,
describe: 'Process saved artifacts from disk. (Artifacts folder path may be provided, otherwise defaults to ./latest-run/)',
},
'only-audits': {
array: true,
type: 'string',
coerce: splitCommaSeparatedValues,
describe: 'Only run the specified audits',
},
'only-categories': {
array: true,
type: 'string',
coerce: splitCommaSeparatedValues,
describe: 'Only run the specified categories. Available categories: accessibility, best-practices, performance, pwa, seo',
},
'skip-audits': {
array: true,
type: 'string',
coerce: splitCommaSeparatedValues,
describe: 'Run everything except these audits',
},
'budget-path': {
type: 'string',
describe: 'The path to the budget.json file for LightWallet.',
},
'disable-full-page-screenshot': {
type: 'boolean',
describe: 'Disables collection of the full page screenshot, which can be quite large',
},
})
.group([
'save-assets', 'list-all-audits', 'list-locales', 'list-trace-categories', 'additional-trace-categories',
'config-path', 'preset', 'chrome-flags', 'port', 'hostname', 'form-factor', 'screenEmulation', 'emulatedUserAgent',
'max-wait-for-load', 'enable-error-reporting', 'gather-mode', 'audit-mode',
'only-audits', 'only-categories', 'skip-audits', 'budget-path', 'disable-full-page-screenshot',
], 'Configuration:')
// Output
.options({
'output': {
type: 'array',
default: /** @type {const} */ (['html']),
coerce: coerceOutput,
describe: 'Reporter for the results, supports multiple values. choices: "json", "html", "csv"',
},
'output-path': {
type: 'string',
coerce: coerceOutputPath,
describe: `The file path to output the results. Use 'stdout' to write to stdout.
If using JSON output, default is stdout.
If using HTML or CSV output, default is a file in the working directory with a name based on the test URL and date.
If using multiple outputs, --output-path is appended with the standard extension for each output type. "reports/my-run" -> "reports/my-run.report.html", "reports/my-run.report.json", etc.
Example: --output-path=./lighthouse-results.html`,
},
'view': {
type: 'boolean',
default: false,
describe: 'Open HTML report in your browser',
},
})
.group(['output', 'output-path', 'view'], 'Output:')
// Other options.
.options({
'locale': {
coerce: coerceLocale,
describe: 'The locale/language the report should be formatted in',
},
'blocked-url-patterns': {
array: true,
type: 'string',
describe: 'Block any network requests to the specified URL patterns',
},
'disable-storage-reset': {
type: 'boolean',
describe: 'Disable clearing the browser cache and other storage APIs before a run',
},
'throttling-method': {
type: 'string',
describe: 'Controls throttling method',
},
})
// Throttling settings, parsed as an object.
.option('throttling', {
coerce: coerceThrottling,
})
.describe({
'throttling.rttMs': 'Controls simulated network RTT (TCP layer)',
'throttling.throughputKbps': 'Controls simulated network download throughput',
'throttling.requestLatencyMs': 'Controls emulated network RTT (HTTP layer)',
'throttling.downloadThroughputKbps': 'Controls emulated network download throughput',
'throttling.uploadThroughputKbps': 'Controls emulated network upload throughput',
'throttling.cpuSlowdownMultiplier': 'Controls simulated + emulated CPU throttling',
})
.options({
'extra-headers': {
coerce: coerceExtraHeaders,
describe: 'Set extra HTTP Headers to pass with request',
},
'precomputed-lantern-data-path': {
type: 'string',
describe: 'Path to the file where lantern simulation data should be read from, overwriting the lantern observed estimates for RTT and server latency.',
},
'lantern-data-output-path': {
type: 'string',
describe: 'Path to the file where lantern simulation data should be written to, can be used in a future run with the `precomputed-lantern-data-path` flag.',
},
'plugins': {
array: true,
type: 'string',
coerce: splitCommaSeparatedValues,
describe: 'Run the specified plugins',
},
'channel': {
type: 'string',
default: 'cli',
},
'chrome-ignore-default-flags': {
type: 'boolean',
default: false,
},
})
// Choices added outside of `options()` and cast so tsc picks them up.
.choices('form-factor', /** @type {const} */ (['mobile', 'desktop']))
.choices('throttling-method', /** @type {const} */ (['devtools', 'provided', 'simulate']))
.choices('preset', /** @type {const} */ (['perf', 'experimental', 'desktop']))
.check(argv => {
// Lighthouse doesn't need a URL if...
// - We're just listing the available options.
// - We're just printing the config.
// - We're in auditMode (and we have artifacts already)
// If one of these don't apply, if no URL, stop the program and ask for one.
const isPrintSomethingMode = argv.listAllAudits || argv.listLocales || argv.listTraceCategories;
const isOnlyAuditMode = !!argv.auditMode && !argv.gatherMode;
if (isPrintSomethingMode || isOnlyAuditMode) {
return true;
} else if (argv._.length > 0) {
return true;
}
throw new Error('Please provide a url');
})
.epilogue('For more information on Lighthouse, see https://developers.google.com/web/tools/lighthouse/.')
.wrap(y.terminalWidth());
}
/**
* @param {string=} manualArgv
* @param {{noExitOnFailure?: boolean}=} options
* @return {LH.CliFlags}
*/
function getFlags(manualArgv, options = {}) {
let parser = getYargsParser(manualArgv);
if (options.noExitOnFailure) {
// Silence console.error() logging and don't process.exit().
// `parser.fail(false)` can be used in yargs once v17 is released.
parser = parser.fail((msg, err) => {
if (err) throw err;
else if (msg) throw new Error(msg);
});
}
// Augmenting yargs type with auto-camelCasing breaks in tsc@4.1.2 and @types/yargs@15.0.11,
// so for now cast to add yarg's camelCase properties to type.
const argv = /** @type {Awaited<typeof parser.argv>} */ (parser.argv);
const cliFlags = /** @type {typeof argv & LH.Util.CamelCasify<typeof argv>} */ (argv);
// yargs will return `undefined` for options that have a `coerce` function but
// are not actually present in the user input. Instead of passing properties
// explicitly set to undefined, delete them from the flags object.
for (const [k, v] of Object.entries(cliFlags)) {
if (v === undefined) delete cliFlags[k];
}
return cliFlags;
}
/**
* Support comma-separated values for some array flags by splitting on any ',' found.
* @param {Array<string>=} strings
* @return {Array<string>=}
*/
function splitCommaSeparatedValues(strings) {
if (!strings) return;
return strings.flatMap(value => value.split(','));
}
/**
* @param {unknown} value
* @return {boolean|string|undefined}
*/
function coerceOptionalStringBoolean(value) {
if (value === undefined) return;
if (typeof value !== 'string' && typeof value !== 'boolean') {
throw new Error('Invalid value: Argument must be a string or a boolean');
}
return value;
}
/**
* Coerce output CLI input to `LH.SharedFlagsSettings['output']` or throw if not possible.
* @param {Array<unknown>} values
* @return {Array<LH.OutputMode>}
*/
function coerceOutput(values) {
const outputTypes = ['json', 'html', 'csv'];
const errorHint = `Argument 'output' must be an array from choices "${outputTypes.join('", "')}"`;
if (!values.every(/** @return {item is string} */ item => typeof item === 'string')) {
throw new Error('Invalid values. ' + errorHint);
}
// Allow parsing of comma-separated values.
const strings = values.flatMap(value => value.split(','));
const validValues = strings.filter(/** @return {str is LH.OutputMode} */ str => {
if (!outputTypes.includes(str)) {
throw new Error(`"${str}" is not a valid 'output' value. ` + errorHint);
}
return true;
});
return validValues;
}
/**
* Verifies outputPath is something we can actually write to.
* @param {unknown=} value
* @return {string=}
*/
function coerceOutputPath(value) {
if (value === undefined) return;
if (typeof value !== 'string' || !value || !fs.existsSync(path.dirname(value))) {
throw new Error(`--output-path (${value}) cannot be written to`);
}
return value;
}
/**
* Verifies value is a string, then coerces type to LH.Locale for convenience. However, don't
* allowlist specific locales. Why? So we can support the user who requests 'es-MX' (unsupported)
* and we'll fall back to 'es' (supported).
* @param {unknown} value
* @return {LH.Locale|undefined}
*/
function coerceLocale(value) {
if (value === undefined) return;
if (typeof value !== 'string') throw new Error(`Invalid value: Argument 'locale' must be a string`);
return /** @type {LH.Locale} */ (value);
}
/**
* `--extra-headers` comes in as a JSON string or a path to a JSON string, but the flag value
* needs to be the parsed object. Load file (if necessary) and returns the parsed object.
* @param {unknown} value
* @return {LH.SharedFlagsSettings['extraHeaders']}
*/
function coerceExtraHeaders(value) {
// TODO: this function does not actually verify the object type.
if (value === undefined) return value;
if (typeof value === 'object') return /** @type {LH.SharedFlagsSettings['extraHeaders']} */ (value);
if (typeof value !== 'string') {
throw new Error(`Invalid value: Argument 'extra-headers' must be a string`);
}
// (possibly) load and parse extra headers from JSON.
if (!value.startsWith('{')) {
// If not a JSON object, assume it's a path to a JSON file.
return JSON.parse(fs.readFileSync(value, 'utf-8'));
}
return JSON.parse(value);
}
/**
* Take yarg's unchecked object value and ensure it's proper throttling settings.
* @param {unknown} value
* @return {LH.ThrottlingSettings|undefined}
*/
function coerceThrottling(value) {
if (value === undefined) return;
if (!isObjectOfUnknownValues(value)) {
throw new Error(`Invalid value: Argument 'throttling' must be an object, specified per-property ('throttling.rttMs', 'throttling.throughputKbps', etc)`);
}
/** @type {Array<keyof LH.ThrottlingSettings>} */
const throttlingKeys = [
'rttMs',
'throughputKbps',
'requestLatencyMs',
'downloadThroughputKbps',
'uploadThroughputKbps',
'cpuSlowdownMultiplier',
];
/** @type {LH.ThrottlingSettings} */
const throttlingSettings = {};
for (const key of throttlingKeys) {
const possibleSetting = value[key];
if (possibleSetting !== undefined && typeof possibleSetting !== 'number') {
throw new Error(`Invalid value: 'throttling.${key}' must be a number`);
}
// Note: this works type-wise because the throttling settings all have the same type.
throttlingSettings[key] = possibleSetting;
}
return throttlingSettings;
}
/**
* Take yarg's unchecked object value and ensure it is a proper LH.screenEmulationSettings.
* @param {unknown} value
* @return {Partial<LH.ScreenEmulationSettings>|undefined}
*/
function coerceScreenEmulation(value) {
if (value === undefined) return;
if (!isObjectOfUnknownValues(value)) {
throw new Error(`Invalid value: Argument 'screenEmulation' must be an object, specified per-property ('screenEmulation.width', 'screenEmulation.deviceScaleFactor', etc)`);
}
/** @type {Array<keyof LH.ScreenEmulationSettings>} */
const keys = ['width', 'height', 'deviceScaleFactor', 'mobile', 'disabled'];
/** @type {Partial<LH.ScreenEmulationSettings>} */
const screenEmulationSettings = {};
for (const key of keys) {
const possibleSetting = value[key];
switch (key) {
case 'width':
case 'height':
case 'deviceScaleFactor':
if (possibleSetting !== undefined && typeof possibleSetting !== 'number') {
throw new Error(`Invalid value: 'screenEmulation.${key}' must be a number`);
}
screenEmulationSettings[key] = possibleSetting;
break;
case 'mobile':
case 'disabled':
// Manually coerce 'true'/'false' strings to booleans since nested property types aren't set.
if (possibleSetting === 'true') {
screenEmulationSettings[key] = true;
} else if (possibleSetting === 'false') {
screenEmulationSettings[key] = false;
} else if (possibleSetting === undefined || typeof possibleSetting === 'boolean') {
screenEmulationSettings[key] = possibleSetting;
} else {
throw new Error(`Invalid value: 'screenEmulation.${key}' must be a boolean`);
}
break;
default:
throw new Error(`Unrecognized screenEmulation option: ${key}`);
}
}
return screenEmulationSettings;
}
export {
getFlags,
getYargsParser,
};

4
node_modules/lighthouse/cli/commands/commands.d.ts generated vendored Normal file
View File

@@ -0,0 +1,4 @@
export { listAudits } from "./list-audits.js";
export { listTraceCategories } from "./list-trace-categories.js";
export { listLocales } from "./list-locales.js";
//# sourceMappingURL=commands.d.ts.map

9
node_modules/lighthouse/cli/commands/commands.js generated vendored Normal file
View File

@@ -0,0 +1,9 @@
/**
* @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.
*/
export {listAudits} from './list-audits.js';
export {listTraceCategories} from './list-trace-categories.js';
export {listLocales} from './list-locales.js';

View File

@@ -0,0 +1,2 @@
export function listAudits(): void;
//# sourceMappingURL=list-audits.d.ts.map

15
node_modules/lighthouse/cli/commands/list-audits.js generated vendored Normal file
View File

@@ -0,0 +1,15 @@
/**
* @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 {getAuditList} from '../../core/index.js';
function listAudits() {
const audits = getAuditList().map((i) => i.replace(/\.js$/, ''));
process.stdout.write(JSON.stringify({audits}, null, 2));
process.exit(0);
}
export {listAudits};

View File

@@ -0,0 +1,2 @@
export function listLocales(): void;
//# sourceMappingURL=list-locales.d.ts.map

15
node_modules/lighthouse/cli/commands/list-locales.js generated vendored Normal file
View File

@@ -0,0 +1,15 @@
/**
* @license Copyright 2021 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
import {locales} from '../../shared/localization/locales.js';
function listLocales() {
const localesList = Object.keys(locales);
process.stdout.write(JSON.stringify({locales: localesList}, null, 2));
process.exit(0);
}
export {listLocales};

View File

@@ -0,0 +1,2 @@
export function listTraceCategories(): void;
//# sourceMappingURL=list-trace-categories.d.ts.map

View File

@@ -0,0 +1,14 @@
/**
* @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 {traceCategories} from '../../core/index.js';
function listTraceCategories() {
process.stdout.write(JSON.stringify({traceCategories}));
process.exit(0);
}
export {listTraceCategories};

3
node_modules/lighthouse/cli/index.d.ts generated vendored Normal file
View File

@@ -0,0 +1,3 @@
#!/usr/bin/env node
export {};
//# sourceMappingURL=index.d.ts.map

10
node_modules/lighthouse/cli/index.js generated vendored Executable file
View File

@@ -0,0 +1,10 @@
#!/usr/bin/env node
/**
* @license Copyright 2017 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
import {begin} from './bin.js';
await begin();

28
node_modules/lighthouse/cli/printer.d.ts generated vendored Normal file
View File

@@ -0,0 +1,28 @@
/**
* Verify output path to use, either stdout or a file path.
* @param {string} path
* @return {string}
*/
export function checkOutputPath(path: string): string;
/**
* Writes the output.
* @param {string} output
* @param {LH.OutputMode} mode
* @param {string} path
* @return {Promise<void>}
*/
export function write(output: string, mode: LH.OutputMode, path: string): Promise<void>;
/**
* An enumeration of acceptable output modes:
* 'json': JSON formatted results
* 'html': An HTML report
* 'csv': CSV formatted results
* @type {LH.Util.SelfMap<LH.OutputMode>}
*/
export const OutputMode: LH.Util.SelfMap<LH.OutputMode>;
/**
* Returns a list of valid output options.
* @return {Array<string>}
*/
export function getValidOutputOptions(): Array<string>;
//# sourceMappingURL=printer.d.ts.map

99
node_modules/lighthouse/cli/printer.js generated vendored Normal file
View File

@@ -0,0 +1,99 @@
/**
* @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 fs from 'fs';
import log from 'lighthouse-logger';
/**
* An enumeration of acceptable output modes:
* 'json': JSON formatted results
* 'html': An HTML report
* 'csv': CSV formatted results
* @type {LH.Util.SelfMap<LH.OutputMode>}
*/
const OutputMode = {
json: 'json',
html: 'html',
csv: 'csv',
};
/**
* Verify output path to use, either stdout or a file path.
* @param {string} path
* @return {string}
*/
function checkOutputPath(path) {
if (!path) {
log.warn('Printer', 'No output path set; using stdout');
return 'stdout';
}
return path;
}
/**
* Writes the output to stdout.
* @param {string} output
* @return {Promise<void>}
*/
function writeToStdout(output) {
return new Promise(resolve => {
// small delay to avoid race with debug() logs
setTimeout(_ => {
process.stdout.write(`${output}\n`);
resolve();
}, 50);
});
}
/**
* Writes the output to a file.
* @param {string} filePath
* @param {string} output
* @param {LH.OutputMode} outputMode
* @return {Promise<void>}
*/
function writeFile(filePath, output, outputMode) {
return new Promise((resolve, reject) => {
// TODO: make this mkdir to the filePath.
fs.writeFile(filePath, output, (err) => {
if (err) {
return reject(err);
}
log.log('Printer', `${OutputMode[outputMode]} output written to ${filePath}`);
resolve();
});
});
}
/**
* Writes the output.
* @param {string} output
* @param {LH.OutputMode} mode
* @param {string} path
* @return {Promise<void>}
*/
async function write(output, mode, path) {
const outputPath = checkOutputPath(path);
return outputPath === 'stdout' ?
writeToStdout(output) :
writeFile(outputPath, output, mode);
}
/**
* Returns a list of valid output options.
* @return {Array<string>}
*/
function getValidOutputOptions() {
return Object.keys(OutputMode);
}
export {
checkOutputPath,
write,
OutputMode,
getValidOutputOptions,
};

24
node_modules/lighthouse/cli/run.d.ts generated vendored Normal file
View File

@@ -0,0 +1,24 @@
export type ExitError = Error & {
code: string;
friendlyMessage?: string;
};
/**
* exported for testing
* @param {string|Array<string>} flags
* @return {Array<string>}
*/
export function parseChromeFlags(flags?: string | Array<string>): Array<string>;
/**
* @param {LH.RunnerResult} runnerResult
* @param {LH.CliFlags} flags
* @return {Promise<void>}
*/
export function saveResults(runnerResult: LH.RunnerResult, flags: LH.CliFlags): Promise<void>;
/**
* @param {string} url
* @param {LH.CliFlags} flags
* @param {LH.Config|undefined} config
* @return {Promise<LH.RunnerResult|undefined>}
*/
export function runLighthouse(url: string, flags: LH.CliFlags, config: LH.Config | undefined): Promise<LH.RunnerResult | undefined>;
//# sourceMappingURL=run.d.ts.map

284
node_modules/lighthouse/cli/run.js generated vendored Normal file
View File

@@ -0,0 +1,284 @@
/**
* @license Copyright 2017 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/* eslint-disable no-console */
import path from 'path';
import os from 'os';
import psList from 'ps-list';
import * as ChromeLauncher from 'chrome-launcher';
import yargsParser from 'yargs-parser';
import log from 'lighthouse-logger';
import open from 'open';
import * as Printer from './printer.js';
import lighthouse, {legacyNavigation} from '../core/index.js';
import {getLhrFilenamePrefix} from '../report/generator/file-namer.js';
import * as assetSaver from '../core/lib/asset-saver.js';
import UrlUtils from '../core/lib/url-utils.js';
/** @typedef {Error & {code: string, friendlyMessage?: string}} ExitError */
const _RUNTIME_ERROR_CODE = 1;
const _PROTOCOL_TIMEOUT_EXIT_CODE = 67;
/**
* exported for testing
* @param {string|Array<string>} flags
* @return {Array<string>}
*/
function parseChromeFlags(flags = '') {
// flags will be a string if there is only one chrome-flag parameter:
// i.e. `lighthouse --chrome-flags="--user-agent='My Agent' --headless"`
// flags will be an array if there are multiple chrome-flags parameters
// i.e. `lighthouse --chrome-flags="--user-agent='My Agent'" --chrome-flags="--headless"`
const trimmedFlags = (Array.isArray(flags) ? flags : [flags])
// `child_process.execFile` and other programmatic invocations will pass Lighthouse arguments atomically.
// Many developers aren't aware of this and attempt to pass arguments to LH as they would to a shell `--chromeFlags="--headless --no-sandbox"`.
// In this case, yargs will see `"--headless --no-sandbox"` and treat it as a single argument instead of the intended `--headless --no-sandbox`.
// We remove quotes that surround the entire expression to make this work.
// i.e. `child_process.execFile("lighthouse", ["http://google.com", "--chrome-flags='--headless --no-sandbox'")`
// the following regular expression removes those wrapping quotes:
.map((flagsGroup) => flagsGroup.replace(/^\s*('|")(.+)\1\s*$/, '$2').trim())
.join(' ').trim();
const parsed = yargsParser(trimmedFlags, {
configuration: {'camel-case-expansion': false, 'boolean-negation': false},
});
return Object
.keys(parsed)
// Remove unnecessary _ item provided by yargs,
.filter(key => key !== '_')
// Avoid '=true', then reintroduce quotes
.map(key => {
if (parsed[key] === true) return `--${key}`;
// ChromeLauncher passes flags to Chrome as atomic arguments, so do not double quote
// i.e. `lighthouse --chrome-flags="--user-agent='My Agent'"` becomes `chrome "--user-agent=My Agent"`
// see https://github.com/GoogleChrome/lighthouse/issues/3744
return `--${key}=${parsed[key]}`;
});
}
/**
* Attempts to connect to an instance of Chrome with an open remote-debugging
* port. If none is found, launches a debuggable instance.
* @param {LH.CliFlags} flags
* @return {Promise<ChromeLauncher.LaunchedChrome>}
*/
function getDebuggableChrome(flags) {
if (process.platform === 'darwin' && process.arch === 'x64') {
const cpus = os.cpus();
if (cpus[0].model.includes('Apple')) {
throw new Error(
'Launching Chrome on Mac Silicon (arm64) from an x64 Node installation results in ' +
'Rosetta translating the Chrome binary, even if Chrome is already arm64. This would ' +
'result in huge performance issues. To resolve this, you must run Lighthouse CLI with ' +
'a version of Node built for arm64. You should also confirm that your Chrome install ' +
'says arm64 in chrome://version');
}
}
return ChromeLauncher.launch({
port: flags.port,
ignoreDefaultFlags: flags.chromeIgnoreDefaultFlags,
chromeFlags: parseChromeFlags(flags.chromeFlags),
logLevel: flags.logLevel,
});
}
/** @return {never} */
function printConnectionErrorAndExit() {
console.error('Unable to connect to Chrome');
return process.exit(_RUNTIME_ERROR_CODE);
}
/** @return {never} */
function printProtocolTimeoutErrorAndExit() {
console.error('Debugger protocol timed out while connecting to Chrome.');
return process.exit(_PROTOCOL_TIMEOUT_EXIT_CODE);
}
/**
* @param {ExitError} err
* @return {never}
*/
function printRuntimeErrorAndExit(err) {
console.error('Runtime error encountered:', err.friendlyMessage || err.message);
if (err.stack) {
console.error(err.stack);
}
return process.exit(_RUNTIME_ERROR_CODE);
}
/**
* @param {ExitError} err
* @return {never}
*/
function printErrorAndExit(err) {
if (err.code === 'ECONNREFUSED') {
return printConnectionErrorAndExit();
} else if (err.code === 'CRI_TIMEOUT') {
return printProtocolTimeoutErrorAndExit();
} else {
return printRuntimeErrorAndExit(err);
}
}
/**
* @param {LH.RunnerResult} runnerResult
* @param {LH.CliFlags} flags
* @return {Promise<void>}
*/
async function saveResults(runnerResult, flags) {
const cwd = process.cwd();
if (flags.lanternDataOutputPath) {
const devtoolsLog = runnerResult.artifacts.devtoolsLogs.defaultPass;
await assetSaver.saveLanternNetworkData(devtoolsLog, flags.lanternDataOutputPath);
}
const shouldSaveResults = flags.auditMode || (flags.gatherMode === flags.auditMode);
if (!shouldSaveResults) return;
const {lhr, artifacts, report} = runnerResult;
// Use the output path as the prefix for all generated files.
// If no output path is set, generate a file prefix using the URL and date.
const configuredPath = !flags.outputPath || flags.outputPath === 'stdout' ?
getLhrFilenamePrefix(lhr) :
flags.outputPath.replace(/\.\w{2,4}$/, '');
const resolvedPath = path.resolve(cwd, configuredPath);
if (flags.saveAssets) {
await assetSaver.saveAssets(artifacts, lhr.audits, resolvedPath);
}
for (const outputType of flags.output) {
const extension = outputType;
const output = report[flags.output.indexOf(outputType)];
let outputPath = `${resolvedPath}.report.${extension}`;
// If there was only a single output and the user specified an outputPath, force usage of it.
if (flags.outputPath && flags.output.length === 1) outputPath = flags.outputPath;
await Printer.write(output, outputType, outputPath);
if (outputType === Printer.OutputMode[Printer.OutputMode.html]) {
if (flags.view) {
open(outputPath, {wait: false});
} else {
// eslint-disable-next-line max-len
log.log('CLI', 'Protip: Run lighthouse with `--view` to immediately open the HTML report in your browser');
}
}
}
}
/**
* Attempt to kill the launched Chrome, if defined.
* @param {ChromeLauncher.LaunchedChrome=} launchedChrome
* @return {Promise<void>}
*/
async function potentiallyKillChrome(launchedChrome) {
if (!launchedChrome) return;
/** @type {NodeJS.Timeout} */
let timeout;
const timeoutPromise = new Promise((_, reject) => {
timeout = setTimeout(reject, 5000, new Error('Timed out waiting to kill Chrome'));
});
return Promise.race([
launchedChrome.kill(),
timeoutPromise,
]).catch(async err => {
const runningProcesses = await psList();
if (!runningProcesses.some(proc => proc.pid === launchedChrome.pid)) {
log.warn('CLI', 'Warning: Chrome process could not be killed because it already exited.');
return;
}
throw new Error(`Couldn't quit Chrome process. ${err}`);
}).finally(() => {
clearTimeout(timeout);
});
}
/**
* @param {string} url
* @param {LH.CliFlags} flags
* @param {LH.Config|undefined} config
* @return {Promise<LH.RunnerResult|undefined>}
*/
async function runLighthouse(url, flags, config) {
/** @param {any} reason */
async function handleTheUnhandled(reason) {
process.stderr.write(`Unhandled Rejection. Reason: ${reason}\n`);
await potentiallyKillChrome(launchedChrome).catch(() => {});
setTimeout(_ => {
process.exit(1);
}, 100);
}
process.on('unhandledRejection', handleTheUnhandled);
/** @type {ChromeLauncher.LaunchedChrome|undefined} */
let launchedChrome;
try {
if (url && flags.auditMode && !flags.gatherMode) {
log.warn('CLI', 'URL parameter is ignored if -A flag is used without -G flag');
}
const shouldGather = flags.gatherMode || flags.gatherMode === flags.auditMode;
const shouldUseLocalChrome = UrlUtils.isLikeLocalhost(flags.hostname);
if (shouldGather && shouldUseLocalChrome) {
launchedChrome = await getDebuggableChrome(flags);
flags.port = launchedChrome.port;
}
if (flags.legacyNavigation) {
log.warn('CLI', 'Legacy navigation CLI is deprecated');
flags.channel = 'legacy-navigation-cli';
} else if (!flags.channel) {
flags.channel = 'cli';
}
const runnerResult = flags.legacyNavigation ?
await legacyNavigation(url, flags, config) :
await lighthouse(url, flags, config);
// If in gatherMode only, there will be no runnerResult.
if (runnerResult) {
await saveResults(runnerResult, flags);
}
await potentiallyKillChrome(launchedChrome);
process.removeListener('unhandledRejection', handleTheUnhandled);
// Runtime errors indicate something was *very* wrong with the page result.
// We don't want the user to have to parse the report to figure it out, so we'll still exit
// with an error code after we saved the results.
if (runnerResult?.lhr.runtimeError) {
const {runtimeError} = runnerResult.lhr;
return printErrorAndExit({
name: 'LighthouseError',
friendlyMessage: runtimeError.message,
code: runtimeError.code,
message: runtimeError.message,
});
}
return runnerResult;
} catch (err) {
await potentiallyKillChrome(launchedChrome).catch(() => {});
return printErrorAndExit(err);
}
}
export {
parseChromeFlags,
saveResults,
runLighthouse,
};

5
node_modules/lighthouse/cli/sentry-prompt.d.ts generated vendored Normal file
View File

@@ -0,0 +1,5 @@
/**
* @return {Promise<boolean>}
*/
export function askPermission(): Promise<boolean>;
//# sourceMappingURL=sentry-prompt.d.ts.map

78
node_modules/lighthouse/cli/sentry-prompt.js generated vendored Normal file
View File

@@ -0,0 +1,78 @@
/**
* @license Copyright 2017 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
import Configstore from 'configstore';
import Confirm from 'enquirer';
import log from 'lighthouse-logger';
const MAXIMUM_WAIT_TIME = 20 * 1000;
// eslint-disable-next-line max-len
const MESSAGE = `${log.reset}We're constantly trying to improve Lighthouse and its reliability.\n ` +
`${log.reset}Learn more: https://github.com/GoogleChrome/lighthouse/blob/main/docs/error-reporting.md \n ` +
` ${log.bold}May we anonymously report runtime exceptions to improve the tool over time?${log.reset} `; // eslint-disable-line max-len
/**
* @return {Promise<boolean>}
*/
function prompt() {
if (!process.stdout.isTTY || process.env.CI) {
// Default non-interactive sessions to false
return Promise.resolve(false);
}
/** @type {NodeJS.Timer|undefined} */
let timeout;
const prompt = new Confirm.Confirm({
name: 'isErrorReportingEnabled',
initial: false,
message: MESSAGE,
actions: {ctrl: {}},
});
const timeoutPromise = new Promise((resolve) => {
timeout = setTimeout(() => {
prompt.close().then(() => {
log.warn('CLI', 'No response to error logging preference, errors will not be reported.');
resolve(false);
});
}, MAXIMUM_WAIT_TIME);
});
return Promise.race([
prompt.run().then(result => {
clearTimeout(/** @type {NodeJS.Timer} */ (timeout));
return result;
}),
timeoutPromise,
]);
}
/**
* @return {Promise<boolean>}
*/
function askPermission() {
return Promise.resolve().then(_ => {
const configstore = new Configstore('lighthouse');
let isErrorReportingEnabled = configstore.get('isErrorReportingEnabled');
if (typeof isErrorReportingEnabled === 'boolean') {
return Promise.resolve(isErrorReportingEnabled);
}
return prompt()
.then(response => {
isErrorReportingEnabled = response;
configstore.set('isErrorReportingEnabled', isErrorReportingEnabled);
return isErrorReportingEnabled;
});
// Error accessing configstore; default to false.
}).catch(_ => false);
}
export {
askPermission,
};

View File

@@ -0,0 +1,69 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`getAssertionReport works (multiple failing) 1`] = `
"X difference at cumulative-layout-shift audit.details.items.length
expected: []
found: [{\\"cumulativeLayoutShiftMainFrame\\":0.13570762803819444}]
X difference at cumulative-layout-shift audit.details.blah
expected: 123
found: undefined
found result:
{
\\"id\\": \\"cumulative-layout-shift\\",
\\"title\\": \\"Cumulative Layout Shift\\",
\\"description\\": \\"Cumulative Layout Shift measures the movement of visible elements within the viewport. [Learn more about the Cumulative Layout Shift metric](https://web.dev/cls/).\\",
\\"score\\": 0.8,
\\"scoreDisplayMode\\": \\"numeric\\",
\\"numericValue\\": 0.13570762803819444,
\\"numericUnit\\": \\"unitless\\",
\\"displayValue\\": \\"0.136\\",
\\"details\\": {
\\"type\\": \\"debugdata\\",
\\"items\\": [
{
\\"cumulativeLayoutShiftMainFrame\\": 0.13570762803819444
}
]
}
}"
`;
exports[`getAssertionReport works (trivial failing) 1`] = `
"X difference at cumulative-layout-shift audit.details.items.length
expected: []
found: [{\\"cumulativeLayoutShiftMainFrame\\":0.13570762803819444}]
found result:
{
\\"id\\": \\"cumulative-layout-shift\\",
\\"title\\": \\"Cumulative Layout Shift\\",
\\"description\\": \\"Cumulative Layout Shift measures the movement of visible elements within the viewport. [Learn more about the Cumulative Layout Shift metric](https://web.dev/cls/).\\",
\\"score\\": 0.8,
\\"scoreDisplayMode\\": \\"numeric\\",
\\"numericValue\\": 0.13570762803819444,
\\"numericUnit\\": \\"unitless\\",
\\"displayValue\\": \\"0.136\\",
\\"details\\": {
\\"type\\": \\"debugdata\\",
\\"items\\": [
{
\\"cumulativeLayoutShiftMainFrame\\": 0.13570762803819444
}
]
}
}"
`;
exports[`getAssertionReport works (trivial failing, actual undefined) 1`] = `
"Error: Config did not trigger run of expected audit cumulative-layout-shift-no-exist
X difference at cumulative-layout-shift-no-exist audit
expected: {\\"details\\":{\\"items\\":[]}}
found: undefined
found result:
undefined"
`;

View File

@@ -0,0 +1,12 @@
export default exclusions;
/**
* @license Copyright 2022 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/**
* List of smoke tests excluded per runner. eg: 'cli': ['a11y', 'dbw']
* @type {Record<string, Array<string>>}
*/
declare const exclusions: Record<string, Array<string>>;
//# sourceMappingURL=exclusions.d.ts.map

View File

@@ -0,0 +1,36 @@
/**
* @license Copyright 2022 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/**
* List of smoke tests excluded per runner. eg: 'cli': ['a11y', 'dbw']
* @type {Record<string, Array<string>>}
*/
const exclusions = {
'bundle': [],
'cli': [],
'devtools': [
// Disabled because normal Chrome usage makes DevTools not function on
// these poorly constructed pages
'errors-expired-ssl', 'errors-infinite-loop',
// Disabled because Chrome will follow the redirect first, and Lighthouse will
// only ever see/run the final URL.
'redirects-client-paint-server', 'redirects-multiple-server',
'redirects-single-server', 'redirects-single-client',
'redirects-history-push-state', 'redirects-scripts',
// Disabled because these tests use settings that cannot be fully configured in
// DevTools (e.g. throttling method "provided").
'metrics-tricky-tti', 'metrics-tricky-tti-late-fcp', 'screenshot',
// Disabled because of differences that need further investigation
'byte-efficiency', 'byte-gzip', 'perf-preload',
],
};
// https://github.com/GoogleChrome/lighthouse/issues/14271
for (const array of Object.values(exclusions)) {
array.push('lantern-idle-callback-short');
}
export default exclusions;

View File

@@ -0,0 +1,4 @@
export default smokeTests;
/** @type {ReadonlyArray<Smokehouse.TestDfn>} */
declare const smokeTests: ReadonlyArray<Smokehouse.TestDfn>;
//# sourceMappingURL=core-tests.d.ts.map

View File

@@ -0,0 +1,134 @@
/**
* @license Copyright 2019 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
import a11y from './test-definitions/a11y.js';
import byteEfficiency from './test-definitions/byte-efficiency.js';
import byteGzip from './test-definitions/byte-gzip.js';
import cspAllowAll from './test-definitions/csp-allow-all.js';
import cspBlockAll from './test-definitions/csp-block-all.js';
import dbw from './test-definitions/dobetterweb.js';
import errorsExpiredSsl from './test-definitions/errors-expired-ssl.js';
import errorsIframeExpiredSsl from './test-definitions/errors-iframe-expired-ssl.js';
import errorsInfiniteLoop from './test-definitions/errors-infinite-loop.js';
import formsAutoComplete from './test-definitions/forms-autocomplete.js';
import fpsMax from './test-definitions/fps-max.js';
import fpsMaxPassive from './test-definitions/fps-max-passive.js';
import fpsScaled from './test-definitions/fps-scaled.js';
import issuesMixedContent from './test-definitions/issues-mixed-content.js';
import lanternFetch from './test-definitions/lantern-fetch.js';
import lanternIdleCallbackLong from './test-definitions/lantern-idle-callback-long.js';
import lanternIdleCallbackShort from './test-definitions/lantern-idle-callback-short.js';
import lanternOnline from './test-definitions/lantern-online.js';
import lanternSetTimeout from './test-definitions/lantern-set-timeout.js';
import lanternXhr from './test-definitions/lantern-xhr.js';
import legacyJavascript from './test-definitions/legacy-javascript.js';
import metricsDebugger from './test-definitions/metrics-debugger.js';
import metricsDelayedFcp from './test-definitions/metrics-delayed-fcp.js';
import metricsDelayedLcp from './test-definitions/metrics-delayed-lcp.js';
import metricsTrickyTti from './test-definitions/metrics-tricky-tti.js';
import metricsTrickyTtiLateFcp from './test-definitions/metrics-tricky-tti-late-fcp.js';
import offlineOnlineOnly from './test-definitions/offline-online-only.js';
import offlineReady from './test-definitions/offline-ready.js';
import offlineSwBroken from './test-definitions/offline-sw-broken.js';
import offlineSwSlow from './test-definitions/offline-sw-slow.js';
import oopifRequests from './test-definitions/oopif-requests.js';
import oopifScripts from './test-definitions/oopif-scripts.js';
import perfBudgets from './test-definitions/perf-budgets.js';
import perfDebug from './test-definitions/perf-debug.js';
import perfDiagnosticsAnimations from './test-definitions/perf-diagnostics-animations.js';
import perfDiagnosticsThirdParty from './test-definitions/perf-diagnostics-third-party.js';
import perfDiagnosticsUnsizedImages from './test-definitions/perf-diagnostics-unsized-images.js';
import perfFonts from './test-definitions/perf-fonts.js';
import perfFrameMetrics from './test-definitions/perf-frame-metrics.js';
import perfPreload from './test-definitions/perf-preload.js';
import perfTraceElements from './test-definitions/perf-trace-elements.js';
import pubads from './test-definitions/pubads.js';
import pwaAirhorner from './test-definitions/pwa-airhorner.js';
import pwaCaltrain from './test-definitions/pwa-caltrain.js';
import pwaChromestatus from './test-definitions/pwa-chromestatus.js';
import pwaRocks from './test-definitions/pwa-rocks.js';
import pwaSvgomg from './test-definitions/pwa-svgomg.js';
import redirectsClientPaintServer from './test-definitions/redirects-client-paint-server.js';
import redirectsHistoryPushState from './test-definitions/redirects-history-push-state.js';
import redirectsMultipleServer from './test-definitions/redirects-multiple-server.js';
import redirectsScripts from './test-definitions/redirects-scripts.js';
import redirectsSelf from './test-definitions/redirects-self.js';
import redirectsSingleClient from './test-definitions/redirects-single-client.js';
import redirectsSingleServer from './test-definitions/redirects-single-server.js';
import screenshot from './test-definitions/screenshot.js';
import seoFailing from './test-definitions/seo-failing.js';
import seoPassing from './test-definitions/seo-passing.js';
import seoStatus403 from './test-definitions/seo-status-403.js';
import seoTapTargets from './test-definitions/seo-tap-targets.js';
import sourceMaps from './test-definitions/source-maps.js';
import timing from './test-definitions/timing.js';
/** @type {ReadonlyArray<Smokehouse.TestDfn>} */
const smokeTests = [
a11y,
byteEfficiency,
byteGzip,
cspAllowAll,
cspBlockAll,
dbw,
errorsExpiredSsl,
errorsIframeExpiredSsl,
errorsInfiniteLoop,
formsAutoComplete,
fpsMax,
fpsScaled,
fpsMaxPassive,
issuesMixedContent,
lanternFetch,
lanternIdleCallbackLong,
lanternIdleCallbackShort,
lanternOnline,
lanternSetTimeout,
lanternXhr,
legacyJavascript,
metricsDebugger,
metricsDelayedFcp,
metricsDelayedLcp,
metricsTrickyTti,
metricsTrickyTtiLateFcp,
offlineOnlineOnly,
offlineReady,
offlineSwBroken,
offlineSwSlow,
oopifRequests,
oopifScripts,
perfBudgets,
perfDebug,
perfDiagnosticsAnimations,
perfDiagnosticsThirdParty,
perfDiagnosticsUnsizedImages,
perfFonts,
perfFrameMetrics,
perfPreload,
perfTraceElements,
pubads,
pwaAirhorner,
pwaCaltrain,
pwaChromestatus,
pwaRocks,
pwaSvgomg,
redirectsClientPaintServer,
redirectsHistoryPushState,
redirectsMultipleServer,
redirectsScripts,
redirectsSelf,
redirectsSingleClient,
redirectsSingleServer,
screenshot,
seoFailing,
seoPassing,
seoStatus403,
seoTapTargets,
sourceMaps,
timing,
];
export default smokeTests;

View File

@@ -0,0 +1,16 @@
/**
* @license Copyright 2021 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/**
* COMPAT: update from the old TestDefn format (array of `expectations` per
* definition) to the new format (single `expectations` per def), doing our best
* generating some unique IDs.
* TODO: remove in Lighthouse 9+ once PubAds (and others?) are updated.
* @see https://github.com/GoogleChrome/lighthouse/issues/11950
* @param {ReadonlyArray<Smokehouse.BackCompatTestDefn>} allTestDefns
* @return {Array<Smokehouse.TestDfn>}
*/
export function updateTestDefnFormat(allTestDefns: ReadonlyArray<Smokehouse.BackCompatTestDefn>): Array<Smokehouse.TestDfn>;
//# sourceMappingURL=back-compat-util.d.ts.map

View File

@@ -0,0 +1,41 @@
/**
* @license Copyright 2021 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/**
* COMPAT: update from the old TestDefn format (array of `expectations` per
* definition) to the new format (single `expectations` per def), doing our best
* generating some unique IDs.
* TODO: remove in Lighthouse 9+ once PubAds (and others?) are updated.
* @see https://github.com/GoogleChrome/lighthouse/issues/11950
* @param {ReadonlyArray<Smokehouse.BackCompatTestDefn>} allTestDefns
* @return {Array<Smokehouse.TestDfn>}
*/
function updateTestDefnFormat(allTestDefns) {
const expandedTestDefns = allTestDefns.map(testDefn => {
if (Array.isArray(testDefn.expectations)) {
// Create a testDefn per expectation.
return testDefn.expectations.map((expectations, index) => {
return {
...testDefn,
id: `${testDefn.id}-${index}`,
expectations,
};
});
} else {
// New object to make tsc happy.
return {
...testDefn,
expectations: testDefn.expectations,
};
}
});
return expandedTestDefns.flat();
}
export {
updateTestDefnFormat,
};

View File

@@ -0,0 +1,8 @@
/**
* @param {Smokehouse.SmokehouseLibOptions} options
*/
export function smokehouse(options: Smokehouse.SmokehouseLibOptions): Promise<{
success: boolean;
testResults: import("../smokehouse.js").SmokehouseResult[];
}>;
//# sourceMappingURL=lib.d.ts.map

View File

@@ -0,0 +1,48 @@
/**
* @license Copyright 2020 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/**
* @fileoverview Smoke test runner.
* Used to test integrations that run Lighthouse within a browser (i.e. LR, DevTools)
* Supports skipping and modifiying expectations to match the environment.
*/
/* eslint-disable no-console */
import cloneDeep from 'lodash/cloneDeep.js';
import smokeTests from '../core-tests.js';
import {runSmokehouse, getShardedDefinitions} from '../smokehouse.js';
/**
* @param {Smokehouse.SmokehouseLibOptions} options
*/
async function smokehouse(options) {
const {urlFilterRegex, skip, modify, shardArg, ...smokehouseOptions} = options;
const clonedTests = cloneDeep(smokeTests);
const modifiedTests = [];
for (const test of clonedTests) {
if (urlFilterRegex && !test.expectations.lhr.requestedUrl.match(urlFilterRegex)) {
continue;
}
const reasonToSkip = skip && skip(test, test.expectations);
if (reasonToSkip) {
console.log(`skipping ${test.expectations.lhr.requestedUrl}: ${reasonToSkip}`);
continue;
}
modify && modify(test, test.expectations);
modifiedTests.push(test);
}
const shardedTests = getShardedDefinitions(modifiedTests, shardArg);
return runSmokehouse(shardedTests, smokehouseOptions);
}
export {smokehouse};

View File

@@ -0,0 +1,2 @@
export * from "../smokehouse.js";
//# sourceMappingURL=node.d.ts.map

View File

@@ -0,0 +1,12 @@
/**
* @license Copyright 2019 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/**
* @fileoverview A smokehouse frontend for running within a node process.
*/
// Smokehouse is runnable from within node, so just a no-op for now.
export * from '../smokehouse.js';

View File

@@ -0,0 +1,3 @@
#!/usr/bin/env node
export {};
//# sourceMappingURL=smokehouse-bin.d.ts.map

View File

@@ -0,0 +1,264 @@
#!/usr/bin/env node
/**
* @license Copyright 2019 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/**
* @fileoverview A smokehouse frontend for running from the command line. Parse
* flags, start fixture webservers, then run smokehouse.
*/
/* eslint-disable no-console */
import path from 'path';
import fs from 'fs';
import url from 'url';
import cloneDeep from 'lodash/cloneDeep.js';
import yargs from 'yargs';
import * as yargsHelpers from 'yargs/helpers';
import log from 'lighthouse-logger';
import {runSmokehouse, getShardedDefinitions} from '../smokehouse.js';
import {updateTestDefnFormat} from './back-compat-util.js';
import {LH_ROOT} from '../../../../root.js';
import exclusions from '../config/exclusions.js';
import {saveArtifacts} from '../../../../core/lib/asset-saver.js';
import {saveLhr} from '../../../../core/lib/asset-saver.js';
const coreTestDefnsPath =
path.join(LH_ROOT, 'cli/test/smokehouse/core-tests.js');
/**
* Possible Lighthouse runners. Loaded dynamically so e.g. a CLI run isn't
* contingent on having built all the bundles.
*/
const runnerPaths = {
cli: '../lighthouse-runners/cli.js',
bundle: '../lighthouse-runners/bundle.js',
devtools: '../lighthouse-runners/devtools.js',
};
/**
* Determine batches of smoketests to run, based on the `requestedIds`.
* @param {Array<Smokehouse.TestDfn>} allTestDefns
* @param {Array<string>} requestedIds
* @param {Set<string>} excludedTests
* @return {Array<Smokehouse.TestDfn>}
*/
function getDefinitionsToRun(allTestDefns, requestedIds, excludedTests) {
let smokes = [];
const usage = ` ${log.dim}yarn smoke ${allTestDefns.map(t => t.id).join(' ')}${log.reset}\n`;
if (requestedIds.length === 0) {
smokes = [...allTestDefns];
console.log('Running ALL smoketests. Equivalent to:');
console.log(usage);
} else {
smokes = allTestDefns.filter(test => {
// Include all tests that *include* requested id.
// e.g. a requested 'pwa' will match 'pwa-airhorner', 'pwa-caltrain', etc
return requestedIds.some(requestedId => test.id.includes(requestedId));
});
console.log(`Running ONLY smoketests for: ${smokes.map(t => t.id).join(' ')}\n`);
}
const unmatchedIds = requestedIds.filter(requestedId => {
return !allTestDefns.map(t => t.id).some(id => id.includes(requestedId));
});
if (unmatchedIds.length) {
console.log(log.redify(`Smoketests not found for: ${unmatchedIds.join(' ')}`));
console.log(`Check test exclusions (${[...excludedTests].join(' ')})\n`);
console.log(usage);
}
if (!smokes.length) {
throw new Error('no smoketest found to run');
}
return smokes;
}
/**
* Prune the `networkRequests` from the test expectations when `takeNetworkRequestUrls`
* is not defined. Custom servers may not have this method available in-process.
* Also asserts that any expectation with `networkRequests` is run serially. For core
* tests, we don't currently have a good way to map requests to test definitions if
* the tests are run in parallel.
* @param {Array<Smokehouse.TestDfn>} testDefns
* @param {Function|undefined} takeNetworkRequestUrls
* @return {Array<Smokehouse.TestDfn>}
*/
function pruneExpectedNetworkRequests(testDefns, takeNetworkRequestUrls) {
const pruneNetworkRequests = !takeNetworkRequestUrls;
const clonedDefns = cloneDeep(testDefns);
for (const {id, expectations, runSerially} of clonedDefns) {
if (!runSerially && expectations.networkRequests) {
throw new Error(`'${id}' must be set to 'runSerially: true' to assert 'networkRequests'`);
}
if (pruneNetworkRequests && expectations.networkRequests) {
// eslint-disable-next-line max-len
const msg = `'networkRequests' cannot be asserted in test '${id}'. They should only be asserted on tests from an in-process server`;
if (process.env.CI) {
// If we're in CI, we require any networkRequests expectations to be asserted.
throw new Error(msg);
}
console.warn(log.redify('Warning:'),
`${msg}. Pruning expectation: ${JSON.stringify(expectations.networkRequests)}`);
expectations.networkRequests = undefined;
}
}
return clonedDefns;
}
/**
* CLI entry point.
*/
async function begin() {
const y = yargs(yargsHelpers.hideBin(process.argv));
const rawArgv = y
.help('help')
.usage('node $0 [<options>] <test-ids>')
.example('node $0 -j=1 pwa seo', 'run pwa and seo tests serially')
.option('_', {
array: true,
type: 'string',
})
.options({
'debug': {
type: 'boolean',
default: false,
describe: 'Save test artifacts and output verbose logs',
},
'legacy-navigation': {
type: 'boolean',
default: false,
describe: 'Use the legacy navigation runner',
},
'jobs': {
type: 'number',
alias: 'j',
describe: 'Manually set the number of jobs to run at once. `1` runs all tests serially',
},
'retries': {
type: 'number',
describe: 'The number of times to retry failing tests before accepting. Defaults to 0',
},
'runner': {
default: 'cli',
choices: ['cli', 'bundle', 'devtools'],
describe: 'The method of running Lighthouse',
},
'tests-path': {
type: 'string',
describe: 'The path to a set of test definitions to run. Defaults to core smoke tests.',
},
'shard': {
type: 'string',
// eslint-disable-next-line max-len
describe: 'A argument of the form "n/d", which divides the selected tests into d groups and runs the nth group. n and d must be positive integers with 1 ≤ n ≤ d.',
},
'ignore-exclusions': {
type: 'boolean',
default: false,
describe: 'Ignore any smoke test exclusions set.',
},
})
.wrap(y.terminalWidth())
.argv;
// Augmenting yargs type with auto-camelCasing breaks in tsc@4.1.2 and @types/yargs@15.0.11,
// so for now cast to add yarg's camelCase properties to type.
const argv =
/** @type {Awaited<typeof rawArgv> & LH.Util.CamelCasify<Awaited<typeof rawArgv>>} */ (rawArgv);
const jobs = Number.isFinite(argv.jobs) ? argv.jobs : undefined;
const retries = Number.isFinite(argv.retries) ? argv.retries : undefined;
const runnerPath = runnerPaths[/** @type {keyof typeof runnerPaths} */ (argv.runner)];
if (argv.runner === 'bundle') {
console.log('\n✨ Be sure to have recently run this: yarn build-all');
}
const {runLighthouse, setup} = await import(runnerPath);
runLighthouse.runnerName = argv.runner;
// Find test definition file and filter by requestedTestIds.
let testDefnPath = argv.testsPath || coreTestDefnsPath;
testDefnPath = path.resolve(process.cwd(), testDefnPath);
const requestedTestIds = argv._;
const {default: rawTestDefns} = await import(url.pathToFileURL(testDefnPath).href);
const allTestDefns = updateTestDefnFormat(rawTestDefns);
const excludedTests = new Set(exclusions[argv.runner] || []);
const filteredTestDefns = argv.ignoreExclusions ?
allTestDefns : allTestDefns.filter(test => !excludedTests.has(test.id));
const requestedTestDefns = getDefinitionsToRun(filteredTestDefns,
requestedTestIds, excludedTests);
const testDefns = getShardedDefinitions(requestedTestDefns, argv.shard);
let smokehouseResult;
let servers;
let takeNetworkRequestUrls = undefined;
try {
// If running the core tests, spin up the test server.
if (testDefnPath === coreTestDefnsPath) {
const {createServers} = await import('../../fixtures/static-server.js');
servers = await createServers();
takeNetworkRequestUrls = servers[0].takeRequestUrls.bind(servers[0]);
}
const prunedTestDefns = pruneExpectedNetworkRequests(testDefns, takeNetworkRequestUrls);
const options = {
jobs,
retries,
isDebug: argv.debug,
useLegacyNavigation: argv.legacyNavigation,
lighthouseRunner: runLighthouse,
takeNetworkRequestUrls,
setup,
};
smokehouseResult = (await runSmokehouse(prunedTestDefns, options));
} finally {
servers?.forEach(s => s.close());
}
if (!smokehouseResult.success) {
const failedTestResults = smokehouseResult.testResults.filter(r => r.failed);
// Save failed runs to directory. In CI, this is uploaded as an artifact.
const failuresDir = `${LH_ROOT}/.tmp/smokehouse-failures`;
fs.rmSync(failuresDir, {recursive: true, force: true});
fs.mkdirSync(failuresDir);
for (const testResult of failedTestResults) {
for (let i = 0; i < testResult.runs.length; i++) {
const runDir = `${failuresDir}/${i}/${testResult.id}`;
fs.mkdirSync(runDir, {recursive: true});
const run = testResult.runs[i];
await saveArtifacts(run.artifacts, runDir);
await saveLhr(run.lhr, runDir);
fs.writeFileSync(`${runDir}/assertionLog.txt`, run.assertionLog);
fs.writeFileSync(`${runDir}/lighthouseLog.txt`, run.lighthouseLog);
if (run.networkRequests) {
fs.writeFileSync(`${runDir}/networkRequests.txt`, run.networkRequests.join('\n'));
}
}
}
const cmd = `yarn smoke ${failedTestResults.map(r => r.id).join(' ')}`;
console.log(`rerun failures: ${cmd}`);
}
const exitCode = smokehouseResult.success ? 0 : 1;
process.exit(exitCode);
}
await begin();

View File

@@ -0,0 +1,21 @@
/**
* @license Copyright 2019 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/**
* An extension of Error that includes any stdout or stderr from a child
* process. Based on the error thrown by `child_process.exec()`.
* https://github.com/nodejs/node/blob/3aeae8d81b7b78668c37f7a07a72d94781126d49/lib/child_process.js#L150-L176
*/
export class ChildProcessError extends Error {
/**
* @param {string} message
* @param {string=} stdout
* @param {string=} stderr
*/
constructor(message: string, stdout?: string | undefined, stderr?: string | undefined);
stdout: string;
stderr: string;
}
//# sourceMappingURL=child-process-error.d.ts.map

View File

@@ -0,0 +1,25 @@
/**
* @license Copyright 2019 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/**
* An extension of Error that includes any stdout or stderr from a child
* process. Based on the error thrown by `child_process.exec()`.
* https://github.com/nodejs/node/blob/3aeae8d81b7b78668c37f7a07a72d94781126d49/lib/child_process.js#L150-L176
*/
class ChildProcessError extends Error {
/**
* @param {string} message
* @param {string=} stdout
* @param {string=} stderr
*/
constructor(message, stdout = '', stderr = '') {
super(message);
this.stdout = stdout;
this.stderr = stderr;
}
}
export {ChildProcessError};

View File

@@ -0,0 +1,78 @@
/**
* @license Copyright 2019 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/**
* A class that maintains a concurrency pool to coordinate many jobs that should
* only be run `concurrencyLimit` at a time.
* API inspired by http://bluebirdjs.com/docs/api/promise.map.html, but
* independent callers of `concurrentMap()` share the same concurrency limit.
*/
export class ConcurrentMapper {
/**
* Runs callbackfn on `values` in parallel, at a max of `concurrency` at a
* time. Resolves to an array of the results or rejects with the first
* rejected result. Default `concurrency` limit is `Infinity`.
* @template T, U
* @param {Array<T>} values
* @param {(value: T, index: number, array: Array<T>) => Promise<U>} callbackfn
* @param {{concurrency: number}} [options]
* @return {Promise<Array<U>>}
*/
static map<T_1, U_2>(values: T_1[], callbackfn: (value: T_1, index: number, array: T_1[]) => Promise<U_2>, options?: {
concurrency: number;
} | undefined): Promise<U_2[]>;
/** @type {Set<Promise<unknown>>} */
_promisePool: Set<Promise<unknown>>;
/**
* The limits of all currently running jobs. There will be duplicates.
* @type {Array<number>}
*/
_allConcurrencyLimits: Array<number>;
/**
* Returns whether there are fewer running jobs than the minimum current
* concurrency limit and the proposed new `concurrencyLimit`.
* @param {number} concurrencyLimit
*/
_canRunMoreAtLimit(concurrencyLimit: number): boolean;
/**
* Add a job to pool.
* @param {Promise<unknown>} job
* @param {number} concurrencyLimit
*/
_addJob(job: Promise<unknown>, concurrencyLimit: number): void;
/**
* Remove a job from pool.
* @param {Promise<unknown>} job
* @param {number} concurrencyLimit
*/
_removeJob(job: Promise<unknown>, concurrencyLimit: number): void;
/**
* Runs callbackfn on `values` in parallel, at a max of `concurrency` at
* a time across all callers on this instance. Resolves to an array of the
* results (for each caller separately) or rejects with the first rejected
* result. Default `concurrency` limit is `Infinity`.
* @template T, U
* @param {Array<T>} values
* @param {(value: T, index: number, array: Array<T>) => Promise<U>} callbackfn
* @param {{concurrency: number}} [options]
* @return {Promise<Array<U>>}
*/
pooledMap<T, U>(values: T[], callbackfn: (value: T, index: number, array: T[]) => Promise<U>, options?: {
concurrency: number;
} | undefined): Promise<U[]>;
/**
* Runs `fn` concurrent to other operations in the pool, at a max of
* `concurrency` at a time across all callers on this instance. Default
* `concurrency` limit is `Infinity`.
* @template U
* @param {() => Promise<U>} fn
* @param {{concurrency: number}} [options]
* @return {Promise<U>}
*/
runInPool<U_1>(fn: () => Promise<U_1>, options?: {
concurrency: number;
} | undefined): Promise<U_1>;
}
//# sourceMappingURL=concurrent-mapper.d.ts.map

View File

@@ -0,0 +1,125 @@
/**
* @license Copyright 2019 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/**
* A class that maintains a concurrency pool to coordinate many jobs that should
* only be run `concurrencyLimit` at a time.
* API inspired by http://bluebirdjs.com/docs/api/promise.map.html, but
* independent callers of `concurrentMap()` share the same concurrency limit.
*/
class ConcurrentMapper {
constructor() {
/** @type {Set<Promise<unknown>>} */
this._promisePool = new Set();
/**
* The limits of all currently running jobs. There will be duplicates.
* @type {Array<number>}
*/
this._allConcurrencyLimits = [];
}
/**
* Runs callbackfn on `values` in parallel, at a max of `concurrency` at a
* time. Resolves to an array of the results or rejects with the first
* rejected result. Default `concurrency` limit is `Infinity`.
* @template T, U
* @param {Array<T>} values
* @param {(value: T, index: number, array: Array<T>) => Promise<U>} callbackfn
* @param {{concurrency: number}} [options]
* @return {Promise<Array<U>>}
*/
static async map(values, callbackfn, options) {
const cm = new ConcurrentMapper();
return cm.pooledMap(values, callbackfn, options);
}
/**
* Returns whether there are fewer running jobs than the minimum current
* concurrency limit and the proposed new `concurrencyLimit`.
* @param {number} concurrencyLimit
*/
_canRunMoreAtLimit(concurrencyLimit) {
return this._promisePool.size < concurrencyLimit &&
this._promisePool.size < Math.min(...this._allConcurrencyLimits);
}
/**
* Add a job to pool.
* @param {Promise<unknown>} job
* @param {number} concurrencyLimit
*/
_addJob(job, concurrencyLimit) {
this._promisePool.add(job);
this._allConcurrencyLimits.push(concurrencyLimit);
}
/**
* Remove a job from pool.
* @param {Promise<unknown>} job
* @param {number} concurrencyLimit
*/
_removeJob(job, concurrencyLimit) {
this._promisePool.delete(job);
const limitIndex = this._allConcurrencyLimits.indexOf(concurrencyLimit);
if (limitIndex === -1) {
throw new Error('No current limit found for finishing job');
}
this._allConcurrencyLimits.splice(limitIndex, 1);
}
/**
* Runs callbackfn on `values` in parallel, at a max of `concurrency` at
* a time across all callers on this instance. Resolves to an array of the
* results (for each caller separately) or rejects with the first rejected
* result. Default `concurrency` limit is `Infinity`.
* @template T, U
* @param {Array<T>} values
* @param {(value: T, index: number, array: Array<T>) => Promise<U>} callbackfn
* @param {{concurrency: number}} [options]
* @return {Promise<Array<U>>}
*/
async pooledMap(values, callbackfn, options = {concurrency: Infinity}) {
const {concurrency} = options;
const result = [];
for (let i = 0; i < values.length; i++) {
// Wait until concurrency allows another run.
while (!this._canRunMoreAtLimit(concurrency)) {
// Unconditionally catch since we only care about our own failures
// (caught in the Promise.all below), not other callers.
await Promise.race(this._promisePool).catch(() => {});
}
// innerPromise removes itself from the pool and resolves on return from callback.
const innerPromise = callbackfn(values[i], i, values)
.finally(() => this._removeJob(innerPromise, concurrency));
this._addJob(innerPromise, concurrency);
result.push(innerPromise);
}
return Promise.all(result);
}
/**
* Runs `fn` concurrent to other operations in the pool, at a max of
* `concurrency` at a time across all callers on this instance. Default
* `concurrency` limit is `Infinity`.
* @template U
* @param {() => Promise<U>} fn
* @param {{concurrency: number}} [options]
* @return {Promise<U>}
*/
async runInPool(fn, options = {concurrency: Infinity}) {
// Let pooledMap handle the pool management for the cost of boxing a fake `value`.
const result = await this.pooledMap([''], fn, options);
return result[0];
}
}
export {ConcurrentMapper};

View File

@@ -0,0 +1,33 @@
/**
* @license Copyright 2019 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/**
* A simple buffered log to use in place of `console`.
*/
export class LocalConsole {
_log: string;
/**
* @param {string} str
*/
log(str: string): void;
/**
* Log but without the ending newline.
* @param {string} str
*/
write(str: string): void;
/**
* @return {string}
*/
getLog(): string;
/**
* Append a stdout and stderr to this log.
* @param {{stdout: string, stderr: string}} stdStrings
*/
adoptStdStrings(stdStrings: {
stdout: string;
stderr: string;
}): void;
}
//# sourceMappingURL=local-console.d.ts.map

View File

@@ -0,0 +1,50 @@
/**
* @license Copyright 2019 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/**
* A simple buffered log to use in place of `console`.
*/
class LocalConsole {
constructor() {
this._log = '';
}
/**
* @param {string} str
*/
log(str) {
this._log += str + '\n';
}
/**
* Log but without the ending newline.
* @param {string} str
*/
write(str) {
this._log += str;
}
/**
* @return {string}
*/
getLog() {
return this._log;
}
/**
* Append a stdout and stderr to this log.
* @param {{stdout: string, stderr: string}} stdStrings
*/
adoptStdStrings(stdStrings) {
this.write(stdStrings.stdout);
// stderr accrues many empty lines. Don't log unless there's content.
if (/\S/.test(stdStrings.stderr)) {
this.write(stdStrings.stderr);
}
}
}
export {LocalConsole};

View File

@@ -0,0 +1,16 @@
/**
* Launch Chrome and do a full Lighthouse run via the Lighthouse DevTools bundle.
* @param {string} url
* @param {LH.Config=} config
* @param {{isDebug?: boolean, useLegacyNavigation?: boolean}=} testRunnerOptions
* @return {Promise<{lhr: LH.Result, artifacts: LH.Artifacts, log: string}>}
*/
export function runLighthouse(url: string, config?: LH.Config | undefined, testRunnerOptions?: {
isDebug?: boolean;
useLegacyNavigation?: boolean;
} | undefined): Promise<{
lhr: LH.Result;
artifacts: LH.Artifacts;
log: string;
}>;
//# sourceMappingURL=bundle.d.ts.map

View File

@@ -0,0 +1,156 @@
/**
* @license Copyright 2019 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/**
* @fileoverview A runner that launches Chrome and executes Lighthouse via a
* bundle to test that bundling has produced correct and runnable code.
* Currently uses `lighthouse-dt-bundle.js`.
* Runs in a worker to avoid messing up marky's global state.
*/
import fs from 'fs';
import os from 'os';
import {Worker, isMainThread, parentPort, workerData} from 'worker_threads';
import {once} from 'events';
import puppeteer from 'puppeteer-core';
import ChromeLauncher from 'chrome-launcher';
import {CriConnection} from '../../../../core/legacy/gather/connections/cri.js';
import {LH_ROOT} from '../../../../root.js';
import {loadArtifacts, saveArtifacts} from '../../../../core/lib/asset-saver.js';
// This runs only in the worker. The rest runs on the main thread.
if (!isMainThread && parentPort) {
(async () => {
const {url, config, testRunnerOptions} = workerData;
try {
const result = await runBundledLighthouse(url, config, testRunnerOptions);
// Save to assets directory because LighthouseError won't survive postMessage.
const assetsDir = fs.mkdtempSync(os.tmpdir() + '/smoke-bundle-assets-');
await saveArtifacts(result.artifacts, assetsDir);
const value = {
lhr: result.lhr,
assetsDir,
};
parentPort?.postMessage({type: 'result', value});
} catch (err) {
console.error(err);
parentPort?.postMessage({type: 'error', value: err});
}
})();
}
/**
* @param {string} url
* @param {LH.Config|undefined} config
* @param {{isDebug?: boolean, useLegacyNavigation?: boolean}} testRunnerOptions
* @return {Promise<{lhr: LH.Result, artifacts: LH.Artifacts}>}
*/
async function runBundledLighthouse(url, config, testRunnerOptions) {
if (isMainThread || !parentPort) {
throw new Error('must be called in worker');
}
const originalBuffer = global.Buffer;
const originalRequire = global.require;
if (typeof globalThis === 'undefined') {
// @ts-expect-error - exposing for loading of dt-bundle.
global.globalThis = global;
}
// Load bundle, which creates a `global.runBundledLighthouse`.
await import(LH_ROOT + '/dist/lighthouse-dt-bundle.js');
global.require = originalRequire;
global.Buffer = originalBuffer;
/** @type {import('../../../../core/index.js')['default']} */
// @ts-expect-error - not worth giving test global an actual type.
const lighthouse = global.runBundledLighthouse;
/** @type {import('../../../../core/index.js')['legacyNavigation']} */
// @ts-expect-error - not worth giving test global an actual type.
const legacyNavigation = global.runBundledLighthouseLegacyNavigation;
// Launch and connect to Chrome.
const launchedChrome = await ChromeLauncher.launch();
const port = launchedChrome.port;
// Run Lighthouse.
try {
const logLevel = testRunnerOptions.isDebug ? 'verbose' : 'info';
let runnerResult;
if (testRunnerOptions.useLegacyNavigation) {
const connection = new CriConnection(port);
runnerResult =
await legacyNavigation(url, {port, logLevel}, config, connection);
} else {
// Puppeteer is not included in the bundle, we must create the page here.
const browser = await puppeteer.connect({browserURL: `http://localhost:${port}`});
const page = await browser.newPage();
runnerResult = await lighthouse(url, {port, logLevel}, config, page);
}
if (!runnerResult) throw new Error('No runnerResult');
return {
lhr: runnerResult.lhr,
artifacts: runnerResult.artifacts,
};
} finally {
// Clean up and return results.
await launchedChrome.kill();
}
}
/**
* Launch Chrome and do a full Lighthouse run via the Lighthouse DevTools bundle.
* @param {string} url
* @param {LH.Config=} config
* @param {{isDebug?: boolean, useLegacyNavigation?: boolean}=} testRunnerOptions
* @return {Promise<{lhr: LH.Result, artifacts: LH.Artifacts, log: string}>}
*/
async function runLighthouse(url, config, testRunnerOptions = {}) {
/** @type {string[]} */
const logs = [];
const worker = new Worker(new URL(import.meta.url), {
stdout: true,
stderr: true,
workerData: {url, config, testRunnerOptions},
});
worker.stdout.setEncoding('utf8');
worker.stderr.setEncoding('utf8');
worker.stdout.addListener('data', (data) => {
logs.push(`[STDOUT] ${data}`);
});
worker.stderr.addListener('data', (data) => {
logs.push(`[STDERR] ${data}`);
});
const [workerResponse] = await once(worker, 'message');
const log = logs.join('') + '\n';
if (workerResponse.type === 'error') {
new Error(`Worker returned an error: ${workerResponse.value}\nLog:\n${log}`);
}
const result = workerResponse.value;
if (!result.lhr || !result.assetsDir) {
throw new Error(`invalid response from worker:\n${JSON.stringify(result, null, 2)}`);
}
const artifacts = loadArtifacts(result.assetsDir);
fs.rmSync(result.assetsDir, {recursive: true});
return {
lhr: result.lhr,
artifacts,
log,
};
}
export {
runLighthouse,
};

View File

@@ -0,0 +1,18 @@
/// <reference path="../../../../../../types/internal/lighthouse-logger.d.ts" />
/**
* Launch Chrome and do a full Lighthouse run via the Lighthouse CLI.
* @param {string} url
* @param {LH.Config=} config
* @param {{isDebug?: boolean, useFraggleRock?: boolean}=} testRunnerOptions
* @return {Promise<{lhr: LH.Result, artifacts: LH.Artifacts, log: string}>}
*/
export function runLighthouse(url: string, config?: LH.Config | undefined, testRunnerOptions?: {
isDebug?: boolean | undefined;
useFraggleRock?: boolean | undefined;
} | undefined): Promise<{
lhr: LH.Result;
artifacts: LH.Artifacts;
log: string;
}>;
import log from 'lighthouse-logger';
//# sourceMappingURL=cli.d.ts.map

View File

@@ -0,0 +1,134 @@
/**
* @license Copyright 2019 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/**
* @fileoverview A runner that executes Lighthouse via the Lighthouse CLI to
* test the full pipeline, from parsing arguments on the command line to writing
* results to disk. When complete, reads back the artifacts and LHR and returns
* them.
*/
import {promises as fs} from 'fs';
import {promisify} from 'util';
import {execFile} from 'child_process';
import log from 'lighthouse-logger';
import * as assetSaver from '../../../../core/lib/asset-saver.js';
import {LocalConsole} from '../lib/local-console.js';
import {ChildProcessError} from '../lib/child-process-error.js';
import {LH_ROOT} from '../../../../root.js';
const execFileAsync = promisify(execFile);
/**
* Launch Chrome and do a full Lighthouse run via the Lighthouse CLI.
* @param {string} url
* @param {LH.Config=} config
* @param {{isDebug?: boolean, useFraggleRock?: boolean}=} testRunnerOptions
* @return {Promise<{lhr: LH.Result, artifacts: LH.Artifacts, log: string}>}
*/
async function runLighthouse(url, config, testRunnerOptions = {}) {
const {isDebug} = testRunnerOptions;
const tmpDir = `${LH_ROOT}/.tmp/smokehouse`;
await fs.mkdir(tmpDir, {recursive: true});
const tmpPath = await fs.mkdtemp(`${tmpDir}/smokehouse-`);
return internalRun(url, tmpPath, config, testRunnerOptions)
// Wait for internalRun() before removing scratch directory.
.finally(() => !isDebug && fs.rm(tmpPath, {recursive: true, force: true}));
}
/**
* Internal runner.
* @param {string} url
* @param {string} tmpPath
* @param {LH.Config=} config
* @param {{isDebug?: boolean, useLegacyNavigation?: boolean}=} options
* @return {Promise<{lhr: LH.Result, artifacts: LH.Artifacts, log: string}>}
*/
async function internalRun(url, tmpPath, config, options) {
const {isDebug = false, useLegacyNavigation = false} = options || {};
const localConsole = new LocalConsole();
const outputPath = `${tmpPath}/smokehouse.report.json`;
const artifactsDirectory = `${tmpPath}/artifacts/`;
const args = [
`${LH_ROOT}/cli/index.js`,
`${url}`,
`--output-path=${outputPath}`,
'--output=json',
`-G=${artifactsDirectory}`,
`-A=${artifactsDirectory}`,
'--port=0',
'--quiet',
];
if (useLegacyNavigation) {
args.push('--legacy-navigation');
}
// Config can be optionally provided.
if (config) {
const configPath = `${tmpPath}/config.json`;
await fs.writeFile(configPath, JSON.stringify(config));
args.push(`--config-path=${configPath}`);
}
const command = 'node';
const env = {...process.env, NODE_ENV: 'test'};
localConsole.log(`${log.dim}$ ${command} ${args.join(' ')} ${log.reset}`);
/** @type {{stdout: string, stderr: string, code?: number}} */
let execResult;
try {
execResult = await execFileAsync(command, args, {env});
} catch (e) {
// exec-thrown errors have stdout, stderr, and exit code from child process.
execResult = e;
}
const exitCode = execResult.code || 0;
if (isDebug) {
localConsole.log(`exit code ${exitCode}`);
localConsole.log(`STDOUT: ${execResult.stdout}`);
localConsole.log(`STDERR: ${execResult.stderr}`);
}
try {
await fs.access(outputPath);
} catch (e) {
throw new ChildProcessError(`Lighthouse run failed to produce a report and exited with ${exitCode}.`, // eslint-disable-line max-len
localConsole.getLog());
}
/** @type {LH.Result} */
const lhr = JSON.parse(await fs.readFile(outputPath, 'utf8'));
const artifacts = assetSaver.loadArtifacts(artifactsDirectory);
// Output has been established as existing, so can log for debug.
if (isDebug) {
localConsole.log(`LHR output available at: ${outputPath}`);
localConsole.log(`Artifacts avaiable in: ${artifactsDirectory}`);
}
// There should either be both an error exitCode and a lhr.runtimeError or neither.
if (Boolean(exitCode) !== Boolean(lhr.runtimeError)) {
const runtimeErrorCode = lhr.runtimeError?.code;
throw new ChildProcessError(`Lighthouse did not exit with an error correctly, exiting with ${exitCode} but with runtimeError '${runtimeErrorCode}'`, // eslint-disable-line max-len
localConsole.getLog());
}
return {
lhr,
artifacts,
log: localConsole.getLog(),
};
}
export {
runLighthouse,
};

View File

@@ -0,0 +1,23 @@
/**
* Launch Chrome and do a full Lighthouse run via DevTools.
* By default, the latest DevTools frontend is used (.tmp/chromium-web-tests/devtools/devtools-frontend)
* unless DEVTOOLS_PATH is set.
* CHROME_PATH determines which Chrome is usedotherwise the default is puppeteer's chrome binary.
* @param {string} url
* @param {LH.Config=} config
* @param {{isDebug?: boolean, useLegacyNavigation?: boolean}=} testRunnerOptions
* @return {Promise<{lhr: LH.Result, artifacts: LH.Artifacts, log: string}>}
*/
export function runLighthouse(url: string, config?: LH.Config | undefined, testRunnerOptions?: {
isDebug?: boolean;
useLegacyNavigation?: boolean;
} | undefined): Promise<{
lhr: LH.Result;
artifacts: LH.Artifacts;
log: string;
}>;
/**
* Download/pull latest DevTools, build Lighthouse for DevTools, roll to DevTools, and build DevTools.
*/
export function setup(): Promise<void>;
//# sourceMappingURL=devtools.d.ts.map

View File

@@ -0,0 +1,72 @@
/**
* @license Copyright 2021 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/**
* @fileoverview A runner that launches Chrome and executes Lighthouse via DevTools.
*/
import fs from 'fs';
import os from 'os';
import {execFileSync} from 'child_process';
import {LH_ROOT} from '../../../../root.js';
import {testUrlFromDevtools} from '../../../../core/scripts/pptr-run-devtools.js';
const devtoolsDir =
process.env.DEVTOOLS_PATH || `${LH_ROOT}/.tmp/chromium-web-tests/devtools/devtools-frontend`;
/**
* Download/pull latest DevTools, build Lighthouse for DevTools, roll to DevTools, and build DevTools.
*/
async function setup() {
if (process.env.CI) return;
process.env.DEVTOOLS_PATH = devtoolsDir;
execFileSync('bash',
['core/test/devtools-tests/download-devtools.sh'],
{stdio: 'inherit'}
);
execFileSync('bash',
['core/test/devtools-tests/roll-devtools.sh'],
{stdio: 'inherit'}
);
}
/**
* Launch Chrome and do a full Lighthouse run via DevTools.
* By default, the latest DevTools frontend is used (.tmp/chromium-web-tests/devtools/devtools-frontend)
* unless DEVTOOLS_PATH is set.
* CHROME_PATH determines which Chrome is usedotherwise the default is puppeteer's chrome binary.
* @param {string} url
* @param {LH.Config=} config
* @param {{isDebug?: boolean, useLegacyNavigation?: boolean}=} testRunnerOptions
* @return {Promise<{lhr: LH.Result, artifacts: LH.Artifacts, log: string}>}
*/
async function runLighthouse(url, config, testRunnerOptions = {}) {
const chromeFlags = [
`--custom-devtools-frontend=file://${devtoolsDir}/out/LighthouseIntegration/gen/front_end`,
];
const {lhr, artifacts, logs} = await testUrlFromDevtools(url, {
config,
chromeFlags,
useLegacyNavigation: testRunnerOptions.useLegacyNavigation,
});
if (testRunnerOptions.isDebug) {
const outputDir = fs.mkdtempSync(os.tmpdir() + '/lh-smoke-cdt-runner-');
fs.writeFileSync(`${outputDir}/lhr.json`, JSON.stringify(lhr));
fs.writeFileSync(`${outputDir}/artifacts.json`, JSON.stringify(artifacts));
console.log(`${url} results saved at ${outputDir}`);
}
const log = logs.join('') + '\n';
return {lhr, artifacts, log};
}
export {
runLighthouse,
setup,
};

208
node_modules/lighthouse/cli/test/smokehouse/readme.md generated vendored Normal file
View File

@@ -0,0 +1,208 @@
# Smokehouse
Smokehouse is the Lighthouse end-to-end/smoke test runner. It takes in a set of URLs (usually pointing to custom-built test sites), runs Lighthouse on them, and compares the results against a set of expectations.
By default this is done using the Lighthouse CLI (to exercise the full pipeline) with the tests listed in [`smokehouse/core-tests.js`](./core-tests.js).
## Options
See [`SmokehouseOptions`](https://github.com/GoogleChrome/lighthouse/blob/main/cli/test/smokehouse/smokehouse.js#L23).
## Test definitions
| Name | Type | Description |
| -------------- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `id` | `string` | The string identifier of the test. |
| `expectations` | `{lhr: Object, artifacts: Object}` | See below. |
| `config` | `LH.Config` (optional) | An optional Lighthouse config. If not specified, the default config is used. |
| `runSerially` | `boolean` (optional) | An optional flag. If set to true, the test won't be run in parallel to other tests. Useful if the test is performance sensitive. |
### Expectations
The smoke test expectations can assert the values of the Lighthouse result (the `lhr`) and gathered `artifacts` for multiple URLs. The URL to be tested is specified in the expectations's `requestedUrl` field.
The expectations are asserted as a subset of the actual results: all values in the expectations must be in the actual results, but not all actual results must be asserted.
Examples can be found in the [core tests](./test-definitions/).
### Special numeric expectations
If checking a number somewhere in the Lighthouse results, numeric comparisons can be used in place of a raw expected number. This allows asserting ranges or categories of numbers where the exact value isn't necessarily important, or to allow for expected variability in a test.
The comparator is specified with a string, and the actual value being tested must be a number. Whitespace may be included in the string for readability.
The following operators are supported:
| Operator | Example |
| -------- | ------------ |
| `>` | `'>0'` |
| `>=` | `'>=5'` |
| `<` | `'<1'` |
| `<=` | `'<=10'` |
| `+/-` | `'100+/-10'` |
| `±` | `'100±10'` |
**Examples**:
| Actual | Expected | Result |
| -- | -- | -- |
| `{timeInMs: 50}` | `{timeInMs: '>0'}` | ✅ PASS |
| `{numericValue: 3969.135}` | `{numericValue: '1000±100'}` | ❌ FAIL |
### Special string expectations
If checking a string somewhere in the Lighthouse results, a regular expression can be used in place of a string literal.
**Examples**:
| Actual | Expected | Result |
| -- | -- | -- |
| `{displayValue: '4.0 s'}` | `{displayValue: /^\d+\.\d+/}` | ✅ PASS |
| `{url: 'http://example.com'}` | `{url: /^https/}` | ❌ FAIL |
### Special array expectations
Individual elements of an array can be asserted by using numeric properties in an object, e.g. asserting the third element in an array is 5: `{2: 5}`.
However, if an array literal is used as the expectation, an extra condition is enforced that the actual array _must_ have the same length as the provided expected array.
Arrays and objects can be checked against a subset of elements using the special `_includes` property. The value of `_includes` _must_ be an array. Each assertion in `_includes` will remove the matching item from consideration for the rest.
Arrays and objects can be asserted to not match any elements using the special `_excludes` property. The value of `_excludes` _must_ be an array. If an `_includes` check is defined before an `_excludes` check, only the element not matched under the previous will be considered.
If an object is checked using `_includes` or `_excludes`, it will be checked against the `Object.entries` array.
**Examples**:
| Actual | Expected | Result |
| -- | -- | -- |
| `[{url: 'http://badssl.com'}, {url: 'http://example.com'}]` | `{1: {url: 'http://example.com'}}` | ✅ PASS |
| `[{timeInMs: 5}, {timeInMs: 15}]` | `{length: 2}` | ✅ PASS |
| `[{timeInMs: 5}, {timeInMs: 15}]` | `{_includes: [{timeInMs: 5}]}` | ✅ PASS |
| `[{timeInMs: 5}, {timeInMs: 15}]` | `{_includes: [{timeInMs: 5}, {timeInMs: 5}]}` | ❌ FAIL |
| `[{timeInMs: 5}, {timeInMs: 15}]` | `{_includes: [{timeInMs: 5}], _excludes: [{timeInMs: 5}]}` | ✅ PASS |
| `[{timeInMs: 5}, {timeInMs: 15}]` | `{_includes: [{timeInMs: 5}], _excludes: [{timeInMs: 15}]}` | ❌ FAIL |
| `[{timeInMs: 5}, {timeInMs: 15}]` | `{_includes: [{timeInMs: 5}], _excludes: [{}]}` | ❌ FAIL |
| `[{timeInMs: 5}, {timeInMs: 15}]` | `[{timeInMs: 5}]` | ❌ FAIL |
| `{'foo': 1}` | `{_includes: [['foo', 1]]}` | ✅ PASS |
| `{'foo': 1, 'bar': 2}` | `{_includes: [['foo', 1]], _excludes: [['bar', 2]]}` | ❌ FAIL |
### Special environment checks
If an expectation requires a minimum version of Chromium, use `_minChromiumVersion: xx.x.x.x` to conditionally ignore that entire object in the expectation.
Can be as specific as you like (`_minChromiumVersion: xx` works too).
**Examples**:
```js
{
artifacts: {
InspectorIssues: {
// Mixed Content issues weren't added to the protocol until M84.
_minChromiumVersion: '84', // The entire `InspectorIssues` is ignored for older Chrome.
mixedContent: [
{
resourceType: 'Image',
resolutionStatus: 'MixedContentWarning',
insecureURL: 'http://www.mixedcontentexamples.com/Content/Test/steveholt.jpg',
mainResourceURL: 'https://www.mixedcontentexamples.com/Test/NonSecureImage',
request: {
url: 'http://www.mixedcontentexamples.com/Content/Test/steveholt.jpg',
},
},
],
},
TraceElements: {
// ... anything here won't be ignored
}
},
```
All pruning checks:
- `_minChromiumVersion`
- `_maxChromiumVersion`
- `_legacyOnly`
- `_fraggleRockOnly`
- `_runner` (set to same value provided to CLI --runner flag, ex: `'devtools'`)
- `_excludeRunner` (set to same value provided to CLI --runner flag, ex: `'devtools'`)
## Pipeline
The different frontends launch smokehouse with a set of tests to run. Smokehouse then coordinates the tests using a particular method of running Lighthouse (CLI, as a bundle, etc).
```
Smokehouse Frontends Lighthouse Runners
+------------+
| |
| bin.js +----+ +--------------+
| | | | |
+------------+ | +-->+ cli.js |
| | | |
+------------+ | +---------------+ | +--------------+
| | | testDefns> | | config> |
| node.js +---------------->+ smokehouse.js +<---------+
| | | | | <lhr | +--------------+
+------------+ | +-------+-------+ | | |
| ^ +-->+ bundle.js |
+------------+ | | | | |
| | | | | +--------------+
| lib.js +----+ v |
| | +--------+--------+ |
+------------+ | | | +--------------+
| report/assert | | | |
| | +-->+ devtools.js |
+-----------------+ | |
+--------------+
```
### Smokehouse frontends
- `frontends/smokehouse-bin.js` - runs smokehouse from the command line
- `lib` - configurable entrypoint to smokehouse, can be bundled to run in a browser environment
- `node.js` - run smokehouse from a node process
### Smokehouse
- `smokehouse.js` - takes a set of smoke-test definitions and runs them via a passed-in runner. Smokehouse is bundleable and can run in a browser as long as runner used is bundleable as well.
### Lighthouse runners
- `lighthouse-runners/cli.js` - the original test runner, exercising the Lighthouse CLI from command-line argument parsing to the results written to disk on completion.
- `lighthouse-runners/bundle.js` - a smoke test runner that operates on an already-bundled version of Lighthouse for end-to-end testing of that version.
- `lighthouse-runners/devtools.js` - a smoke test runner that operates on Lighthouse running from inside DevTools.
## Custom smoke tests (for plugins et al.)
Smokehouse comes with a core set of test definitions, but it can run any set of tests. Custom extensions of Lighthouse (like plugins) can provide their own tests and run them via the same infrastructure. For example:
- have a test site on a public URL or via a local server (e.g. `https://localhost:8080`)
- create a test definition (e.g. in `plugin-tests.js`)
```js
const smokeTests = [{
id: 'pluginTest',
expectations: require('./expectations.js'),
// config: ..., // If left out, uses default LH config
// runSerially: true, // If test is perf-sensitive
};
module.exports = smokeTests;
```
- create a test expectations file (e.g. `expectations.js`)
```js
const expectations = [{
lhr: {
requestedUrl: 'http://localhost:8080/index.html',
finalDisplayedUrl: 'http://localhost:8080/index.html',
audits: {
'preload-as': {
score: 1,
displayValue: /^Found 0 preload requests/,
},
},
},
};
module.exports = expectations;
```
- with `lighthouse` installed as a dependency/peer dependency, run
`yarn smokehouse --tests-path plugin-tests.js`
or
`npx --no-install smokehouse --tests-path plugin-tests.js`

View File

@@ -0,0 +1,2 @@
export {};
//# sourceMappingURL=report-assert-test.d.ts.map

View File

@@ -0,0 +1,298 @@
/**
* @license Copyright 2022 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/* eslint-disable no-control-regex */
import {readJson} from '../../../core/test/test-utils.js';
import {findDifferences, getAssertionReport} from './report-assert.js';
describe('findDiffersences', () => {
const testCases = {
'works (trivial passing)': {
actual: {},
expected: {},
diffs: null,
},
'works (trivial fail)': {
actual: {},
expected: {a: 1},
diffs: [{path: '.a', actual: undefined, expected: 1}],
},
'works (trivial fail, actual undefined)': {
actual: undefined,
expected: {a: 1},
diffs: [{path: '', actual: undefined, expected: {a: 1}}],
},
'works (trivial fail, nested)': {
actual: {a: {b: 2}},
expected: {a: {b: 1}},
diffs: [{path: '.a.b', actual: 2, expected: 1}],
},
'works (trivial fail, nested actual undefined)': {
actual: {a: undefined},
expected: {a: {b: 1}},
diffs: [{path: '.a', actual: undefined, expected: {b: 1}}],
},
'works (multiple fail 1)': {
actual: {},
expected: {a: 1, b: 2},
diffs: [
{path: '.a', actual: undefined, expected: 1},
{path: '.b', actual: undefined, expected: 2},
],
},
'works (multiple fail 2)': {
actual: {nested: {array: [0, 1, 2]}},
expected: {nested: {array: [2, 1, 0]}},
diffs: [
{path: '.nested.array[0]', actual: 0, expected: 2},
{path: '.nested.array[2]', actual: 2, expected: 0},
],
},
'range (1)': {
actual: {duration: 100},
expected: {duration: '>=100'},
diffs: null,
},
'range (2)': {
actual: {},
expected: {duration: '>=100'},
diffs: [{path: '.duration', actual: undefined, expected: '>=100'}],
},
'range (3)': {
actual: {duration: 100},
expected: {duration: '>100'},
diffs: [{path: '.duration', actual: 100, expected: '>100'}],
},
'range (4)': {
actual: {duration: 100},
expected: {duration: '<100'},
diffs: [{path: '.duration', actual: 100, expected: '<100'}],
},
'array (1)': {
actual: {prices: [0, 1, 2, 3, 4, 5]},
expected: {prices: {length: 6}},
diffs: null,
},
'array (2)': {
actual: {prices: [0, 1, 2, 3, 4, 5]},
expected: {prices: {length: '>0'}},
diffs: null,
},
'array (3)': {
actual: {prices: [0, 1, 2, 3, 4, 5]},
expected: {prices: [0, 1, 2, 3, 4, 5]},
diffs: null,
},
'array (4)': {
actual: {prices: [0, 1, 2, 3, 4, 5]},
expected: {prices: [0, 1, 2, 3, 4, 5, 6]},
diffs: [
{path: '.prices[6]', actual: undefined, expected: 6},
{path: '.prices.length', actual: [0, 1, 2, 3, 4, 5], expected: [0, 1, 2, 3, 4, 5, 6]},
],
},
'array (5)': {
actual: {prices: [0, 1, 2, 3, 4, 5]},
expected: {prices: []},
diffs: [{path: '.prices.length', actual: [0, 1, 2, 3, 4, 5], expected: []}],
},
'array (6)': {
actual: {prices: [0, 1, 2, 3, 4, 5]},
expected: {prices: {'3': '>=3'}},
diffs: null,
},
'array (7)': {
actual: {prices: [0, 1, 2, {nested: 3}, 4, 5]},
expected: {prices: {'3': {nested: '>3'}}},
diffs: [{path: '.prices[3].nested', actual: 3, expected: '>3'}],
},
'_includes (1)': {
actual: {prices: [0, 1, 2, 3, 4, 5]},
expected: {prices: {_includes: [4]}},
diffs: null,
},
'_includes (2)': {
actual: {prices: [0, 1, 2, 3, 4, 5]},
expected: {prices: {_includes: [4, 4]}},
diffs: [{path: '.prices', actual: 'Item not found in array', expected: 4}],
},
'_includes (3)': {
actual: {prices: [0, 1, 2, 3, 4, 5]},
expected: {prices: {_includes: [100]}},
diffs: [{path: '.prices', actual: 'Item not found in array', expected: 100}],
},
'_includes (4)': {
actual: {prices: ['0', '1', '2', '3', '4', '5']},
expected: {prices: {_includes: [/\d/, /\d/, /\d/, /\d/, /\d/, /\d/]}},
diffs: null,
},
'_includes (object)': {
actual: {'0-alpha': 1, '1-beta': 2, '3-gamma': 3},
expected: {_includes: [
['0-alpha', '<2'],
[/[0-9]-beta/, 2],
]},
diffs: null,
},
'_excludes (1)': {
actual: {prices: [0, 1, 2, 3, 4, 5]},
expected: {prices: {_excludes: [100]}},
diffs: null,
},
'_excludes (2)': {
actual: {prices: [0, 1, 2, 3, 4, 5]},
expected: {prices: {_excludes: [2]}},
diffs: [{path: '.prices', actual: 2, expected: {
expectedExclusion: 2,
message: 'Expected to not find matching entry via _excludes',
}}],
},
'_excludes (object)': {
actual: {'0-alpha': 1, '1-beta': 2, '3-gamma': 3},
expected: {_excludes: [
[/[0-9]-beta/, 2],
]},
diffs: [{path: '', actual: ['1-beta', 2], expected: {
expectedExclusion: [/[0-9]-beta/, 2],
message: 'Expected to not find matching entry via _excludes',
}}],
},
'_includes and _excludes (1)': {
actual: {prices: [0, 1, 2, 3, 4, 5]},
expected: {prices: {_includes: [2], _excludes: [2]}},
diffs: null,
},
// Order matters.
'_includes and _excludes (2)': {
actual: {prices: [0, 1, 2, 3, 4, 5]},
expected: {prices: {_excludes: [2], _includes: [2]}},
diffs: [{path: '.prices', actual: 2, expected: {
expectedExclusion: 2,
message: 'Expected to not find matching entry via _excludes',
}}],
},
'_includes and _excludes (3)': {
actual: {prices: [0, 1, 2, 3, 4, 5]},
expected: {prices: {_includes: [2], _excludes: [2, 1]}},
diffs: [{path: '.prices', actual: 1, expected: {
expectedExclusion: 1,
message: 'Expected to not find matching entry via _excludes',
}}],
},
'_includes and _excludes (object)': {
actual: {'0-alpha': 1, '1-beta': 2, '3-gamma': 3},
expected: {
_includes: [
['0-alpha', '<2'],
],
_excludes: [
[/[0-9]-alpha/, 1],
[/[0-9]-beta/, 2],
],
},
diffs: [{path: '', actual: ['1-beta', 2], expected: {
expectedExclusion: [/[0-9]-beta/, 2],
message: 'Expected to not find matching entry via _excludes',
}}],
},
};
for (const [testName, {actual, expected, diffs}] of Object.entries(testCases)) {
it(testName, () => {
expect(findDifferences('', actual, expected)).toEqual(diffs);
});
}
});
/**
* Removes ANSI codes.
* TODO: should make it so logger can disable these.
* @param {string} text
*/
function clean(text) {
return text
.replace(/\x1B.*?m/g, '')
.replace(/\x1b.*?m/g, '')
.replace(/[✘×]/g, 'X')
.trim();
}
describe('getAssertionReport', () => {
const lhr = readJson('core/test/results/sample_v2.json');
const artifacts = readJson('core/test/results/artifacts/artifacts.json');
it('works (trivial passing)', () => {
const report = getAssertionReport({lhr, artifacts}, {
lhr: {
audits: {},
requestedUrl: 'http://localhost:10200/dobetterweb/dbw_tester.html',
finalDisplayedUrl: 'http://localhost:10200/dobetterweb/dbw_tester.html',
},
});
expect(report).toMatchObject({passed: 3, failed: 0, log: ''});
});
it('works (trivial failing)', () => {
const report = getAssertionReport({lhr, artifacts}, {
lhr: {
audits: {
'cumulative-layout-shift': {
details: {
items: [],
},
},
},
requestedUrl: 'http://localhost:10200/dobetterweb/dbw_tester.html',
finalDisplayedUrl: 'http://localhost:10200/dobetterweb/dbw_tester.html',
},
});
expect(report).toMatchObject({passed: 3, failed: 1});
expect(clean(report.log)).toMatchSnapshot();
});
it('works (trivial failing, actual undefined)', () => {
const report = getAssertionReport({lhr, artifacts}, {
lhr: {
audits: {
'cumulative-layout-shift-no-exist': {
details: {
items: [],
},
},
},
requestedUrl: 'http://localhost:10200/dobetterweb/dbw_tester.html',
finalDisplayedUrl: 'http://localhost:10200/dobetterweb/dbw_tester.html',
},
});
expect(report).toMatchObject({passed: 3, failed: 1});
expect(clean(report.log)).toMatchSnapshot();
});
it('works (multiple failing)', () => {
const report = getAssertionReport({lhr, artifacts}, {
lhr: {
audits: {
'cumulative-layout-shift': {
details: {
items: [],
blah: 123,
},
},
},
requestedUrl: 'http://localhost:10200/dobetterweb/dbw_tester.html',
finalDisplayedUrl: 'http://localhost:10200/dobetterweb/dbw_tester.html',
},
});
expect(report).toMatchObject({passed: 3, failed: 1});
expect(clean(report.log)).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,50 @@
/// <reference path="../../../../../types/internal/lighthouse-logger.d.ts" />
export type Difference = {
path: string;
actual: any;
expected: any;
};
export type Comparison = {
name: string;
actual: any;
expected: any;
equal: boolean;
diffs: Difference[] | null;
};
/**
* Log all the comparisons between actual and expected test results, then print
* summary. Returns count of passed and failed tests.
* @param {{lhr: LH.Result, artifacts: LH.Artifacts, networkRequests?: string[]}} actual
* @param {Smokehouse.ExpectedRunnerResult} expected
* @param {{runner?: string, isDebug?: boolean, useLegacyNavigation?: boolean}=} reportOptions
* @return {{passed: number, failed: number, log: string}}
*/
export function getAssertionReport(actual: {
lhr: LH.Result;
artifacts: LH.Artifacts;
networkRequests?: string[];
}, expected: Smokehouse.ExpectedRunnerResult, reportOptions?: {
runner?: string;
isDebug?: boolean;
useLegacyNavigation?: boolean;
} | undefined): {
passed: number;
failed: number;
log: string;
};
/**
* Walk down expected result, comparing to actual result. If a difference is found,
* the path to the difference is returned, along with the expected primitive value
* and the value actually found at that location. If no difference is found, returns
* null.
*
* Only checks own enumerable properties, not object prototypes, and will loop
* until the stack is exhausted, so works best with simple objects (e.g. parsed JSON).
* @param {string} path
* @param {*} actual
* @param {*} expected
* @return {Difference[]|null}
*/
export function findDifferences(path: string, actual: any, expected: any): Difference[] | null;
import log from 'lighthouse-logger';
//# sourceMappingURL=report-assert.d.ts.map

View File

@@ -0,0 +1,520 @@
/**
* @license Copyright 2019 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/**
* @fileoverview An assertion library for comparing smoke-test expectations
* against the results actually collected from Lighthouse.
*/
import cloneDeep from 'lodash/cloneDeep.js';
import log from 'lighthouse-logger';
import {LocalConsole} from './lib/local-console.js';
import {chromiumVersionCheck} from './version-check.js';
/**
* @typedef Difference
* @property {string} path
* @property {any} actual
* @property {any} expected
*/
/**
* @typedef Comparison
* @property {string} name
* @property {any} actual
* @property {any} expected
* @property {boolean} equal
* @property {Difference[]|null} diffs
*/
const NUMBER_REGEXP = /(?:\d|\.)+/.source;
const OPS_REGEXP = /<=?|>=?|\+\/-|±/.source;
// An optional number, optional whitespace, an operator, optional whitespace, a number.
const NUMERICAL_EXPECTATION_REGEXP =
new RegExp(`^(${NUMBER_REGEXP})?\\s*(${OPS_REGEXP})\\s*(${NUMBER_REGEXP})$`);
/**
* Checks if the actual value matches the expectation. Does not recursively search. This supports
* - Greater than/less than operators, e.g. "<100", ">90"
* - Regular expressions
* - Strict equality
* - plus or minus a margin of error, e.g. '10+/-5', '100±10'
*
* @param {*} actual
* @param {*} expected
* @return {boolean}
*/
function matchesExpectation(actual, expected) {
if (typeof actual === 'number' && NUMERICAL_EXPECTATION_REGEXP.test(expected)) {
const parts = expected.match(NUMERICAL_EXPECTATION_REGEXP);
const [, prefixNumber, operator, postfixNumber] = parts;
switch (operator) {
case '>':
return actual > postfixNumber;
case '>=':
return actual >= postfixNumber;
case '<':
return actual < postfixNumber;
case '<=':
return actual <= postfixNumber;
case '+/-':
case '±':
return Math.abs(actual - prefixNumber) <= postfixNumber;
default:
throw new Error(`unexpected operator ${operator}`);
}
} else if (typeof actual === 'string' && expected instanceof RegExp && expected.test(actual)) {
return true;
} else {
// Strict equality check, plus NaN equivalence.
return Object.is(actual, expected);
}
}
/**
* Walk down expected result, comparing to actual result. If a difference is found,
* the path to the difference is returned, along with the expected primitive value
* and the value actually found at that location. If no difference is found, returns
* null.
*
* Only checks own enumerable properties, not object prototypes, and will loop
* until the stack is exhausted, so works best with simple objects (e.g. parsed JSON).
* @param {string} path
* @param {*} actual
* @param {*} expected
* @return {Difference[]|null}
*/
function findDifferences(path, actual, expected) {
if (matchesExpectation(actual, expected)) {
return null;
}
// If they aren't both an object we can't recurse further, so this is the difference.
if (actual === null || expected === null || typeof actual !== 'object' ||
typeof expected !== 'object' || expected instanceof RegExp) {
return [{
path,
actual,
expected,
}];
}
/** @type {Difference[]} */
const diffs = [];
/** @type {any[]|undefined} */
let inclExclCopy;
// We only care that all expected's own properties are on actual (and not the other way around).
// Note an expected `undefined` can match an actual that is either `undefined` or not defined.
for (const key of Object.keys(expected)) {
// Bracket numbers, but property names requiring quotes will still be unquoted.
const keyAccessor = /^\d+$/.test(key) ? `[${key}]` : `.${key}`;
const keyPath = path + keyAccessor;
const expectedValue = expected[key];
if (key === '_includes') {
if (Array.isArray(actual)) {
inclExclCopy = [...actual];
} else if (typeof actual === 'object') {
inclExclCopy = Object.entries(actual);
}
if (!Array.isArray(expectedValue)) throw new Error('Array subset must be array');
if (!inclExclCopy) {
diffs.push({
path,
actual: 'Actual value is not an array or object',
expected,
});
continue;
}
for (const expectedEntry of expectedValue) {
const matchingIndex =
inclExclCopy.findIndex(actualEntry =>
!findDifferences(keyPath, actualEntry, expectedEntry));
if (matchingIndex !== -1) {
inclExclCopy.splice(matchingIndex, 1);
continue;
}
diffs.push({
path,
actual: 'Item not found in array',
expected: expectedEntry,
});
}
continue;
}
if (key === '_excludes') {
// Re-use state from `_includes` check, if there was one.
if (!inclExclCopy) {
if (Array.isArray(actual)) {
// We won't be removing items, so we can just copy the reference.
inclExclCopy = actual;
} else if (typeof actual === 'object') {
inclExclCopy = Object.entries(actual);
}
}
if (!Array.isArray(expectedValue)) throw new Error('Array subset must be array');
if (!inclExclCopy) {
diffs.push({
path,
actual: 'Actual value is not an array or object',
expected,
});
continue;
}
const expectedExclusions = expectedValue;
for (const expectedExclusion of expectedExclusions) {
const matchingIndex = inclExclCopy.findIndex(actualEntry =>
!findDifferences(keyPath, actualEntry, expectedExclusion));
if (matchingIndex !== -1) {
diffs.push({
path,
actual: inclExclCopy[matchingIndex],
expected: {
message: 'Expected to not find matching entry via _excludes',
expectedExclusion,
},
});
}
}
continue;
}
const actualValue = actual[key];
const subDifferences = findDifferences(keyPath, actualValue, expectedValue);
if (subDifferences) diffs.push(...subDifferences);
}
// If the expected value is an array, assert the length as well.
// This still allows for asserting that the first n elements of an array are specified elements,
// but requires using an object literal (ex: {0: x, 1: y, 2: z} matches [x, y, z, q, w, e] and
// {0: x, 1: y, 2: z, length: 5} does not match [x, y, z].
if (Array.isArray(expected) && actual.length !== expected.length) {
diffs.push({
path: `${path}.length`,
actual,
expected,
});
}
if (diffs.length === 0) return null;
return diffs;
}
/**
* @param {string} name name of the value being asserted on (e.g. the result of a certain audit)
* @param {any} actualResult
* @param {any} expectedResult
* @return {Comparison}
*/
function makeComparison(name, actualResult, expectedResult) {
const diffs = findDifferences(name, actualResult, expectedResult);
return {
name,
actual: actualResult,
expected: expectedResult,
equal: !diffs,
diffs,
};
}
/**
* Delete expectations that don't match environment criteria.
* @param {LocalConsole} localConsole
* @param {LH.Result} lhr
* @param {Smokehouse.ExpectedRunnerResult} expected
* @param {{runner?: string, useLegacyNavigation?: boolean}=} reportOptions
*/
function pruneExpectations(localConsole, lhr, expected, reportOptions) {
const isLegacyNavigation = reportOptions?.useLegacyNavigation;
/**
* Lazily compute the Chrome version because some reports are explicitly asserting error conditions.
* @returns {string}
*/
function getChromeVersionString() {
const userAgent = lhr.environment.hostUserAgent;
const userAgentMatch = /Chrome\/([\d.]+)/.exec(userAgent); // Chrome/85.0.4174.0
if (!userAgentMatch) throw new Error('Could not get chrome version.');
const versionString = userAgentMatch[1];
if (versionString.split('.').length !== 4) throw new Error(`unexpected ua: ${userAgent}`);
return versionString;
}
/**
* @param {*} obj
*/
function failsChromeVersionCheck(obj) {
return !chromiumVersionCheck({
version: getChromeVersionString(),
min: obj._minChromiumVersion,
max: obj._maxChromiumVersion,
});
}
/**
* @param {*} obj
*/
function pruneRecursively(obj) {
/**
* @param {string} key
*/
const remove = (key) => {
if (Array.isArray(obj)) {
obj.splice(Number(key), 1);
} else {
delete obj[key];
}
};
// Because we may be deleting keys, we should iterate the keys backwards
// otherwise arrays with multiple pruning checks will skip elements.
for (const [key, value] of Object.entries(obj).reverse()) {
if (!value || typeof value !== 'object') {
continue;
}
if (failsChromeVersionCheck(value)) {
localConsole.log([
`[${key}] failed chrome version check, pruning expectation:`,
JSON.stringify(value, null, 2),
`Actual Chromium version: ${getChromeVersionString()}`,
].join(' '));
remove(key);
} else if (value._legacyOnly && !isLegacyNavigation) {
localConsole.log([
`[${key}] marked legacy only but run is Fraggle Rock, pruning expectation:`,
JSON.stringify(value, null, 2),
].join(' '));
remove(key);
} else if (value._fraggleRockOnly && isLegacyNavigation) {
localConsole.log([
`[${key}] marked Fraggle Rock only but run is legacy, pruning expectation:`,
JSON.stringify(value, null, 2),
`Actual channel: ${lhr.configSettings.channel}`,
].join(' '));
remove(key);
} else if (value._runner && reportOptions?.runner !== value._runner) {
localConsole.log([
`[${key}] is only for runner ${value._runner}, pruning expectation:`,
JSON.stringify(value, null, 2),
].join(' '));
remove(key);
} else if (value._excludeRunner && reportOptions?.runner === value._excludeRunner) {
localConsole.log([
`[${key}] is excluded for runner ${value._excludeRunner}, pruning expectation:`,
JSON.stringify(value, null, 2),
].join(' '));
remove(key);
} else {
pruneRecursively(value);
}
}
delete obj._legacyOnly;
delete obj._fraggleRockOnly;
delete obj._skipInBundled;
delete obj._minChromiumVersion;
delete obj._maxChromiumVersion;
delete obj._runner;
delete obj._excludeRunner;
}
const cloned = cloneDeep(expected);
pruneRecursively(cloned);
return cloned;
}
/**
* Collate results into comparisons of actual and expected scores on each audit/artifact.
* @param {LocalConsole} localConsole
* @param {{lhr: LH.Result, artifacts: LH.Artifacts, networkRequests?: string[]}} actual
* @param {Smokehouse.ExpectedRunnerResult} expected
* @return {Comparison[]}
*/
function collateResults(localConsole, actual, expected) {
// If actual run had a runtimeError, expected *must* have a runtimeError.
// Relies on the fact that an `undefined` argument to makeComparison() can only match `undefined`.
const runtimeErrorAssertion = makeComparison('runtimeError', actual.lhr.runtimeError,
expected.lhr.runtimeError);
// Same for warnings, exclude the slow CPU warning which is flaky and differs between CI machines.
const warnings = actual.lhr.runWarnings
.filter(warning => !warning.includes('loaded too slowly'))
.filter(warning => !warning.includes('a slower CPU'));
const runWarningsAssertion = makeComparison('runWarnings', warnings,
expected.lhr.runWarnings || []);
/** @type {Comparison[]} */
let artifactAssertions = [];
if (expected.artifacts) {
const expectedArtifacts = expected.artifacts;
const artifactNames = /** @type {(keyof LH.Artifacts)[]} */ (Object.keys(expectedArtifacts));
const actualArtifacts = actual.artifacts || {};
artifactAssertions = artifactNames.map(artifactName => {
if (!(artifactName in actualArtifacts)) {
localConsole.log(log.redify('Error: ') +
`Config run did not generate artifact ${artifactName}`);
}
const actualResult = actualArtifacts[artifactName];
const expectedResult = expectedArtifacts[artifactName];
return makeComparison(artifactName + ' artifact', actualResult, expectedResult);
});
}
/** @type {Comparison[]} */
let auditAssertions = [];
auditAssertions = Object.keys(expected.lhr.audits).map(auditName => {
const actualResult = actual.lhr.audits[auditName];
if (!actualResult) {
localConsole.log(log.redify('Error: ') +
`Config did not trigger run of expected audit ${auditName}`);
}
const expectedResult = expected.lhr.audits[auditName];
return makeComparison(auditName + ' audit', actualResult, expectedResult);
});
/** @type {Comparison[]} */
const extraAssertions = [];
if (expected.lhr.timing) {
const comparison = makeComparison('timing', actual.lhr.timing, expected.lhr.timing);
extraAssertions.push(comparison);
}
if (expected.networkRequests) {
extraAssertions.push(makeComparison(
'Requests',
actual.networkRequests,
expected.networkRequests
));
}
if (expected.lhr.fullPageScreenshot) {
extraAssertions.push(makeComparison('fullPageScreenshot', actual.lhr.fullPageScreenshot,
expected.lhr.fullPageScreenshot));
}
return [
makeComparison('final url', actual.lhr.finalDisplayedUrl, expected.lhr.finalDisplayedUrl),
runtimeErrorAssertion,
runWarningsAssertion,
...artifactAssertions,
...auditAssertions,
...extraAssertions,
];
}
/**
* @param {unknown} obj
*/
function isPlainObject(obj) {
return Object.prototype.toString.call(obj) === '[object Object]';
}
/**
* Log the result of an assertion of actual and expected results to the provided
* console.
* @param {LocalConsole} localConsole
* @param {Comparison} assertion
*/
function reportAssertion(localConsole, assertion) {
// @ts-expect-error - this doesn't exist now but could one day, so try not to break the future
const _toJSON = RegExp.prototype.toJSON;
// @ts-expect-error
// eslint-disable-next-line no-extend-native
RegExp.prototype.toJSON = RegExp.prototype.toString;
if (assertion.equal) {
if (isPlainObject(assertion.actual)) {
localConsole.log(` ${log.greenify(log.tick)} ${assertion.name}`);
} else {
localConsole.log(` ${log.greenify(log.tick)} ${assertion.name}: ` +
log.greenify(assertion.actual));
}
} else {
if (assertion.diffs?.length) {
for (const diff of assertion.diffs) {
const msg = `
${log.redify(log.cross)} difference at ${log.bold}${diff.path}${log.reset}
expected: ${JSON.stringify(diff.expected)}
found: ${JSON.stringify(diff.actual)}\n`;
localConsole.log(msg);
}
const fullActual = assertion.actual !== undefined ?
JSON.stringify(assertion.actual, null, 2).replace(/\n/g, '\n ') :
'undefined\n ';
localConsole.log(` found result:
${log.redify(fullActual)}
`);
} else {
localConsole.log(` ${log.redify(log.cross)} ${assertion.name}:
expected: ${JSON.stringify(assertion.expected)}
found: ${JSON.stringify(assertion.actual)}
`);
}
}
// @ts-expect-error
// eslint-disable-next-line no-extend-native
RegExp.prototype.toJSON = _toJSON;
}
/**
* Log all the comparisons between actual and expected test results, then print
* summary. Returns count of passed and failed tests.
* @param {{lhr: LH.Result, artifacts: LH.Artifacts, networkRequests?: string[]}} actual
* @param {Smokehouse.ExpectedRunnerResult} expected
* @param {{runner?: string, isDebug?: boolean, useLegacyNavigation?: boolean}=} reportOptions
* @return {{passed: number, failed: number, log: string}}
*/
function getAssertionReport(actual, expected, reportOptions = {}) {
const localConsole = new LocalConsole();
expected = pruneExpectations(localConsole, actual.lhr, expected, reportOptions);
const comparisons = collateResults(localConsole, actual, expected);
let correctCount = 0;
let failedCount = 0;
comparisons.forEach(assertion => {
if (assertion.equal) {
correctCount++;
} else {
failedCount++;
}
if (!assertion.equal || reportOptions.isDebug) {
reportAssertion(localConsole, assertion);
}
});
return {
passed: correctCount,
failed: failedCount,
log: localConsole.getLog(),
};
}
export {
getAssertionReport,
findDifferences,
};

View File

@@ -0,0 +1,35 @@
export type ChildProcessError = import('./lib/child-process-error.js').ChildProcessError;
export type Run = {
networkRequests: string[] | undefined;
lhr: LH.Result;
artifacts: LH.Artifacts;
lighthouseLog: string;
assertionLog: string;
};
export type SmokehouseResult = {
id: string;
passed: number;
failed: number;
runs: Run[];
};
/**
* Runs the selected smoke tests. Returns whether all assertions pass.
* @param {Array<Smokehouse.TestDfn>} smokeTestDefns
* @param {Smokehouse.SmokehouseOptions} smokehouseOptions
* @return {Promise<{success: boolean, testResults: SmokehouseResult[]}>}
*/
export function runSmokehouse(smokeTestDefns: Array<Smokehouse.TestDfn>, smokehouseOptions: Smokehouse.SmokehouseOptions): Promise<{
success: boolean;
testResults: SmokehouseResult[];
}>;
/**
* Parses the cli `shardArg` flag into `shardNumber/shardTotal`. Splits
* `testDefns` into `shardTotal` shards and returns the `shardNumber`th shard.
* Shards will differ in size by at most 1.
* Shard params must be 1 ≤ shardNumber ≤ shardTotal.
* @param {Array<Smokehouse.TestDfn>} testDefns
* @param {string=} shardArg
* @return {Array<Smokehouse.TestDfn>}
*/
export function getShardedDefinitions(testDefns: Array<Smokehouse.TestDfn>, shardArg?: string | undefined): Array<Smokehouse.TestDfn>;
//# sourceMappingURL=smokehouse.d.ts.map

View File

@@ -0,0 +1,336 @@
/**
* @license Copyright 2016 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/**
* @fileoverview An end-to-end test runner for Lighthouse. Takes a set of smoke
* test definitions and a method of running Lighthouse, returns whether all the
* smoke tests passed.
*/
/* eslint-disable no-console */
/** @typedef {import('./lib/child-process-error.js').ChildProcessError} ChildProcessError */
/**
* @typedef Run
* @property {string[] | undefined} networkRequests
* @property {LH.Result} lhr
* @property {LH.Artifacts} artifacts
* @property {string} lighthouseLog
* @property {string} assertionLog
*/
/**
* @typedef SmokehouseResult
* @property {string} id
* @property {number} passed
* @property {number} failed
* @property {Run[]} runs
*/
import assert from 'assert/strict';
import log from 'lighthouse-logger';
import {runLighthouse as cliLighthouseRunner} from './lighthouse-runners/cli.js';
import {getAssertionReport} from './report-assert.js';
import {LocalConsole} from './lib/local-console.js';
import {ConcurrentMapper} from './lib/concurrent-mapper.js';
// The number of concurrent (`!runSerially`) tests to run if `jobs` isn't set.
const DEFAULT_CONCURRENT_RUNS = 5;
const DEFAULT_RETRIES = 0;
/**
* Runs the selected smoke tests. Returns whether all assertions pass.
* @param {Array<Smokehouse.TestDfn>} smokeTestDefns
* @param {Smokehouse.SmokehouseOptions} smokehouseOptions
* @return {Promise<{success: boolean, testResults: SmokehouseResult[]}>}
*/
async function runSmokehouse(smokeTestDefns, smokehouseOptions) {
const {
isDebug,
useLegacyNavigation,
jobs = DEFAULT_CONCURRENT_RUNS,
retries = DEFAULT_RETRIES,
lighthouseRunner = Object.assign(cliLighthouseRunner, {runnerName: 'cli'}),
takeNetworkRequestUrls,
setup,
} = smokehouseOptions;
assertPositiveInteger('jobs', jobs);
assertNonNegativeInteger('retries', retries);
try {
await setup?.();
} catch (err) {
console.error(log.redify('\nERROR DURING SETUP:'));
console.error(log.redify(err.stack || err));
return {success: false, testResults: []};
}
// Run each testDefn in parallel based on the concurrencyLimit.
const concurrentMapper = new ConcurrentMapper();
const testOptions = {
isDebug,
useLegacyNavigation,
retries,
lighthouseRunner,
takeNetworkRequestUrls,
};
const smokePromises = smokeTestDefns.map(testDefn => {
// If defn is set to `runSerially`, we'll run it in succession with other tests, not parallel.
const concurrency = testDefn.runSerially ? 1 : jobs;
return concurrentMapper.runInPool(() => runSmokeTest(testDefn, testOptions), {concurrency});
});
const testResults = await Promise.all(smokePromises);
// Print final summary.
let passingCount = 0;
let failingCount = 0;
for (const testResult of testResults) {
passingCount += testResult.passed;
failingCount += testResult.failed;
}
if (passingCount) console.log(log.greenify(`${getAssertionLog(passingCount)} passing in total`));
if (failingCount) console.log(log.redify(`${getAssertionLog(failingCount)} failing in total`));
// Print id(s) and fail if there were failing tests.
const failingDefns = testResults.filter(result => result.failed);
if (failingDefns.length) {
const testNames = failingDefns.map(d => d.id).join(', ');
console.log(log.redify(`We have ${failingDefns.length} failing smoketest(s): ${testNames}`));
return {success: false, testResults};
}
return {success: true, testResults};
}
/**
* @param {string} loggableName
* @param {number} value
*/
function assertPositiveInteger(loggableName, value) {
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`${loggableName} must be a positive integer`);
}
}
/**
* @param {string} loggableName
* @param {number} value
*/
function assertNonNegativeInteger(loggableName, value) {
if (!Number.isInteger(value) || value < 0) {
throw new Error(`${loggableName} must be a non-negative integer`);
}
}
/** @param {string} str */
function purpleify(str) {
return `${log.purple}${str}${log.reset}`;
}
/**
* @param {LH.Config=} config
* @return {LH.Config|undefined}
*/
function convertToLegacyConfig(config) {
if (!config) return config;
return {
...config,
passes: [{
passName: 'defaultPass',
pauseAfterFcpMs: config.settings?.pauseAfterFcpMs,
pauseAfterLoadMs: config.settings?.pauseAfterLoadMs,
networkQuietThresholdMs: config.settings?.networkQuietThresholdMs,
cpuQuietThresholdMs: config.settings?.cpuQuietThresholdMs,
blankPage: config.settings?.blankPage,
}],
};
}
/**
* Run Lighthouse in the selected runner.
* @param {Smokehouse.TestDfn} smokeTestDefn
* @param {{isDebug?: boolean, useLegacyNavigation?: boolean, retries: number, lighthouseRunner: Smokehouse.LighthouseRunner, takeNetworkRequestUrls?: () => string[]}} testOptions
* @return {Promise<SmokehouseResult>}
*/
async function runSmokeTest(smokeTestDefn, testOptions) {
const {id, expectations} = smokeTestDefn;
const {
lighthouseRunner,
retries,
isDebug,
useLegacyNavigation,
takeNetworkRequestUrls,
} = testOptions;
const requestedUrl = expectations.lhr.requestedUrl;
console.log(`${purpleify(id)} smoketest starting…`);
// Rerun test until there's a passing result or retries are exhausted to prevent flakes.
/** @type {Run[]} */
const runs = [];
let result;
let report;
const bufferedConsole = new LocalConsole();
bufferedConsole.log(`\n${purpleify(id)}: testing '${requestedUrl}'…`);
for (let i = 0; i <= retries; i++) {
if (i !== 0) {
bufferedConsole.log(` Retrying run (${i} out of ${retries} retries)…`);
}
let config = smokeTestDefn.config;
if (useLegacyNavigation) {
config = convertToLegacyConfig(config);
}
// Run Lighthouse.
try {
result = {
...await lighthouseRunner(requestedUrl, config, {isDebug, useLegacyNavigation}),
networkRequests: takeNetworkRequestUrls ? takeNetworkRequestUrls() : undefined,
};
if (!result.lhr?.audits || !result.artifacts) {
// Something went really wrong and the runner didn't catch it.
throw new Error('lighthouse runner returned a bad result. got lhr:\n' +
JSON.stringify(result.lhr, null, 2));
}
} catch (e) {
// Clear the network requests so that when we retry, we don't see duplicates.
if (takeNetworkRequestUrls) takeNetworkRequestUrls();
logChildProcessError(bufferedConsole, e);
continue; // Retry, if possible.
}
// Assert result.
report = getAssertionReport(result, expectations, {
runner: lighthouseRunner.runnerName,
isDebug,
useLegacyNavigation,
});
runs.push({
...result,
lighthouseLog: result.log,
assertionLog: report.log,
});
if (report.failed) {
bufferedConsole.log(` ${getAssertionLog(report.failed)} failed.`);
continue; // Retry, if possible.
}
break; // Passing result, no need to retry.
}
bufferedConsole.log(` smoketest results:`);
// Write result log if we have one.
if (result) bufferedConsole.write(result.log);
// If there's not an assertion report, just report the whole thing as a single failure.
if (report) bufferedConsole.write(report.log);
const passed = report ? report.passed : 0;
const failed = report ? report.failed : 1;
const correctStr = getAssertionLog(passed);
const colorFn = passed === 0 ? log.redify : log.greenify;
bufferedConsole.log(` Correctly passed ${colorFn(correctStr)}`);
if (failed) {
const failedString = getAssertionLog(failed);
bufferedConsole.log(` Failed ${log.redify(failedString)}`);
}
bufferedConsole.log(`${purpleify(id)} smoketest complete.`);
// Log now so right after finish, but all at once so not interleaved with other tests.
console.log(bufferedConsole.getLog());
return {
id,
passed,
failed,
runs,
};
}
/**
* Logs an error to the console, including stdout and stderr if `err` is a
* `ChildProcessError`.
* @param {LocalConsole} localConsole
* @param {ChildProcessError|Error} err
*/
function logChildProcessError(localConsole, err) {
if ('stdout' in err && 'stderr' in err) {
localConsole.adoptStdStrings(err);
}
localConsole.log(log.redify(err.stack || err.message));
}
/**
* @param {number} count
* @return {string}
*/
function getAssertionLog(count) {
const plural = count === 1 ? '' : 's';
return `${count} assertion${plural}`;
}
/**
* Parses the cli `shardArg` flag into `shardNumber/shardTotal`. Splits
* `testDefns` into `shardTotal` shards and returns the `shardNumber`th shard.
* Shards will differ in size by at most 1.
* Shard params must be 1 ≤ shardNumber ≤ shardTotal.
* @param {Array<Smokehouse.TestDfn>} testDefns
* @param {string=} shardArg
* @return {Array<Smokehouse.TestDfn>}
*/
function getShardedDefinitions(testDefns, shardArg) {
if (!shardArg) return testDefns;
// eslint-disable-next-line max-len
const errorMessage = `'shard' must be of the form 'n/d' and n and d must be positive integers with 1 ≤ n ≤ d. Got '${shardArg}'`;
const match = /^(?<shardNumber>\d+)\/(?<shardTotal>\d+)$/.exec(shardArg);
assert(match?.groups, errorMessage);
const shardNumber = Number(match.groups.shardNumber);
const shardTotal = Number(match.groups.shardTotal);
assert(shardNumber > 0 && Number.isInteger(shardNumber), errorMessage);
assert(shardTotal > 0 && Number.isInteger(shardTotal));
assert(shardNumber <= shardTotal, errorMessage);
// Array is sharded with `Math.ceil(length / shardTotal)` shards first
// and then the remaining `Math.floor(length / shardTotal) shards.
// e.g. `[0, 1, 2, 3]` split into 3 shards is `[[0, 1], [2], [3]]`.
const baseSize = Math.floor(testDefns.length / shardTotal);
const biggerSize = baseSize + 1;
const biggerShardCount = testDefns.length % shardTotal;
// Since we don't have tests for this file, construct all shards so correct
// structure can be asserted.
const shards = [];
let index = 0;
for (let i = 0; i < shardTotal; i++) {
const shardSize = i < biggerShardCount ? biggerSize : baseSize;
shards.push(testDefns.slice(index, index + shardSize));
index += shardSize;
}
assert.strictEqual(shards.length, shardTotal);
assert.deepStrictEqual(shards.flat(), testDefns);
const shardDefns = shards[shardNumber - 1];
console.log(`In this shard (${shardArg}), running: ${shardDefns.map(d => d.id).join(' ')}\n`);
return shardDefns;
}
export {
runSmokehouse,
getShardedDefinitions,
};

View File

@@ -0,0 +1,2 @@
export {};
//# sourceMappingURL=version-check-test.d.ts.map

View File

@@ -0,0 +1,43 @@
/**
* @license Copyright 2022 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
import {chromiumVersionCheck, compareVersions} from './version-check.js';
describe('version check', () => {
it('compareVersions', async () => {
expect(compareVersions([100, 0, 0, 0], [100, 0, 0, 0])).toBe(0);
expect(compareVersions([101, 0, 0, 0], [100, 0, 0, 0])).toBe(1);
expect(compareVersions([99, 0, 0, 0], [100, 0, 0, 0])).toBe(-1);
expect(compareVersions([100, 0, 10, 0], [100, 0, 10, 0])).toBe(0);
expect(compareVersions([100, 0, 11, 0], [100, 0, 10, 0])).toBe(1);
expect(compareVersions([100, 0, 9, 0], [100, 0, 10, 0])).toBe(-1);
expect(compareVersions([100, 0, 0, 0], [100])).toBe(0);
expect(compareVersions([100, 0, 0, 1], [100])).toBe(1);
expect(compareVersions([99, 0, 0, 0], [100])).toBe(-1);
});
it('chromiumVersionCheck', async () => {
expect(chromiumVersionCheck({version: '100'})).toBe(true);
expect(chromiumVersionCheck({version: '100', min: '100'})).toBe(true);
expect(chromiumVersionCheck({version: '100', max: '100'})).toBe(true);
expect(chromiumVersionCheck({version: '100', min: '101'})).toBe(false);
expect(chromiumVersionCheck({version: '100', max: '99'})).toBe(false);
expect(chromiumVersionCheck({version: '100.0.2331.3'})).toBe(true);
expect(chromiumVersionCheck({version: '100.0.2331.3', min: '100.0.2331.3'})).toBe(true);
expect(chromiumVersionCheck({version: '100.0.2331.3', min: '100.0.0.0'})).toBe(true);
expect(chromiumVersionCheck({version: '100.0.2331.3', max: '100.0.3333.3'})).toBe(true);
expect(chromiumVersionCheck({version: '100.0.2331.3', min: '100.0.2331.2'})).toBe(true);
expect(chromiumVersionCheck({version: '100.0.2331.3', max: '99'})).toBe(false);
expect(chromiumVersionCheck({
version: '100.0.2331.3', min: '100.0.2331.0', max: '100.0.2331.10'})).toBe(true);
expect(chromiumVersionCheck({
version: '100.3.2331.3', min: '100.0.2331.0', max: '100.0.2331.10'})).toBe(false);
});
});

View File

@@ -0,0 +1,15 @@
/**
* Returns false if fails check.
* @param {{version: string, min?: string, max?: string}} opts
*/
export function chromiumVersionCheck(opts: {
version: string;
min?: string | undefined;
max?: string | undefined;
}): boolean;
/**
* @param {number[]} versionA
* @param {number[]} versionB
*/
export function compareVersions(versionA: number[], versionB: number[]): 1 | 0 | -1;
//# sourceMappingURL=version-check.d.ts.map

View File

@@ -0,0 +1,48 @@
/**
* @license Copyright 2022 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
/**
* @fileoverview Compares chromium version strings: 103.0.5017.0
*/
/**
* @param {string} versionString
* @return {number[]}
*/
function parseVersion(versionString) {
const versionParts = versionString.split('.');
return versionParts.map(Number);
}
/**
* @param {number[]} versionA
* @param {number[]} versionB
*/
function compareVersions(versionA, versionB) {
for (let i = 0; i < versionA.length; i++) {
if ((versionA[i] ?? 0) > (versionB[i] ?? 0)) return 1;
if ((versionA[i] ?? 0) < (versionB[i] ?? 0)) return -1;
}
return 0;
}
/**
* Returns false if fails check.
* @param {{version: string, min?: string, max?: string}} opts
*/
function chromiumVersionCheck(opts) {
const version = parseVersion(opts.version);
const min = opts.min && parseVersion(opts.min);
const max = opts.max && parseVersion(opts.max);
if (min && compareVersions(version, min) === -1) return false;
if (max && compareVersions(version, max) === 1) return false;
return true;
}
export {
chromiumVersionCheck,
compareVersions,
};