UNPKG

phantomcss

Version:

A CasperJS module for automating visual regression testing of Web apps, live style guides and responsive layouts.

760 lines (609 loc) 20.7 kB
/* James Cryer / Huddle / 2016 https://github.com/Huddle/PhantomCSS http://tldr.huddle.com/blog/css-testing/ */ var fs = require( 'fs' ); var _src = '.' + fs.separator + 'screenshots'; var _results; // for backwards compatibility results and src are the same - but you can change it! var _failures = '.' + fs.separator + 'failures'; var _count = 0; var exitStatus; var _hideElements; var _waitTimeout = 60000; var _addLabelToFailedImage = true; var _mismatchTolerance = 0.05; var _resembleOutputSettings = {}; var _cleanupComparisonImages = false; var _failOnCaptureError = false; var diffsCreated = []; var _resemblePath; var _resembleContainerPath; var _libraryRoot; var _rebase = false; var _prefixCount = false; var _isCount = true; var _baselineImageSuffix = ""; var _diffImageSuffix = ".diff"; var _failureImageSuffix = ".fail"; var _captureWaitEnabled = true; exports.screenshot = screenshot; exports.compareAll = compareAll; exports.compareMatched = compareMatched; exports.compareExplicit = compareExplicit; exports.compareSession = compareSession; exports.compareFiles = compareFiles; exports.waitForTests = waitForTests; exports.init = init; exports.done = done; exports.update = update; exports.turnOffAnimations = turnOffAnimations; exports.getExitStatus = getExitStatus; exports.getCreatedDiffFiles = getCreatedDiffFiles; function update( options ) { function stripslash( str ) { return ( str || '' ).replace( /\/\//g, '/' ).replace( /\\/g, '\\' ); } options = options || {}; casper = options.casper || casper; _waitTimeout = options.waitTimeout || _waitTimeout; _libraryRoot = options.libraryRoot; _resemblePath = _resemblePath || getResemblePath( _libraryRoot ); _resembleContainerPath = _resembleContainerPath || getResembleContainerPath( _libraryRoot ); _src = stripslash( options.screenshotRoot || _src ); _results = stripslash( options.comparisonResultRoot || options.screenshotRoot || _results || _src ); _failures = options.failedComparisonsRoot === false ? false : stripslash( options.failedComparisonsRoot || _failures ); _fileNameGetter = options.fileNameGetter || _fileNameGetter; _prefixCount = options.prefixCount || _prefixCount; _isCount = ( options.addIteratorToImage !== false ); _onPass = options.onPass || _onPass; _onFail = options.onFail || _onFail; _onTimeout = options.onTimeout || _onTimeout; _onNewImage = options.onNewImage || _onNewImage; _onComplete = options.onComplete || options.report || _onComplete; _onCaptureFail = options.onCaptureFail || _onCaptureFail; _failOnCaptureError = options.failOnCaptureError || _failOnCaptureError; _hideElements = options.hideElements; _mismatchTolerance = isNaN(options.mismatchTolerance) ? _mismatchTolerance : options.mismatchTolerance; _rebase = isNotUndefined(options.rebase) ? options.rebase : _rebase; _resembleOutputSettings = options.outputSettings || _resembleOutputSettings; _resembleOutputSettings.useCrossOrigin=false; // turn off x-origin attr in Resemble to support SlimerJS _cleanupComparisonImages = options.cleanupComparisonImages || _cleanupComparisonImages; _baselineImageSuffix = options.baselineImageSuffix || _baselineImageSuffix; _diffImageSuffix = options.diffImageSuffix || _diffImageSuffix; _failureImageSuffix = options.failureImageSuffix || _failureImageSuffix; _captureWaitEnabled = isNotUndefined(options.captureWaitEnabled) ? options.captureWaitEnabled : _captureWaitEnabled; if ( options.addLabelToFailedImage !== undefined ) { _addLabelToFailedImage = options.addLabelToFailedImage; } if ( _cleanupComparisonImages ) { _results += fs.separator + generateRandomString(); } } function isNotUndefined(val){ return val !== void 0; } function init( options ) { update( options ); } function done(){ _count = 0; } function getResemblePath( root ) { var path; if(root){ path = [ root, 'node_modules', 'resemblejs', 'resemble.js' ].join( fs.separator ); if ( !_isFile( path ) ) { path = [ root, '..', 'resemblejs', 'resemble.js' ].join( fs.separator ); } } else { require('resemblejs'); for(var c in require.cache) { if(/resemblejs/.test(c)) { path = require.cache[c].filename; break; } } } if ( !_isFile( path ) ) { throw "[PhantomCSS] Resemble.js not found: " + path; } return path; } function getResembleContainerPath(root) { var path; if(root){ path = root + fs.separator + 'resemblejscontainer.html'; } else { for (var c in require.cache) { if (/phantomcss/.test(c)) { path = require.cache[c].filename.replace('phantomcss.js', 'resemblejscontainer.html'); break; } } } if ( !_isFile(path) ) { throw '[PhantomCSS] Can\'t find Resemble container. (' + path + ')'; } return path; } function turnOffAnimations() { console.log( '[PhantomCSS] Turning off animations' ); casper.evaluate( function turnOffAnimations() { function disableAnimations() { var jQuery = window.jQuery; if ( jQuery ) { jQuery.fx.off = true; } var css = document.createElement( "style" ); css.type = "text/css"; css.innerHTML = "* { -webkit-transition: none !important; transition: none !important; -webkit-animation: none !important; animation: none !important; }"; document.body.appendChild( css ); } if ( document.readyState !== "loading" ) { disableAnimations(); } else { window.addEventListener( 'load', disableAnimations, false ); } } ); } function _fileNameGetter( root, fileName ) { var name; // If no iterator, enforce filename. if ( !_isCount && !fileName ) { throw 'Filename is required when addIteratorToImage option is false.'; } fileName = fileName || "screenshot"; if ( !_isCount ) { name = root + fs.separator + fileName; _count++; } else { if ( _prefixCount ) { name = root + fs.separator + _count++ + "_" + fileName; } else { name = root + fs.separator + fileName + "_" + _count++; } } if ( _isFile( name + _baselineImageSuffix + '.png' ) ) { return name + _diffImageSuffix + '.png'; } else { return name + _baselineImageSuffix + '.png'; } } function _replaceDiffSuffix( str ) { return str.replace( _diffImageSuffix, _baselineImageSuffix ); } function _isFile( path ) { var exists = false; try { exists = fs.isFile( path ); } catch ( e ) { if ( e.name !== 'NS_ERROR_FILE_TARGET_DOES_NOT_EXIST' && e.name !== 'NS_ERROR_FILE_NOT_FOUND' ) { // We weren't expecting this exception throw e; } } return exists; } function screenshot( target, timeToWait, hideSelector, fileName ) { var name; if ( isComponentsConfig( target ) ) { for ( name in target ) { if ( isComponentsConfig( target[ name ] ) ) { waitAndHideToCapture( target[ name ].selector, name, target[ name ].ignore, target[ name ].wait ); } else { waitAndHideToCapture( target[ name ], name ); } } } else { if ( isNaN( Number( timeToWait ) ) && ( typeof timeToWait === 'string' ) ) { fileName = timeToWait; timeToWait = void 0; } waitAndHideToCapture( target, fileName, hideSelector, timeToWait ); } } function isComponentsConfig( obj ) { return ( Object.prototype.toString.call( obj ) === '[object Object]' ) && ( isClipRect( obj ) === false ); } function grab( filepath, target ) { if ( isClipRect( target ) ) { casper.capture( filepath, target ); } else { casper.captureSelector( filepath, target ); } } function capture( srcPath, resultPath, target ) { var originalForResult = _replaceDiffSuffix( resultPath ); var originalFromSource = _replaceDiffSuffix( srcPath ); try { if ( _rebase ) { grab( originalFromSource, target ); if ( isThisImageADiff( resultPath ) ) { // Tidy up. Remove old diff after rebase removeFile( resultPath ); } _onNewImage( { filename: originalFromSource } ); } else if ( isThisImageADiff( resultPath ) ) { grab( resultPath, target ); diffsCreated.push( resultPath ); if ( srcPath !== resultPath ) { // also copy the original over to the result directory copyAndReplaceFile( originalFromSource, originalForResult ); } } else { grab( srcPath, target ); if ( srcPath !== resultPath ) { // can't use copyAndReplaceFile yet, so just capture again grab( resultPath, target ); } _onNewImage( { filename: resultPath } ); } } catch ( ex ) { _onCaptureFail(ex, target) } } function isClipRect( value ) { return ( typeof value === 'object' && typeof value.top === 'number' && typeof value.left === 'number' && typeof value.width === 'number' && typeof value.height === 'number' ); } function isThisImageADiff( path ) { var sanitizedDiffSuffix = _diffImageSuffix.replace( /[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&" ); var diffRegex = new RegExp( sanitizedDiffSuffix + "\\.png" ); return diffRegex.test( path ); } function copyAndReplaceFile( src, dest ) { removeFile( dest ); fs.copy( src, dest ); } function removeFile( filepath ) { if ( _isFile( filepath ) ) { fs.remove( filepath ); } } function asyncCompare( one, two, func ) { if ( !casper.evaluate( function () { return window._imagediff_; } ) ) { initClient(); } casper.fillSelectors( 'form#image-diff-form', { '[name=one]': one, '[name=two]': two } ); casper.evaluate( function ( filename ) { window._imagediff_.run( filename ); }, { label: _addLabelToFailedImage ? one : false } ); casper.waitFor( function check() { return this.evaluate( function () { return window._imagediff_.hasResult; } ); }, function () { var mismatch = casper.evaluate( function () { return window._imagediff_.getResult(); } ); if ( Number( mismatch ) ) { func( false, mismatch ); } else { func( true ); } }, function () { func( false ); }, _waitTimeout ); } function getDiffs( root, collection ) { var symDict = { '..': 1, '.': 1}; if(!collection) {collection = [];} if ( fs.isDirectory( root ) ) { fs.list( root ).forEach( function(leaf){ var newroot = root + fs.separator + leaf; if ( symDict[ leaf ] ) { return true; } getDiffs(newroot, collection); } ); } else if ( isThisImageADiff( root.toLowerCase() ) ) { collection.push( root ); } return collection; } function filterOn(include, exclude){ return function(path){ var includeAble = (include === void 0) || include.test( path.toLowerCase() ); var excludeAble = exclude && exclude.test( path.toLowerCase() ); return !excludeAble && includeAble; } } function getCreatedDiffFiles() { var d = diffsCreated; diffsCreated = []; return d; } function compareMatched( match, exclude ) { // Search for diff images, but only compare matched filenames compareAll( exclude, void 0, match); } function compareExplicit( list ) { // An explicit list of diff images to compare ['/dialog.diff.png', '/header.diff.png'] compareAll( void 0, list ); } function compareSession( list ) { // compare the diffs created in this session compareAll( void 0, getCreatedDiffFiles() ); } function compareFiles( baseFile, file ) { var test = { filename: baseFile }; if ( !_isFile( baseFile ) ) { test.error = true; } else { casper.thenOpen( 'about:blank', function () {}); // reset page (fixes bug where failure screenshots leak between captures) casper.thenOpen( 'file:///' + _resembleContainerPath, function () { asyncCompare( baseFile, file, function ( isSame, mismatch ) { if ( !isSame ) { test.fail = true; casper.waitFor( function check() { return casper.evaluate( function () { return window._imagediff_.hasImage; } ); }, function () { var failFile, safeFileName, increment; if ( _failures ) { // flattened structure for failed diffs so that it is easier to preview failFile = _failures + fs.separator + file.split( /\/|\\/g ).pop().replace( _diffImageSuffix + '.png', '' ).replace( '.png', '' ); safeFileName = failFile; increment = 0; while ( _isFile( safeFileName + _failureImageSuffix + '.png' ) ) { increment++; safeFileName = failFile + '.' + increment; } failFile = safeFileName + _failureImageSuffix + '.png'; casper.captureSelector( failFile, 'img' ); test.failFile = failFile; } if ( file.indexOf( _diffImageSuffix + '.png' ) !== -1 ) { casper.captureSelector( file.replace( _diffImageSuffix + '.png', _failureImageSuffix + '.png' ), 'img' ); } else { casper.captureSelector( file.replace( '.png', _failureImageSuffix + '.png' ), 'img' ); } casper.evaluate( function () { window._imagediff_.hasImage = false; } ); if ( mismatch ) { test.mismatch = mismatch; _onFail( test ); // casper.test.fail throws and error, this function call is aborted return; // Just to make it clear what is happening } else { _onTimeout( test ); } }, function () {}, _waitTimeout ); } else { test.success = true; _onPass( test ); } } ); } ); } return test; } function str2RegExp(str){ return typeof str === 'string' ? new RegExp( str ) : str; } function compareAll( exclude, diffList, include ) { var tests = []; if ( !diffList ) { diffList = getDiffs( _results ); if(exclude || include){ diffList = diffList.filter(filterOn( str2RegExp(include), str2RegExp(exclude) )); } //diffList.forEach(function(path){console.log( '[PhantomCSS] Attempting visual comparison of ' + path );}) } diffList.forEach( function ( file ) { var baseFile = _replaceDiffSuffix( file ); tests.push( compareFiles( baseFile, file ) ); } ); waitForTests( tests ); } function waitForTests( tests ) { casper.then( function () { casper.waitFor( function () { return tests.length === tests.reduce( function ( count, test ) { if ( test.success || test.fail || test.error ) { return count + 1; } else { return count; } }, 0 ); }, function () { var fails = 0, errors = 0; tests.forEach( function ( test ) { if ( test.fail ) { fails++; } else if ( test.error ) { errors++; } } ); _onComplete( tests, fails, errors ); }, function () { }, _waitTimeout ); } ); } function initClient() { casper.page.injectJs( _resemblePath ); casper.evaluate( function ( mismatchTolerance, resembleOutputSettings ) { var result; var div = document.createElement( 'div' ); // this is a bit of hack, need to get images into browser for analysis div.style = "display:block;position:absolute;border:0;top:10px;left:0;"; // div.style = "display:block;position:absolute;border:0;top:0;left:0;height:1px;width:1px;"; div.innerHTML = '<form id="image-diff-form">' + '<input type="file" id="image-diff-one" name="one"/>' + '<input type="file" id="image-diff-two" name="two"/>' + '</form><div id="image-diff"></div>'; document.body.appendChild( div ); if ( resembleOutputSettings ) { resemble.outputSettings( resembleOutputSettings ); } window._imagediff_ = { hasResult: false, hasImage: false, run: run, getResult: function () { window._imagediff_.hasResult = false; return result; } }; function run( label ) { function render( data ) { var img = new Image(); img.onload = function () { window._imagediff_.hasImage = true; }; document.getElementById( 'image-diff' ).appendChild( img ); img.src = data.getImageDataUrl( label ); } resemble( document.getElementById( 'image-diff-one' ).files[ 0 ] ). compareTo( document.getElementById( 'image-diff-two' ).files[ 0 ] ). ignoreAntialiasing(). // <-- muy importante onComplete( function ( data ) { var diffImage; var misMatchPercentage = mismatchTolerance < 0.01 ? data.rawMisMatchPercentage : data.misMatchPercentage; if ( Number( misMatchPercentage ) > mismatchTolerance ) { result = misMatchPercentage; } else { result = false; } window._imagediff_.hasResult = true; if ( Number( misMatchPercentage ) > mismatchTolerance ) { render( data ); } } ); } }, _mismatchTolerance, _resembleOutputSettings ); } function _onPass( test ) { console.log( '\n' ); var name = 'Should look the same ' + test.filename; casper.test.pass(name, {name: name}); } function _onCaptureFail( ex, target ) { console.log( "[PhantomCSS] Screenshot capture failed: " + ex.message ); if (_failOnCaptureError) { var name = 'Capture screenshot ' + target; casper.test.fail(name, {name:name, message: 'Failed to capture ' + target + ' - ' + ex.message }); } } function _onFail( test ) { console.log('\n'); var name = 'Should look the same ' + test.filename; casper.test.fail(name, {name:name, message: 'Looks different (' + test.mismatch + '% mismatch) ' + test.failFile }); } function _onTimeout( test ) { console.log( '\n' ); casper.test.info( 'Could not complete image comparison for ' + test.filename ); } function _onNewImage( test ) { console.log( '\n' ); casper.test.info( 'New screenshot at ' + test.filename ); } function _onComplete( tests, noOfFails, noOfErrors ) { if ( tests.length === 0 ) { console.log( "\nMust be your first time?" ); console.log( "Some screenshots have been generated in the directory " + _results ); console.log( "This is your 'baseline', check the images manually. If they're wrong, delete the images." ); console.log( "The next time you run these tests, new screenshots will be taken. These screenshots will be compared to the original." ); console.log( 'If they are different, PhantomCSS will report a failure.' ); } else { if ( noOfFails === 0 ) { console.log( "\nPhantomCSS found " + tests.length + " tests, None of them failed. Which is good right?" ); console.log( "\nIf you want to make them fail, change some CSS." ); } else { console.log( "\nPhantomCSS found " + tests.length + " tests, " + noOfFails + ' of them failed.' ); if ( _failures ) { console.log( '\nPhantomCSS has created some images that try to show the difference (in the directory ' + _failures + '). Fuchsia colored pixels indicate a difference betwen the new and old screenshots.' ); } } if ( noOfErrors !== 0 ) { console.log( "There were " + noOfErrors + "errors. Is it possible that a baseline image was deleted but not the diff?" ); } if ( _cleanupComparisonImages ) { fs.removeTree( _results ); } exitStatus = noOfErrors + noOfFails; } } function waitAndHideToCapture( target, fileName, hideSelector, timeToWait ) { var srcPath = _fileNameGetter( _src, fileName ); var resultPath = srcPath.replace( _src, _results ); function runCapture() { if ( hideSelector || _hideElements ) { casper.evaluate( setVisibilityToHidden, { s1: _hideElements, s2: hideSelector } ); } capture( srcPath, resultPath, target ); } if(_captureWaitEnabled) { casper.wait(timeToWait || 250, runCapture); // give a bit of time for all the images appear } else { runCapture(); } } function setVisibilityToHidden( s1, s2 ) { // executes in browser scope var selector; var elements; var i; var jQuery = window.jQuery; if ( jQuery ) { if ( s1 ) { jQuery( s1 ).css( 'visibility', 'hidden' ); } if ( s2 ) { jQuery( s2 ).css( 'visibility', 'hidden' ); } return; } // Ensure at least an empty string s1 = s1 || ''; s2 = s2 || ''; // Create a combined selector, removing leading/trailing commas selector = ( s1 + ',' + s2 ).replace( /(^,|,$)/g, '' ); elements = document.querySelectorAll( selector ); i = elements.length; while ( i-- ) { elements[ i ].style.visibility = 'hidden'; } } function getExitStatus() { return exitStatus; } function generateRandomString() { return ( Math.random() + 1 ).toString( 36 ).substring( 7 ); }