lighthouse
Version:
Automated auditing, performance metrics, and best practices for the web.
504 lines (446 loc) • 16 kB
JavaScript
/**
* @license
* Copyright 2019 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview An assertion library for comparing smoke-test expectations
* against the results actually collected from Lighthouse.
*/
import {cloneDeep} from 'lodash-es';
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}=} reportOptions
*/
function pruneExpectations(localConsole, lhr, expected, reportOptions) {
/**
* 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._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._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}=} 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,
};