All files / src conductor.js

100% Statements 97/97
96.67% Branches 29/30
100% Functions 10/10
100% Lines 94/94

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295                    1x 1x 1x   1x 1x 1x 1x 1x 1x 1x                                                             4x 4x 4x 4x 4x       4x                   4x     4x                 4x   4x 4x 4x 4x                   4x 4x 3x 5x 5x     4x 5x     4x 1x 1x     4x 5x 5x             5x               4x                                                                 4x 4x 5x 11x 10x   11x   11x 61x 60x   1x             4x 4x 5x   5x 5x 18x           4x 4x 4x       4x   4x   4x                         4x     4x 4x 4x 4x       24x       11x 2x   9x 5x   4x           13x     4x 4x   4x 4x   4x 4x   4x 7x 7x   7x 24x 24x 24x   24x           24x 24x 24x                 24x 2x           4x     4x     1x  
'use strict';
/**
 * The program interface for Fresnel commands.
 *
 * Interacts with: {@link external:puppeteer/Browser puppeteer/Browser},
 * {@link Writer}, {@link Probe}, and {@link Report}.
 *
 * @module conductor
 */
 
const fs = require( 'fs' );
const path = require( 'path' );
const puppeteer = require( 'puppeteer' );
 
const Registry = require( './Registry' );
const Writer = require( './Writer' );
const hasOwn = Object.prototype.hasOwnProperty;
const is = require( './util/is' );
const probesIndex = require( './probes' );
const recorder = require( './recorder' );
const reportsIndex = require( './reports' );
 
/**
 * Create a Fresnel record.
 *
 * This runs the scenarios from the given configuration object, and saves the
 * record and probe artefacts to an out subdirectory named after the label.
 *
 * The Scenario URL may have placeholders for variables. These allow scenarios
 * to adapt to the current environment. For example, when testing an app
 * like MediaWiki, the hostname and port of the web server may vary in each
 * CI or development environment.
 *
 * @param {Object} config Configuration object, e.g. from `.fresnel.yml`.
 * @param {string} outputDir File path
 * @param {string} label Record label. Must be valid as a directory name.
 * @param {Function} progress Callback for handling internal events
 *  as the recording progresses.
 * @return {Object} Fresnel record.
 * @throws {Error} If configuration is invalid.
 * @throws {Error} If Writer can't create the output directory.
 */
async function record( config, outputDir, label, progress = () => {} ) {
	// Step 1: Preparations
	//
	// - Apply default config.
	// - Validate config.
	// - Create output directory (if needed).
	// - Open the probes and reports registries.
	// - Create an empty recording to store the data we'll gather.
 
	config = Object.assign( { warmup: false, runs: 1 }, config );
	is.config( config );
	const writer = new Writer( outputDir ).child( label );
	const probeReg = new Registry( Registry.isProbe, probesIndex );
	const reportReg = new Registry( Registry.isReport, reportsIndex );
 
	// The record will contain meta-data about every scenario,
	// and the data collected by probes on each of the runs.
	const rec = {
		scenarios: {}
	};
 
	// Step 2: Start the browser
	//
	// - Determine the CLI options for the Chromium executable.
	//   Wikimedia CI uses Docker and sets --no-sandbox through this mechanism.
	// - Use puppeteer to launch the browser (Headless Chromium).
 
	const launchOpts = ( process.env.CHROMIUM_FLAGS ) ?
		{ args: process.env.CHROMIUM_FLAGS.split( /\s+/ ) } :
		{};
	const browser = await puppeteer.launch( launchOpts );
 
	// Step 3: Perform each configured scenario
	//
	// - Warmup the given url on the server by opening it once in a browser (optional).
	// - Open the url N times in a browser, and each time collect data
	//   from the probes and add the probe's data to the record.
	// - Close the browser.
 
	progress( 'conductor/record-start', config );
 
	try {
		for ( const key in config.scenarios ) {
			const scenario = config.scenarios[ key ];
			rec.scenarios[ key ] = {
				options: {
					url: scenario.url,
					viewport: scenario.viewport,
					reports: scenario.reports || []
				},
				runs: []
			};
 
			// Get the Probe objects for this scenario.
			const probes = new Set();
			if ( scenario.reports ) {
				scenario.reports.forEach( ( reportKey ) => {
					const report = reportReg.get( reportKey );
					report.probes.forEach( ( probe ) => probes.add( probeReg.get( probe ) ) );
				} );
			}
			if ( scenario.probes ) {
				scenario.probes.forEach( ( probe ) => probes.add( probeReg.get( probe ) ) );
			}
 
			if ( config.warmup ) {
				progress( 'conductor/warmup' );
				await recorder.warmup( scenario, browser );
			}
 
			for ( let run = 0; run < config.runs; run++ ) {
				progress( 'conductor/record-run', { scenario: key, run: run } );
				const probeDatas = await recorder.run(
					scenario,
					probes,
					writer.child( `scenario-${key}-run-${run}` ),
					browser,
					progress
				);
				rec.scenarios[ key ].runs.push( probeDatas );
			}
		}
 
	} finally {
		// Use finally, as this should also happen in case of failure.
		// This ensures the Fresnel process can exit cleanly after an error
		// (it can't exit with an active child process).
		await browser.close();
	}
 
	// Step 4: Analyse the data.
	//
	// Combine values from individual runs. For two runs like this:
	//
	//     record.scenarios[key].runs: [
	//       {
	//         myProbe: { x: 1.4 }
	//       },
	//       {
	//         myProbe: { x: 2.1 }
	//       }
	//     ]
	//
	// The combined version becomes:
	//
	//     record.scenarios[key].combined: {
	//       myProbe: {
	//         x: [ 1.4, 2.1 ]
	//       }
	//     }
	//
	// Then, the Report objects analyse the data and we get:
	//
	//     record.scenarios[key].analysed: {
	//       myProbe: {
	//         x: { mean: 1.75, stdev: 0.35 }
	//       }
	//     }
	//
	function addCombinedData( scenario ) {
		const combined = scenario.combined = {};
		for ( const run of scenario.runs ) {
			for ( const probeName in run ) {
				if ( !hasOwn.call( combined, probeName ) ) {
					combined[ probeName ] = {};
				}
				const data = combined[ probeName ];
 
				for ( const dataKey in run[ probeName ] ) {
					if ( !hasOwn.call( data, dataKey ) ) {
						data[ dataKey ] = [ run[ probeName ][ dataKey ] ];
					} else {
						data[ dataKey ].push( run[ probeName ][ dataKey ] );
					}
				}
			}
		}
	}
	function addAnalysedData( scenario ) {
		const analysed = scenario.analysed = {};
		for ( const reportName of scenario.options.reports ) {
			analysed[ reportName ] = {};
 
			const report = reportReg.get( reportName );
			for ( const metric in report.metrics ) {
				analysed[ reportName ][ metric ] =
					report.metrics[ metric ].analyse( scenario.combined );
			}
		}
	}
 
	for ( const key in rec.scenarios ) {
		addCombinedData( rec.scenarios[ key ] );
		addAnalysedData( rec.scenarios[ key ] );
	}
 
	// Step 5: Lastly, write the record to disk.
	fs.writeFileSync( writer.getPath( 'record.json' ), JSON.stringify( rec, null, 2 ) );
 
	progress( 'conductor/record-end', { label: label } );
 
	return rec;
}
 
/**
 * Compare two Fresnel records.
 *
 * @param {string} outputDir File path
 * @param {string} labelA Record label
 * @param {string} labelB Record label
 * @return {Object} Comparison
 * @throws {Error} If records could not be read
 */
async function compare( outputDir, labelA, labelB ) {
	const reportReg = new Registry( Registry.isReport, reportsIndex );
 
	// Step 1: Read the original records
	const pathA = path.join( path.resolve( outputDir ), labelA, 'record.json' );
	const pathB = path.join( path.resolve( outputDir ), labelB, 'record.json' );
	const fileA = require( pathA );
	const fileB = require( pathB );
 
	// Step 2: Compare the analysed records.
	function makeJudgement( threshold, diff ) {
		if ( threshold > 0 ) {
			// This metric is characterised as "lower values are better".
			// - If the difference is positive and higher than this, it's bad.
			// - If the difference is negative and bigger than this, it's good.
			if ( diff > threshold ) {
				return false;
			}
			if ( diff < 0 && Math.abs( diff ) > threshold ) {
				return true;
			}
			return null;
		}
		// Idea: Support metrics characterised as "higher values are better".
		// Use a negative threshold value.
		// - If the difference is negative and lower than this, it's bad.
		// - If the difference is positive and bigger than this, it's good.
		return null;
	}
	function makeComparison( recordA, recordB ) {
		const result = {};
		const warnings = [];
 
		for ( const scenarioKey in recordA.scenarios ) {
			const compared = result[ scenarioKey ] = {};
 
			const scenarioA = recordA.scenarios[ scenarioKey ];
			const scenarioB = recordB.scenarios[ scenarioKey ];
 
			for ( const reportName in scenarioA.analysed ) {
				const report = reportReg.get( reportName );
				compared[ reportName ] = {};
 
				for ( const metricKey in report.metrics ) {
					const metric = report.metrics[ metricKey ];
					const a = scenarioA.analysed[ reportName ][ metricKey ];
					const b = scenarioB.analysed[ reportName ][ metricKey ];
					/* istanbul ignore if */
					if ( !a || !b ) {
						// If the evaluated commit changes the Fresnel configuration,
						// so that one of the scenarios or metrics exists in only one
						// of "before" or "after", then we can't compare it.
						continue;
					}
					const diff = metric.compare( a, b );
					const judgement = makeJudgement( metric.threshold, diff );
					const item = compared[ reportName ][ metricKey ] = {
						caption: metric.caption,
						unit: metric.unit,
						a: a,
						b: b,
						diff: diff,
						compareUnit: metric.compareUnit || metric.unit,
						judgement: judgement
					};
					if ( judgement === false ) {
						warnings.push( item );
					}
				}
			}
		}
 
		return { result, warnings };
	}
 
	return makeComparison( fileA, fileB );
}
 
module.exports = { record, compare };