Selaa lähdekoodia

Merge pull request #48 from gmetais/thumbnail

Display a screenshot on dashboard
Gaël Métais 10 vuotta sitten
vanhempi
commit
2264445772

+ 0 - 1
Gruntfile.js

@@ -330,7 +330,6 @@ module.exports = function(grunt) {
 
 
     grunt.registerTask('test', [
     grunt.registerTask('test', [
         'build',
         'build',
-        'jshint',
         'express:testSuite',
         'express:testSuite',
         'clean:coverage',
         'clean:coverage',
         'copy-test-server-settings',
         'copy-test-server-settings',

+ 25 - 1
front/src/css/dashboard.css

@@ -5,7 +5,14 @@
   text-align: center;
   text-align: center;
 }
 }
 .summary .globalScore {
 .summary .globalScore {
-  margin-bottom: 3em;
+  display: table;
+  width: 60%;
+  margin: 3em auto;
+}
+.summary .globalScore > div {
+  display: table-cell;
+  width: 50%;
+  vertical-align: middle;
 }
 }
 .summary .globalScore .globalGrade {
 .summary .globalScore .globalGrade {
   margin: 0.5 auto;
   margin: 0.5 auto;
@@ -22,6 +29,23 @@
   font-weight: bold;
   font-weight: bold;
   margin: 0.5em 0 1em;
   margin: 0.5em 0 1em;
 }
 }
+.summary .globalScore .screenshotWrapper:hover {
+  opacity: 0.75;
+}
+.summary .globalScore .screenshotWrapper:hover:after {
+  position: absolute;
+  width: 1.25em;
+  height: 1.25em;
+  top: 0.7em;
+  left: 1.55em;
+  font-size: 3em;
+  color: #FFF;
+  background: #000;
+  border-radius: 0.2em;
+  text-align: center;
+  content: "+";
+  opacity: 0.6;
+}
 .summary .notations {
 .summary .notations {
   display: table;
   display: table;
   width: 80%;
   width: 80%;

+ 38 - 0
front/src/css/main.css

@@ -113,6 +113,44 @@ h1 span {
   margin-top: 4em;
   margin-top: 4em;
   color: black;
   color: black;
 }
 }
+.screenshotWrapper.desktop {
+  display: inline-block;
+  position: relative;
+  border: 0.2em solid #AAA;
+  background: #000;
+  padding: 0.5em;
+  border-top-left-radius: 0.4em;
+  border-top-right-radius: 0.4em;
+}
+.screenshotWrapper.desktop:before {
+  position: absolute;
+  width: 15em;
+  height: 0.6em;
+  bottom: -0.75em;
+  left: -1em;
+  background: #CCC;
+  border-bottom-left-radius: 0.2em;
+  border-bottom-right-radius: 0.2em;
+  content: " ";
+}
+.screenshotWrapper.desktop:after {
+  position: absolute;
+  width: 0.4em;
+  height: 0.2em;
+  bottom: -0.55em;
+  left: 12.5em;
+  background: lime;
+  content: " ";
+}
+.screenshotWrapper.desktop > div {
+  width: 12em;
+  height: 6.75em;
+  overflow: scroll;
+  position: relative;
+}
+.screenshotWrapper.desktop .screenshotImage {
+  width: 100%;
+}
 .star {
 .star {
   font-weight: bold;
   font-weight: bold;
 }
 }

+ 7 - 0
front/src/css/screenshot.css

@@ -0,0 +1,7 @@
+.screenshot.board {
+  text-align: center;
+}
+.screenshot .screenshotWrapper {
+  font-size: 2.08333333333333em;
+  margin-bottom: 0.5em;
+}

+ 5 - 0
front/src/js/app.js

@@ -6,6 +6,7 @@ var yltApp = angular.module('YellowLabTools', [
     'dashboardCtrl',
     'dashboardCtrl',
     'queueCtrl',
     'queueCtrl',
     'ruleCtrl',
     'ruleCtrl',
+    'screenshotCtrl',
     'timelineCtrl',
     'timelineCtrl',
     'runsFactory',
     'runsFactory',
     'resultsFactory',
     'resultsFactory',
@@ -46,6 +47,10 @@ yltApp.config(['$routeProvider', '$locationProvider',
                 templateUrl: 'views/timeline.html',
                 templateUrl: 'views/timeline.html',
                 controller: 'TimelineCtrl'
                 controller: 'TimelineCtrl'
             }).
             }).
+            when('/result/:runId/screenshot', {
+                templateUrl: 'views/screenshot.html',
+                controller: 'ScreenshotCtrl'
+            }).
             when('/result/:runId/rule/:policy', {
             when('/result/:runId/rule/:policy', {
                 templateUrl: 'views/rule.html',
                 templateUrl: 'views/rule.html',
                 controller: 'RuleCtrl'
                 controller: 'RuleCtrl'

+ 2 - 1
front/src/js/controllers/dashboardCtrl.js

@@ -36,7 +36,8 @@ dashboardCtrl.controller('DashboardCtrl', ['$scope', '$rootScope', '$routeParams
     $scope.testAgain = function() {
     $scope.testAgain = function() {
         Runs.save({
         Runs.save({
                 url: $scope.result.params.url,
                 url: $scope.result.params.url,
-                waitForResponse: false
+                waitForResponse: false,
+                screenshot: true
             }, function(data) {
             }, function(data) {
                 $location.path('/queue/' + data.runId);
                 $location.path('/queue/' + data.runId);
             });
             });

+ 2 - 1
front/src/js/controllers/indexCtrl.js

@@ -5,7 +5,8 @@ indexCtrl.controller('IndexCtrl', ['$scope', '$location', 'Runs', function($scop
         if ($scope.url) {
         if ($scope.url) {
             Runs.save({
             Runs.save({
                 url: $scope.url,
                 url: $scope.url,
-                waitForResponse: false
+                waitForResponse: false,
+                screenshot: true
             }, function(data) {
             }, function(data) {
                 $location.path('/queue/' + data.runId);
                 $location.path('/queue/' + data.runId);
             });
             });

+ 2 - 1
front/src/js/controllers/ruleCtrl.js

@@ -31,7 +31,8 @@ ruleCtrl.controller('RuleCtrl', ['$scope', '$rootScope', '$routeParams', '$locat
     $scope.testAgain = function() {
     $scope.testAgain = function() {
         Runs.save({
         Runs.save({
                 url: $scope.result.params.url,
                 url: $scope.result.params.url,
-                waitForResponse: false
+                waitForResponse: false,
+                screenshot: true
             }, function(data) {
             }, function(data) {
                 $location.path('/queue/' + data.runId);
                 $location.path('/queue/' + data.runId);
             });
             });

+ 37 - 0
front/src/js/controllers/screenshotCtrl.js

@@ -0,0 +1,37 @@
+var screenshotCtrl = angular.module('screenshotCtrl', ['resultsFactory', 'menuService']);
+
+screenshotCtrl.controller('ScreenshotCtrl', ['$scope', '$rootScope', '$routeParams', '$location', 'Results', 'Runs', 'Menu', function($scope, $rootScope, $routeParams, $location, Results, Runs, Menu) {
+    $scope.runId = $routeParams.runId;
+    $scope.Menu = Menu.setCurrentPage(null, $scope.runId);
+    
+    function loadResults() {
+        // Load result if needed
+        if (!$rootScope.loadedResult || $rootScope.loadedResult.runId !== $routeParams.runId) {
+            Results.get({runId: $routeParams.runId}, function(result) {
+                $rootScope.loadedResult = result;
+                $scope.result = result;
+                init();
+            }, function(err) {
+                $scope.error = true;
+            });
+        } else {
+            $scope.result = $rootScope.loadedResult;
+        }
+    }
+
+    $scope.backToDashboard = function() {
+        $location.path('/result/' + $scope.runId);
+    };
+
+    $scope.testAgain = function() {
+        Runs.save({
+                url: $scope.result.params.url,
+                waitForResponse: false,
+                screenshot: true
+            }, function(data) {
+                $location.path('/queue/' + data.runId);
+            });
+    };
+
+    loadResults();
+}]);

+ 2 - 1
front/src/js/controllers/timelineCtrl.js

@@ -134,7 +134,8 @@ timelineCtrl.controller('TimelineCtrl', ['$scope', '$rootScope', '$routeParams',
     $scope.testAgain = function() {
     $scope.testAgain = function() {
         Runs.save({
         Runs.save({
                 url: $scope.result.params.url,
                 url: $scope.result.params.url,
-                waitForResponse: false
+                waitForResponse: false,
+                screenshot: true
             }, function(data) {
             }, function(data) {
                 $location.path('/queue/' + data.runId);
                 $location.path('/queue/' + data.runId);
             });
             });

+ 29 - 1
front/src/less/dashboard.less

@@ -7,7 +7,16 @@
 }
 }
 
 
 .summary .globalScore {
 .summary .globalScore {
-    margin-bottom: 3em;
+    display: table;
+    width: 60%;
+    margin: 3em auto;
+
+    > div {
+        display: table-cell;
+        width: 50%;
+        vertical-align: middle;
+    }
+
     .globalGrade {
     .globalGrade {
         margin: 0.5 auto;
         margin: 0.5 auto;
         width: 2.5em;
         width: 2.5em;
@@ -23,6 +32,25 @@
         font-weight: bold;
         font-weight: bold;
         margin: 0.5em 0 1em;
         margin: 0.5em 0 1em;
     }
     }
+
+    .screenshotWrapper:hover {
+        opacity: 0.75;
+
+        &:after {
+            position: absolute;
+            width: 1.25em;
+            height: 1.25em;
+            top: 0.7em;
+            left: 1.55em;
+            font-size: 3em;
+            color: #FFF;
+            background: #000;
+            border-radius: 0.2em;
+            text-align: center;
+            content: "+";
+            opacity: 0.6;
+        }
+    }
 }
 }
 
 
 .summary .notations {
 .summary .notations {

+ 44 - 0
front/src/less/main.less

@@ -113,6 +113,50 @@ h1 span {
 }
 }
 
 
 
 
+.screenshotWrapper.desktop {
+    display: inline-block;
+    position: relative;
+    border: 0.2em solid #AAA;
+    background: #000;
+    padding: 0.5em;
+    border-top-left-radius: 0.4em;
+    border-top-right-radius: 0.4em;
+
+    &:before {
+        position: absolute;
+        width: 15em;
+        height: 0.6em;
+        bottom: -0.75em;
+        left: -1em;
+        background: #CCC;
+        border-bottom-left-radius: 0.2em;
+        border-bottom-right-radius: 0.2em;
+        content: " ";
+    }
+
+    &:after {
+        position: absolute;
+        width: 0.4em;
+        height: 0.2em;
+        bottom: -0.55em;
+        left: 12.5em;
+        background: lime;
+        content: " ";
+    }
+
+    > div {
+        width: 12em;
+        height: 6.75em;
+        overflow: scroll;
+        position: relative;
+    }
+
+    .screenshotImage {
+        width: 100%;
+    }
+}
+
+
 .star {
 .star {
     font-weight: bold;
     font-weight: bold;
     span {
     span {

+ 8 - 0
front/src/less/screenshot.less

@@ -0,0 +1,8 @@
+.screenshot.board {
+    text-align: center;
+}
+
+.screenshot .screenshotWrapper {
+    font-size: 2.08333333333333em;
+    margin-bottom: 0.5em;
+}

+ 2 - 0
front/src/main.html

@@ -12,6 +12,7 @@
     <link rel="stylesheet" type="text/css" href="/css/dashboard.css">
     <link rel="stylesheet" type="text/css" href="/css/dashboard.css">
     <link rel="stylesheet" type="text/css" href="/css/queue.css">
     <link rel="stylesheet" type="text/css" href="/css/queue.css">
     <link rel="stylesheet" type="text/css" href="/css/rule.css">
     <link rel="stylesheet" type="text/css" href="/css/rule.css">
+    <link rel="stylesheet" type="text/css" href="/css/screenshot.css">
     <link rel="stylesheet" type="text/css" href="/css/timeline.css">
     <link rel="stylesheet" type="text/css" href="/css/timeline.css">
     <link rel="stylesheet" type="text/css" href="/css/about.css">
     <link rel="stylesheet" type="text/css" href="/css/about.css">
     <!-- endbuild -->
     <!-- endbuild -->
@@ -27,6 +28,7 @@
     <script src="/js/controllers/dashboardCtrl.js"></script>
     <script src="/js/controllers/dashboardCtrl.js"></script>
     <script src="/js/controllers/queueCtrl.js"></script>
     <script src="/js/controllers/queueCtrl.js"></script>
     <script src="/js/controllers/ruleCtrl.js"></script>
     <script src="/js/controllers/ruleCtrl.js"></script>
+    <script src="/js/controllers/screenshotCtrl.js"></script>
     <script src="/js/controllers/timelineCtrl.js"></script>
     <script src="/js/controllers/timelineCtrl.js"></script>
     <script src="/js/models/resultsFactory.js"></script>
     <script src="/js/models/resultsFactory.js"></script>
     <script src="/js/models/runsFactory.js"></script>
     <script src="/js/models/runsFactory.js"></script>

+ 15 - 4
front/src/views/dashboard.html

@@ -2,10 +2,21 @@
 <div class="summary board">
 <div class="summary board">
     
     
     <div class="globalScore" ng-if="globalScore === 0 || globalScore > 0">
     <div class="globalScore" ng-if="globalScore === 0 || globalScore > 0">
-        <h2> Global score</h2>
-        <div class="globalScoreDisplay">
-            <grade score="result.scoreProfiles.generic.globalScore" class="globalGrade"></grade>
-            <div class="on100">{{globalScore}}/100</div>
+        <div>
+            <h2>Global score</h2>
+            <div class="globalScoreDisplay">
+                <grade score="result.scoreProfiles.generic.globalScore" class="globalGrade"></grade>
+                <div class="on100">{{globalScore}}/100</div>
+            </div>
+        </div>
+        <div>
+            <a href="/result/{{result.runId}}/screenshot">
+                <div class="screenshotWrapper desktop">
+                    <div>
+                        <img class="screenshotImage" ng-src="{{result.screenshotUrl}}"/>
+                    </div>
+                </div>
+            </a>
         </div>
         </div>
     </div>
     </div>
 
 

+ 14 - 0
front/src/views/screenshot.html

@@ -0,0 +1,14 @@
+<div ng-include="'views/resultSubHeader.html'"></div>
+<div class="screenshot board">
+    <h2>Screenshot</h2>
+
+    <div class="screenshotWrapper desktop">
+        <div>
+            <img class="screenshotImage" ng-src="{{result.screenshotUrl}}"/>
+        </div>
+    </div>
+
+    <p>(scroll on the screenshot to see under the fold)</p>
+
+    <div class="backToDashboard"><a href="#" ng-click="backToDashboard()">Back to dashboard</a></div>
+</div>

+ 2 - 2
lib/metadata/policies.js

@@ -416,7 +416,7 @@ var policies = {
         "message": "<p>This is the number of different colors defined in CSS.</p><p>Your CSS project will be easier to maintain if you keep a small color set.</p>",
         "message": "<p>This is the number of different colors defined in CSS.</p><p>Your CSS project will be easier to maintain if you keep a small color set.</p>",
         "isOkThreshold": 30,
         "isOkThreshold": 30,
         "isBadThreshold": 150,
         "isBadThreshold": 150,
-        "isAbnormalThreshold": 300,
+        "isAbnormalThreshold": 400,
         "hasOffenders": true,
         "hasOffenders": true,
         "offendersTransformFn": function(offenders, ruleObject) {
         "offendersTransformFn": function(offenders, ruleObject) {
             var deduplicatedObj = {};
             var deduplicatedObj = {};
@@ -771,7 +771,7 @@ var policies = {
                 list: offenders.map(function(offender) {
                 list: offenders.map(function(offender) {
                     var splittedOffender = offendersHelpers.cssOffenderPattern(offender);
                     var splittedOffender = offendersHelpers.cssOffenderPattern(offender);
 
 
-                    var rule = splittedOffender.css;
+                    var rule = splittedOffender.css || '';
                     var redundanters = [
                     var redundanters = [
                         ['ul', 'li'],
                         ['ul', 'li'],
                         ['ol', 'li'],
                         ['ol', 'li'],

+ 5 - 1
lib/runner.js

@@ -43,7 +43,11 @@ var Runner = function(params) {
         delete data.toolsResults.phantomas.metrics.javascriptExecutionTree;
         delete data.toolsResults.phantomas.metrics.javascriptExecutionTree;
         delete data.toolsResults.phantomas.offenders.javascriptExecutionTree;
         delete data.toolsResults.phantomas.offenders.javascriptExecutionTree;
 
 
-        //Finished!
+        return data;
+
+    }).then(function(data) {
+
+        // Finished!
         deferred.resolve(data);
         deferred.resolve(data);
 
 
     }).fail(function(err) {
     }).fail(function(err) {

+ 135 - 0
lib/screenshotHandler.js

@@ -0,0 +1,135 @@
+var debug       = require('debug')('ylt:screenshotHandler');
+var lwip        = require('lwip');
+var tmp         = require('temporary');
+var Q           = require('q');
+var fs          = require('fs');
+var path        = require('path');
+
+
+var screenshotHandler = function() {
+
+    this.getScreenshotTempFile = function() {
+        
+        var screenshotTmpFolder = new tmp.Dir();
+        var tmpFilePath = path.join(screenshotTmpFolder.path, 'screenshot.jpg');
+        var that = this;
+        
+        return {
+            
+            getTmpFolder: function() {
+                return screenshotTmpFolder;
+            },
+            
+            getTmpFilePath: function() {
+                return tmpFilePath;
+            },
+            
+            toThumbnail: function(width) {
+                return that.optimize(tmpFilePath, width);
+            },
+            
+            deleteTmpFile: function() {
+                return that.deleteTmpFileAndFolder(tmpFilePath, screenshotTmpFolder);
+            }
+        };
+    };
+
+
+    this.optimize = function(imagePath, width) {
+        var that = this;
+
+        debug('Starting screenshot transformation');
+
+        return this.openImage(imagePath)
+
+            .then(function(image) {
+
+                return that.resizeImage(image, width);
+
+            })
+
+            .then(this.toBuffer);
+    };
+
+
+    this.openImage = function(imagePath) {
+        var deferred = Q.defer();
+
+        lwip.open(imagePath, function(err, image){
+            if (err) {
+                debug('Could not open imagePath %s', imagePath);
+                debug(err);
+
+                deferred.reject(err);
+            } else {
+                debug('Image correctly open');
+                deferred.resolve(image);
+            }
+        });
+
+        return deferred.promise;
+    };
+
+
+    this.resizeImage = function(image, newWidth) {
+        var deferred = Q.defer();
+
+        var currentWidth = image.width();
+        var ratio = newWidth / currentWidth;
+
+        image.scale(ratio, function(err, image){
+            if (err) {
+                debug('Could not resize image');
+                debug(err);
+
+                deferred.reject(err);
+            } else {
+                debug('Image correctly resized');
+                deferred.resolve(image);
+            }
+        });
+
+        return deferred.promise;        
+    };
+
+
+    this.toBuffer = function(image) {
+        var deferred = Q.defer();
+
+        image.toBuffer('jpg', {quality: 90}, function(err, buffer){
+            if (err) {
+                debug('Could not save image to buffer');
+                debug(err);
+
+                deferred.reject(err);
+            } else {
+                debug('Image correctly transformed to buffer');
+                deferred.resolve(buffer);
+            }
+        });
+
+        return deferred.promise;        
+    };
+
+
+    this.deleteTmpFileAndFolder = function(tmpFilePath, screenshotTmpFolder) {
+        var deferred = Q.defer();
+
+        fs.unlink(tmpFilePath, function (err) {
+            if (err) {
+                debug('Screenshot file not found, could not be deleted. But it is not a problem.');
+            } else {
+                debug('Screenshot file deleted.');
+            }
+
+            screenshotTmpFolder.rmdir();
+            debug('Screenshot temp folder deleted');
+
+            deferred.resolve();
+        });
+
+        return deferred.promise;
+    };
+};
+
+module.exports = new screenshotHandler();

+ 115 - 52
lib/server/controllers/apiController.js

@@ -1,6 +1,8 @@
 var debug               = require('debug')('ylt:server');
 var debug               = require('debug')('ylt:server');
+var Q                   = require('q');
 
 
 var ylt                 = require('../../index');
 var ylt                 = require('../../index');
+var ScreenshotHandler   = require('../../screenshotHandler');
 var RunsQueue           = require('../datastores/runsQueue');
 var RunsQueue           = require('../datastores/runsQueue');
 var RunsDatastore       = require('../datastores/runsDatastore');
 var RunsDatastore       = require('../datastores/runsDatastore');
 var ResultsDatastore    = require('../datastores/resultsDatastore');
 var ResultsDatastore    = require('../datastores/resultsDatastore');
@@ -24,10 +26,17 @@ var ApiController = function(app) {
             params: {
             params: {
                 url: req.body.url,
                 url: req.body.url,
                 waitForResponse: req.body.waitForResponse !== false && req.body.waitForResponse !== 'false' && req.body.waitForResponse !== 0,
                 waitForResponse: req.body.waitForResponse !== false && req.body.waitForResponse !== 'false' && req.body.waitForResponse !== 0,
-                partialResult: req.body.partialResult || null
+                partialResult: req.body.partialResult || null,
+                screenshot: req.body.screenshot || false
             }
             }
         };
         };
 
 
+        // Create a temporary folder to save the screenshot
+        var screenshot;
+        if (run.params.screenshot) {
+            screenshot = ScreenshotHandler.getScreenshotTempFile();
+        }
+
         // Add test to the testQueue
         // Add test to the testQueue
         debug('Adding test %s to the queue', run.runId);
         debug('Adding test %s to the queue', run.runId);
         var queuePromise = queue.push(run.runId);
         var queuePromise = queue.push(run.runId);
@@ -49,81 +58,120 @@ var ApiController = function(app) {
 
 
             debug('Launching test %s on %s', run.runId, run.params.url);
             debug('Launching test %s on %s', run.runId, run.params.url);
 
 
-            ylt(run.params.url)
+            var runOptions = {
+                screenshot: run.params.screenshot ? screenshot.getTmpFilePath() : false
+            };
 
 
-                .then(function(data) {
+            return ylt(run.params.url, runOptions);
 
 
-                    debug('Success');
-                    
+        })
+        // Phantomas completed, let's save the screenshot if any
+        .then(function(data) {
+
+            debug('Success');
+            data.runId = run.runId;
+
+            
+            // Some conditional steps are made if there is a screenshot
+            var screenshotPromise = Q.resolve();
+
+            if (run.params.screenshot) {
+                
+                // Replace the empty promise created earlier with Q.resolve()
+                screenshotPromise = screenshot.toThumbnail(400)
+                
+                    // Read screenshot
+                    .then(function(screenshotBuffer) {
+                        
+                        if (screenshotBuffer) {
+                            debug('Image optimized');
+                            data.screenshotBuffer = screenshotBuffer;
+
+                            // Official path to get the image
+                            data.screenshotUrl = '/api/results/' + data.runId + '/screenshot.jpg';
+                        }
 
 
-                    // Save result in datastore
-                    data.runId = run.runId;
-                    resultsDatastore.saveResult(data)
-                        .then(function() {
-
-                            runsDatastore.markAsComplete(run.runId);
-                            
-                            // Send result if the user was waiting
-                            if (run.params.waitForResponse) {
-
-                                // If the user only wants a portion of the result (partialResult option)
-                                switch(run.params.partialResult) {
-                                    case 'generalScores': 
-                                        res.redirect(302, '/api/results/' + run.runId + '/generalScores');
-                                        break;
-                                    case 'rules': 
-                                        res.redirect(302, '/api/results/' + run.runId + '/rules');
-                                        break;
-                                    case 'javascriptExecutionTree':
-                                        res.redirect(302, '/api/results/' + run.runId + '/javascriptExecutionTree');
-                                        break;
-                                    case 'phantomas':
-                                        res.redirect(302, '/api/results/' + run.runId + '/toolsResults/phantomas');
-                                        break;
-                                    default:
-                                        res.redirect(302, '/api/results/' + run.runId);
-                                }
-                            }
-                            
-                        })
-                        .fail(function(err) {
-                            debug('Saving results to resultsDatastore failed:');
-                            debug(err);
-
-                            res.status(500).send('Saving results failed');
-                        });
+                    })
+                    // Delete screenshot temporary file
+                    .then(screenshot.deleteTmpFile);
 
 
+            }
+
+            // Let's continue
+            screenshotPromise
+
+                // Save results
+                .then(function() {
+                    delete data.params.options.screenshot;
+                    return resultsDatastore.saveResult(data);
                 })
                 })
 
 
+                // Mark as the run as complete and send the response if the request is still waiting
+                .then(function() {
+
+                    debug('Result saved in datastore');
+
+                    runsDatastore.markAsComplete(run.runId);
+
+                    if (run.params.waitForResponse) {
+
+                        // If the user only wants a portion of the result (partialResult option)
+                        switch(run.params.partialResult) {
+                            case 'generalScores': 
+                                res.redirect(302, '/api/results/' + run.runId + '/generalScores');
+                                break;
+                            case 'rules': 
+                                res.redirect(302, '/api/results/' + run.runId + '/rules');
+                                break;
+                            case 'javascriptExecutionTree':
+                                res.redirect(302, '/api/results/' + run.runId + '/javascriptExecutionTree');
+                                break;
+                            case 'phantomas':
+                                res.redirect(302, '/api/results/' + run.runId + '/toolsResults/phantomas');
+                                break;
+                            default:
+                                res.redirect(302, '/api/results/' + run.runId);
+                        }
+                    }
+                                    
+                })
                 .fail(function(err) {
                 .fail(function(err) {
-                    
                     console.error('Test failed for URL: %s', run.params.url);
                     console.error('Test failed for URL: %s', run.params.url);
                     console.error(err.toString());
                     console.error(err.toString());
 
 
                     runsDatastore.markAsFailed(run.runId, err.toString());
                     runsDatastore.markAsFailed(run.runId, err.toString());
 
 
-                    res.status(400).send('Bad request');
+                    res.status(500).send('An error occured');
+                });
+
+        })
+
+        .fail(function(err) {
                     
                     
-                })
+            console.error('Test failed for URL: %s', run.params.url);
+            console.error(err.toString());
 
 
-                .finally(function() {
-                    queue.remove(run.runId);
-                });
+            runsDatastore.markAsFailed(run.runId, err.toString());
 
 
-        }).fail(function(err) {
-            console.error('Error or YLT\'s core instanciation');
-            console.error(err);
-            console.error(err.stack);
+            res.status(400).send('Bad request');
+            
+        })
+
+        .finally(function() {
+            queue.remove(run.runId);
         });
         });
 
 
-        // The user doesn't not want to wait for the response, sending the run ID only
+
+        // The user doesn't want to wait for the response, sending the run ID only
         if (!run.params.waitForResponse) {
         if (!run.params.waitForResponse) {
             console.log('Sending response without waiting.');
             console.log('Sending response without waiting.');
             res.setHeader('Content-Type', 'application/json');
             res.setHeader('Content-Type', 'application/json');
             res.send(JSON.stringify({runId: run.runId}));
             res.send(JSON.stringify({runId: run.runId}));
         }
         }
+
     });
     });
 
 
+
     // Retrive one run by id
     // Retrive one run by id
     app.get('/api/runs/:id', function(req, res) {
     app.get('/api/runs/:id', function(req, res) {
         var runId = req.params.id;
         var runId = req.params.id;
@@ -221,6 +269,21 @@ var ApiController = function(app) {
             });
             });
     }
     }
 
 
+    // Retrive one result by id
+    app.get('/api/results/:id/screenshot.jpg', function(req, res) {
+        var runId = req.params.id;
+
+        resultsDatastore.getScreenshot(runId)
+            .then(function(screenshotBuffer) {
+                
+                res.setHeader('Content-Type', 'image/jpeg');
+                res.send(screenshotBuffer);
+
+            }).fail(function() {
+                res.status(404).send('Not found');
+            });
+    });
+
 };
 };
 
 
 module.exports = ApiController;
 module.exports = ApiController;

+ 1 - 1
lib/server/controllers/frontController.js

@@ -7,7 +7,7 @@ var FrontController = function(app) {
     var cacheDuration = 365 * 24 * 60 * 60 * 1000; // One year
     var cacheDuration = 365 * 24 * 60 * 60 * 1000; // One year
     var assetsPath = (app.get('env') === 'development') ? '../../../front/src' : '../../../front/build';
     var assetsPath = (app.get('env') === 'development') ? '../../../front/src' : '../../../front/build';
     
     
-    var routes = ['/', '/about', '/result/:runId', '/result/:runId/timeline', '/result/:runId/rule/:policy', '/queue/:runId'];
+    var routes = ['/', '/about', '/result/:runId', '/result/:runId/timeline', '/result/:runId/screenshot', '/result/:runId/rule/:policy', '/queue/:runId'];
     routes.forEach(function(route) {
     routes.forEach(function(route) {
         app.get(route, function(req, res) {
         app.get(route, function(req, res) {
             res.setHeader('Cache-Control', 'public, max-age=20');
             res.setHeader('Cache-Control', 'public, max-age=20');

+ 40 - 9
lib/server/datastores/resultsDatastore.js

@@ -9,24 +9,28 @@ function ResultsDatastore() {
     'use strict';
     'use strict';
 
 
     var resultFileName = 'results.json';
     var resultFileName = 'results.json';
+    var resultScreenshotName = 'screenshot.jpg';
     var resultsFolderName = 'results';
     var resultsFolderName = 'results';
     var resultsDir = path.join(__dirname, '..', '..', '..', resultsFolderName);
     var resultsDir = path.join(__dirname, '..', '..', '..', resultsFolderName);
 
 
 
 
     this.saveResult = function(testResults) {
     this.saveResult = function(testResults) {
-        var promise = createResultFolder(testResults.runId);
+        
+        return createResultFolder(testResults.runId)
 
 
-        debug('Saving results to disk...');
+            .then(function() {
+                return saveScreenshotIfExists(testResults);
+            })
 
 
-        promise.then(function() {
+            .then(function() {
 
 
-            var resultFilePath = path.join(resultsDir, testResults.runId, resultFileName);
-            debug('Destination file is %s', resultFilePath);
-            
-            return Q.nfcall(fs.writeFile, resultFilePath, JSON.stringify(testResults, null, 2));
-        });
+                debug('Saving results to disk...');
 
 
-        return promise;
+                var resultFilePath = path.join(resultsDir, testResults.runId, resultFileName);
+                debug('Destination file is %s', resultFilePath);
+                
+                return Q.nfcall(fs.writeFile, resultFilePath, JSON.stringify(testResults, null, 2));
+            });
     };
     };
 
 
 
 
@@ -84,6 +88,33 @@ function ResultsDatastore() {
 
 
         return deferred.promise;
         return deferred.promise;
     }
     }
+
+    // If there is a screenshot, save it as screenshot.jpg in the same folder as the results
+    function saveScreenshotIfExists(testResults) {
+        var deferred = Q.defer();
+
+        if (testResults.screenshotBuffer) {
+
+            var screenshotFilePath = path.join(resultsDir, testResults.runId, resultScreenshotName);
+            fs.writeFile(screenshotFilePath, testResults.screenshotBuffer);
+
+            delete testResults.screenshotBuffer;
+
+        } else {
+            deferred.resolve();
+        }
+
+        return deferred;
+    }
+
+    this.getScreenshot = function(runId) {
+
+        var screenshotFilePath = path.join(resultsDir, runId, resultScreenshotName);
+
+        debug('Getting screenshot (runID = %s) from disk...', runId);
+        
+        return Q.nfcall(fs.readFile, screenshotFilePath);
+    };
 }
 }
 
 
 module.exports = ResultsDatastore;
 module.exports = ResultsDatastore;

+ 3 - 0
lib/server/datastores/runsQueue.js

@@ -1,4 +1,5 @@
 var Q = require('q');
 var Q = require('q');
+var debug = require('debug')('ylt:runsQueue');
 
 
 
 
 function RunsQueue() {
 function RunsQueue() {
@@ -11,6 +12,8 @@ function RunsQueue() {
         var deferred = Q.defer();
         var deferred = Q.defer();
         var startingPosition = queue.length;
         var startingPosition = queue.length;
 
 
+        debug('Adding run %s to the queue, position is %d', runId, startingPosition);
+
         if (startingPosition === 0) {
         if (startingPosition === 0) {
             
             
             // The queue is empty, let's run immediatly
             // The queue is empty, let's run immediatly

+ 13 - 12
lib/tools/phantomas/phantomasWrapper.js

@@ -1,9 +1,9 @@
-var async           = require('async');
-var Q               = require('q');
-var ps              = require('ps-node');
-var path            = require('path');
-var debug           = require('debug')('ylt:phantomaswrapper');
-var phantomas       = require('phantomas');
+var async                   = require('async');
+var Q                       = require('q');
+var ps                      = require('ps-node');
+var path                    = require('path');
+var debug                   = require('debug')('ylt:phantomaswrapper');
+var phantomas               = require('phantomas');
 
 
 
 
 var PhantomasWrapper = function() {
 var PhantomasWrapper = function() {
@@ -20,17 +20,18 @@ var PhantomasWrapper = function() {
     this.execute = function(data) {
     this.execute = function(data) {
 
 
         var deferred = Q.defer();
         var deferred = Q.defer();
-
         var task = data.params;
         var task = data.params;
 
 
+
         var options = {
         var options = {
             // Cusomizable options
             // Cusomizable options
-            timeout: task.options.timeout || 60,
+            'timeout': task.options.timeout || 60,
             'js-deep-analysis': task.options.jsDeepAnalysis || false,
             'js-deep-analysis': task.options.jsDeepAnalysis || false,
             'user-agent': 'Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/27.0.1453.110 Safari/537.36',
             'user-agent': 'Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/27.0.1453.110 Safari/537.36',
+            'screenshot': task.options.screenshot || false,
 
 
             // Mandatory
             // Mandatory
-            reporter: 'json:pretty',
+            'reporter': 'json:pretty',
             'analyze-css': true,
             'analyze-css': true,
             'skip-modules': [
             'skip-modules': [
                 'blockDomains', // not needed
                 'blockDomains', // not needed
@@ -44,7 +45,6 @@ var PhantomasWrapper = function() {
                 'jQuery', // overridden
                 'jQuery', // overridden
                 'jserrors', // overridden
                 'jserrors', // overridden
                 'pageSource', // not needed
                 'pageSource', // not needed
-                'screenshot', // not needed for the moment
                 'waitForSelector', // not needed
                 'waitForSelector', // not needed
                 'windowPerformance' // overriden
                 'windowPerformance' // overriden
             ].join(','),
             ].join(','),
@@ -100,7 +100,7 @@ var PhantomasWrapper = function() {
                 debug('Returning from Phantomas');
                 debug('Returning from Phantomas');
 
 
                 // Adding some YellowLabTools errors here
                 // Adding some YellowLabTools errors here
-                if (json && json.metrics && !json.metrics.javascriptExecutionTree) {
+                if (json && json.metrics && (!json.metrics.javascriptExecutionTree || !json.offenders.javascriptExecutionTree)) {
                     err = 1001;
                     err = 1001;
                 }
                 }
 
 
@@ -130,10 +130,11 @@ var PhantomasWrapper = function() {
             if (err) {
             if (err) {
                 debug('All ' + triesNumber + ' attemps failed for the test');
                 debug('All ' + triesNumber + ' attemps failed for the test');
                 deferred.reject(err);
                 deferred.reject(err);
+
             } else {
             } else {
 
 
-                // Success!!!
                 deferred.resolve(json);
                 deferred.resolve(json);
+
             }
             }
         });
         });
 
 

+ 3 - 1
package.json

@@ -16,10 +16,12 @@
     "cors": "^2.5.2",
     "cors": "^2.5.2",
     "debug": "~2.1.0",
     "debug": "~2.1.0",
     "express": "~4.10.6",
     "express": "~4.10.6",
+    "lwip": "0.0.6",
     "phantomas": "1.9.0",
     "phantomas": "1.9.0",
     "ps-node": "0.0.3",
     "ps-node": "0.0.3",
     "q": "~1.1.2",
     "q": "~1.1.2",
-    "rimraf": "~2.2.8"
+    "rimraf": "~2.2.8",
+    "temporary": "0.0.8"
   },
   },
   "devDependencies": {
   "devDependencies": {
     "chai": "^1.10.0",
     "chai": "^1.10.0",

BIN
screenshot.png


+ 46 - 2
test/api/apiTest.js

@@ -16,6 +16,7 @@ describe('api', function() {
 
 
     var syncRunResultUrl;
     var syncRunResultUrl;
     var asyncRunId;
     var asyncRunId;
+    var screenshotUrl;
 
 
 
 
     it('should refuse a query with an invalid key', function(done) {
     it('should refuse a query with an invalid key', function(done) {
@@ -94,7 +95,8 @@ describe('api', function() {
             url: serverUrl + '/api/runs',
             url: serverUrl + '/api/runs',
             body: {
             body: {
                 url: wwwUrl + '/simple-page.html',
                 url: wwwUrl + '/simple-page.html',
-                waitForResponse: true
+                waitForResponse: true,
+                screenshot: true
             },
             },
             json: true,
             json: true,
             headers: {
             headers: {
@@ -131,7 +133,6 @@ describe('api', function() {
         }, function(error, response, body) {
         }, function(error, response, body) {
             if (!error && response.statusCode === 302) {
             if (!error && response.statusCode === 302) {
 
 
-                console.log(response.headers.location);
                 response.headers.should.have.a.property('location').that.is.a('string');
                 response.headers.should.have.a.property('location').that.is.a('string');
                 response.headers.location.should.contain('/rules');
                 response.headers.location.should.contain('/rules');
 
 
@@ -160,6 +161,16 @@ describe('api', function() {
                 body.should.have.a.property('toolsResults').that.is.an('object');
                 body.should.have.a.property('toolsResults').that.is.an('object');
                 body.should.have.a.property('javascriptExecutionTree').that.is.an('object');
                 body.should.have.a.property('javascriptExecutionTree').that.is.an('object');
 
 
+                // Check if the screenshot temporary file was correctly removed
+                body.params.options.should.not.have.a.property('screenshot');
+                // Check if the screenshot buffer was correctly removed
+                body.should.not.have.a.property('screenshotBuffer');
+                // Check if the screenshot url is here
+                body.should.have.a.property('screenshotUrl');
+                body.screenshotUrl.should.equal('/api/results/' + body.runId + '/screenshot.jpg');
+
+                screenshotUrl = body.screenshotUrl;
+
                 done();
                 done();
 
 
             } else {
             } else {
@@ -516,4 +527,37 @@ describe('api', function() {
         });
         });
     });
     });
 
 
+
+    it('should retrieve the screenshot', function(done) {
+        this.timeout(5000);
+
+        request({
+            method: 'GET',
+            url: serverUrl + screenshotUrl
+        }, function(error, response, body) {
+            if (!error && response.statusCode === 200) {
+                response.headers['content-type'].should.equal('image/jpeg');
+                done();
+            } else {
+                done(error || response.statusCode);
+            }
+        });
+    });
+
+
+    it('should fail on a unexistant screenshot', function(done) {
+        this.timeout(5000);
+
+        request({
+            method: 'GET',
+            url: serverUrl + '/api/results/000000/screenshot.jpg'
+        }, function(error, response, body) {
+            if (!error && response.statusCode === 404) {
+                done();
+            } else {
+                done(error || response.statusCode);
+            }
+        });
+    });
+
 });
 });

+ 47 - 0
test/api/resultsDatastoreTest.js

@@ -1,6 +1,9 @@
 var should = require('chai').should();
 var should = require('chai').should();
 var resultsDatastore = require('../../lib/server/datastores/resultsDatastore');
 var resultsDatastore = require('../../lib/server/datastores/resultsDatastore');
 
 
+var fs = require('fs');
+var path = require('path');
+
 describe('resultsDatastore', function() {
 describe('resultsDatastore', function() {
     
     
     var datastore = new resultsDatastore();
     var datastore = new resultsDatastore();
@@ -71,4 +74,48 @@ describe('resultsDatastore', function() {
                 done();
                 done();
             });
             });
     });
     });
+
+
+    var testId3 = '555555';
+    var testData3 = {
+        runId: testId3,
+        other: {
+            foo: 'foo',
+            bar: 2
+        },
+        screenshotBuffer: fs.readFileSync(path.join(__dirname, '../fixtures/logo-large.png'))
+    };
+
+    it('should store a test with a screenshot', function(done) {
+
+        datastore.saveResult(testData3).then(function() {
+            done();
+        }).fail(function(err) {
+            done(err);
+        });
+    });
+
+    it('should have a normal result', function(done) {
+        datastore.getResult(testId3)
+            .then(function(results) {
+
+                results.should.not.have.a.property('screenshot');
+
+                done();
+            })
+            .fail(function(err) {
+                done(err);
+            });
+    });
+
+    it('should retrieve the saved image', function() {
+        datastore.getScreenshot(testId3)
+            .then(function(imageBuffer) {
+                imageBuffer.should.be.an.instanceof(Buffer);
+                done();
+            })
+            .fail(function(err) {
+                done(err);
+            });
+    });
 });
 });

+ 1 - 1
test/api/runsQueueTest.js

@@ -1,5 +1,5 @@
 var should = require('chai').should();
 var should = require('chai').should();
-var runsQueue = require('../../lib/server/datastores/runsQueue.js');
+var runsQueue = require('../../lib/server/datastores/runsQueue');
 
 
 describe('runsQueue', function() {
 describe('runsQueue', function() {
 
 

+ 128 - 0
test/api/screenshotHandlerTest.js

@@ -0,0 +1,128 @@
+var should = require('chai').should();
+var ScreenshotHandler = require('../../lib/screenshotHandler');
+
+var fs = require('fs');
+var path = require('path');
+
+describe('screenshotHandler', function() {
+
+    var imagePath = path.join(__dirname, '../fixtures/logo-large.png');
+    var screenshot, lwipImage;
+
+    
+    it('should open an image and return an lwip object', function(done) {
+        ScreenshotHandler.openImage(imagePath)
+            .then(function(image) {
+                lwipImage = image;
+
+                lwipImage.should.be.an('object');
+                lwipImage.width().should.equal(620);
+                lwipImage.height().should.equal(104);
+
+                done();
+            })
+            .fail(function(err) {
+                done(err);
+            });
+    });
+
+    
+    it('should resize an lwip image', function(done) {
+        ScreenshotHandler.resizeImage(lwipImage, 310)
+            .then(function(image) {
+                lwipImage = image;
+
+                lwipImage.width().should.equal(310);
+                lwipImage.height().should.equal(52);
+
+                done();
+            })
+            .fail(function(err) {
+                done(err);
+            });
+    });
+
+
+    it('should transform a lwip image into a buffer', function(done) {
+        ScreenshotHandler.toBuffer(lwipImage)
+            .then(function(buffer) {
+                buffer.should.be.an.instanceof(Buffer);
+                done();
+            })
+            .fail(function(err) {
+                done(err);
+            });
+    });
+
+
+    it('should optimize an image and return a buffered version', function(done) {
+        ScreenshotHandler.optimize(imagePath, 200)
+            .then(function(buffer) {
+                buffer.should.be.an.instanceof(Buffer);
+                done();
+            })
+            .fail(function(err) {
+                done(err);
+            });
+    });
+
+
+    it('should provide a temporary file object', function() {
+        screenshot = ScreenshotHandler.getScreenshotTempFile();
+
+        screenshot.should.have.a.property('getTmpFolder').that.is.a('function');
+        screenshot.should.have.a.property('getTmpFilePath').that.is.a('function');
+        screenshot.should.have.a.property('toThumbnail').that.is.a('function');
+        screenshot.should.have.a.property('deleteTmpFile').that.is.a('function');
+    });
+
+
+    it('should have created the temporary folder', function() {
+        var folder = screenshot.getTmpFolder();
+        fs.existsSync(folder.path).should.equal(true);
+    });
+
+
+    it('should respond a temporary file', function() {
+        var file = screenshot.getTmpFilePath();
+        file.should.have.string('/screenshot.jpg');
+    });
+
+
+    it('should delete the temp folder when there is no file', function(done) {
+        var tmpFolderPath = screenshot;
+
+        screenshot.deleteTmpFile()
+            .delay(1000)
+            .then(function() {
+                fs.existsSync(screenshot.getTmpFolder().path).should.equal(false);
+                done();
+            })
+            .fail(function(err) {
+                done(err);
+            });
+    });
+
+    it('should delete the temp folder with the screenshot inside', function(done) {
+        screenshot = ScreenshotHandler.getScreenshotTempFile();
+        var tmpFolderPath = screenshot.getTmpFolder().path;
+        var tmpImagePath = path.join(tmpFolderPath, 'screenshot.jpg');
+
+        // Copy image
+        var testImage = fs.readFileSync(imagePath);
+        fs.writeFileSync(tmpImagePath, testImage);
+
+        fs.existsSync(tmpImagePath).should.equal(true);
+
+        screenshot.deleteTmpFile()
+            .delay(1000)
+            .then(function() {
+                fs.existsSync(tmpImagePath).should.equal(false);
+                fs.existsSync(tmpFolderPath).should.equal(false);
+                done();
+            })
+            .fail(function(err) {
+                done(err);
+            });
+    });
+});

BIN
test/fixtures/logo-large.png