Browse Source

Merge pull request #49 from gmetais/dashboard

Merging branch dashboard
Gaël Métais 10 years ago
parent
commit
dcdd93bcaa
63 changed files with 3360 additions and 393 deletions
  1. 12 9
      Gruntfile.js
  2. 40 7
      bin/cli.js
  3. 2 1
      bower.json
  4. 25 13
      front/src/css/dashboard.css
  5. 12 0
      front/src/css/index.css
  6. 50 0
      front/src/css/main.css
  7. 0 3
      front/src/css/queue.css
  8. 108 0
      front/src/css/rule.css
  9. 7 0
      front/src/css/screenshot.css
  10. 9 3
      front/src/js/app.js
  11. 0 5
      front/src/js/controllers/aboutCtrl.js
  12. 2 7
      front/src/js/controllers/dashboardCtrl.js
  13. 2 8
      front/src/js/controllers/indexCtrl.js
  14. 13 1
      front/src/js/controllers/queueCtrl.js
  15. 2 8
      front/src/js/controllers/ruleCtrl.js
  16. 31 0
      front/src/js/controllers/screenshotCtrl.js
  17. 2 7
      front/src/js/controllers/timelineCtrl.js
  18. 120 0
      front/src/js/directives/offendersDirectives.js
  19. 26 0
      front/src/js/services/apiService.js
  20. 29 13
      front/src/less/dashboard.less
  21. 14 0
      front/src/less/index.less
  22. 57 0
      front/src/less/main.less
  23. 0 4
      front/src/less/queue.less
  24. 128 0
      front/src/less/rule.less
  25. 8 0
      front/src/less/screenshot.less
  26. 6 1
      front/src/main.html
  27. 18 7
      front/src/views/dashboard.html
  28. 7 0
      front/src/views/domElementButton.html
  29. 19 1
      front/src/views/index.html
  30. 31 18
      front/src/views/queue.html
  31. 136 9
      front/src/views/rule.html
  32. 14 0
      front/src/views/screenshot.html
  33. 1 1
      lib/index.js
  34. 659 55
      lib/metadata/policies.js
  35. 3 2
      lib/metadata/scoreProfileGeneric.json
  36. 178 0
      lib/offendersHelpers.js
  37. 55 17
      lib/rulesChecker.js
  38. 5 1
      lib/runner.js
  39. 135 0
      lib/screenshotHandler.js
  40. 120 52
      lib/server/controllers/apiController.js
  41. 1 1
      lib/server/controllers/frontController.js
  42. 40 9
      lib/server/datastores/resultsDatastore.js
  43. 1 1
      lib/server/datastores/runsDatastore.js
  44. 3 0
      lib/server/datastores/runsQueue.js
  45. 79 0
      lib/tools/phantomas/custom_modules/modules/cachYLT/cachYLT.js
  46. 1 1
      lib/tools/phantomas/custom_modules/modules/domQYLT/domQYLT.js
  47. 62 48
      lib/tools/phantomas/custom_modules/modules/jQYLT/jQYLT.js
  48. 0 29
      lib/tools/phantomas/custom_modules/modules/jsFileLoadYLT/jsFileLoadYLT.js
  49. 54 0
      lib/tools/phantomas/custom_modules/util/collection.js
  50. 27 15
      lib/tools/phantomas/phantomasWrapper.js
  51. 7 4
      package.json
  52. BIN
      screenshot.png
  53. 46 2
      test/api/apiTest.js
  54. 47 0
      test/api/resultsDatastoreTest.js
  55. 1 1
      test/api/runsQueueTest.js
  56. 128 0
      test/api/screenshotHandlerTest.js
  57. 403 9
      test/core/customPoliciesTest.js
  58. 32 2
      test/core/indexTest.js
  59. 283 0
      test/core/offendersHelpersTest.js
  60. 1 1
      test/core/rulesCheckerTest.js
  61. BIN
      test/fixtures/logo-large.png
  62. 26 8
      test/fixtures/rulesCheckerOutput.json
  63. 32 9
      test/fixtures/rulesCheckerPolicies.js

+ 12 - 9
Gruntfile.js

@@ -93,7 +93,9 @@ module.exports = function(grunt) {
             coverage: {
                 files: [
                     {src: ['test/**'], dest: 'coverage/'},
-                    {src: ['lib/metadata/**'], dest: 'coverage/'}
+                    {src: ['lib/metadata/**'], dest: 'coverage/'},
+                    {src: ['node_modules/phantomas/**'], dest: 'coverage/'},
+                    {src: ['lib/tools/phantomas/custom_modules/**'], dest: 'coverage/'}
                 ]
             },
             build: {
@@ -139,7 +141,7 @@ module.exports = function(grunt) {
                 options: {
                     reporter: 'spec',
                 },
-                src: ['coverage/test/api/apiTest.js']
+                src: ['test/core/offendersHelpersTest.js']
             },
             coverage: {
                 options: {
@@ -182,6 +184,13 @@ module.exports = function(grunt) {
                     showStack: true
                 }
             },
+            'test-current-work': {
+                options: {
+                    port: 8387,
+                    server: './bin/server.js',
+                    showStack: true
+                }
+            },
             testSuite: {
                 options: {
                     port: 8388,
@@ -321,7 +330,6 @@ module.exports = function(grunt) {
 
     grunt.registerTask('test', [
         'build',
-        'jshint',
         'express:testSuite',
         'clean:coverage',
         'copy-test-server-settings',
@@ -336,16 +344,11 @@ module.exports = function(grunt) {
     ]);
 
     grunt.registerTask('test-current-work', [
-        'build',
         'jshint',
         'express:testSuite',
         'clean:coverage',
         'copy-test-server-settings',
-        'lineremover:beforeCoverage',
-        'copy:beforeCoverage',
-        'blanket',
-        'copy:coverage',
-        'express:test',
+        'express:test-current-work',
         'mochaTest:test-current-work',
         'clean:tmp'
     ]);

+ 40 - 7
bin/cli.js

@@ -1,22 +1,55 @@
 #!/usr/bin/env node
 
 var debug = require('debug')('ylt:cli');
+var meow = require('meow');
+var path = require('path');
 
 var ylt = require('../lib/index');
 
+var cli = meow({
+    help: [
+        'Usage',
+        '  yellowlabtools <url> <options>',
+        '',
+        'Options:',
+        '  --screenshot     Will take a screenshot and use this value as the output path. It needs to end with ".png".',
+        ''
+    ].join('\n'),
+    pkg: '../package.json'
+});
+
+
+
 // Check parameters
-if (process.argv.length !== 3) {
-    console.error('Incorrect parameters');
-    console.error('\nUsage: ylt <pageUrl>\n');
+if (cli.input.length < 1) {
+    console.error('Incorrect parameters: url not provided');
+    process.exit(1);
+}
+
+var url = cli.input[0];
+
+var options = {};
+
+// Screenshot option
+var screenshot = cli.flags.screenshot;
+if (screenshot && (typeof screenshot !== 'string' || screenshot.toLowerCase().indexOf('.png', screenshot.length - 4) === -1)) {
+    console.error('Incorrect parameters: screenshot must be a path that ends with ".png"');
     process.exit(1);
 }
+if (screenshot) {
+    if (path.resolve(screenshot) !== path.normalize(screenshot)) {
+        // It is not an absolute path, so it is relative to the current command-line directory
+        screenshot = path.join(process.cwd(), screenshot);
+    }
+    options.screenshot = cli.flags.screenshot;
+}
+
 
-var url = process.argv[2];
 
-(function execute(url) {
+(function execute(url, options) {
     'use strict';
 
-    ylt(url).
+    ylt(url, options).
 
         then(function(data) {
 
@@ -32,4 +65,4 @@ var url = process.argv[2];
 
     debug('Test launched...');
 
-})(url);
+})(url, options);

+ 2 - 1
bower.json

@@ -3,6 +3,7 @@
   "dependencies": {
     "angular": "~1.3.8",
     "angular-route": "~1.3.8",
-    "angular-resource": "~1.3.7"
+    "angular-resource": "~1.3.7",
+    "angular-sanitize": "~1.4.0-beta.0"
   }
 }

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

@@ -5,7 +5,14 @@
   text-align: center;
 }
 .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 {
   margin: 0.5 auto;
@@ -22,6 +29,23 @@
   font-weight: bold;
   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.85;
+}
 .summary .notations {
   display: table;
   width: 80%;
@@ -122,18 +146,6 @@
 .summary .fromShare {
   margin-bottom: 3em;
 }
-.summary .fromShare a {
-  font-size: 1em;
-  padding: 0.3em 0.5em;
-  margin: 0.5em;
-  line-height: 2em;
-  border: 0 solid;
-  border-radius: 0.5em;
-  box-shadow: 0.1em 0.2em 0 0 #5e2846;
-  background: #e74c3c;
-  color: #fff;
-  text-decoration: none;
-}
 .summary .apiTip {
   font-size: 0.8em;
   margin-bottom: 4em;

+ 12 - 0
front/src/css/index.css

@@ -13,6 +13,18 @@
 .launchBtn.disabled {
   background: #deaca6;
 }
+.features {
+  display: table;
+  width: 50%;
+  margin: 8em auto 0;
+  font-size: 0.9em;
+  color: #413;
+}
+.features > div {
+  width: 33.3%;
+  display: table-cell;
+  padding: 0 1.5em;
+}
 input[type=submit],
 input.url {
   padding: 0 0.5em;

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

@@ -113,6 +113,56 @@ h1 span {
   margin-top: 4em;
   color: black;
 }
+a.linkButton {
+  font-size: 1em;
+  padding: 0.3em 0.5em;
+  margin: 0.5em;
+  line-height: 2em;
+  border: 0 solid;
+  border-radius: 0.5em;
+  box-shadow: 0.1em 0.2em 0 0 #5e2846;
+  background: #e74c3c;
+  color: #fff;
+  text-decoration: none;
+}
+.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 {
   font-weight: bold;
 }

+ 0 - 3
front/src/css/queue.css

@@ -6,6 +6,3 @@
   font-size: 0.8em;
   margin-bottom: 6em;
 }
-.queueLink {
-  color: #FFF;
-}

+ 108 - 0
front/src/css/rule.css

@@ -82,3 +82,111 @@
   font-size: 3em;
   margin-bottom: 1em;
 }
+.offenders .offenderButton {
+  display: inline-block;
+  position: relative;
+  background: #efe;
+  padding: 0 0.5em;
+  margin: 0.2em 0;
+  border-radius: 0.4em;
+  z-index: 1;
+}
+.offenders .offenderButton.opens {
+  padding-right: 0.75em;
+}
+.offenders .offenderButton.opens:after {
+  position: relative;
+  left: 0.5em;
+  content: '\25BC';
+  font-size: 0.8em;
+}
+.offenders .offenderButton > div {
+  display: none;
+  position: absolute;
+  right: 0;
+  min-width: 100%;
+  background: inherit;
+  border-bottom-left-radius: 0.4em;
+  border-bottom-right-radius: 0.4em;
+  border-top: 1px solid #999;
+}
+.offenders .offenderButton .domTree {
+  text-align: left;
+  white-space: nowrap;
+}
+.offenders .offenderButton .domTree > div {
+  margin: 0.5em;
+}
+.offenders .offenderButton .domTree > div div {
+  margin-left: 1em;
+}
+.offenders .offenderButton .backtrace,
+.offenders .offenderButton .cssFileAndLine {
+  white-space: nowrap;
+  padding: 0.5em;
+}
+.offenders .offenderButton.opens.mouseOver {
+  border-bottom-left-radius: 0;
+  border-bottom-right-radius: 0;
+  background: #ffe0cc;
+  z-index: 2;
+}
+.offenders .offenderButton.opens.mouseOver > div {
+  display: block;
+}
+.offendersHtml {
+  display: inline-block;
+}
+.domTree div {
+  text-align: left;
+  margin-left: 1em;
+}
+.domTree div span:only-child {
+  font-weight: bold;
+}
+.domTree div span:only-child span {
+  font-style: italic;
+  font-weight: normal;
+}
+.colorPalette {
+  width: 30em;
+  border: 2px solid #000;
+  text-align: left;
+  /* Checkerboard background */
+  background-color: #ddd;
+  background-image: linear-gradient(45deg, #aaaaaa 25%, transparent 25%, transparent 75%, #aaaaaa 75%, #aaaaaa), linear-gradient(45deg, #aaaaaa 25%, transparent 25%, transparent 75%, #aaaaaa 75%, #aaaaaa);
+  background-size: 1em 1em;
+  background-position: 0 0, 0.5em 0.5em;
+}
+.colorPalette > div {
+  display: inline-block;
+  height: 2em;
+  position: relative;
+}
+.colorPalette > div div {
+  display: none;
+  position: absolute;
+  left: 100%;
+  top: 100%;
+  background: #FFF;
+  padding: 0.5em;
+  border: 2px solid #f1c40f;
+  border-radius: 0.5em;
+  white-space: nowrap;
+  z-index: 3;
+  font-weight: bold;
+}
+.colorPalette > div:hover div {
+  display: block;
+}
+.colorPalette > div:hover:after {
+  content: " ";
+  position: absolute;
+  background-color: inherit;
+  left: -0.2em;
+  top: -0.2em;
+  width: 100%;
+  height: 100%;
+  z-index: 2;
+  border: 0.2em solid #f1c40f;
+}

+ 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;
+}

+ 9 - 3
front/src/js/app.js

@@ -1,15 +1,18 @@
 var yltApp = angular.module('YellowLabTools', [
     'ngRoute',
+    'ngSanitize',
     'indexCtrl',
-    'aboutCtrl',
     'dashboardCtrl',
     'queueCtrl',
     'ruleCtrl',
+    'screenshotCtrl',
     'timelineCtrl',
     'runsFactory',
     'resultsFactory',
+    'apiService',
     'menuService',
     'gradeDirective',
+    'offendersDirectives'
 ]);
 
 yltApp.run(['$rootScope', '$location', function($rootScope, $location) {
@@ -33,8 +36,7 @@ yltApp.config(['$routeProvider', '$locationProvider',
                 controller: 'QueueCtrl'
             }).
             when('/about', {
-                templateUrl: 'views/about.html',
-                controller: 'AboutCtrl'
+                templateUrl: 'views/about.html'
             }).
             when('/result/:runId', {
                 templateUrl: 'views/dashboard.html',
@@ -44,6 +46,10 @@ yltApp.config(['$routeProvider', '$locationProvider',
                 templateUrl: 'views/timeline.html',
                 controller: 'TimelineCtrl'
             }).
+            when('/result/:runId/screenshot', {
+                templateUrl: 'views/screenshot.html',
+                controller: 'ScreenshotCtrl'
+            }).
             when('/result/:runId/rule/:policy', {
                 templateUrl: 'views/rule.html',
                 controller: 'RuleCtrl'

+ 0 - 5
front/src/js/controllers/aboutCtrl.js

@@ -1,5 +0,0 @@
-var aboutCtrl = angular.module('aboutCtrl', []);
-
-aboutCtrl.controller('AboutCtrl', ['$scope', function($scope) {
-    $scope.about = "this is about YLT";
-}]);

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

@@ -1,6 +1,6 @@
 var dashboardCtrl = angular.module('dashboardCtrl', ['resultsFactory', 'menuService']);
 
-dashboardCtrl.controller('DashboardCtrl', ['$scope', '$rootScope', '$routeParams', '$location', 'Results', 'Runs', 'Menu', function($scope, $rootScope, $routeParams, $location, Results, Runs, Menu) {
+dashboardCtrl.controller('DashboardCtrl', ['$scope', '$rootScope', '$routeParams', '$location', 'Results', 'API', 'Menu', function($scope, $rootScope, $routeParams, $location, Results, API, Menu) {
     $scope.runId = $routeParams.runId;
     $scope.Menu = Menu.setCurrentPage('dashboard', $scope.runId);
     $scope.fromSocialShare = $location.search().share;
@@ -34,12 +34,7 @@ dashboardCtrl.controller('DashboardCtrl', ['$scope', '$rootScope', '$routeParams
     };
 
     $scope.testAgain = function() {
-        Runs.save({
-                url: $scope.result.params.url,
-                waitForResponse: false
-            }, function(data) {
-                $location.path('/queue/' + data.runId);
-            });
+        API.launchTest($scope.result.params.url);
     };
 
     /// When comming from a social shared link, the user needs to click on "See full report" button to display the full dashboard.

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

@@ -1,15 +1,9 @@
 var indexCtrl = angular.module('indexCtrl', []);
 
-indexCtrl.controller('IndexCtrl', ['$scope', '$location', 'Runs', function($scope, $location, Runs) {
+indexCtrl.controller('IndexCtrl', ['$scope', '$location', 'API', function($scope, $location, API) {
     $scope.launchTest = function() {
         if ($scope.url) {
-            Runs.save({
-                url: $scope.url,
-                waitForResponse: false
-            }, function(data) {
-                $location.path('/queue/' + data.runId);
-            });
-            
+            API.launchTest($scope.url);
         }
     };
 }]);

+ 13 - 1
front/src/js/controllers/queueCtrl.js

@@ -1,6 +1,6 @@
 var queueCtrl = angular.module('queueCtrl', ['runsFactory']);
 
-queueCtrl.controller('QueueCtrl', ['$scope', '$routeParams', '$location', 'Runs', function($scope, $routeParams, $location, Runs) {
+queueCtrl.controller('QueueCtrl', ['$scope', '$routeParams', '$location', 'Runs', 'API', function($scope, $routeParams, $location, Runs, API) {
     $scope.runId = $routeParams.runId;
 
     var numberOfTries = 0;
@@ -9,6 +9,8 @@ queueCtrl.controller('QueueCtrl', ['$scope', '$routeParams', '$location', 'Runs'
         Runs.get({runId: $scope.runId}, function(data) {
             $scope.url = data.params.url;
             $scope.status = data.status;
+            $scope.notFound = false;
+            $scope.connectionLost = false;
 
             if (data.status.statusCode === 'running' || data.status.statusCode === 'awaiting') {
                 numberOfTries ++;
@@ -21,6 +23,16 @@ queueCtrl.controller('QueueCtrl', ['$scope', '$routeParams', '$location', 'Runs'
             } else {
                 // Handled by the view
             }
+        }, function(response) {
+            if (response.status === 404) {
+                $scope.notFound = true;
+                $scope.connectionLost = false;
+            } else if (response.status === 0) {
+                // Connection lost, retry in 10 seconds
+                setTimeout(getRunStatus, 10000);
+                $scope.connectionLost = true;
+                $scope.notFound = false;
+            }
         });
     }
     

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

@@ -1,6 +1,6 @@
 var ruleCtrl = angular.module('ruleCtrl', []);
 
-ruleCtrl.controller('RuleCtrl', ['$scope', '$rootScope', '$routeParams', '$location', '$sce', 'Menu', 'Results', 'Runs', function($scope, $rootScope, $routeParams, $location, $sce, Menu, Results, Runs) {
+ruleCtrl.controller('RuleCtrl', ['$scope', '$rootScope', '$routeParams', '$location', '$sce', 'Menu', 'Results', 'API', function($scope, $rootScope, $routeParams, $location, $sce, Menu, Results, API) {
     $scope.runId = $routeParams.runId;
     $scope.policyName = $routeParams.policy;
     $scope.Menu = Menu.setCurrentPage(null, $scope.runId);
@@ -22,7 +22,6 @@ ruleCtrl.controller('RuleCtrl', ['$scope', '$rootScope', '$routeParams', '$locat
 
     function init() {
         $scope.rule = $scope.result.rules[$scope.policyName];
-        $scope.message = $sce.trustAsHtml($scope.rule.policy.message);
     }
 
     $scope.backToDashboard = function() {
@@ -30,12 +29,7 @@ ruleCtrl.controller('RuleCtrl', ['$scope', '$rootScope', '$routeParams', '$locat
     };
 
     $scope.testAgain = function() {
-        Runs.save({
-                url: $scope.result.params.url,
-                waitForResponse: false
-            }, function(data) {
-                $location.path('/queue/' + data.runId);
-            });
+        API.launchTest($scope.result.params.url);
     };
 
     loadResults();

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

@@ -0,0 +1,31 @@
+var screenshotCtrl = angular.module('screenshotCtrl', ['resultsFactory', 'menuService']);
+
+screenshotCtrl.controller('ScreenshotCtrl', ['$scope', '$rootScope', '$routeParams', '$location', 'Results', 'API', 'Menu', function($scope, $rootScope, $routeParams, $location, Results, API, 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() {
+        API.launchTest($scope.result.params.url);
+    };
+
+    loadResults();
+}]);

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

@@ -1,6 +1,6 @@
 var timelineCtrl = angular.module('timelineCtrl', []);
 
-timelineCtrl.controller('TimelineCtrl', ['$scope', '$rootScope', '$routeParams', '$location', '$timeout', 'Menu', 'Results', 'Runs', function($scope, $rootScope, $routeParams, $location, $timeout, Menu, Results, Runs) {
+timelineCtrl.controller('TimelineCtrl', ['$scope', '$rootScope', '$routeParams', '$location', '$timeout', 'Menu', 'Results', 'API', function($scope, $rootScope, $routeParams, $location, $timeout, Menu, Results, API) {
     $scope.runId = $routeParams.runId;
     $scope.Menu = Menu.setCurrentPage('timeline', $scope.runId);
 
@@ -132,12 +132,7 @@ timelineCtrl.controller('TimelineCtrl', ['$scope', '$rootScope', '$routeParams',
     };
 
     $scope.testAgain = function() {
-        Runs.save({
-                url: $scope.result.params.url,
-                waitForResponse: false
-            }, function(data) {
-                $location.path('/queue/' + data.runId);
-            });
+        API.launchTest($scope.result.params.url);
     };
 
     loadResults();

+ 120 - 0
front/src/js/directives/offendersDirectives.js

@@ -0,0 +1,120 @@
+var offendersDirectives = angular.module('offendersDirectives', []);
+
+offendersDirectives.directive('domTree', function() {
+    return {
+        restrict: 'E',
+        scope: {
+            tree: '='
+        },
+        template: '<div class="domTree"></div>',
+        replace: true,
+        link: function(scope, element, attrs) {
+            
+            function recursiveHtmlBuilder(tree) {
+                var html = '';
+                var keys = Object.keys(tree);
+                
+                keys.forEach(function(key) {
+                    if (isNaN(tree[key])) {
+                        html += '<div><span>' + key + '</span>' + recursiveHtmlBuilder(tree[key]) + '</div>';
+                    } else if (tree[key] > 1) {
+                        html += '<div><span>' + key + ' <span>(x' + tree[key] + ')</span></span></div>';
+                    } else {
+                        html += '<div><span>' + key + '</span></div>';
+                    }
+                });
+
+                return html;
+            }
+
+            element.append(recursiveHtmlBuilder(scope.tree));
+        }
+    };
+});
+
+offendersDirectives.directive('domElementButton', function() {
+    return {
+        restrict: 'E',
+        scope: {
+            obj: '='
+        },
+        templateUrl: 'views/domElementButton.html',
+        replace: true
+    };
+});
+
+offendersDirectives.filter('shortenUrl', function() {
+    return function(url, maxLength) {
+        if (!maxLength) {
+            maxLength = 110;
+        }
+
+        // Why dividing by 2.1? Because it adds a 5% margin.
+        var leftLength = Math.floor((maxLength - 5) / 2.1);
+        var rightLength = Math.ceil((maxLength - 5) / 2.1);
+
+        return (url.length > maxLength) ? url.substr(0, leftLength) + ' ... ' + url.substr(-rightLength) : url;
+    };
+});
+
+offendersDirectives.directive('urlLink', function() {
+    return {
+        restrict: 'E',
+        scope: {
+            url: '=',
+            maxLength: '='
+        },
+        template: '<a href="{{url}}" target="_blank" title="{{url}}">{{url | shortenUrl:maxLength}}</a>',
+        replace: true
+    };
+});
+
+offendersDirectives.filter('encodeURIComponent', function() {
+    return window.encodeURIComponent;
+});
+
+offendersDirectives.directive('fileAndLine', function() {
+    return {
+        restrict: 'E',
+        scope: {
+            file: '=',
+            line: '=',
+            column: '='
+        },
+        template: '<span><span ng-if="file"><url-link url="file" max-length="60"></url-link></span><span ng-if="!file">&lt;inline CSS&gt;</span> @ {{line}}:{{column}}</span>',
+        replace: true
+    };
+});
+
+offendersDirectives.directive('fileAndLineButton', function() {
+    return {
+        restrict: 'E',
+        scope: {
+            file: '=',
+            line: '=',
+            column: '='
+        },
+        template: '<div class="offenderButton opens">css file<div class="cssFileAndLine"><file-and-line file="file" line="line" column="column" button="true"></file-and-line></div></div>',
+        replace: true
+    };
+});
+
+offendersDirectives.directive('offenderButton', function() {
+    return {
+        restrict: 'C',
+        link: function(scope, element, attrs) {
+
+            console.log('initializing touchstart');
+
+            element.bind('touchstart mouseenter', function(e) {
+                element.addClass('mouseOver');
+                e.preventDefault();
+            });
+
+            element.bind('touchend mouseleave click', function(e) {
+                element.removeClass('mouseOver');
+                e.preventDefault();
+            });
+        }
+    };
+});

+ 26 - 0
front/src/js/services/apiService.js

@@ -0,0 +1,26 @@
+var apiService = angular.module('apiService', []);
+
+apiService.factory('API', ['$location', 'Runs', 'Results', function($location, Runs, Results) {
+
+    return {
+
+        launchTest: function(url) {
+            Runs.save({
+                url: url,
+                waitForResponse: false,
+                screenshot: true
+            }, function(data) {
+                $location.path('/queue/' + data.runId);
+            }, function(response) {
+                if (response.status === 429) {
+                    alert('Too many requests, you reached the max number of requests allowed in 24h');
+                } else {
+                    alert('An error occured...');
+                }
+            });
+        }
+
+
+    };
+
+}]);

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

@@ -7,7 +7,16 @@
 }
 
 .summary .globalScore {
-    margin-bottom: 3em;
+    display: table;
+    width: 60%;
+    margin: 3em auto;
+
+    > div {
+        display: table-cell;
+        width: 50%;
+        vertical-align: middle;
+    }
+
     .globalGrade {
         margin: 0.5 auto;
         width: 2.5em;
@@ -23,6 +32,25 @@
         font-weight: bold;
         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.85;
+        }
+    }
 }
 
 .summary .notations {
@@ -114,18 +142,6 @@
 
 .summary .fromShare {
     margin-bottom: 3em;
-    a {
-        font-size: 1em;
-        padding: 0.3em 0.5em;
-        margin: 0.5em;
-        line-height: 2em;
-        border: 0 solid;
-        border-radius: 0.5em;
-        box-shadow: 0.1em 0.2em 0 0 #5e2846;
-        background: #e74c3c;
-        color: #fff;
-        text-decoration: none;
-    }
 }
 
 .summary .apiTip {

+ 14 - 0
front/src/less/index.less

@@ -16,6 +16,20 @@
     }
 }
 
+.features {
+    display: table;
+    width: 50%;
+    margin: 8em auto 0;
+    font-size: 0.9em;
+    color: #413;
+
+    > div {
+        width: 33.3%;
+        display: table-cell;
+        padding: 0 1.5em;
+    }
+}
+
 input[type=submit], input.url {
     padding: 0 0.5em;
     margin: 0.5em;

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

@@ -112,6 +112,63 @@ h1 span {
     }
 }
 
+a.linkButton {
+    font-size: 1em;
+    padding: 0.3em 0.5em;
+    margin: 0.5em;
+    line-height: 2em;
+    border: 0 solid;
+    border-radius: 0.5em;
+    box-shadow: 0.1em 0.2em 0 0 #5e2846;
+    background: #e74c3c;
+    color: #fff;
+    text-decoration: none;
+}
+
+
+.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 {
     font-weight: bold;

+ 0 - 4
front/src/less/queue.less

@@ -6,8 +6,4 @@
 .statusSubMessage {
     font-size: 0.8em;
     margin-bottom: 6em;
-}
-
-.queueLink {
-    color: #FFF;
 }

+ 128 - 0
front/src/less/rule.less

@@ -88,4 +88,132 @@
         font-size: 3em;
         margin-bottom: 1em;
     }
+}
+
+.offenders {
+    .offenderButton {
+        display: inline-block;
+        position: relative;
+        background: #efe;
+        padding: 0 0.5em;
+        margin: 0.2em 0;
+        border-radius: 0.4em;
+        z-index: 1;
+
+        &.opens {
+            padding-right: 0.75em;
+
+            &:after {
+                position: relative;
+                left: 0.5em;
+                content: '\25BC';
+                font-size: 0.8em;
+            }
+        }
+
+        > div {
+            display: none;
+            position: absolute;
+            right: 0;
+            min-width: 100%;
+            background: inherit;
+            border-bottom-left-radius: 0.4em;
+            border-bottom-right-radius: 0.4em;
+            border-top: 1px solid #999;
+        }
+
+        .domTree {
+            text-align: left;
+            white-space: nowrap;
+
+            > div {
+                margin: 0.5em;
+
+                div {
+                    margin-left: 1em;
+                }
+            }
+        }
+
+        .backtrace, .cssFileAndLine {
+            white-space: nowrap;
+            padding: 0.5em;
+        }
+
+        &.opens.mouseOver {
+            border-bottom-left-radius: 0;
+            border-bottom-right-radius: 0;
+            background: #ffe0cc;
+            z-index: 2;
+
+            > div {
+                display: block;
+            }
+        }
+    }
+}
+
+.offendersHtml {
+    display: inline-block;
+}
+
+.domTree div {
+    text-align: left;
+    margin-left: 1em;
+
+    span:only-child {
+        font-weight: bold;
+        span {
+            font-style: italic;
+            font-weight: normal;
+        }
+    }
+}
+
+.colorPalette {
+    width: 30em;
+    border: 2px solid #000;
+    text-align: left;
+
+    /* Checkerboard background */
+    background-color: #ddd;
+    background-image: linear-gradient(45deg, #AAA 25%, transparent 25%, transparent 75%, #AAA 75%, #AAA), linear-gradient(45deg, #AAA 25%, transparent 25%, transparent 75%, #AAA 75%, #AAA);
+    background-size:1em 1em;
+    background-position:0 0, 0.5em 0.5em;
+
+    > div {
+        display: inline-block;
+        height: 2em;
+        position: relative;
+
+        div {
+            display: none;
+            position: absolute;
+            left: 100%;
+            top: 100%;
+            background: #FFF;
+            padding: 0.5em;
+            border: 2px solid #f1c40f;
+            border-radius: 0.5em;
+            white-space: nowrap;
+            z-index: 3;
+            font-weight: bold;
+        }
+
+        &:hover div {
+            display: block;
+        }
+
+        &:hover:after {
+            content: " ";
+            position: absolute;
+            background-color: inherit;
+            left: -0.2em;
+            top: -0.2em;
+            width: 100%;
+            height: 100%;
+            z-index: 2;
+            border: 0.2em solid #f1c40f;
+        }
+    }
 }

+ 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;
+}

+ 6 - 1
front/src/main.html

@@ -5,6 +5,7 @@
     <base href="/">
     <link rel="icon" type="image/png" href="/img/favicon.png">
     <meta property="og:image" content="/img/logo-large.png" />
+    <meta name="description" content="Free online web performance tool. Audit your webpage for performance and front-end quality issues. And it's open-source!" />
 
     <!-- build:css /css/styles.css-->
     <link rel="stylesheet" type="text/css" href="/css/main.css">
@@ -12,6 +13,7 @@
     <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/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/about.css">
     <!-- endbuild -->
@@ -20,17 +22,20 @@
     <script src="/bower_components/angular/angular.min.js"></script>
     <script src="/bower_components/angular-route/angular-route.min.js"></script>
     <script src="/bower_components/angular-resource/angular-resource.min.js"></script>
+    <script src="/bower_components/angular-sanitize/angular-sanitize.min.js"></script>
     <script src="/js/app.js"></script>
     <script src="/js/controllers/indexCtrl.js"></script>
-    <script src="/js/controllers/aboutCtrl.js"></script>
     <script src="/js/controllers/dashboardCtrl.js"></script>
     <script src="/js/controllers/queueCtrl.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/models/resultsFactory.js"></script>
     <script src="/js/models/runsFactory.js"></script>
+    <script src="/js/services/apiService.js"></script>
     <script src="/js/services/menuService.js"></script>
     <script src="/js/directives/gradeDirective.js"></script>
+    <script src="/js/directives/offendersDirectives.js"></script>
     <!-- endbuild -->
 <head>
 

+ 18 - 7
front/src/views/dashboard.html

@@ -2,10 +2,21 @@
 <div class="summary board">
     
     <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>
 
@@ -35,7 +46,7 @@
         </div>
     </div>
 
-    <div ng-if="!error" class="apiTip">
+    <div ng-if="!error && !fromSocialShare" class="apiTip">
         <b>Did you know? Yellow Lab Tools now has an API</b>! <a href="{{getAPIUrl()}}" target="_blank">Here</a> is the JSON output for this run. Checkout <a href="https://github.com/gmetais/YellowLabTools/wiki/Public-API" target="_blank">the API doc</a>.
     </div>
 
@@ -49,8 +60,8 @@
 
     <div class="fromShare" ng-if="!error && fromSocialShare">
         <p>Yellow Lab Tools is a free online tool that analyzes performance and front-end quality.</p>
-        <a href="" ng-click="seeFullReport()">See the full report for this page</a>
-        <a href="/">Test another webpage</a>
+        <a class="linkButton" href="" ng-click="seeFullReport()">See the full report for this page</a>
+        <a class="linkButton" href="/">Test another webpage</a>
     </div>
 
     <div ng-if="error">

+ 7 - 0
front/src/views/domElementButton.html

@@ -0,0 +1,7 @@
+<div class="offenderButton" ng-class="{opens: obj.tree}">
+    <span ng-if="obj.type == 'html' || obj.type == 'body' || obj.type == 'head' || obj.type == 'window' || obj.type == 'document' || obj.type == 'fragment'"><b>{{obj.type}}</b></span>
+    <span ng-if="obj.type == 'domElement'">DOM element <b>{{obj.element}}</b></span>
+    <span ng-if="obj.type == 'fragmentElement'">Fragment element <b>{{obj.element}}</b></span>
+    <span ng-if="obj.type == 'createdElement'">Created element <b>{{obj.element}}</b></span>
+    <dom-tree ng-if="obj.tree" tree="obj.tree"></dom-tree>
+</div>

+ 19 - 1
front/src/views/index.html

@@ -3,4 +3,22 @@
 <form ng-submit="launchTest()" >
     <input type="text" name="url" ng-model="url" placeholder="http://www.mysite.com" class="url" />
     <input type="submit" value="Launch test" class="launchBtn" ng-class="{disabled: !url}" />
-</form>
+</form>
+
+
+<div class="features">
+    <div>
+        <h3>Front-end quality</h3>
+        <p>Audits quality problems in the HTML, CSS and JS</p>
+    </div>
+
+    <div>
+        <h3>WebPerf</h3>
+        <p>Checks if performance good practices are applied</p>
+    </div>
+
+    <div>
+        <h3>JS Profiling</h3>
+        <p>Untangles the JavaScript spaghetti code</p>
+    </div>
+</div>

+ 31 - 18
front/src/views/queue.html

@@ -1,23 +1,36 @@
-<p>Tested url: &nbsp; <a href="{{url}}" target="_blank" class="testedUrl">{{url}}</a></p>
+<p ng-if="url">Tested url: &nbsp; <a href="{{url}}" target="_blank" class="testedUrl">{{url}}</a></p>
 
-<div ng-if="status.statusCode == 'failed'">
-    <div class="status">Test failed</div>
-    <p class="statusSubMessage">{{status.error}}</p>
-    <p><a class="queueLink" href="https://github.com/gmetais/YellowLabTools/issues" target="_blank">Report a bug on GitHub</a></p>
-    <p><a class="queueLink" href="/">Back to index</a></p>
-</div>
-<div ng-if="status.statusCode == 'awaiting'">
-    <div class="status">
-        <ng-pluralize count="status.position" when="{'one': 'Waiting behind 1 other test', 'other': 'Waiting behind {} other tests'}">
-        </ng-pluralize>
+<div ng-if="!notFound && !connectionLost">
+    <div ng-if="status.statusCode == 'failed'">
+        <div class="status">Test failed</div>
+        <p class="statusSubMessage">{{status.error}}</p>
+        
+        <a class="linkButton" href="https://github.com/gmetais/YellowLabTools/issues" target="_blank">Report the issue on GitHub</a>
+        <a class="linkButton" href="/">New test</a>
+    </div>
+    <div ng-if="status.statusCode == 'awaiting'">
+        <div class="status">
+            <ng-pluralize count="status.position" when="{'one': 'Waiting behind 1 other test', 'other': 'Waiting behind {} other tests'}">
+            </ng-pluralize>
+        </div>
+        <p class="statusSubMessage">(auto-refresh activated)</p>
+    </div>
+    <div ng-if="status.statusCode == 'running'">
+        <div class="status">Test is running...</div>
+        <p class="statusSubMessage">(auto-refresh activated)</p>
+    </div>
+    <div ng-if="status.statusCode == 'complete'">
+        <div class="status">Test complete</div>
+        <p class="statusSubMessage">Opening results...</p>
     </div>
-    <p class="statusSubMessage">(auto-refresh activated)</p>
 </div>
-<div ng-if="status.statusCode == 'running'">
-    <div class="status">Test is running...</div>
-    <p class="statusSubMessage">(auto-refresh activated)</p>
+<div ng-if="notFound == true">
+    <div class="status">Error 404 (test not found)</div>
+    <p class="statusSubMessage">The server probably rebooted. We are very sorry about that, please try to launch the test again.</p>
+    
+    <a class="linkButton" href="/">New test</a>
 </div>
-<div ng-if="status.statusCode == 'complete'">
-    <div class="status">Test complete</div>
-    <p class="statusSubMessage">Opening results...</p>
+<div ng-if="connectionLost == true">
+    <div class="status">Connection lost with server</div>
+    <p class="statusSubMessage">Check your wifi cable, or maybe YellowLab.tools is rebooting.</p>
 </div>

+ 136 - 9
front/src/views/rule.html

@@ -10,23 +10,150 @@
         </div>
         <div class="right">
             <h3>Value: {{rule.value}}</h3>
-            <div ng-bind-html="message" class="message"></div>
+            <div ng-bind-html="rule.policy.message" class="message"></div>
         </div>
     </div>
     <div ng-if="rule.abnormal" class="warning">
         <h3>Warning</h3>
         <p>This rule reached the abnormality threshold, which means there is a real problem you should care about.</p>
     </div>
-    <div class="offenders">
-        <h3>
-            <ng-pluralize count="rule.offenders.length || 0" when="{'0': 'No offenders', 'one': '1 offender', 'other': '{} offenders'}">
-            </ng-pluralize>
-        </h3>
-        <div class="offendersTable">
-            <div ng-repeat="offender in rule.offenders track by $index">
-                <div>{{offender}}</div>
+    <div class="offenders" ng-if="rule.policy.hasOffenders">
+        <h3><ng-pluralize count="rule.offendersObj.count" when="{'0': 'No offenders', 'one': '1 offender', 'other': '{} offenders'}"></ng-pluralize></h3>
+
+        <div ng-if="rule.offendersObj.list" class="offendersTable">
+            <div ng-repeat="offender in rule.offendersObj.list track by $index">
+                <div ng-if="offender.parseError">
+                    {{offender.parseError}}
+                </div>
+                <div ng-if="!offender.parseError">
+
+                    <div ng-if="policyName === 'DOMidDuplicated'">
+                        <b>{{offender.id}}</b>: {{offender.occurrences}} occurrences
+                    </div>
+
+                    <div ng-if="policyName === 'DOMinserts'">
+                        <dom-element-button obj="offender.insertedElement"></dom-element-button> appended to <dom-element-button obj="offender.receiverElement"></dom-element-button>
+                    </div>
+                    
+                    <div ng-if="policyName === 'DOMqueriesWithoutResults'">
+                        <b>{{offender.query}}</b> (in <dom-element-button obj="offender.context"></dom-element-button>) using {{offender.fn}}
+                    </div>
+
+                    <div ng-if="policyName === 'DOMqueriesAvoidable'">
+                        <b>{{offender.query}}</b> (in <dom-element-button obj="offender.context"></dom-element-button>) using {{offender.fn}}: <b>{{offender.count}} queries</b>
+                    </div>
+
+                    <div ng-if="policyName === 'eventsBound'">
+                        <b>{{offender.eventName}}</b> bound to <dom-element-button obj="offender.element"></dom-element-button>
+                    </div>
+
+                    <div ng-if="policyName === 'jsErrors'">
+                        <b>{{offender.error}}</b>
+                        <div class="offenderButton" ng-if="offender.backtrace.length == 0">no backtrace</div>
+                        <div class="offenderButton opens" ng-if="offender.backtrace.length > 0">
+                            backtrace
+                            <div class="backtrace">
+                                <div ng-repeat="obj in offender.backtrace track by $index">
+                                    <span ng-if="obj.functionName">{{obj.functionName}}()</span>
+                                    <url-link url="obj.file" max-length="60"></url-link>
+                                    line {{obj.line}}
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+
+                    <div ng-if="policyName === 'cssParsingErrors'">
+                        <b>{{offender.error}}</b>
+                        <file-and-line file="offender.file" line="offender.line" column="offender.column"></file-and-line>
+                        <span ng-if="offender.file">(<a href="http://jigsaw.w3.org/css-validator/validator?profile=css3&usermedium=all&warning=no&uri={{offender.file | encodeURIComponent}}" target="_blank">Check on the W3C validator</a>)</span>
+                    </div>
+
+                    <div ng-if="policyName === 'cssComplexSelectors' || policyName === 'cssComplexSelectorsByAttribute' || policyName === 'cssImports' || policyName === 'cssUniversalSelectors' || policyName === 'cssRedundantBodySelectors' || policyName === 'cssRedundantChildNodesSelectors'">
+                        <span ng-if="offender.bolded" ng-bind-html="offender.bolded"></span>
+                        <b ng-if="!offender.bolded">{{offender.css}}</b>
+                        <file-and-line-button file="offender.file" line="offender.line" column="offender.column"></file-and-line-button>
+                    </div>
+
+                    <div ng-if="policyName === 'cssDuplicatedSelectors'">
+                        {{offender.rule}} (<b>x{{offender.occurrences}}</b>)
+                    </div>
+
+                    <div ng-if="policyName === 'cssDuplicatedProperties'">
+                        Property <b>{{offender.property}}</b> duplicated in <b>{{offender.rule}} { }</b>
+                        <file-and-line-button file="offender.file" line="offender.line" column="offender.column"></file-and-line-button>
+                    </div>
+
+                    <div ng-if="policyName === 'cssEmptyRules'">
+                        <b>{{offender.css}} { }</b>
+                        <file-and-line-button file="offender.file" line="offender.line" column="offender.column"></file-and-line-button>
+                    </div>
+
+                    <div ng-if="policyName === 'cssExpressions'">
+                        {{offender.rule}} {{ '{' + offender.property}}: <b>expression(</b>{{offender.expression}}<b>)</b>}
+                        <file-and-line-button file="offender.file" line="offender.line" column="offender.column"></file-and-line-button>
+                    </div>
+
+                    <div ng-if="policyName === 'cssImportants'">
+                        {{offender.rule}} {{ '{' + offender.property}}: {{offender.value}} <b>!important</b>}
+                        <file-and-line-button file="offender.file" line="offender.line" column="offender.column"></file-and-line-button>
+                    </div>
+
+                    <div ng-if="policyName === 'cssOldIEFixes'">
+                        <span ng-if="offender.browser"><b>{{offender.browser}} fix:</b></span>
+                        <span ng-bind-html="offender.bolded"></span>
+                        <file-and-line-button file="offender.file" line="offender.line" column="offender.column"></file-and-line-button>
+                    </div>
+
+                    <div ng-if="policyName === 'cssOldPropertyPrefixes'">
+                        {{offender.rule}} {<b>{{offender.property}}</b>: {{offender.value + '}' }}
+                        <br>
+                        <b>{{offender.message}}</b>
+                        <file-and-line-button file="offender.file" line="offender.line" column="offender.column"></file-and-line-button>
+                    </div>
+
+                    <div ng-if="policyName === 'requests' || policyName === 'htmlCount' || policyName === 'jsCount' || policyName === 'cssCount' || policyName === 'imageCount' || policyName === 'webfontCount' || policyName === 'videoCount' || policyName === 'jsonCount' || policyName === 'otherCount' || policyName === 'smallJsFiles' || policyName === 'smallCssFiles' || policyName === 'smallImages'">
+                        <url-link url="offender.file" max-length="100"></url-link>
+                        <span ng-if="offender.size || offender.size === 0">({{offender.size}} kB)</span>
+                    </div>
+
+                    <div ng-if="policyName === 'notFound' || policyName === 'closedConnections' || policyName === 'multipleRequests' || policyName === 'cachingDisabled' || policyName === 'cachingNotSpecified'">
+                        <url-link url="offender" max-length="100"></url-link>
+                    </div>
+
+                    <div ng-if="policyName === 'cachingTooShort'">
+                        <url-link url="offender.file" max-length="100"></url-link>
+                        cached for <b>{{offender.ttlWithUnit}} {{offender.unit}}</b>
+                    </div>
+
+                    <div ng-if="policyName === 'domains'">
+                        <b>{{offender.domain}}</b>
+                        (<ng-pluralize count="offender.requests" when="{'one':'1 request','other':'{} requests'}"></ng-pluralize>)
+                    </div>
+
+                    <div ng-if="policyName === 'globalVariables' || policyName === 'jQueryVersionsLoaded'">
+                        {{offender}}
+                    </div>
+
+                </div>
+            </div>
+        </div>
+
+
+        <div ng-if="!rule.offendersObj.list" class="offendersHtml">
+            
+            <div ng-if="policyName === 'DOMelementMaxDepth'">
+                <dom-tree tree="rule.offendersObj.tree"></dom-tree>
             </div>
+
+            <div ng-if="policyName === 'cssColors' && rule.offendersObj.count > 0">
+                <p>This is the colors palette, sized by total occurrences:</p>
+                <div class="colorPalette">
+                    <div ng-repeat="offender in rule.offendersObj.palette" style="background-color: {{offender.color}}; width: {{offender.occurrences * 100 / rule.offendersObj.palette[0].occurrences}}%"><div>{{offender.color}} ({{offender.occurrences}} times)</div></div>
+                </div>
+            </div>
+
         </div>
+
     </div>
     <div ng-if="!rule && rule !== null" class="notFound">
         <h2>404</h2>

+ 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>

+ 1 - 1
lib/index.js

@@ -14,7 +14,7 @@ var yellowLabTools = function(url, options) {
 
     } else {
 
-        if (url.indexOf('http://') !== 0 && url.indexOf('https://') !== 0) {
+        if (url.toLowerCase().indexOf('http://') !== 0 && url.toLowerCase().indexOf('https://') !== 0) {
             url = 'http://' + url;
         }
 

File diff suppressed because it is too large
+ 659 - 55
lib/metadata/policies.js


+ 3 - 2
lib/metadata/scoreProfileGeneric.json

@@ -32,7 +32,7 @@
             "label": "jQuery version",
             "policies": {
                 "jQueryVersion": 5,
-                "jQueryDifferentVersions": 0.1
+                "jQueryVersionsLoaded": 0.1
             }
         },
         "cssSyntaxError": {
@@ -46,7 +46,8 @@
             "policies": {
                 "cssRules": 2,
                 "cssComplexSelectors": 2,
-                "cssComplexSelectorsByAttribute": 1.5
+                "cssComplexSelectorsByAttribute": 1.5,
+                "cssColors": 0.5
             }
         },
         "badCSS": {

+ 178 - 0
lib/offendersHelpers.js

@@ -0,0 +1,178 @@
+
+
+var OffendersHelpers = function() {
+    
+    this.domPathToArray = function(str) {
+        return str.split(/\s?>\s?/);
+    };
+
+    this.listOfDomArraysToTree = function(listOfDomArrays) {
+        var result = {};
+
+        function recursiveTreeBuilder(tree, domArray) {
+            if (domArray.length > 0) {
+                var currentDomElement = domArray.shift();
+                if (tree === null) {
+                    tree = {};
+                }
+                tree[currentDomElement] = recursiveTreeBuilder(tree[currentDomElement] || null, domArray);
+                return tree;
+            } else if (tree === null) {
+                return 1;
+            } else {
+                return tree + 1;
+            }
+        }
+
+        listOfDomArrays.forEach(function(domArray) {
+            result = recursiveTreeBuilder(result, domArray);
+        });
+
+        return result;
+    };
+
+    this.domPathToDomElementObj = function(domPath) {
+        var domArray = this.domPathToArray(domPath);
+        var domTree = this.listOfDomArraysToTree([this.domPathToArray(domPath)]);
+
+        if (domArray[0] === 'html') {
+            return {
+                type: 'html'
+            };
+        }
+        if (domArray[0] === 'body') {
+            if (domArray.length === 1) {
+                return {
+                    type: 'body'
+                };
+            } else {
+                return {
+                    type: 'domElement',
+                    element: domArray[domArray.length - 1],
+                    tree: domTree
+                };
+            }
+        }
+        if (domArray[0] === 'head') {
+            return {
+                type: 'head'
+            };
+        }
+        if (domArray[0] === '#document') {
+            return {
+                type: 'document'
+            };
+        }
+        if (domArray[0] === 'window') {
+            return {
+                type: 'window'
+            };
+        }
+        if (domArray[0] === 'DocumentFragment') {
+            if (domArray.length === 1) {
+                return {
+                    type: 'fragment'
+                };
+            } else {
+                return {
+                    type: 'fragmentElement',
+                    element: domArray[domArray.length - 1],
+                    tree: domTree
+                };
+            }
+        }
+        
+        // Not attached element, such as just created with document.createElement()
+        if (domArray.length === 1) {
+            return {
+                type: 'createdElement',
+                element: domPath
+            };
+        } else {
+            return {
+                type: 'createdElement',
+                element: domArray[domArray.length - 1],
+                tree: domTree
+            };
+        }
+    };
+
+
+    this.backtraceToArray = function(str) {
+        var traceArray = str.split(/ \/ /);
+
+        if (traceArray) {
+            var results = [];
+            var parts = null;
+
+            for (var i=0 ; i<traceArray.length ; i++) {
+                parts = /^(([\w$]+) )?([^ ]+):(\d+)$/.exec(traceArray[i]);
+
+                if (parts) {
+                    var obj = {
+                        file: parts[3],
+                        line: parseInt(parts[4], 10)
+                    };
+
+                    if (parts[2]) {
+                        obj.functionName = parts[2];
+                    }
+
+                    results.push(obj);
+                } else {
+                    return null;
+                }
+            }
+            return results;
+        } else {
+            return null;
+        }
+    };
+
+
+    this.sortVarsLikeChromeDevTools = function(vars) {
+        return vars.sort(function(a, b) {
+            return (a < b) ? -1 : 1;
+        });
+    };
+
+    this.urlToLink = function(url) {
+        var shortUrl = (url.length > 110) ? url.substr(0, 47) + ' ... ' + url.substr(-48) : url;
+        return '<a href="' + url + '" target="_blank" title="' + url + '">' + shortUrl + '</a>';
+    };
+
+    this.cssOffenderPattern = function(offender) {
+        var parts = /^(.*) (?:<([^ \(]*)>|\[inline CSS\]) @ (\d+):(\d+)$/.exec(offender);
+        
+        if (!parts) {
+            return {
+                offender: offender
+            };
+        } else {
+            return {
+                css: parts[1],
+                file: parts[2] || null,
+                line: parseInt(parts[3], 10),
+                column: parseInt(parts[4], 10)
+            };
+        }
+    };
+
+    this.fileWithSizePattern = function(fileWithSize) {
+        var parts = /^([^ ]*) \((\d+\.\d{2}) kB\)$/.exec(fileWithSize);
+
+        if (!parts) {
+            return {
+                file: fileWithSize
+            };
+        } else {
+            return {
+                file: parts[1],
+                size: parseFloat(parts[2])
+            };
+        }
+    };
+
+};
+
+module.exports = new OffendersHelpers();

+ 55 - 17
lib/rulesChecker.js

@@ -1,3 +1,5 @@
+var extend = require('util')._extend;
+
 var debug = require('debug')('ylt:ruleschecker');
 
 var RulesChecker = function() {
@@ -21,31 +23,67 @@ var RulesChecker = function() {
 
                     rule = {
                         value: data.toolsResults[policy.tool].metrics[metricName],
-                        policy: policy
+                        policy: extend({}, policy) // Clone object policy instead of reference
                     };
 
-                    // Take DOMqueriesAvoidable's offenders from DOMqueriesDuplicated, for example.
-                    if (policy.takeOffendersFrom) {
-                        var fromList = policy.takeOffendersFrom;
+
+                    // Deal with offenders
+                    if (policy.hasOffenders) {
+
                         var offenders = [];
-                        
-                        // takeOffendersFrom option can be a string or an array of strings.
-                        if (typeof fromList === 'string') {
-                            fromList = [fromList];
+
+                        // Take DOMqueriesAvoidable's offenders from DOMqueriesDuplicated, for example.
+                        if (policy.takeOffendersFrom) {
+                            
+                            var fromList = policy.takeOffendersFrom;
+                            
+                            // takeOffendersFrom option can be a string or an array of strings.
+                            if (typeof fromList === 'string') {
+                                fromList = [fromList];
+                            }
+                            
+                            fromList.forEach(function(from) {
+                                if (data.toolsResults[policy.tool] &&
+                                        data.toolsResults[policy.tool].offenders &&
+                                        data.toolsResults[policy.tool].offenders[from]) {
+                                    offenders = offenders.concat(data.toolsResults[policy.tool].offenders[from]);
+                                }
+                            });
+
+                            data.toolsResults[policy.tool].offenders[metricName] = offenders;
+
+                        } else if (data.toolsResults[policy.tool] &&
+                                data.toolsResults[policy.tool].offenders &&
+                                data.toolsResults[policy.tool].offenders[metricName]) {
+                            offenders = data.toolsResults[policy.tool].offenders[metricName];
                         }
+
+                        var offendersObj = {};
                         
-                        fromList.forEach(function(from) {
-                            offenders = offenders.concat(data.toolsResults[policy.tool].offenders[from]);
-                        });
+                        // It is possible to declare a transformation function for the offenders.
+                        // The function should take an array of strings as single parameter and return a string.
+                        if (policy.offendersTransformFn) {
+
+                            try {
+                                offendersObj = policy.offendersTransformFn(offenders, rule);
+                            } catch(err) {
+                                debug('Error while transforming offenders for %s', metricName);
+                                debug(err);
+                            }
+                            
+                            delete rule.policy.offendersTransformFn;
+
+                        } else {
+
+                            offendersObj = {
+                                count: offenders.length,
+                                list: offenders
+                            };
+                        }
 
-                        data.toolsResults[policy.tool].offenders[metricName] = offenders;
+                        rule.offendersObj = offendersObj;
                     }
 
-                    if (data.toolsResults[policy.tool].offenders &&
-                        data.toolsResults[policy.tool].offenders[metricName] &&
-                        data.toolsResults[policy.tool].offenders[metricName].length > 0) {
-                            rule.offenders = data.toolsResults[policy.tool].offenders[metricName];
-                    }
 
                     rule.bad = rule.value > policy.isOkThreshold;
                     rule.abnormal = policy.isAbnormalThreshold && rule.value >= policy.isAbnormalThreshold;

+ 5 - 1
lib/runner.js

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

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

@@ -1,6 +1,8 @@
 var debug               = require('debug')('ylt:server');
+var Q                   = require('q');
 
 var ylt                 = require('../../index');
+var ScreenshotHandler   = require('../../screenshotHandler');
 var RunsQueue           = require('../datastores/runsQueue');
 var RunsDatastore       = require('../datastores/runsDatastore');
 var ResultsDatastore    = require('../datastores/resultsDatastore');
@@ -18,16 +20,28 @@ var ApiController = function(app) {
     // Create a new run
     app.post('/api/runs', function(req, res) {
 
+        // Add http to the test URL
+        if (req.body.url && req.body.url.toLowerCase().indexOf('http://') !== 0 && req.body.url.toLowerCase().indexOf('https://') !== 0) {
+            req.body.url = 'http://' + req.body.url;
+        }
+
         // Grab the test parameters and generate a random run ID
         var run = {
             runId: (Date.now()*1000 + Math.round(Math.random()*1000)).toString(36),
             params: {
                 url: req.body.url,
                 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
         debug('Adding test %s to the queue', run.runId);
         var queuePromise = queue.push(run.runId);
@@ -49,81 +63,120 @@ var ApiController = function(app) {
 
             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';
+                        }
+
+                    })
+                    // Delete screenshot temporary file
+                    .then(screenshot.deleteTmpFile);
+
+            }
 
-                    // 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');
-                        });
+            // 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) {
-                    
                     console.error('Test failed for URL: %s', run.params.url);
                     console.error(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) {
             console.log('Sending response without waiting.');
             res.setHeader('Content-Type', 'application/json');
             res.send(JSON.stringify({runId: run.runId}));
         }
+
     });
 
+
     // Retrive one run by id
     app.get('/api/runs/:id', function(req, res) {
         var runId = req.params.id;
@@ -221,6 +274,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;

+ 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 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) {
         app.get(route, function(req, res) {
             res.setHeader('Cache-Control', 'public, max-age=20');

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

@@ -9,24 +9,28 @@ function ResultsDatastore() {
     'use strict';
 
     var resultFileName = 'results.json';
+    var resultScreenshotName = 'screenshot.jpg';
     var resultsFolderName = 'results';
     var resultsDir = path.join(__dirname, '..', '..', '..', resultsFolderName);
 
 
     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;
     }
+
+    // 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;

+ 1 - 1
lib/server/datastores/runsDatastore.js

@@ -65,7 +65,7 @@ function RunsDatastore() {
                 errorMessage = "Error 253: Phantomas config error";
                 break;
             case '254':
-                errorMessage = "Error 254: page loading failed in Phantomas";
+                errorMessage = "Error 254: page loading failed in PhantomJS";
                 break;
             case '255':
                 errorMessage = "Error 255: Phantomas error";

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

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

+ 79 - 0
lib/tools/phantomas/custom_modules/modules/cachYLT/cachYLT.js

@@ -0,0 +1,79 @@
+/**
+ * Analyzes HTTP caching headers
+ *
+ * @see https://developers.google.com/speed/docs/best-practices/caching
+ */
+
+exports.version = '0.2.a';
+
+exports.module = function(phantomas) {
+    'use strict';
+    
+    var cacheControlRegExp = /max-age=(\d+)/;
+
+    function getCachingTime(url, headers) {
+        // false means "no caching"
+        var ttl = false,
+            headerName,
+            now = new Date(),
+            headerDate;
+
+        for (headerName in headers) {
+            var value = headers[headerName];
+
+            switch (headerName.toLowerCase()) {
+                // parse max-age=...
+                //
+                // max-age=2592000
+                // public, max-age=300, must-revalidate
+                case 'cache-control':
+                    var matches = value.match(cacheControlRegExp);
+
+                    if (matches) {
+                        ttl = parseInt(matches[1], 10);
+                    }
+                    break;
+
+                    // catch Expires and Pragma headers
+                case 'expires':
+                case 'pragma':
+                    // and Varnish specific headers
+                case 'x-pass-expires':
+                case 'x-pass-cache-control':
+                    phantomas.incrMetric('oldCachingHeaders'); // @desc number of responses with old, HTTP 1.0 caching headers (Expires and Pragma)
+                    phantomas.addOffender('oldCachingHeaders', url + ' - ' + headerName + ': ' + value);
+                    headerDate = Date.parse(value);
+                    if (headerDate) ttl = Math.round((headerDate - now) / 1000);
+                    break;
+            }
+        }
+
+        //console.log(JSON.stringify(headers)); console.log("TTL: " + ttl + ' s');
+
+        return ttl;
+    }
+
+    phantomas.setMetric('cachingNotSpecified'); // @desc number of responses with no caching header sent (no Cache-Control header)
+    phantomas.setMetric('cachingTooShort'); // @desc number of responses with too short (less than a week) caching time
+    phantomas.setMetric('cachingDisabled'); // @desc number of responses with caching disabled (max-age=0)
+
+    phantomas.setMetric('oldCachingHeaders');
+
+    phantomas.on('recv', function(entry, res) {
+        var ttl = getCachingTime(entry.url, entry.headers);
+
+        // static assets
+        if (entry.isImage || entry.isJS || entry.isCSS) {
+            if (ttl === false) {
+                phantomas.incrMetric('cachingNotSpecified');
+                phantomas.addOffender('cachingNotSpecified', entry.url);
+            } else if (ttl <= 0) {
+                phantomas.incrMetric('cachingDisabled');
+                phantomas.addOffender('cachingDisabled', entry.url);
+            } else if (ttl < 7 * 86400) {
+                phantomas.incrMetric('cachingTooShort');
+                phantomas.addOffender('cachingTooShort', entry.url + ' cached for ' + ttl + ' s');
+            }
+        }
+    });
+};

+ 1 - 1
lib/tools/phantomas/custom_modules/modules/domQYLT/domQYLT.js

@@ -289,7 +289,7 @@ exports.module = function(phantomas) {
 
     // count DOM queries by either ID, tag name, class name and selector query
     // @see https://dvcs.w3.org/hg/domcore/raw-file/tip/Overview.html#dom-document-doctype
-    var Collection = require('../../../../../../node_modules/phantomas/lib/collection'),
+    var Collection = require('../../util/collection'),
         DOMqueries = new Collection();
 
     phantomas.on('domQuery', function(type, query, fnName, context) {

+ 62 - 48
lib/tools/phantomas/custom_modules/modules/jQYLT/jQYLT.js

@@ -7,16 +7,17 @@
 /* global document: true, window: true */
 /* jshint -W030 */
 
-exports.version = '0.2.a';
+exports.version = '1.0.a';
 
 exports.module = function(phantomas) {
     'use strict';
 
     phantomas.setMetric('jQueryVersion', ''); // @desc version of jQuery framework (if loaded) [string]
+    phantomas.setMetric('jQueryVersionsLoaded'); // @desc number of loaded jQuery "instances" (even in the same version)
     phantomas.setMetric('jQueryOnDOMReadyFunctions'); // @desc number of functions bound to onDOMReady event
+    phantomas.setMetric('jQueryWindowOnLoadFunctions'); // @desc number of functions bound to windowOnLoad event
     phantomas.setMetric('jQuerySizzleCalls'); // @desc number of calls to Sizzle (including those that will be resolved using querySelectorAll)
-    phantomas.setMetric('jQuerySizzleCallsDuplicated'); // @desc number of calls on the same Sizzle request
-    phantomas.setMetric('jQueryDifferentVersions'); //@desc number of different jQuery versions loaded on the page (not counting iframes)
+    //phantomas.setMetric('jQueryEventTriggers'); // @desc number of jQuery event triggers
 
     var jQueryFunctions = [
         // DOM manipulations
@@ -110,30 +111,28 @@ exports.module = function(phantomas) {
         phantomas.evaluate(function(jQueryFunctions) {
             (function(phantomas) {
                 var jQuery;
+                var oldJQuery;
 
-                // TODO: create a helper - phantomas.spyGlobalVar() ?
-                window.__defineSetter__('jQuery', function(val) {
+                phantomas.spyGlobalVar('jQuery', function(jQuery) {
                     var version;
-                    var jQueryFn;
-                    var oldJQuery = jQuery;
 
-                    if (!val || !val.fn) {
+                    if (!jQuery || !jQuery.fn) {
                         phantomas.log('jQuery: unable to detect version!');
                         return;
                     }
 
-                    version = val.fn.jquery;
-                    jQuery = val;
-                    jQueryFn = val.fn;
-                    // Older jQuery (v?.?) compatibility
-                    if (!jQueryFn) {
-                        jQueryFn = jQuery;
+                    // Tag the current version of jQuery to avoid multiple reports of jQuery being loaded
+                    // when it's actually only restored via $.noConflict(true) - see comments in #435
+                    if (jQuery.__phantomas === true) {
+                        phantomas.log('jQuery: this instance has already been seen by phantomas');
+                        return;
                     }
+                    jQuery.__phantomas = true;
 
-                    phantomas.log('jQuery: loaded v' + version);
-                    phantomas.setMetric('jQueryVersion', version);
+                    // report the version of jQuery
+                    version = jQuery.fn.jquery;
                     phantomas.emit('jQueryLoaded', version);
-                    
+
                     phantomas.pushContext({
                         type: (oldJQuery) ? 'jQuery version change' : 'jQuery loaded',
                         callDetails: {
@@ -141,11 +140,13 @@ exports.module = function(phantomas) {
                         },
                         backtrace: phantomas.getBacktrace()
                     });
+                    oldJQuery = version;
 
                     // jQuery.ready.promise
                     // works for jQuery 1.8.0+ (released Aug 09 2012)
-                    phantomas.spy(val.ready, 'promise', function(func) {
+                    phantomas.spy(jQuery.ready, 'promise', function(func) {
                         phantomas.incrMetric('jQueryOnDOMReadyFunctions');
+                        phantomas.addOffender('jQueryOnDOMReadyFunctions', phantomas.getCaller(3));
 
                         phantomas.pushContext({
                             type: 'jQuery - onDOMReady',
@@ -160,9 +161,9 @@ exports.module = function(phantomas) {
 
                     // Sizzle calls - jQuery.find
                     // works for jQuery 1.3+ (released Jan 13 2009)
-                    phantomas.spy(val, 'find', function(selector, context) {
+                    phantomas.spy(jQuery, 'find', function(selector, context) {
                         phantomas.incrMetric('jQuerySizzleCalls');
-                        phantomas.emit('onSizzleCall', selector + ' (context: ' + (phantomas.getDOMPath(context) || 'unknown') + ')');
+                        phantomas.addOffender('jQuerySizzleCalls', '%s (in %s)', selector, (phantomas.getDOMPath(context) || 'unknown'));
                         
                         phantomas.enterContext({
                             type: 'jQuery - find',
@@ -183,12 +184,39 @@ exports.module = function(phantomas) {
                         phantomas.leaveContext(moreData);
                     }) || phantomas.log('jQuery: can not measure jQuerySizzleCalls (jQuery used on the page is too old)!');
 
+                    /*if (!jQuery.event) {
+                        phantomas.spy(jQuery.event, 'trigger', function(ev, data, elem) {
+                            var path = phantomas.getDOMPath(elem),
+                                type = ev.type || ev;
+
+                            phantomas.log('Event: triggered "%s" on "%s"', type, path);
+
+                            phantomas.incrMetric('jQueryEventTriggers');
+                            phantomas.addOffender('jQueryEventTriggers', '"%s" on "%s"', type, path);
+                        });
+                    }*/
+
+                    // jQuery events bound to window' onLoad event (#451)
+                    phantomas.spy(jQuery.fn, 'on', function(eventName, func) {
+                        if ((eventName === 'load') && (this[0] === window)) {
+                            phantomas.incrMetric('jQueryWindowOnLoadFunctions');
+                            phantomas.addOffender('jQueryWindowOnLoadFunctions', phantomas.getCaller(2));
+
+                            phantomas.pushContext({
+                                type: 'jQuery - windowOnLoad',
+                                callDetails: {
+                                    arguments: [func]
+                                },
+                                backtrace: phantomas.getBacktrace()
+                            });
+                        }
+                    });
 
                     // Add spys on many jQuery functions
                     jQueryFunctions.forEach(function(functionName) {
                         var capitalizedName = functionName.substring(0,1).toUpperCase() + functionName.substring(1);
                         
-                        phantomas.spy(jQueryFn, functionName, function(args) {
+                        phantomas.spy(jQuery.fn, functionName, function(args) {
 
                             // Clean args
                             args = [].slice.call(arguments);
@@ -263,42 +291,28 @@ exports.module = function(phantomas) {
                             phantomas.leaveContext();
                         }) || phantomas.log('jQuery: can not track jQuery - ' + capitalizedName + ' (this version of jQuery doesn\'t support it)');
                     });
-
-
-                });
-
-                window.__defineGetter__('jQuery', function() {
-                    return jQuery;
                 });
             })(window.__phantomas);
         }, jQueryFunctions);
     });
 
 
-    // count Sizzle calls to detect duplicated queries
-    var Collection = require('../../../../../../node_modules/phantomas/lib/collection'),
-        sizzleCalls = new Collection(),
-        jQueryLoading = new Collection();
-
-    phantomas.on('onSizzleCall', function(request) {
-        sizzleCalls.push(request);
+    // store the last resource that was received
+    // try to report where given jQuery version was loaded from
+    phantomas.on('recv', function(entry) {
+        if (entry.isJS) {
+            lastUrl = entry.url;
+        }
     });
 
     phantomas.on('jQueryLoaded', function(version) {
-        jQueryLoading.push(version);
-    });
+        phantomas.log('jQuery: loaded v' + version);
+        phantomas.setMetric('jQueryVersion', version);
+
+        // report multiple jQuery "instances" (issue #435)
+        phantomas.incrMetric('jQueryVersionsLoaded');
+        phantomas.addOffender('jQueryVersionsLoaded', 'v%s', version);
 
-    phantomas.on('report', function() {
-        sizzleCalls.sort().forEach(function(id, cnt) {
-            if (cnt > 1) {
-                phantomas.incrMetric('jQuerySizzleCallsDuplicated');
-                phantomas.addOffender('jQuerySizzleCallsDuplicated', '%s: %d', id, cnt);
-            }
-        });
-
-        jQueryLoading.forEach(function(version) {
-            phantomas.incrMetric('jQueryDifferentVersions');
-            phantomas.addOffender('jQueryDifferentVersions', '%s', version);
-        });
+        phantomas.log('jQuery: v%s (probably loaded from <%s>)', version, lastUrl);
     });
 };

+ 0 - 29
lib/tools/phantomas/custom_modules/modules/jsFileLoadYLT/jsFileLoadYLT.js

@@ -1,29 +0,0 @@
-/**
- * Meters the number of page errors, and provides traces as offenders for "jsErrors" metric
- */
-
-exports.version = '0.0';
-
-exports.module = function(phantomas) {
-    'use strict';
-    
-    /*phantomas.on('recv', function(entry, res) {
-        if (!entry.isJS) {
-            return;
-        }
-
-        // Yeah, this is weird, i'm sending the information back to the browser...
-        phantomas.evaluate(function(url) {
-            (function(phantomas) {
-
-                phantomas.pushContext({
-                    type: 'script loaded',
-                    callDetails: {
-                        arguments: [url]
-                    }
-                });
-
-            })(window.__phantomas);
-        }, entry.url);
-    });*/
-};

+ 54 - 0
lib/tools/phantomas/custom_modules/util/collection.js

@@ -0,0 +1,54 @@
+/**
+ * Push items and count them
+ */
+
+function collection() {
+    /* jshint validthis: true */
+    this.items = {};
+}
+
+collection.prototype = {
+    push: function(item) {
+        if (typeof this.items[item] === 'undefined') {
+            this.items[item] = {
+                cnt: 1
+            };
+        } else {
+            this.items[item].cnt++;
+        }
+    },
+
+    sort: function() {
+        var newItems = {},
+            sortedKeys;
+
+        // sort in descending order (by cnt)
+        sortedKeys = Object.keys(this.items).sort((function(a, b) {
+            return this.items[b].cnt - this.items[a].cnt;
+        }).bind(this));
+
+        // build new items dictionary
+        sortedKeys.forEach(function(key) {
+            newItems[key] = this.items[key];
+        }, this);
+
+        this.items = newItems;
+        return this;
+    },
+
+    forEach: function(callback) {
+        Object.keys(this.items).forEach(function(key) {
+            callback(key, this.items[key].cnt);
+        }, this);
+    },
+
+    has: function(item) {
+        return (typeof this.items[item] !== 'undefined');
+    },
+
+    getLength: function() {
+        return Object.keys(this.items).length;
+    }
+};
+
+module.exports = collection;

+ 27 - 15
lib/tools/phantomas/phantomasWrapper.js

@@ -1,8 +1,9 @@
-var async           = require('async');
-var Q               = require('q');
-var ps              = require('ps-node');
-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() {
@@ -19,35 +20,38 @@ var PhantomasWrapper = function() {
     this.execute = function(data) {
 
         var deferred = Q.defer();
-
         var task = data.params;
 
+
         var options = {
             // Cusomizable options
-            timeout: task.options.timeout || 60,
+            'timeout': task.options.timeout || 60,
             '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',
+            'screenshot': task.options.screenshot || false,
+            'viewport': '1366x768',
 
             // Mandatory
-            reporter: 'json:pretty',
+            'reporter': 'json:pretty',
             'analyze-css': true,
             'skip-modules': [
                 'blockDomains', // not needed
+                'caching', // overriden
                 'domMutations', // not compatible with webkit
                 'domQueries', // overriden
                 'eventListeners', // overridden
                 'filmStrip', // not needed
                 'har', // not needed for the moment
-                'javaScriptBottlenecks', // needs to be launched after custom module scopeYLT,
+                'javaScriptBottlenecks', // needs to be launched after custom module scopeYLT
+                'jQuery', // overridden
                 'jserrors', // overridden
                 'pageSource', // not needed
-                'screenshot', // not needed for the moment
                 'waitForSelector', // not needed
                 'windowPerformance' // overriden
             ].join(','),
             'include-dirs': [
-                'lib/tools/phantomas/custom_modules/core',
-                'lib/tools/phantomas/custom_modules/modules'
+                path.join(__dirname, 'custom_modules/core'),
+                path.join(__dirname, 'custom_modules/modules')
             ].join(',')
         };
 
@@ -55,7 +59,14 @@ var PhantomasWrapper = function() {
         debug('If you want to reproduce the phantomas task only, copy the following command line:');
         var optionsString = '';
         for (var opt in options) {
-            optionsString += ' ' + '--' + opt + '=' + options[opt];
+            var value = options[opt];
+            
+            if ((typeof value === 'string' || value instanceof String) && value.indexOf(' ') >= 0) {
+                value = '"' + value + '"';
+            }
+
+            optionsString += ' ' + '--' + opt + '=' + value;
+
         }
         debug('node node_modules/phantomas/bin/phantomas.js --url=' + task.url + optionsString + ' --verbose');
 
@@ -90,7 +101,7 @@ var PhantomasWrapper = function() {
                 debug('Returning from Phantomas');
 
                 // Adding some YellowLabTools errors here
-                if (json && json.metrics && !json.metrics.javascriptExecutionTree) {
+                if (json && json.metrics && (!json.metrics.javascriptExecutionTree || !json.offenders.javascriptExecutionTree)) {
                     err = 1001;
                 }
 
@@ -120,10 +131,11 @@ var PhantomasWrapper = function() {
             if (err) {
                 debug('All ' + triesNumber + ' attemps failed for the test');
                 deferred.reject(err);
+
             } else {
 
-                // Success!!!
                 deferred.resolve(json);
+
             }
         });
 

+ 7 - 4
package.json

@@ -1,6 +1,7 @@
 {
   "name": "yellowlabtools",
-  "version": "1.1.0",
+  "version": "1.2.0",
+  "description": "Online tool to audit a webpage for performance and front-end quality issues",
   "repository": {
     "type": "git",
     "url": "git://github.com/gmetais/YellowLabTools.git"
@@ -16,10 +17,13 @@
     "cors": "^2.5.2",
     "debug": "~2.1.0",
     "express": "~4.10.6",
-    "phantomas": "1.8.0",
+    "lwip": "0.0.6",
+    "meow": "^3.0.0",
+    "phantomas": "1.9.0",
     "ps-node": "0.0.3",
     "q": "~1.1.2",
-    "rimraf": "~2.2.8"
+    "rimraf": "~2.2.8",
+    "temporary": "0.0.8"
   },
   "devDependencies": {
     "chai": "^1.10.0",
@@ -44,7 +48,6 @@
     "grunt-usemin": "^3.0.0",
     "matchdep": "^0.3.0",
     "mocha": "^2.1.0",
-    "phantomjs": "^1.9.13",
     "request": "^2.51.0",
     "sinon": "^1.12.1",
     "sinon-chai": "^2.6.0"

BIN
screenshot.png


+ 46 - 2
test/api/apiTest.js

@@ -16,6 +16,7 @@ describe('api', function() {
 
     var syncRunResultUrl;
     var asyncRunId;
+    var screenshotUrl;
 
 
     it('should refuse a query with an invalid key', function(done) {
@@ -94,7 +95,8 @@ describe('api', function() {
             url: serverUrl + '/api/runs',
             body: {
                 url: wwwUrl + '/simple-page.html',
-                waitForResponse: true
+                waitForResponse: true,
+                screenshot: true
             },
             json: true,
             headers: {
@@ -131,7 +133,6 @@ describe('api', function() {
         }, function(error, response, body) {
             if (!error && response.statusCode === 302) {
 
-                console.log(response.headers.location);
                 response.headers.should.have.a.property('location').that.is.a('string');
                 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('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();
 
             } 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 resultsDatastore = require('../../lib/server/datastores/resultsDatastore');
 
+var fs = require('fs');
+var path = require('path');
+
 describe('resultsDatastore', function() {
     
     var datastore = new resultsDatastore();
@@ -71,4 +74,48 @@ describe('resultsDatastore', function() {
                 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 runsQueue = require('../../lib/server/datastores/runsQueue.js');
+var runsQueue = require('../../lib/server/datastores/runsQueue');
 
 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);
+            });
+    });
+});

+ 403 - 9
test/core/customPoliciesTest.js

@@ -4,10 +4,362 @@ var rulesChecker = require('../../lib/rulesChecker');
 describe('rulesChecker', function() {
     
     var policies = require('../../lib/metadata/policies.js');
+    var results;
 
  
+    it('should transform DOMelementMaxDepth offenders', function() {
+        results = rulesChecker.check({
+            "toolsResults": {
+                "phantomas": {
+                    "metrics": {
+                        "DOMelementMaxDepth": 3
+                    },
+                    "offenders": {
+                        "DOMelementMaxDepth": [
+                            "body > div#foo > span.bar"
+                        ]
+                    }
+                }
+            }
+        }, policies);
+
+        results.should.have.a.property('DOMelementMaxDepth');
+        results.DOMelementMaxDepth.should.have.a.property('offendersObj').that.deep.equals({
+            "count": 1,
+            "tree": {
+                "body": {
+                    "div#foo": {
+                        "span.bar": 1
+                    }
+                }
+            }
+        });
+    });
+
+
+    it('should transform DOMidDuplicated offenders', function() {
+        results = rulesChecker.check({
+            "toolsResults": {
+                "phantomas": {
+                    "metrics": {
+                        "DOMidDuplicated": 2
+                    },
+                    "offenders": {
+                        "DOMidDuplicated": [
+                            "colorswitch-30883-30865: 4 occurrences",
+                            "foo: 1 occurrences"
+                        ]
+                    }
+                }
+            }
+        }, policies);
+
+        results.should.have.a.property('DOMidDuplicated');
+        results.DOMidDuplicated.should.have.a.property('offendersObj').that.deep.equals({
+            "count": 2,
+            "list": [
+                {
+                    "id": "colorswitch-30883-30865",
+                    "occurrences": 4
+                },
+                {
+                    "id": "foo",
+                    "occurrences": 1
+                }
+            ]
+        });
+    });
+
+    
+    it('should transform DOMinserts offenders', function() {
+        results = rulesChecker.check({
+            "toolsResults": {
+                "phantomas": {
+                    "metrics": {
+                        "DOMinserts": 4
+                    },
+                    "offenders": {
+                        "DOMinserts": [
+                            "\"div\" appended to \"html\"",
+                            "\"DocumentFragment > link[0]\" appended to \"head\"",
+                            "\"div#Netaff-yh1XbS0vK3NaRGu\" appended to \"body > div#Global\"",
+                            "\"img\" appended to \"body\""
+                        ]
+                    }
+                }
+            }
+        }, policies);
+
+        results.should.have.a.property('DOMinserts');
+        results.DOMinserts.should.have.a.property('offendersObj').that.deep.equals({
+            "count": 4,
+            "list": [
+                {
+                    "insertedElement": {
+                        "type": "createdElement",
+                        "element": "div"
+                    },
+                    "receiverElement": {
+                        "type": "html"
+                    }
+                },
+                {
+                    "insertedElement": {
+                        "type": "fragmentElement",
+                        "element": "link[0]",
+                        "tree": {
+                            "DocumentFragment": {
+                                "link[0]": 1
+                            }
+                        }
+                    },
+                    "receiverElement": {
+                        "type": "head"
+                    }
+                },
+                {
+                    "insertedElement": {
+                        "type": "createdElement",
+                        "element": "div#Netaff-yh1XbS0vK3NaRGu"
+                    },
+                    "receiverElement": {
+                        "type": "domElement",
+                        "element": "div#Global",
+                        "tree": {
+                            "body": {
+                                "div#Global": 1
+                            }
+                        }
+                    }
+                },
+                {
+                    "insertedElement": {
+                        "type": "createdElement",
+                        "element": "img"
+                    },
+                    "receiverElement": {
+                        "type": "body"
+                    }
+                }
+            ]
+        });
+    });
+
+    
+    it('should transform DOMqueriesWithoutResults offenders', function() {
+        results = rulesChecker.check({
+            "toolsResults": {
+                "phantomas": {
+                    "metrics": {
+                        "DOMqueriesWithoutResults": 2
+                    },
+                    "offenders": {
+                        "DOMqueriesWithoutResults": [
+                            "#SearchMenu (in #document) using getElementById",
+                            ".partnership-link (in body > div#Global > div#Header > ul#MainMenu) using getElementsByClassName"
+                        ]
+                    }
+                }
+            }
+        }, policies);
+
+        results.should.have.a.property('DOMqueriesWithoutResults');
+        results.DOMqueriesWithoutResults.should.have.a.property('offendersObj').that.deep.equals({
+            "count": 2,
+            "list": [
+                {
+                    "context": {
+                        "type": "document"
+                    },
+                    "fn": "getElementById",
+                    "query": "#SearchMenu "
+                },
+                {
+                    "context": {
+                        "element": "ul#MainMenu",
+                        "tree": {
+                            "body": {
+                                "div#Global": {
+                                    "div#Header": {
+                                        "ul#MainMenu": 1
+                                    }
+                                }
+                            }
+                        },
+                        "type": "domElement"
+                    },
+                    "fn": "getElementsByClassName",
+                    "query": ".partnership-link "
+                }
+            ]
+        });
+    });
+
+
+    it('should transform DOMqueriesAvoidable offenders', function() {
+        results = rulesChecker.check({
+            "toolsResults": {
+                "phantomas": {
+                    "metrics": {
+                        "DOMqueriesAvoidable": 2
+                    },
+                    "offenders": {
+                        "DOMqueriesDuplicated": [
+                            "id \"#j2t-top-cart\" with getElementById (in context #document): 4 queries",
+                            "class \".listingResult\" with getElementsByClassName (in context body > div#Global > div#Listing): 4 queries"
+                        ]
+                    }
+                }
+            }
+        }, policies);
+
+        results.should.have.a.property('DOMqueriesAvoidable');
+        results.DOMqueriesAvoidable.should.have.a.property('offendersObj').that.deep.equals({
+            "count": 2,
+            "list": [
+                {
+                    "query": "#j2t-top-cart",
+                    "context": {
+                        "type": "document"
+                    },
+                    "fn": "getElementById ",
+                    "count": 4
+                },
+                {
+                    "query": ".listingResult",
+                    "context": {
+                        "type": "domElement",
+                        "element": "div#Listing",
+                        "tree": {
+                            "body": {
+                                "div#Global": {
+                                    "div#Listing": 1
+                                }
+                            }
+                        }
+                    },
+                    "fn": "getElementsByClassName ",
+                    "count": 4
+                }
+            ]
+        });
+    });
+
+
+    it('should transform eventsBound offenders', function() {
+        results = rulesChecker.check({
+            "toolsResults": {
+                "phantomas": {
+                    "metrics": {
+                        "eventsBound": 2
+                    },
+                    "offenders": {
+                        "eventsBound": [
+                            "\"DOMContentLoaded\" bound to \"#document\"",
+                            "\"unload\" bound to \"window\"",
+                            "\"submit\" bound to \"body > div#Global > div#Header > form#search_mini_form\""
+                        ]
+                    }
+                }
+            }
+        }, policies);
+
+        results.should.have.a.property('eventsBound');
+        results.eventsBound.should.have.a.property('offendersObj').that.deep.equals({
+            "count": 3,
+            "list": [
+                {
+                    "element": {
+                        "type": "document"
+                    },
+                    "eventName": "DOMContentLoaded"
+                },
+                {
+                    "element": {
+                        "type": "window"
+                    },
+                    "eventName": "unload"
+                },
+                {
+                    "element": {
+                        "element": "form#search_mini_form",
+                        "tree": {
+                            "body": {
+                                "div#Global": {
+                                    "div#Header": {
+                                        "form#search_mini_form": 1
+                                    }
+                                }
+                            }
+                        },
+                        "type": "domElement"
+                    },
+                    "eventName": "submit"
+                }
+            ]
+        });
+    });
+
+
+    it('should transform jsErrors offenders', function() {
+        results = rulesChecker.check({
+            "toolsResults": {
+                "phantomas": {
+                    "metrics": {
+                        "jsErrors": 2
+                    },
+                    "offenders": {
+                        "jsErrors": [
+                            "TypeError: 'undefined' is not a function (evaluating 'this.successfullyCollected.bind(this)') - http://asset.easydmp.net/js/collect.js:1160 / callCollecte http://asset.easydmp.net/js/collect.js:1203 / callbackUpdateParams http://asset.easydmp.net/js/collect.js:1135 / http://asset.easydmp.net/js/collect.js:1191",
+                            "TypeError: 'undefined' is not an object (evaluating 'd.readyState') - http://me.hunkal.com/p/:3"
+                        ]
+                    }
+                }
+            }
+        }, policies);
+
+        results.should.have.a.property('jsErrors');
+        results.jsErrors.should.have.a.property('offendersObj').that.deep.equals({
+            "count": 2,
+            "list": [
+                {
+                    "error": "TypeError: 'undefined' is not a function (evaluating 'this.successfullyCollected.bind(this)')",
+                    "backtrace": [
+                        {
+                            "file": "http://asset.easydmp.net/js/collect.js",
+                            "line": 1160
+                        },
+                        {
+                            "file": "http://asset.easydmp.net/js/collect.js",
+                            "line": 1203,
+                            "functionName": "callCollecte"
+                        },
+                        {
+                            "file": "http://asset.easydmp.net/js/collect.js",
+                            "line": 1135,
+                            "functionName": "callbackUpdateParams"
+                        },
+                        {
+                            "file": "http://asset.easydmp.net/js/collect.js",
+                            "line": 1191
+                        }
+                    ]
+                },
+                {
+                    "error": "TypeError: 'undefined' is not an object (evaluating 'd.readyState')",
+                    "backtrace": [
+                        {
+                            "file": "http://me.hunkal.com/p/",
+                            "line": 3
+                        }
+                    ]
+                }
+            ]
+        });
+    });
+
+
     it('should grade correctly jQuery versions', function() {
-        
 
         var versions = {
             '1.2.9': 0,
@@ -52,20 +404,20 @@ describe('rulesChecker', function() {
         results.should.deep.equals({});
 
 
-        // If jQueryDifferentVersions is 0
+        // If jQueryVersionsLoaded is 0
         results = rulesChecker.check({
             "toolsResults": {
                 "phantomas": {
                     "metrics": {
                         "jQueryVersion": "1.6.0",
-                        "jQueryDifferentVersions": 0
+                        "jQueryVersionsLoaded": 0
                     }
                 }
             }
         }, policies);
         results.should.not.have.a.property('jQueryVersion');
-        results.should.have.a.property('jQueryDifferentVersions');
-        results.jQueryDifferentVersions.should.have.a.property('score').that.equals(100);
+        results.should.have.a.property('jQueryVersionsLoaded');
+        results.jQueryVersionsLoaded.should.have.a.property('score').that.equals(100);
 
 
         // If there are more than 1 jQuery version
@@ -74,14 +426,56 @@ describe('rulesChecker', function() {
                 "phantomas": {
                     "metrics": {
                         "jQueryVersion": "1.6.0",
-                        "jQueryDifferentVersions": 2
+                        "jQueryVersionsLoaded": 2
                     }
                 }
             }
         }, policies);
         results.should.not.have.a.property('jQueryVersion');
-        results.should.have.a.property('jQueryDifferentVersions');
-        results.jQueryDifferentVersions.should.have.a.property('score').that.equals(0);
-        results.jQueryDifferentVersions.should.have.a.property('abnormal').that.equals(true);
+        results.should.have.a.property('jQueryVersionsLoaded');
+        results.jQueryVersionsLoaded.should.have.a.property('score').that.equals(0);
+        results.jQueryVersionsLoaded.should.have.a.property('abnormal').that.equals(true);
     });
+
+
+    it('should transform cssParsingErrors offenders', function() {
+        results = rulesChecker.check({
+            "toolsResults": {
+                "phantomas": {
+                    "metrics": {
+                        "cssParsingErrors": 2
+                    },
+                    "offenders": {
+                        "cssParsingErrors": [
+                            "<http://www.sudexpress.com/skin/frontend/sudexpress/default/css/styles.css> (Error: CSS parsing failed: missing '}' @ 4:1)",
+                            "<http://www.sudexpress.com/skin/frontend/sudexpress/default/css/reset.css> (Empty CSS was provided)"
+                        ]
+                    }
+                }
+            }
+        }, policies);
+
+        results.should.have.a.property('cssParsingErrors');
+        results.cssParsingErrors.should.have.a.property('offendersObj').that.deep.equals({
+            "count": 2,
+            "list": [
+                {
+                    "error": "Error: CSS parsing failed: missing '}'",
+                    "file": "http://www.sudexpress.com/skin/frontend/sudexpress/default/css/styles.css",
+                    "line": 4,
+                    "column": 1
+                },
+                {
+                    "error": "Empty CSS was provided",
+                    "file": "http://www.sudexpress.com/skin/frontend/sudexpress/default/css/reset.css",
+                    "line": null,
+                    "column": null
+                }
+            ]
+        });
+    });
+
+    
+    // Enough for the moment, to be complete...
+
 });

+ 32 - 2
test/core/indexTest.js

@@ -2,6 +2,8 @@ var chai                = require('chai');
 var sinon               = require('sinon');
 var sinonChai           = require('sinon-chai');
 var should              = chai.should();
+var path                = require('path');
+var fs                  = require('fs');
 var ylt                 = require('../../lib/index');
 
 chai.use(sinonChai);
@@ -65,14 +67,22 @@ describe('index.js', function() {
                         "message": "<p>A deep DOM makes the CSS matching with DOM elements difficult.</p><p>It also slows down JavaScript modifications to the DOM because changing the dimensions of an element makes the browser re-calculate the dimensions of it's parents. Same thing for JavaScript events, that bubble up to the document root.</p>",
                         "isOkThreshold": 10,
                         "isBadThreshold": 20,
-                        "isAbnormalThreshold": 28
+                        "isAbnormalThreshold": 28,
+                        "hasOffenders": true
                     },
                     "value": 1,
                     "bad": false,
                     "abnormal": false,
                     "score": 100,
                     "abnormalityScore": 0,
-                    "offenders": ["body > h1[1]"]
+                    "offendersObj": {
+                        "count": 1,
+                        "tree": {
+                            "body": {
+                                "h1[1]": 1
+                            }
+                        }
+                    }
                 });
 
                 // Test javascriptExecutionTree
@@ -91,4 +101,24 @@ describe('index.js', function() {
             });
     });
 
+    it('should take a screenshot', function(done) {
+        this.timeout(15000);
+
+        var url = 'http://localhost:8388/simple-page.html';
+        var screenshotPath = path.join(__dirname, '../../.tmp/indexTestScreenshot.png');
+
+        ylt(url, {screenshot: screenshotPath})
+            .then(function(data) {
+
+                data.params.options.should.have.a.property('screenshot').that.equals(screenshotPath);
+                data.should.not.have.a.property('screenshotUrl');
+
+                fs.existsSync(screenshotPath).should.equal(true);
+
+                done();
+            }).fail(function(err) {
+                done(err);
+            });
+    });
+
 });

+ 283 - 0
test/core/offendersHelpersTest.js

@@ -0,0 +1,283 @@
+var should = require('chai').should();
+var offendersHelpers = require('../../lib/offendersHelpers');
+
+describe('offendersHelpers', function() {
+    
+    describe('domPathToArray', function() {
+
+        it('should transform a path to an array', function() {
+            var result = offendersHelpers.domPathToArray('body > section#page > div.alternate-color > ul.retroGuide > li[0] > div.retro-chaine.france2');
+            result.should.deep.equal(['body', 'section#page', 'div.alternate-color', 'ul.retroGuide', 'li[0]', 'div.retro-chaine.france2']);
+        });
+
+        it('should work even if a space is missing', function() {
+           var result = offendersHelpers.domPathToArray('body > section#page> div.alternate-color > ul.retroGuide >li[0] > div.retro-chaine.france2');
+            result.should.deep.equal(['body', 'section#page', 'div.alternate-color', 'ul.retroGuide', 'li[0]', 'div.retro-chaine.france2']); 
+        });
+
+    });
+
+    describe('listOfDomArraysToTree', function() {
+
+        it('should transform a list of arrays into a tree', function() {
+            var input = [
+                ['body', 'section#page', 'div.alternate-color', 'ul.retroGuide', 'li[0]', 'div.retro-chaine.france2'],
+                ['body', 'section#page', 'div.alternate-color', 'ul.retroGuide', 'li[0]', 'div.retro-chaine.france2'],
+                ['body', 'section#page', 'div.alternate-color', 'ul.retroGuide', 'li[1]', 'div.retro-chaine.france2']
+            ];
+
+            var inputClone = input.slice();
+
+            var result = offendersHelpers.listOfDomArraysToTree(input);
+            result.should.deep.equal({
+                'body': {
+                    'section#page': {
+                        'div.alternate-color': {
+                            'ul.retroGuide': {
+                                'li[0]': {
+                                    'div.retro-chaine.france2': 2
+                                },
+                                'li[1]': {
+                                    'div.retro-chaine.france2': 1
+                                }
+                            }
+                        }
+                    }
+                }
+            });
+
+            input.should.deep.equal(inputClone);
+        });
+
+    });
+
+    describe('domPathToDomElementObj', function() {
+
+        it('should transform html', function() {
+            var result = offendersHelpers.domPathToDomElementObj('html');
+            result.should.deep.equal({
+                type: 'html'
+            });
+        });
+
+        it('should transform body', function() {
+            var result = offendersHelpers.domPathToDomElementObj('body');
+            result.should.deep.equal({
+                type: 'body'
+            });
+        });
+
+        it('should transform head', function() {
+            var result = offendersHelpers.domPathToDomElementObj('head');
+            result.should.deep.equal({
+                type: 'head'
+            });
+        });
+
+        it('should transform #document', function() {
+            var result = offendersHelpers.domPathToDomElementObj('#document');
+            result.should.deep.equal({
+                type: 'document'
+            });
+        });
+
+        it('should transform window', function() {
+            var result = offendersHelpers.domPathToDomElementObj('window');
+            result.should.deep.equal({
+                type: 'window'
+            });
+        });
+
+        it('should transform a standard in-body element', function() {
+            var result = offendersHelpers.domPathToDomElementObj('body > div#colorbox > div#cboxContent');
+            result.should.deep.equal({
+                type: 'domElement',
+                element: 'div#cboxContent',
+                tree: {
+                    'body': {
+                        'div#colorbox': {
+                            'div#cboxContent': 1
+                        }
+                    }
+                }
+            });
+        });
+
+        it('should transform a domFragment element', function() {
+            var result = offendersHelpers.domPathToDomElementObj('DocumentFragment');
+            result.should.deep.equal({
+                type: 'fragment'
+            });
+        });
+
+        it('should transform a domFragment element', function() {
+            var result = offendersHelpers.domPathToDomElementObj('DocumentFragment > div#colorbox > div#cboxContent');
+            result.should.deep.equal({
+                type: 'fragmentElement',
+                element: 'div#cboxContent',
+                tree: {
+                    'DocumentFragment': {
+                        'div#colorbox': {
+                            'div#cboxContent': 1
+                        }
+                    }
+                }
+            });
+        });
+
+        it('should transform an not-attached element', function() {
+            var result = offendersHelpers.domPathToDomElementObj('div#sizcache');
+            result.should.deep.equal({
+                type: 'createdElement',
+                element: 'div#sizcache'
+            });
+        });
+
+        it('should transform an not-attached element path', function() {
+            var result = offendersHelpers.domPathToDomElementObj('div > div#sizcache');
+            result.should.deep.equal({
+                type: 'createdElement',
+                element: 'div#sizcache',
+                tree: {
+                    'div': {
+                        'div#sizcache': 1
+                    }
+                }
+            });
+        });
+
+    });
+
+    describe('backtraceToArray', function() {
+
+        it('should transform a backtrace into an array', function() {
+            var result = offendersHelpers.backtraceToArray('http://pouet.com/js/jquery.footer-transverse-min-v1.0.20.js:1 / callback http://pouet.com/js/main.js:1');
+
+            result.should.deep.equal([
+                {
+                    file: 'http://pouet.com/js/jquery.footer-transverse-min-v1.0.20.js',
+                    line: 1
+                },
+                {
+                    functionName: 'callback',
+                    file: 'http://pouet.com/js/main.js',
+                    line: 1
+                }
+            ]);
+        });
+
+        it('should return null if it fails', function() {
+            var result = offendersHelpers.backtraceToArray('http://pouet.com/js/jquery.footer-transverse-min-v1.0.20.js:1 /http://pouet.com/js/main.js:1');
+
+            should.equal(result, null);
+        });
+
+    });
+
+    describe('sortVarsLikeChromeDevTools', function() {
+
+        it('should sort in the same strange order', function() {
+            var result = offendersHelpers.sortVarsLikeChromeDevTools([
+                'a',
+                'aaa',
+                'a2',
+                'b',
+                'A',
+                'AAA',
+                'B',
+                '_a',
+                '_aaa',
+                '__a',
+                'a_a',
+                'aA',
+                'a__',
+                '$',
+                '$a'
+            ]);
+
+            result.should.deep.equal([
+                '$',
+                '$a',
+                'A',
+                'AAA',
+                'B',
+                '__a',
+                '_a',
+                '_aaa',
+                'a',
+                'a2',
+                'aA',
+                'a__',
+                'a_a',
+                'aaa',
+                'b'
+            ]);
+        });
+
+    });
+
+    describe('urlToLink', function() {
+
+        it('should transform an url into an html link', function() {
+            var result = offendersHelpers.urlToLink('http://www.google.com/js/main.js');
+
+            result.should.equal('<a href="http://www.google.com/js/main.js" target="_blank" title="http://www.google.com/js/main.js">http://www.google.com/js/main.js</a>');
+        });
+
+        it('should ellypsis the url if too long', function() {
+            var result = offendersHelpers.urlToLink('http://www.google.com/js/longlonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglong/main.js');
+
+            result.should.equal('<a href="http://www.google.com/js/longlonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglong/main.js" target="_blank" title="http://www.google.com/js/longlonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglong/main.js">http://www.google.com/js/longlonglonglonglonglo ... longlonglonglonglonglonglonglonglonglong/main.js</a>');
+        });
+
+    });
+
+
+    describe('cssOffenderPattern', function() {
+
+        it('should transform a css offender into an object', function() {
+            var result = offendersHelpers.cssOffenderPattern('.pagination .plus ul li <http://www.pouet.com/css/main.css> @ 30:31862');
+
+            result.should.deep.equal({
+                css: '.pagination .plus ul li',
+                file: 'http://www.pouet.com/css/main.css',
+                line: 30,
+                column: 31862
+            });
+        });
+
+        it('should work with an inline css', function() {
+            var result = offendersHelpers.cssOffenderPattern('.pagination .plus ul li [inline CSS] @ 1:32');
+
+            result.should.deep.equal({
+                css: '.pagination .plus ul li',
+                file: null,
+                line: 1,
+                column: 32
+            });
+        });
+
+        it('should handle the case where line and char are not here', function() {
+            var result = offendersHelpers.cssOffenderPattern('.pagination .plus ul li');
+
+            result.should.deep.equal({
+                offender: '.pagination .plus ul li'
+            });
+        });
+
+    });
+
+    describe('fileWithSizePattern', function() {
+
+        it('should return an object', function() {
+            var result = offendersHelpers.fileWithSizePattern('http://img3.pouet.com/2008/portail/js/jq-timer.js (1.72 kB)');
+
+            result.should.deep.equal({
+                file: 'http://img3.pouet.com/2008/portail/js/jq-timer.js',
+                size: 1.72
+            });
+        });
+
+    });
+
+});

+ 1 - 1
test/core/rulesCheckerTest.js

@@ -9,7 +9,7 @@ describe('rulesChecker', function() {
     
     it('should produce a nice rules object', function() {
         var data = require('../fixtures/rulesCheckerInput.json');
-        var policies = require('../fixtures/rulesCheckerPolicies.json');
+        var policies = require('../fixtures/rulesCheckerPolicies');
         var expected = require('../fixtures/rulesCheckerOutput.json');
 
         var results = rulesChecker.check(data, policies);

BIN
test/fixtures/logo-large.png


+ 26 - 8
test/fixtures/rulesCheckerOutput.json

@@ -6,7 +6,8 @@
             "message": "A great message",
             "isOkThreshold": 1000,
             "isBadThreshold": 3000,
-            "isAbnormalThreshold": 5000
+            "isAbnormalThreshold": 5000,
+            "hasOffenders": false
         },
         "value": 1236,
         "bad": true,
@@ -22,10 +23,14 @@
             "isOkThreshold": 1000,
             "isBadThreshold": 3000,
             "isAbnormalThreshold": 5000,
+            "hasOffenders": true,
             "takeOffendersFrom": "metric3"
         },
         "value": 222,
-        "offenders": ["offender1", "offender2"],
+        "offendersObj": {
+            "count": 2,
+            "str": "offender1 - offender2"
+        },
         "bad": false,
         "abnormal": false,
         "score": 100,
@@ -38,10 +43,14 @@
             "message": "A great message",
             "isOkThreshold": 1000,
             "isBadThreshold": 3000,
-            "isAbnormalThreshold": 5000
+            "isAbnormalThreshold": 5000,
+            "hasOffenders": true
         },
         "value": 6666,
-        "offenders": ["offender1", "offender2"],
+        "offendersObj": {
+            "count": 2,
+            "test": "offender1/offender2"
+        },
         "bad": true,
         "abnormal": true,
         "score": 0,
@@ -54,10 +63,14 @@
             "message": "A great message",
             "isOkThreshold": 1000,
             "isBadThreshold": 3000,
-            "isAbnormalThreshold": 5000
+            "isAbnormalThreshold": 5000,
+            "hasOffenders": true
         },
         "value": 1000,
-        "offenders": ["offender3"],
+        "offendersObj": {
+            "count": 1,
+            "list": ["offender3"]
+        },
         "bad": false,
         "abnormal": false,
         "score": 100,
@@ -71,10 +84,14 @@
             "isOkThreshold": 1000,
             "isBadThreshold": 3000,
             "isAbnormalThreshold": 5000,
+            "hasOffenders": true,
             "takeOffendersFrom": ["metric3", "metric4"]
         },
         "value": 3000,
-        "offenders": ["offender1", "offender2", "offender3"],
+        "offendersObj": {
+            "count": 3,
+            "list": ["offender1", "offender2", "offender3"]
+        },
         "bad": true,
         "abnormal": false,
         "score": 0,
@@ -118,7 +135,8 @@
             "message": "<p>This is from another tool!</p>",
             "isOkThreshold": 0,
             "isBadThreshold": 3,
-            "isAbnormalThreshold": 11
+            "isAbnormalThreshold": 11,
+            "hasOffenders": false
         },
         "value": 22,
         "bad": true,

+ 32 - 9
test/fixtures/rulesCheckerPolicies.json → test/fixtures/rulesCheckerPolicies.js

@@ -1,11 +1,13 @@
-{
+var policies = {
+
     "metric1": {
         "tool": "tool1",
         "label": "The metric 1",
         "message": "A great message",
         "isOkThreshold": 1000,
         "isBadThreshold": 3000,
-        "isAbnormalThreshold": 5000
+        "isAbnormalThreshold": 5000,
+        "hasOffenders": false
     },
     "metric2": {
         "tool": "tool1",
@@ -14,7 +16,14 @@
         "isOkThreshold": 1000,
         "isBadThreshold": 3000,
         "isAbnormalThreshold": 5000,
-        "takeOffendersFrom": "metric3"
+        "takeOffendersFrom": "metric3",
+        "hasOffenders": true,
+        "offendersTransformFn": function(offenders) {
+            return {
+                count: 2,
+                str: offenders.join(' - ')
+            };
+        }
     },
     "metric3": {
         "tool": "tool1",
@@ -22,7 +31,14 @@
         "message": "A great message",
         "isOkThreshold": 1000,
         "isBadThreshold": 3000,
-        "isAbnormalThreshold": 5000
+        "isAbnormalThreshold": 5000,
+        "hasOffenders": true,
+        "offendersTransformFn": function(offenders) {
+            return {
+                count: 2,
+                test: offenders.join('/')
+            };
+        }
     },
     "metric4": {
         "tool": "tool1",
@@ -30,7 +46,8 @@
         "message": "A great message",
         "isOkThreshold": 1000,
         "isBadThreshold": 3000,
-        "isAbnormalThreshold": 5000
+        "isAbnormalThreshold": 5000,
+        "hasOffenders": true,
     },
     "metric5": {
         "tool": "tool1",
@@ -39,6 +56,7 @@
         "isOkThreshold": 1000,
         "isBadThreshold": 3000,
         "isAbnormalThreshold": 5000,
+        "hasOffenders": true,
         "takeOffendersFrom": ["metric3", "metric4"]
     },
     "metric6": {
@@ -64,7 +82,8 @@
         "message": "<p>This is from another tool!</p>",
         "isOkThreshold": 0,
         "isBadThreshold": 3,
-        "isAbnormalThreshold": 11
+        "isAbnormalThreshold": 11,
+        "hasOffenders": false,
     },
 
     "unexistantMetric": {
@@ -73,12 +92,16 @@
         "message": "",
         "isOkThreshold": 1000,
         "isBadThreshold": 3000,
-        "isAbnormalThreshold": 5000
+        "isAbnormalThreshold": 5000,
+        "hasOffenders": true
     },
     "unexistantTool": {
         "tool": "unexistant",
         "isOkThreshold": 1000,
         "isBadThreshold": 3000,
-        "isAbnormalThreshold": 5000
+        "isAbnormalThreshold": 5000,
+        "hasOffenders": false
     }
-}
+};
+
+module.exports = policies;

Some files were not shown because too many files changed in this diff