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

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

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

Now the toggle between Classic and React versions works correctly.

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

View File

@@ -0,0 +1,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,
};