UNPKG

lighthouse

Version:

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

330 lines (286 loc) 10.7 kB
/** * @license * Copyright 2016 Google LLC * SPDX-License-Identifier: Apache-2.0 */ /** * @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. */ /** * @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'; import {ChildProcessError} from './lib/child-process-error.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 {Partial<Smokehouse.SmokehouseOptions>} smokehouseOptions * @return {Promise<{success: boolean, testResults: SmokehouseResult[]}>} */ async function runSmokehouse(smokeTestDefns, smokehouseOptions) { const { testRunnerOptions, 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 = { testRunnerOptions, jobs, 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}`; } /** * Run Lighthouse in the selected runner. * @param {Smokehouse.TestDfn} smokeTestDefn * @param {Smokehouse.SmokehouseOptions} testOptions * @return {Promise<SmokehouseResult>} */ async function runSmokeTest(smokeTestDefn, testOptions) { const {id, expectations, config} = smokeTestDefn; const { lighthouseRunner, retries, testRunnerOptions, 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)…`); } const logger = new LocalConsole(); // Run Lighthouse. try { // Each individual runner has internal timeouts, but we've had bugs where // that didn't cover some edge case. So to be safe give a (long) timeout here. const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error('Timed out waiting for provided lighthouseRunner')), 1000 * 120); }); const timedResult = await Promise.race([ lighthouseRunner(requestedUrl, config, logger, testRunnerOptions), timeoutPromise, ]); result = { ...timedResult, networkRequests: takeNetworkRequestUrls ? takeNetworkRequestUrls() : undefined, log: logger.getLog(), }; 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); bufferedConsole.log('Timed out. log from lighthouseRunner:'); bufferedConsole.log(logger.getLog()); continue; // Retry, if possible. } // Assert result. report = getAssertionReport(result, expectations, { runner: lighthouseRunner.runnerName, ...testRunnerOptions, }); 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 (err instanceof ChildProcessError) { localConsole.adoptStdStrings(err); } localConsole.log(log.redify(err.stack || err.message)); if (err.cause) { if (err.cause instanceof Error) { localConsole.log(log.redify(`[cause] ${err.cause.stack || err.cause.message}`)); } else { localConsole.log(log.redify(`[cause] ${err.cause}`)); } } } /** * @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, DEFAULT_RETRIES, DEFAULT_CONCURRENT_RUNS, };