UNPKG

grunt-phantomas

Version:
904 lines (772 loc) 23.2 kB
/* * grunt-phantomas * https://github.com/stefanjudis/grunt-phantomas * * Copyright (c) 2013 stefan judis * Licensed under the MIT license. */ 'use strict'; var Promise = require( 'bluebird' ); var fs = Promise.promisifyAll( require( 'node-fs' ) ); var path = Promise.promisifyAll( require( 'path' ) ); var json2csv = Promise.promisify( require( 'json2csv' ) ); var phantomas = require( 'phantomas' ); var _ = require( 'lodash' ); var minify = require( 'html-minifier' ).minify; /** * Path to generated asset files * @type {String} */ var ASSETS_PATH = path.resolve( __dirname, '../public/' ); /** * Path to index template * @type {String} */ var TEMPLATE_FILE = path.resolve( __dirname, '../tpl/index.tpl' ); /** * Constructor for Phantomas * * @param {Object} grunt grunt * @param {Object} options options * @param {Function} done callback to be called when everything is done * or an error appeared * * @tested */ var Phantomas = function( grunt, options, done ) { this.dataPath = path.normalize( options.indexPath + 'data/' ); this.done = done; this.failedAssertions = []; this.grunt = grunt; this.imagePath = path.normalize( options.indexPath + 'images/' ); this.options = this.normalizeOptions( options ); this.timestamp = +new Date(); this.buildUi = options.buildUi; this.version = require( '../../package.json' ).version; }; /** * Copy all needed assets from 'tasks/public' * to specified index path * * - phantomas.css * - phantomas.js * * @tested */ Phantomas.prototype.copyAssets = function() { this.grunt.log.subhead( 'PHANTOMAS ASSETS COPYING STARTED.' ); return new Promise( function( resolve ) { if ( !fs.existsSync( this.options.indexPath + '/public' ) ) { fs.mkdirSync( this.options.indexPath + '/public' ); } this.copyStyles(); this.copyScripts(); resolve(); }.bind( this ) ); }; /** * Copy script files and create needed folders * * - d3.min.js * - phantomas.min.js * * @tested */ Phantomas.prototype.copyScripts = function() { if ( !fs.existsSync( this.options.indexPath + '/public/scripts' ) ) { fs.mkdirSync( this.options.indexPath + '/public/scripts' ); } var d3 = fs.readFileSync( path.normalize( ASSETS_PATH + '/scripts/d3.min.js' ) ); fs.writeFileSync( path.normalize( this.options.indexPath + '/public/scripts/d3.min.js' ), d3 ); this.grunt.log.ok( 'Phantomas copied asset to \'' + this.options.indexPath + 'public/scripts/d3.min.js\'.' ); var phantomas = fs.readFileSync( path.normalize( ASSETS_PATH + '/scripts/phantomas.min.js' ) ); fs.writeFileSync( path.normalize( this.options.indexPath + '/public/scripts/phantomas.min.js' ), phantomas ); this.grunt.log.ok( 'Phantomas copied asset to \'' + this.options.indexPath + 'public/scripts/phantomas.min.js\'.' ); }; /** * Copy styles file and create needed folders * * @tested */ Phantomas.prototype.copyStyles = function() { if ( !fs.existsSync( this.options.indexPath + '/public/styles' ) ) { fs.mkdirSync( this.options.indexPath + '/public/styles' ); } var styles = fs.readFileSync( path.normalize( ASSETS_PATH + '/styles/phantomas.css' ) ); fs.writeFileSync( path.normalize( this.options.indexPath + '/public/styles/phantomas.css' ), styles ); this.grunt.log.ok( 'Phantomas copied asset to \'' + this.options.indexPath + 'public/styles/phantomas.css\'.' ); if ( this.options.additionalStylesheet ) { if ( fs.existsSync( this.options.additionalStylesheet ) ) { fs.writeFileSync( path.normalize( this.options.indexPath + '/public/styles/custom.css' ), fs.readFileSync( this.options.additionalStylesheet ) ); this.grunt.log.ok( 'Phantomas copied custom stylesheet to \'' + this.options.indexPath + 'public/styles/custom.css\'.' ); } else { this.grunt.log.error( 'Your additional stylesheet \'' + this.options.additionalStylesheet + '\' does not exist.' ); } } }; /** * Create data directory in index path * if it doesn't exist yet * * TODO -> put it together with 'createDataDirectory' * * @return {Promise} Promise * * @tested */ Phantomas.prototype.createDataDirectory = function() { return new Promise( function( resolve ) { var exists = fs.existsSync( this.dataPath ); if ( exists ) { resolve(); } else { fs.mkdirSync( path.normalize( this.options.indexPath + 'data' ) ); resolve(); } }.bind( this ) ); }; /** * Create index directory to make sure * files are writable according to set * indexPath * * TODO -> put it together with 'createDataDirectory' * * @return {Promise} Promise * * @tested */ Phantomas.prototype.createIndexDirectory = function() { return new Promise( function( resolve ) { var exists = fs.existsSync( this.options.indexPath ); if ( exists ) { resolve(); } else { fs.mkdirSync( path.normalize( this.options.indexPath ), '0777', true ); resolve(); } }.bind( this ) ); }; /** * Write final index.html file and handle all metrics * * @param {Array} results content of all metric files * @return {Promise} Promise * * @tested */ Phantomas.prototype.createIndexHtml = function( results ) { return new Promise( function( resolve ) { this.grunt.log.subhead( 'PHANTOMAS index.html WRITING STARTED.' ); var templateResults = []; var images = this.getImages(); // check if all files were valid json results.forEach( function( result ) { if ( result.isFulfilled() ) { templateResults.push( result.value() ); } }.bind( this ) ); this.grunt.file.write( this.options.indexPath + 'index.html', this.grunt.template.process( minify( this.grunt.file.read( TEMPLATE_FILE ), { removeComments : true, // TODO fix me // https://github.com/stefanjudis/grunt-phantomas/issues/93 collapseWhitespace : true } ), { data : { additionalStylesheet : this.options.additionalStylesheet, assertions : this.options.assertions, failedAssertions : this.failedAssertions, group : this.options.group, images : images, meta : phantomas.metadata.metrics, results : templateResults, timestamp : this.timestamp, url : this.options.url, version : this.version } } ) ); this.grunt.log.ok( 'Phantomas created new \'index.html\' at \'' + this.options.indexPath + '\'.' ); resolve( templateResults ); }.bind( this ) ); }; /** * Execute phantomas a given number of times * ( set in options ) * * @return {Promise} Promise that gets resolved when all * executions succeeded * * @tested */ Phantomas.prototype.executePhantomas = function() { var runs = [], callPhantomas = function( url, options ) { return new Promise( function( resolve, reject ) { return phantomas( url, options ).then( resolve, reject ); } ); }; return new Promise( function( resolve, reject ) { var options; this.grunt.log.verbose.writeln( 'Executing phantomas ( ' + this.options.numberOfRuns + ' times ) with following parameters:\n' + JSON.stringify( this.options.options ) ); for ( var i = 0; i < this.options.numberOfRuns; ++i ) { options = _.clone( this.options.options ); // run it only for the first run if ( i === 0 && options[ 'film-strip' ] !== false ) { options[ 'film-strip' ] = true; options[ 'film-strip-dir' ] = this.imagePath + this.timestamp; } runs.push( callPhantomas( this.options.url, options ) ); } Promise .all( runs.map( function( run ) { return run.reflect(); } ) ) .then( function( runs ) { return runs.reduce( function( result, run ) { if ( run.isFulfilled() ) { this.grunt.log.ok( 'Phantomas execution successful.' ); result.push( run.value().json ); } else { var reason = run.reason(); if ( reason.json && reason.json.metrics ) { this.grunt.log.error( 'Phantomas execution failed with ' + reason.code + ' but returned metrics.' ); result.push( reason.json ); } else { this.grunt.log.error( 'Phantomas execution failed with ' + reason.code ); } } return result; }.bind( this ), [] ); }.bind( this ) ) .then( resolve ) .catch( reject ); }.bind( this ) ); }; /** * Format the results of phantomas execution * and calculate statistic data * * @param {Array} results results * @return {Object} formated metrics * * @tested */ Phantomas.prototype.formResult = function( results ) { this.grunt.log.ok( this.options.numberOfRuns + ' Phantomas execution(s) done -> checking results:' ); return new Promise( function( resolve ) { var assertions = this.options.assertions, entries = {}, offenders = {}, fulFilledMetrics = results[ 0 ].metrics, entry, metric; _.each( fulFilledMetrics, function( value, key ) { if ( typeof value !== 'undefined' && typeof value !== 'string' ) { entries[ key ] = { values : [], sum : 0, min : 0, max : 0, median : undefined, average : undefined }; } } ); // process all runs results.forEach( function( run ) { var metric; for ( metric in run.metrics ) { if ( typeof run.metrics[ metric ] !== 'string' && typeof entries[ metric ] !== 'undefined' ) { entries[ metric ].values.push( run.metrics[ metric ] ); } } offenders = _.reduce( run.offenders, function( old, value, key ) { old[ key ] = _.uniq( ( old[ key ] || [] ).concat( value ) ); return old; }, offenders ); }, this ); /** * Avoiding deep nesting for 'calculate stats' * * @param {Number} element element * @return {Boolean} */ function filterEntryValues( element ) { return element !== null; } /** * Avoiding deep nesting for 'calculate stats' * * @param {Number} a value A * @param {Number} b value B * @return {Number} sorting value */ function sortEntryValues ( a, b ) { return a - b; } // calculate stats for ( metric in entries ) { entry = entries[ metric ]; if ( typeof entry.values[ 0 ] !== 'string' ) { entry.values = entry.values .filter( filterEntryValues ) .sort( sortEntryValues ); } if ( entry.values.length === 0 ) { continue; } entry.min = entry.values.slice( 0, 1 ).pop(); entry.max = entry.values.slice( -1 ).pop(); if ( typeof entry.values[ 0 ] === 'string' ) { continue; } for ( var j = 0, len = entry.values.length++; j<len; j++ ) { entry.sum += entry.values[ j ]; } entry.average = + ( len && ( entry.sum / len ).toFixed( 2 ) ); entry.median = + ( ( (len % 2 === 0) ? ( ( entry.values[ len >> 1 ] + entry.values[ len >> 1 + 1 ] ) / 2 ) : entry.values[ len >> 1 ] ).toFixed( 2 ) ); // pushed failed assertion // depending on median // to failedAssertions sum up if ( typeof this.options.assertions[ metric ] !== 'undefined' && _.indexOf( this.failedAssertions, metric ) === -1 && ( this.options.assertions[ metric ].type === '>' || this.options.assertions[ metric ].type === '<' ) && typeof this.options.assertions[ metric ].value === 'number' ) { if ( this.options.assertions[ metric ].type === '>' && entry.median > this.options.assertions[ metric ].value ) { this.failedAssertions.push( metric ); } if ( this.options.assertions[ metric ].type === '<' && entry.median < this.options.assertions[ metric ].value ) { this.failedAssertions.push( metric ); } } } resolve( { assertions : assertions, metrics : entries, offenders : offenders } ); }.bind( this ) ); }; /** * Get array of image paths * * @return {Array} Array of image paths */ Phantomas.prototype.getImages = function() { var files; try { files = fs.readdirSync( this.imagePath + '/' + this.timestamp ); } catch( e ) { this.grunt.log.error( 'NO IMAGES FOR FILM STRIP VIEW FOUND' ); files = []; } return _.sortBy( files, _.bind( function( file ) { return +file.match( /^screenshot-\d\d\d\d-\d\d-\d\dT\d\d-\d\d-\d\d-(\d*).png$/ )[ 1 ]; }, this ) ); }; /** * General function to start the whole thingy * * @tested */ Phantomas.prototype.kickOff = function() { this.grunt.log.subhead( 'PHANTOMAS EXECUTION(S) STARTED.' ); this.createIndexDirectory().bind( this ) // create data directory to prevent // fileIO errors .then( this.createDataDirectory ) // execute the phantomas process // multiple runs according to // configuration .then( this.executePhantomas ) // format result and calculate // max / min / median / average / ... .then( this.formResult ) // write new file(s) with metrics data .then( this.writeData ) // process data and generate // ui if wanted .then( this.processData ) // yeah we're done!!! :) .then( this.showSuccessMessage ) // catch general bluebird error .catch( Promise.RejectionError, function ( e ) { console.error( 'unable to write file, because: ', e.message ); } ) // catch unknown error .catch( function( e ) { this.grunt.log.error( 'SOMETHING WENT WRONG...' ); if ( e.stack ) { this.grunt.log.error( e.stack ); } else { this.grunt.log.error( e ); } this.grunt.event.emit( 'phantomasFailure', e ); }.bind( this ) ) .done(); }; /** * Normalize the handed in options object * to deal with legacy configs and different * allowed option settings * * @param {Object} options options * @return {Object} normalized options * * @tested */ Phantomas.prototype.normalizeOptions = function( options ) { options.assertions = _.mapValues( options.assertions, function( assertion ) { return ( typeof assertion === 'number' ) ? { type : '>', value : assertion } : assertion; } ); return options; }; /** * Notify about not displayed metrics during * the build process * * @param {Object} results results * @return {Promise} Promise * * @tested */ Phantomas.prototype.notifyAboutNotDisplayedMetrics = function( results ) { return new Promise( function( resolve ) { this.grunt.log.subhead( 'CHECKING FOR NOT DISPLAYED METRICS.' ); var resultKeys = _.keys( results[ results.length - 1 ].metrics ); var displayedMetricKeys = _.flatten( _.values( this.options.group ) ); displayedMetricKeys.push( 'timestamp' ); var notDisplayedMetrics = _.difference( resultKeys, displayedMetricKeys ); this.grunt.log.ok( 'You are currently not displaying the following metrics:\n' + notDisplayedMetrics.join( ', ' ) ); resolve(); }.bind( this ) ); }; /** * Process data and build UI if wished * * @return {Promise} Promise * * @tested */ Phantomas.prototype.processData = function() { return new Promise( function( resolve, reject ) { if ( this.options.buildUi ) { if ( _.indexOf( this.options.output, 'json' ) !== -1 ) { // read all generated metric files // and prepare them for ui generation this.readMetricsFiles().bind( this ) // generate index.html .then( this.outputUi ) // resolve everything to go on .then( resolve ); } else { this.grunt.log.error( 'Your set ouput format is not compatible with building the UI.' ); reject( 'Please set \'output\' to \'json\' if you want to build UI\n\n' + '-- or --\n\n' + 'set \'buildUi\' to \'false\' if you want to get only the csv files.' ); } } else { resolve(); } }.bind( this ) ); }; /** * Handle the path of a metrics file and read it * * @param {String} file file path to metrics file * @return {Promise} Promise * * @tested */ Phantomas.prototype.readMetricsFile = function( file ) { return new Promise( function( resolve, reject ) { fs.readFileAsync( this.dataPath + file, { encoding : 'utf8' } ).bind( this ) .then( function( data ) { try { data = JSON.parse( data ); } catch( e ) { // if it's not valid json // let's fail this.grunt.log.error( 'Sorry - ' + this.dataPath + file + ' is malformed' ); return reject( e ); } // set internal timestamp to work with it // on frontend side later on data.timestamp = +file.replace( /\.json/gi, '' ); this.grunt.log.ok( '\'' + file + '\' looks good!' ); // provide backwards compability // if no offenders data is present if ( !data.offenders ) { data.offenders = {}; data.metrics = JSON.parse( JSON.stringify( data ) ); } resolve( data ); }.bind( this ) ); }.bind( this ) ); }; /** * Get data of all metrics files * included in data folder * * @return {Promise} Promise * * @tested */ Phantomas.prototype.readMetricsFiles = function() { return new Promise( function( resolve ) { this.grunt.log.subhead( 'CHECKING ALL WRITTEN FILES FOR VALID JSON.' ); fs.readdirAsync( this.dataPath ).bind( this ) .then( function( files ) { files = files.filter( function( file ) { return file.match( /\.json/gi ); } ).sort(); if ( typeof this.options.limitIncludedRuns === 'number' && this.options.limitIncludedRuns ) { files = files.slice( files.length - this.options.limitIncludedRuns ); } files = files.map( function( file ) { return this.readMetricsFile( file ); }, this ); Promise.settle( files ).bind( this ) .then( resolve ) .catch( function( e ) { console.log( e ); } ); }.bind( this ) ); }.bind( this ) ); }; /** * Generate UI files if wished including creating index.html, * copying assets and so * * Do nothing if 'this.buildUI' is falsy * * @param {Array} files files * @return {Promise} Promise * * @tested */ Phantomas.prototype.outputUi = function( files ) { this.grunt.log.subhead( 'BUILDING THE UI TO DISPLAY YOUR DATA.' ); return new Promise( function( resolve, reject ) { this.createIndexHtml( files ).bind( this ) .then( this.notifyAboutNotDisplayedMetrics ) .then( this.copyAssets ) .then( resolve ) .catch( function( e ) { reject( e ); } ); }.bind( this ) ); }; /** * Show final message and call grunt task callback afterwards * * @tested */ Phantomas.prototype.showSuccessMessage = function() { this.grunt.log.subhead( 'FINISHED PHANTOMAS.' ); this.done(); }; /** * Create json or csv data * * @param {Object} result phantomas result * @return {Promise} Promise * * @tested */ Phantomas.prototype.writeData = function( result ) { this.grunt.log.subhead( 'WRITING RESULT FILES.' ); var runs = []; return new Promise( function( resolve, reject ) { if ( typeof result.metrics.requests !== 'undefined' && result.metrics.requests.values.length ) { // keep backwards compatibility // to not break existant configurations if ( typeof this.options.output === 'string' ) { this.options.output = [ this.options.output ]; } // iterate of output formats // and write data this.options.output.forEach( function( format ) { if ( this._writeData[ format ] !== undefined ) { runs.push( this._writeData[ format ].bind( this )( result ) ); } else { reject( 'Your set ouput format \'' + format + '\' is not supported.\n' + 'PLEASE CHECK DOCUMENTATION FOR SUPPORTED FORMATS.' ); } }, this ); Promise.settle( runs ) .then( resolve ); } else { reject( 'No run was successful.' ); } }.bind( this ) ); }; /** * Object holding function to generate * several data formats * * @type {Object} */ Phantomas.prototype._writeData = { /** * Create CSV with generated data * * @param {Object} result result * @return {Promise} Promise * * @tested */ csv : function( result ) { return new Promise( function( resolve, reject ) { var displayedMetricKeys = _.keys( result.metrics ); var metrics = {}; _.each( result.metrics, function( value, key ){ metrics[ key ] = result.metrics[ key ].average; } ); json2csv( { data : result.metrics, fields : displayedMetricKeys } ) .then( function( csv ) { var fileName = this.dataPath + this.timestamp + '.csv'; fs.writeFileAsync( fileName, csv ).then( resolve ); this.grunt.log.ok( 'CSV file - ' + fileName + ' - written.' ); }.bind( this ) ) .catch( function( e ) { reject( e ); } ); }.bind( this ) ); }, /** * Create JSON with generated data * * @param {Object} result result * @return {Promise} Promise * * @tested */ json : function( result ) { return new Promise( function( resolve, reject ) { var fileName = this.dataPath + this.timestamp + '.json'; fs.writeFileAsync( fileName, JSON.stringify( result, null, 2 ) ) .then( resolve ) .catch( reject ); this.grunt.log.ok( 'JSON file - ' + fileName + ' - written.' ); }.bind( this ) ); } }; module.exports = Phantomas;