UNPKG

lighthouse

Version:

Automated auditing, performance metrics, and best practices for the web.

504 lines (446 loc) 16 kB
/** * @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, };