diff --git a/Gruntfile.js b/Gruntfile.js index 197fe33..9525960 100644 --- a/Gruntfile.js +++ b/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' ]); diff --git a/bin/cli.js b/bin/cli.js index 6d815cf..a9e3c5d 100644 --- a/bin/cli.js +++ b/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 ', + '', + '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 \n'); +if (cli.input.length < 1) { + console.error('Incorrect parameters: url not provided'); process.exit(1); } -var url = process.argv[2]; +var url = cli.input[0]; -(function execute(url) { +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; +} + + + +(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); \ No newline at end of file +})(url, options); \ No newline at end of file diff --git a/bower.json b/bower.json index 8609ca8..eb2c5d9 100644 --- a/bower.json +++ b/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" } } diff --git a/front/src/css/dashboard.css b/front/src/css/dashboard.css index 407887b..41e9467 100644 --- a/front/src/css/dashboard.css +++ b/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; diff --git a/front/src/css/index.css b/front/src/css/index.css index d22b867..f94ce7c 100644 --- a/front/src/css/index.css +++ b/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; diff --git a/front/src/css/main.css b/front/src/css/main.css index 8cab1bf..dea34f0 100644 --- a/front/src/css/main.css +++ b/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; } diff --git a/front/src/css/queue.css b/front/src/css/queue.css index e847481..094ee24 100644 --- a/front/src/css/queue.css +++ b/front/src/css/queue.css @@ -6,6 +6,3 @@ font-size: 0.8em; margin-bottom: 6em; } -.queueLink { - color: #FFF; -} diff --git a/front/src/css/rule.css b/front/src/css/rule.css index 9fea690..2a2dce0 100644 --- a/front/src/css/rule.css +++ b/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; +} diff --git a/front/src/css/screenshot.css b/front/src/css/screenshot.css new file mode 100644 index 0000000..55af543 --- /dev/null +++ b/front/src/css/screenshot.css @@ -0,0 +1,7 @@ +.screenshot.board { + text-align: center; +} +.screenshot .screenshotWrapper { + font-size: 2.08333333333333em; + margin-bottom: 0.5em; +} diff --git a/front/src/js/app.js b/front/src/js/app.js index a7f73d3..49e1125 100644 --- a/front/src/js/app.js +++ b/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' diff --git a/front/src/js/controllers/aboutCtrl.js b/front/src/js/controllers/aboutCtrl.js deleted file mode 100644 index 11957f9..0000000 --- a/front/src/js/controllers/aboutCtrl.js +++ /dev/null @@ -1,5 +0,0 @@ -var aboutCtrl = angular.module('aboutCtrl', []); - -aboutCtrl.controller('AboutCtrl', ['$scope', function($scope) { - $scope.about = "this is about YLT"; -}]); \ No newline at end of file diff --git a/front/src/js/controllers/dashboardCtrl.js b/front/src/js/controllers/dashboardCtrl.js index c1b2ba1..d693e7f 100644 --- a/front/src/js/controllers/dashboardCtrl.js +++ b/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. diff --git a/front/src/js/controllers/indexCtrl.js b/front/src/js/controllers/indexCtrl.js index bec1da3..f9426fa 100644 --- a/front/src/js/controllers/indexCtrl.js +++ b/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); } }; }]); \ No newline at end of file diff --git a/front/src/js/controllers/queueCtrl.js b/front/src/js/controllers/queueCtrl.js index 909b96f..17a3820 100644 --- a/front/src/js/controllers/queueCtrl.js +++ b/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; + } }); } diff --git a/front/src/js/controllers/ruleCtrl.js b/front/src/js/controllers/ruleCtrl.js index 63fa2ef..d5aec07 100644 --- a/front/src/js/controllers/ruleCtrl.js +++ b/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(); diff --git a/front/src/js/controllers/screenshotCtrl.js b/front/src/js/controllers/screenshotCtrl.js new file mode 100644 index 0000000..1120d9c --- /dev/null +++ b/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(); +}]); \ No newline at end of file diff --git a/front/src/js/controllers/timelineCtrl.js b/front/src/js/controllers/timelineCtrl.js index da813f9..6ea6a0c 100644 --- a/front/src/js/controllers/timelineCtrl.js +++ b/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(); diff --git a/front/src/js/directives/offendersDirectives.js b/front/src/js/directives/offendersDirectives.js new file mode 100644 index 0000000..cb691d0 --- /dev/null +++ b/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: '
', + 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 += '
' + key + '' + recursiveHtmlBuilder(tree[key]) + '
'; + } else if (tree[key] > 1) { + html += '
' + key + ' (x' + tree[key] + ')
'; + } else { + html += '
' + key + '
'; + } + }); + + 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: '{{url | shortenUrl:maxLength}}', + replace: true + }; +}); + +offendersDirectives.filter('encodeURIComponent', function() { + return window.encodeURIComponent; +}); + +offendersDirectives.directive('fileAndLine', function() { + return { + restrict: 'E', + scope: { + file: '=', + line: '=', + column: '=' + }, + template: '<inline CSS> @ {{line}}:{{column}}', + replace: true + }; +}); + +offendersDirectives.directive('fileAndLineButton', function() { + return { + restrict: 'E', + scope: { + file: '=', + line: '=', + column: '=' + }, + template: '
css file
', + 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(); + }); + } + }; +}); \ No newline at end of file diff --git a/front/src/js/services/apiService.js b/front/src/js/services/apiService.js new file mode 100644 index 0000000..27aaac9 --- /dev/null +++ b/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...'); + } + }); + } + + + }; + +}]); \ No newline at end of file diff --git a/front/src/less/dashboard.less b/front/src/less/dashboard.less index 9813f0e..379babd 100644 --- a/front/src/less/dashboard.less +++ b/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 { diff --git a/front/src/less/index.less b/front/src/less/index.less index 5bbd573..3092c69 100644 --- a/front/src/less/index.less +++ b/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; diff --git a/front/src/less/main.less b/front/src/less/main.less index ae2cc21..a22bb81 100644 --- a/front/src/less/main.less +++ b/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; diff --git a/front/src/less/queue.less b/front/src/less/queue.less index dc1181b..f8d2cb2 100644 --- a/front/src/less/queue.less +++ b/front/src/less/queue.less @@ -6,8 +6,4 @@ .statusSubMessage { font-size: 0.8em; margin-bottom: 6em; -} - -.queueLink { - color: #FFF; } \ No newline at end of file diff --git a/front/src/less/rule.less b/front/src/less/rule.less index a12463a..ce68297 100644 --- a/front/src/less/rule.less +++ b/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; + } + } } \ No newline at end of file diff --git a/front/src/less/screenshot.less b/front/src/less/screenshot.less new file mode 100644 index 0000000..a214659 --- /dev/null +++ b/front/src/less/screenshot.less @@ -0,0 +1,8 @@ +.screenshot.board { + text-align: center; +} + +.screenshot .screenshotWrapper { + font-size: 2.08333333333333em; + margin-bottom: 0.5em; +} \ No newline at end of file diff --git a/front/src/main.html b/front/src/main.html index d966016..1146a0b 100644 --- a/front/src/main.html +++ b/front/src/main.html @@ -5,6 +5,7 @@ + @@ -12,6 +13,7 @@ + @@ -20,17 +22,20 @@ + - + + + diff --git a/front/src/views/dashboard.html b/front/src/views/dashboard.html index e95421d..ab85f9f 100644 --- a/front/src/views/dashboard.html +++ b/front/src/views/dashboard.html @@ -2,10 +2,21 @@
-

Global score

-
- -
{{globalScore}}/100
+
+

Global score

+
+ +
{{globalScore}}/100
+
+
+
@@ -35,7 +46,7 @@
-
+
Did you know? Yellow Lab Tools now has an API! Here is the JSON output for this run. Checkout the API doc.
@@ -49,8 +60,8 @@

Yellow Lab Tools is a free online tool that analyzes performance and front-end quality.

- See the full report for this page - Test another webpage + See the full report for this page + Test another webpage
diff --git a/front/src/views/domElementButton.html b/front/src/views/domElementButton.html new file mode 100644 index 0000000..78dda24 --- /dev/null +++ b/front/src/views/domElementButton.html @@ -0,0 +1,7 @@ +
+ {{obj.type}} + DOM element {{obj.element}} + Fragment element {{obj.element}} + Created element {{obj.element}} + +
\ No newline at end of file diff --git a/front/src/views/index.html b/front/src/views/index.html index 84ba965..d1518b7 100644 --- a/front/src/views/index.html +++ b/front/src/views/index.html @@ -3,4 +3,22 @@
-
\ No newline at end of file + + + +
+
+

Front-end quality

+

Audits quality problems in the HTML, CSS and JS

+
+ +
+

WebPerf

+

Checks if performance good practices are applied

+
+ +
+

JS Profiling

+

Untangles the JavaScript spaghetti code

+
+
\ No newline at end of file diff --git a/front/src/views/queue.html b/front/src/views/queue.html index c34d3b4..98bcbf7 100644 --- a/front/src/views/queue.html +++ b/front/src/views/queue.html @@ -1,23 +1,36 @@ -

Tested url:   {{url}}

+

Tested url:   {{url}}

-
-
Test failed
-

{{status.error}}

-

Report a bug on GitHub

-

Back to index

-
-
-
- - +
+
+
Test failed
+

{{status.error}}

+ + Report the issue on GitHub + New test +
+
+
+ + +
+

(auto-refresh activated)

+
+
+
Test is running...
+

(auto-refresh activated)

+
+
+
Test complete
+

Opening results...

-

(auto-refresh activated)

-
-
Test is running...
-

(auto-refresh activated)

+
+
Error 404 (test not found)
+

The server probably rebooted. We are very sorry about that, please try to launch the test again.

+ + New test
-
-
Test complete
-

Opening results...

+
+
Connection lost with server
+

Check your wifi cable, or maybe YellowLab.tools is rebooting.

\ No newline at end of file diff --git a/front/src/views/rule.html b/front/src/views/rule.html index ee540ce..c62e14f 100644 --- a/front/src/views/rule.html +++ b/front/src/views/rule.html @@ -10,23 +10,150 @@

Value: {{rule.value}}

-
+

Warning

This rule reached the abnormality threshold, which means there is a real problem you should care about.

-
-

- - -

-
-
-
{{offender}}
+
+

+ +
+
+
+ {{offender.parseError}} +
+
+ +
+ {{offender.id}}: {{offender.occurrences}} occurrences +
+ +
+ appended to +
+ +
+ {{offender.query}} (in ) using {{offender.fn}} +
+ +
+ {{offender.query}} (in ) using {{offender.fn}}: {{offender.count}} queries +
+ +
+ {{offender.eventName}} bound to +
+ +
+ {{offender.error}} +
no backtrace
+
+ backtrace +
+
+ {{obj.functionName}}() + + line {{obj.line}} +
+
+
+
+ +
+ {{offender.error}} + + (Check on the W3C validator) +
+ +
+ + {{offender.css}} + +
+ +
+ {{offender.rule}} (x{{offender.occurrences}}) +
+ +
+ Property {{offender.property}} duplicated in {{offender.rule}} { } + +
+ +
+ {{offender.css}} { } + +
+ +
+ {{offender.rule}} {{ '{' + offender.property}}: expression({{offender.expression}})} + +
+ +
+ {{offender.rule}} {{ '{' + offender.property}}: {{offender.value}} !important} + +
+ +
+ {{offender.browser}} fix: + + +
+ +
+ {{offender.rule}} {{{offender.property}}: {{offender.value + '}' }} +
+ {{offender.message}} + +
+ +
+ + ({{offender.size}} kB) +
+ +
+ +
+ +
+ + cached for {{offender.ttlWithUnit}} {{offender.unit}} +
+ +
+ {{offender.domain}} + () +
+ +
+ {{offender}} +
+ +
+ + +
+ +
+ +
+ +
+

This is the colors palette, sized by total occurrences:

+
+
{{offender.color}} ({{offender.occurrences}} times)
+
+
+ +
+

404

diff --git a/front/src/views/screenshot.html b/front/src/views/screenshot.html new file mode 100644 index 0000000..558846b --- /dev/null +++ b/front/src/views/screenshot.html @@ -0,0 +1,14 @@ +
+
+

Screenshot

+ +
+
+ +
+
+ +

(scroll on the screenshot to see under the fold)

+ + +
\ No newline at end of file diff --git a/lib/index.js b/lib/index.js index 00513bb..81819f4 100644 --- a/lib/index.js +++ b/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; } diff --git a/lib/metadata/policies.js b/lib/metadata/policies.js index b2b70fd..2406e45 100644 --- a/lib/metadata/policies.js +++ b/lib/metadata/policies.js @@ -1,4 +1,5 @@ var debug = require('debug')('ylt:policies'); +var offendersHelpers = require('../offendersHelpers'); var policies = { "DOMelementsCount": { @@ -7,7 +8,8 @@ var policies = { "message": "

A high number of DOM elements means a lot of work for the browser to render the page.

It also slows down JavaScript DOM queries, as there are more elements to search through.

", "isOkThreshold": 1000, "isBadThreshold": 2500, - "isAbnormalThreshold": 4000 + "isAbnormalThreshold": 4000, + "hasOffenders": false }, "DOMelementMaxDepth": { "tool": "phantomas", @@ -15,7 +17,16 @@ var policies = { "message": "

A deep DOM makes the CSS matching with DOM elements difficult.

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.

", "isOkThreshold": 10, "isBadThreshold": 20, - "isAbnormalThreshold": 28 + "isAbnormalThreshold": 28, + "hasOffenders": true, + "offendersTransformFn": function(offenders) { + var domArrays = offenders.map(offendersHelpers.domPathToArray); + + return { + count: offenders.length, + tree: offendersHelpers.listOfDomArraysToTree(domArrays) + }; + } }, "iframesCount": { "tool": "phantomas", @@ -23,7 +34,8 @@ var policies = { "message": "

iFrames are the most complex HTML elements. They are pages, just like the main page, and the browser needs to create a new page context, which has a cost.

", "isOkThreshold": 2, "isBadThreshold": 15, - "isAbnormalThreshold": 30 + "isAbnormalThreshold": 30, + "hasOffenders": false }, "DOMidDuplicated": { "tool": "phantomas", @@ -31,7 +43,28 @@ var policies = { "message": "

IDs of HTML elements must be document-wide unique. This can cause problems with getElementById returning the wrong element.

", "isOkThreshold": 0, "isBadThreshold": 5, - "isAbnormalThreshold": 10 + "isAbnormalThreshold": 10, + "hasOffenders": true, + "offendersTransformFn": function(offenders) { + return { + count: offenders.length, + list: offenders.map(function(offender) { + var parts = /^(.*): ?(\d+) ?occurrences$/.exec(offender); + + if (!parts) { + debug('DOMidDuplicated offenders transform function error with "%s"', offender); + return { + parseError: offender + }; + } + + return { + id: parts[1], + occurrences: parseInt(parts[2], 10) + }; + }) + }; + } }, "DOMinserts": { "tool": "phantomas", @@ -39,7 +72,28 @@ var policies = { "message": "

Working with the DOM in JavaScript triggers layout calculations and slows down the page.

Try, as much as possible, to have an HTML page fully generated by the server instead of making changes with JS.

", "isOkThreshold": 10, "isBadThreshold": 400, - "isAbnormalThreshold": 1000 + "isAbnormalThreshold": 1000, + "hasOffenders": true, + "offendersTransformFn": function(offenders) { + return { + count: offenders.length, + list: offenders.map(function(offender) { + var parts = /^"(.*)" ?appended ?to ?"(.*)"$/.exec(offender); + + if (!parts) { + debug('DOMinserts offenders transform function error with "%s"', offender); + return { + parseError: offender + }; + } + + return { + insertedElement: offendersHelpers.domPathToDomElementObj(parts[1]), + receiverElement: offendersHelpers.domPathToDomElementObj(parts[2]) + }; + }) + }; + } }, "DOMqueries": { "tool": "phantomas", @@ -47,7 +101,38 @@ var policies = { "message": "

DOM queries are like looking in a large catalog of items. Even if the browsers made progress on the performances of queries, websites often make hundreds of them.

Try to reduce the number of queries by refactoring your JavaScript code.

Avoid also to have a read query between two write queries. To be able to reduce the number repaints and optimize performances, browsers buffer the DOM writing operations and treat them in bulk. But each time a DOM reading is asked, the browser needs to empty the buffer. This can be particularly slow inside a loop.

", "isOkThreshold": 50, "isBadThreshold": 1000, - "isAbnormalThreshold": 2000 + "isAbnormalThreshold": 2000, + "hasOffenders": false + }, + "DOMqueriesWithoutResults": { + "tool": "phantomas", + "label": "DOM queries without result", + "message": "

Number of queries that return no result.

It suggests the query is not used on the page, probably because it is some dead code.

Or maybe the code is trying to find an HTML block that is not always here. Look at the JS Timeline to see if the scripts correctly figures out the HTML block is not here and immediatly stops interacting further with the DOM.

", + "isOkThreshold": 0, + "isBadThreshold": 100, + "isAbnormalThreshold": 200, + "hasOffenders": true, + "offendersTransformFn": function(offenders) { + return { + count: offenders.length, + list: offenders.map(function(offender) { + var parts = /^(.*) ?\(in ?(.*)\) ?using ?(.*)$/.exec(offender); + + if (!parts) { + debug('DOMqueriesWithoutResults offenders transform function error with "%s"', offender); + return { + parseError: offender + }; + } + + return { + query: parts[1], + context: offendersHelpers.domPathToDomElementObj(parts[2]), + fn: parts[3] + }; + }) + }; + } }, "DOMqueriesAvoidable": { "tool": "phantomas", @@ -56,15 +141,30 @@ var policies = { "isOkThreshold": 0, "isBadThreshold": 200, "isAbnormalThreshold": 500, - "takeOffendersFrom": "DOMqueriesDuplicated" - }, - "DOMqueriesWithoutResults": { - "tool": "phantomas", - "label": "DOM queries without result", - "message": "

Number of queries that return no result.

It suggests the query is not used on the page, probably because it is some dead code.

Or maybe the code is trying to find an HTML block that is not always here. Look at the JS Timeline to see if the scripts correctly figures out the HTML block is not here and immediatly stops interacting further with the DOM.

", - "isOkThreshold": 0, - "isBadThreshold": 100, - "isAbnormalThreshold": 200 + "hasOffenders": true, + "takeOffendersFrom": "DOMqueriesDuplicated", + "offendersTransformFn": function(offenders) { + return { + count: offenders.length, + list: offenders.map(function(offender) { + var parts = /^[^"]* ?"(.*)" ?with ?(.*) ?\(in ?context ?(.*)\): ?(.*)\s?queries$/.exec(offender); + + if (!parts) { + debug('DOMqueriesAvoidable offenders transform function error with "%s"', offender); + return { + parseError: offender + }; + } + + return { + query: parts[1], + context: offendersHelpers.domPathToDomElementObj(parts[3]), + fn: parts[2], + count: parseInt(parts[4], 10) + }; + }) + }; + } }, "eventsBound": { "tool": "phantomas", @@ -72,7 +172,28 @@ var policies = { "message": "

Binding too many events has a cost.

It can be avoided by using \"event delegation\". Instead of binding events on each element one by one, events delegation binds them on the top level document element and uses the bubbling principle. It will imperceptibly slow down the event when it occurs, but the loading of the page will speed-up.

", "isOkThreshold": 100, "isBadThreshold": 800, - "isAbnormalThreshold": 1500 + "isAbnormalThreshold": 1500, + "hasOffenders": true, + "offendersTransformFn": function(offenders) { + return { + count: offenders.length, + list: offenders.map(function(offender) { + var parts = /^"(.*)" ?bound ?to ?"(.*)"$/.exec(offender); + + if (!parts) { + debug('eventsBound offenders transform function error with "%s"', offender); + return { + parseError: offender + }; + } + + return { + eventName: parts[1], + element: offendersHelpers.domPathToDomElementObj(parts[2]) + }; + }) + }; + } }, "jsErrors": { "tool": "phantomas", @@ -80,7 +201,30 @@ var policies = { "message": "

Just to let you know there are some errors on the page.

Please note that some errors only occur in the PhantomJS browser, so you might need to double check on other browsers.

", "isOkThreshold": 0, "isBadThreshold": 1, - "isAbnormalThreshold": 4 + "isAbnormalThreshold": 4, + "hasOffenders": true, + "offendersTransformFn": function(offenders) { + return { + count: offenders.length, + list: offenders.map(function(offender) { + var parts = /^(.*) - (.*)$/.exec(offender); + + if (!parts) { + debug('jsErrors offenders transform function error with "%s"', offender); + return { + parseError: offender + }; + } + + var backtraceArray = offendersHelpers.backtraceToArray(parts[2]); + + return { + error: parts[1], + backtrace: backtraceArray || [] + }; + }) + }; + } }, "evalCalls": { "tool": "phantomas", @@ -88,7 +232,8 @@ var policies = { "message": "

The 'eval' function is slow and is a bad coding practice. Try to get rid of it.

", "isOkThreshold": 0, "isBadThreshold": 10, - "isAbnormalThreshold": 20 + "isAbnormalThreshold": 20, + "hasOffenders": false }, "documentWriteCalls": { "tool": "phantomas", @@ -96,7 +241,8 @@ var policies = { "message": "

They slow down the page construction, especially if they are used to insert scripts in the page. Remove them ASAP.

If you cannot remove them because they come from a third-party script (such as ads), have a look at PostScribe.

", "isOkThreshold": 0, "isBadThreshold": 10, - "isAbnormalThreshold": 20 + "isAbnormalThreshold": 20, + "hasOffenders": false }, "consoleMessages": { "tool": "phantomas", @@ -104,7 +250,8 @@ var policies = { "message": "

Try to keep your console clean when in production. Debugging is good for development only.

Writing in the console has a cost, especially when dumping large object variables.

There is also a problem with Internet Explorer 8, not knowing the console object.

", "isOkThreshold": 0, "isBadThreshold": 10, - "isAbnormalThreshold": 25 + "isAbnormalThreshold": 25, + "hasOffenders": false }, "globalVariables": { "tool": "phantomas", @@ -112,15 +259,23 @@ var policies = { "message": "

It is a bad practice because they clutter up the global namespace. If two scripts use the same variable name in the global scope, it can cause conflicts and it is generally hard to debug.

Global variables also take a (very) little bit longer to be accessed than variables in the local scope of a function.

", "isOkThreshold": 10, "isBadThreshold": 50, - "isAbnormalThreshold": 200 + "isAbnormalThreshold": 200, + "hasOffenders": true, + "offendersTransformFn": function(offenders) { + return { + count: offenders.length, + list: offendersHelpers.sortVarsLikeChromeDevTools(offenders) + }; + } }, "jQueryVersion": { "label": "jQuery version", "message": "

Current latest versions of jQuery are 1.11 (with support for old IE versions) and 2.1 (without).

Each new version of jQuery optimizes performances. Do not keep an old version of jQuery. Updating can sometimes break a few things, but it is generally quite easy to fix them up. So don't hesitate.

", + "hasOffenders": false, "scoreFn": function(data) { - var differentVersions = data.toolsResults.phantomas.metrics.jQueryDifferentVersions; + var differentVersions = data.toolsResults.phantomas.metrics.jQueryVersionsLoaded; - if (differentVersions === 0 || differentVersions > 1) { + if (differentVersions === 0 || differentVersions > 1 || !data.toolsResults.phantomas.metrics.jQueryVersion) { // Not applicable return null; } else { @@ -157,6 +312,11 @@ var policies = { return null; } + // Truncate version number (can be long sometimes, no clue why but it can...) + if (value.length > 30) { + value = value.substr(0, 28) + '...'; + } + return { value: value, score: score, @@ -167,13 +327,14 @@ var policies = { } } }, - "jQueryDifferentVersions": { + "jQueryVersionsLoaded": { "tool": "phantomas", "label": "Several versions loaded", - "message": "

jQuery is a heavy library. You should never load jQuery more than one on the same page.

", + "message": "

jQuery is a heavy library. You should never load jQuery more than once on the same page.

", "isOkThreshold": 1, "isBadThreshold": 2, - "isAbnormalThreshold": 2 + "isAbnormalThreshold": 2, + "hasOffenders": true }, "cssParsingErrors": { "tool": "phantomas", @@ -181,15 +342,39 @@ var policies = { "message": "

Yellow Lab Tools failed to parse a CSS file. I doubt the problem comes from the css parser.

Maybe a CSS validator can help you.

", "isOkThreshold": 0, "isBadThreshold": 1, - "isAbnormalThreshold": 1 + "isAbnormalThreshold": 1, + "hasOffenders": true, + "offendersTransformFn": function(offenders) { + return { + count: offenders.length, + list: offenders.map(function(offender) { + var parts = /^(?:(?:<([^ \(]*)>|\[inline CSS\]) ?)?(?:\((((?! @ ).)*)(?: @ (\d+):(\d+))?\))?$/.exec(offender); + + if (!parts) { + debug('cssParsingErrors offenders transform function error with "%s"', offender); + return { + parseError: offender + }; + } + + return { + error: parts[2], + file: parts[1] || null, + line: (parts[4] && parts[5]) ? parseInt(parts[4], 10) : null, + column: (parts[4] && parts[5]) ? parseInt(parts[5], 10) : null + }; + }) + }; + } }, "cssRules": { "tool": "phantomas", "label": "Rules count", - "message": "

Having a huge number of CSS rules hurts performances. If the number of CSS rules is higher than the number of DOM elements, there is clearly a problem.

Huge stylesheets generally occur when the different pages of a website load all the CSS, concatenated in a single stylesheet, even if a large part of the rules are page-specific. Solution is to create one main CSS file with global rules and one custom files per page.

", + "message": "

Having a huge number of CSS rules hurts performances. If the number of CSS rules is higher than the number of DOM elements, there is clearly a problem.

Huge stylesheets generally occur when the different pages of a website load all the CSS, concatenated in a single stylesheet, even if a large part of the rules are page-specific. Solution is to create one main CSS file with global rules and one custom file per page.

", "isOkThreshold": 500, "isBadThreshold": 2500, - "isAbnormalThreshold": 4000 + "isAbnormalThreshold": 4000, + "hasOffenders": false }, "cssComplexSelectors": { "tool": "phantomas", @@ -197,7 +382,17 @@ var policies = { "message": "

Complex selectors are CSS selectors with 4 or more expressions, like \"#header ul li .foo\".

They are adding more work for the browser, and this could be avoided by simplifying selectors.

", "isOkThreshold": 0, "isBadThreshold": 500, - "isAbnormalThreshold": 2000 + "isAbnormalThreshold": 2000, + "hasOffenders": true, + "offendersTransformFn": function(offenders) { + return { + count: offenders.length, + list: offenders.map(function(offender) { + var splittedOffender = offendersHelpers.cssOffenderPattern(offender); + return splittedOffender; + }) + }; + } }, "cssComplexSelectorsByAttribute": { "tool": "phantomas", @@ -205,7 +400,66 @@ var policies = { "message": "

Complex attributes selectors are one of these:

  • .foo[type*=bar] (contains bar)
  • .foo[type^=bar] (starts with bar)
  • .foo[type|=bar] (starts with bar or bar-)
  • .foo[type$=bar] (ends with bar)
  • .foo[type~=bar baz] (bar or baz)

Their matching process needs more CPU and it has a cost on performances.

", "isOkThreshold": 0, "isBadThreshold": 50, - "isAbnormalThreshold": 100 + "isAbnormalThreshold": 100, + "hasOffenders": true, + "offendersTransformFn": function(offenders) { + return { + count: offenders.length, + list: offenders.map(function(offender) { + var splittedOffender = offendersHelpers.cssOffenderPattern(offender); + + splittedOffender.bolded = splittedOffender.css.replace(/(\[[^ ]+[~\|\^\$\*]="[^"]+"\])/g, '$1'); + + return splittedOffender; + }) + }; + } + }, + "cssColors": { + "tool": "phantomas", + "label": "Different colors", + "message": "

This is the number of different colors defined in CSS.

Your CSS project will be easier to maintain if you keep a small color set.

", + "isOkThreshold": 30, + "isBadThreshold": 150, + "isAbnormalThreshold": 400, + "hasOffenders": true, + "offendersTransformFn": function(offenders, ruleObject) { + var deduplicatedObj = {}; + + offenders.map(function(offender) { + var parts = /^([^ ]*) \((\d+) times\)$/.exec(offender); + + if (!parts) { + debug('cssColors offenders transform function error with "%s"', offender); + return; + } + + var color = parts[1]; + var count = parseInt(parts[2], 10); + + deduplicatedObj[color] = (deduplicatedObj[color] || 0) + count; + }); + + var deduplicatedTable = []; + for (var color in deduplicatedObj) { + deduplicatedTable.push({ + color: color, + occurrences: deduplicatedObj[color] + }); + } + + deduplicatedTable.sort(function(a, b) { + return b.occurrences - a.occurrences; + }); + + // Override rules.value + ruleObject.value = deduplicatedTable.length; + + return { + count: deduplicatedTable.length, + palette: deduplicatedTable + }; + } }, "cssImports": { "tool": "phantomas", @@ -213,7 +467,17 @@ var policies = { "message": "

It’s bad for performance to use @import because CSS files don't get downloaded in parallel.

You should use <link rel='stylesheet' href='a.css'> instead.

", "isOkThreshold": 0, "isBadThreshold": 1, - "isAbnormalThreshold": 1 + "isAbnormalThreshold": 1, + "hasOffenders": true, + "offendersTransformFn": function(offenders) { + return { + count: offenders.length, + list: offenders.map(function(offender) { + var splittedOffender = offendersHelpers.cssOffenderPattern(offender); + return splittedOffender; + }) + }; + } }, "cssDuplicatedSelectors": { "tool": "phantomas", @@ -221,7 +485,28 @@ var policies = { "message": "

This is when two or more selectors are strictly identical and should be merged.

", "isOkThreshold": 0, "isBadThreshold": 40, - "isAbnormalThreshold": 80 + "isAbnormalThreshold": 80, + "hasOffenders": true, + "offendersTransformFn": function(offenders) { + return { + count: offenders.length, + list: offenders.map(function(offender) { + var parts = /^(.*) \((\d+) times\)$/.exec(offender); + + if (!parts) { + debug('cssDuplicatedSelectors offenders transform function error with "%s"', offender); + return { + parseError: offender + }; + } + + return { + rule: parts[1], + occurrences: parseInt(parts[2], 10) + }; + }) + }; + } }, "cssDuplicatedProperties": { "tool": "phantomas", @@ -229,7 +514,33 @@ var policies = { "message": "

This is the number of property definitions duplicated within a selector.

", "isOkThreshold": 0, "isBadThreshold": 50, - "isAbnormalThreshold": 100 + "isAbnormalThreshold": 100, + "hasOffenders": true, + "offendersTransformFn": function(offenders) { + return { + count: offenders.length, + list: offenders.map(function(offender) { + var splittedOffender = offendersHelpers.cssOffenderPattern(offender); + + var parts = /^([^{]+) {([^ ]+): (.+)}$/.exec(splittedOffender.css); + + if (!parts) { + debug('cssDuplicatedProperties offenders transform function error with "%s"', offender); + return { + parseError: offender + }; + } + + return { + property: parts[2], + rule: parts[1], + file: splittedOffender.file, + line: splittedOffender.line, + column: splittedOffender.column + }; + }) + }; + } }, "cssEmptyRules": { "tool": "phantomas", @@ -237,7 +548,18 @@ var policies = { "message": "

Very easy to fix: remove all empty rules.

", "isOkThreshold": 0, "isBadThreshold": 40, - "isAbnormalThreshold": 100 + "isAbnormalThreshold": 100, + "hasOffenders": true, + "offendersTransformFn": function(offenders) { + return { + count: offenders.length, + list: offenders.map(function(offender) { + var splittedOffender = offendersHelpers.cssOffenderPattern(offender); + + return splittedOffender; + }) + }; + } }, "cssExpressions": { "tool": "phantomas", @@ -245,7 +567,34 @@ var policies = { "message": "

Such as: expression( document.body.clientWidth > 600 ? \"600px\" : \"auto\" )

This is a bad practice as it slows down browsers. There are some simpler CSS3 methods for doing this.

", "isOkThreshold": 0, "isBadThreshold": 1, - "isAbnormalThreshold": 20 + "isAbnormalThreshold": 20, + "hasOffenders": true, + "offendersTransformFn": function(offenders) { + return { + count: offenders.length, + list: offenders.map(function(offender) { + var splittedOffender = offendersHelpers.cssOffenderPattern(offender); + + var parts = /^(.*) {([^ ]+): expression\((.*)\)}$/.exec(splittedOffender.css); + + if (!parts) { + debug('cssExpressions offenders transform function error with "%s"', offender); + return { + parseError: offender + }; + } + + return { + rule: parts[1], + property: parts[2], + expression: parts[3], + file: splittedOffender.file, + line: splittedOffender.line, + column: splittedOffender.column + }; + }) + }; + } }, "cssImportants": { "tool": "phantomas", @@ -253,7 +602,34 @@ var policies = { "message": "

It can be useful, but only as a last resort. It is a bad practice because it overrides the normal cascading logic. The more you use !important, the more you need it again to over-override. This conducts to a poor maintainability.

", "isOkThreshold": 0, "isBadThreshold": 50, - "isAbnormalThreshold": 150 + "isAbnormalThreshold": 150, + "hasOffenders": true, + "offendersTransformFn": function(offenders) { + return { + count: offenders.length, + list: offenders.map(function(offender) { + var splittedOffender = offendersHelpers.cssOffenderPattern(offender); + + var parts = /^(.*) {([^ ]+): (.*) ?\!important}$/.exec(splittedOffender.css); + + if (!parts) { + debug('cssImportants offenders transform function error with "%s"', offender); + return { + parseError: offender + }; + } + + return { + rule: parts[1], + property: parts[2], + value: parts[3], + file: splittedOffender.file, + line: splittedOffender.line, + column: splittedOffender.column + }; + }) + }; + } }, "cssOldIEFixes": { "tool": "phantomas", @@ -261,7 +637,56 @@ var policies = { "message": "

What browser do you need to support? Once you've got the answer, take a look at these old rules that pollute your CSS code and remove them.

IE6:

  • * html
  • html > body (everything but IE6)

IE7:

  • *height: 123px;
  • height: 123px !ie;

IE9:

  • -ms-filter
  • progid:DXImageTransform.Microsoft

", "isOkThreshold": 0, "isBadThreshold": 50, - "isAbnormalThreshold": 300 + "isAbnormalThreshold": 300, + "hasOffenders": true, + "offendersTransformFn": function(offenders) { + return { + count: offenders.length, + list: offenders.map(function(offender) { + var splittedOffender = offendersHelpers.cssOffenderPattern(offender); + + var parts = /^([^{]*)( {([^ ]+): (.*)})?$/.exec(splittedOffender.css); + + if (!parts) { + debug('cssOldIEFixes offenders transform function error with "%s"', offender); + return { + parseError: offender + }; + } + + var rule = parts[1]; + var property = parts[3]; + var value = parts[4]; + var browser = null; + + if (rule.indexOf('* html') === 0) { + rule = rule.replace(/^\* html/, '* html'); + browser = 'IE6'; + } else if (rule.indexOf('html>body') === 0) { + rule = rule.replace(/^html>body/, 'html>body'); + browser = 'IE6'; + } else if (property.indexOf('*') === 0) { + property = '' + property + ''; + browser = 'IE7'; + } else if (value.match(/\!ie$/)) { + value = value.replace(/\!ie$/, '!ie'); + browser = 'IE7'; + } else if (property === '-ms-filter') { + property = '-ms-filter'; + browser = 'IE9'; + } else if (value.indexOf('progid:DXImageTransform.Microsoft') >= 0) { + value = value.replace(/progid:DXImageTransform\.Microsoft/, 'progid:DXImageTransform.Microsoft'); + browser = 'IE9'; + } + + var propertyAndValue = (property && value) ? ' {' + property + ': ' + value + '}' : ''; + splittedOffender.bolded = rule + propertyAndValue; + splittedOffender.browser = browser; + + return splittedOffender; + }) + }; + } }, "cssOldPropertyPrefixes": { "tool": "phantomas", @@ -269,7 +694,35 @@ var policies = { "message": "

Many property prefixes such as -moz- or -webkit- are not needed anymore, or by very few people. You can remove them or replace them with the non-prefixed version. This will help reducing your stylesheets weight.

", "isOkThreshold": 0, "isBadThreshold": 50, - "isAbnormalThreshold": 300 + "isAbnormalThreshold": 300, + "hasOffenders": true, + "offendersTransformFn": function(offenders) { + return { + count: offenders.length, + list: offenders.map(function(offender) { + var splittedOffender = offendersHelpers.cssOffenderPattern(offender); + + var parts = /^([^{]*)(?: ?{ ?([^ ]+): (.*) ?}) \/\/ (.*)$/.exec(splittedOffender.css); + + if (!parts) { + debug('cssOldPropertyPrefixes offenders transform function error with "%s"', offender); + return { + parseError: offender + }; + } + + return { + rule: parts[1], + property: parts[2], + value: parts[3], + message: parts[4], + file: splittedOffender.file, + line: splittedOffender.line, + column: splittedOffender.column + }; + }) + }; + } }, "cssUniversalSelectors": { "tool": "phantomas", @@ -277,7 +730,17 @@ var policies = { "message": "

Universal selectors are the most expensive CSS selectors.

More informations here.

", "isOkThreshold": 0, "isBadThreshold": 40, - "isAbnormalThreshold": 150 + "isAbnormalThreshold": 150, + "hasOffenders": true, + "offendersTransformFn": function(offenders) { + return { + count: offenders.length, + list: offenders.map(function(offender) { + var splittedOffender = offendersHelpers.cssOffenderPattern(offender); + return splittedOffender; + }) + }; + } }, "cssRedundantBodySelectors": { "tool": "phantomas", @@ -285,7 +748,20 @@ var policies = { "message": "

This is one way to remove complexity from a CSS rule. Generally, when \"body\" is specified in a rule it can be removed, because an element is necessarily inside the body.

", "isOkThreshold": 0, "isBadThreshold": 50, - "isAbnormalThreshold": 200 + "isAbnormalThreshold": 200, + "hasOffenders": true, + "offendersTransformFn": function(offenders) { + return { + count: offenders.length, + list: offenders.map(function(offender) { + var splittedOffender = offendersHelpers.cssOffenderPattern(offender); + + splittedOffender.bolded = splittedOffender.css.replace(/body/, 'body'); + + return splittedOffender; + }) + }; + } }, "cssRedundantChildNodesSelectors": { "tool": "phantomas", @@ -293,7 +769,33 @@ var policies = { "message": "

Some tags included inside other tags are obvious. For example, when \"ul li\" is specified in a rule, \"ul\" can be removed because the \"li\" element is always inside a \"ul\". Same thing for \"tr td\", \"select option\", ...

Lowering compexity in CSS selectors can make the page load a little faster.

", "isOkThreshold": 0, "isBadThreshold": 50, - "isAbnormalThreshold": 200 + "isAbnormalThreshold": 200, + "hasOffenders": true, + "offendersTransformFn": function(offenders) { + return { + count: offenders.length, + list: offenders.map(function(offender) { + var splittedOffender = offendersHelpers.cssOffenderPattern(offender); + + var rule = splittedOffender.css || ''; + var redundanters = [ + ['ul', 'li'], + ['ol', 'li'], + ['select', 'option'], + ['table', 'tr'], + ['table', 'th'], + ]; + + redundanters.forEach(function(couple) { + rule = rule.replace(new RegExp('(^| |>)' + couple[0] + '([^ >]*)?([ >]| > )' + couple[1] + '([^\\w-]|$)', 'g'), '$1' + couple[0] + '$2$3' + couple[1] + '$4'); + }); + + splittedOffender.bolded = rule; + + return splittedOffender; + }) + }; + } }, "requests": { "tool": "phantomas", @@ -302,7 +804,19 @@ var policies = { "isOkThreshold": 15, "isBadThreshold": 100, "isAbnormalThreshold": 200, - "takeOffendersFrom": ["htmlCount", "jsCount", "cssCount", "imageCount", "webfontCount", "videoCount", "jsonCount", "jsonCount"] + "hasOffenders": true, + "takeOffendersFrom": ["htmlCount", "jsCount", "cssCount", "imageCount", "webfontCount", "videoCount", "jsonCount", "jsonCount"], + "offendersTransformFn": function(offenders) { + return { + count: offenders.length, + list: offenders + .map(function(offender) { + return offendersHelpers.fileWithSizePattern(offender); + }).sort(function(a, b) { + return b.size - a.size; + }) + }; + } }, "htmlCount": { "tool": "phantomas", @@ -310,7 +824,16 @@ var policies = { "message": "

The number of HTML pages requests, HTML fragments or iframes.

", "isOkThreshold": 10, "isBadThreshold": 20, - "isAbnormalThreshold": 30 + "isAbnormalThreshold": 30, + "hasOffenders": true, + "offendersTransformFn": function(offenders) { + return { + count: offenders.length, + list: offenders.map(function(offender) { + return offendersHelpers.fileWithSizePattern(offender); + }) + }; + } }, "jsCount": { "tool": "phantomas", @@ -318,7 +841,16 @@ var policies = { "message": "

Reduce the number of scripts by concatenating them.

", "isOkThreshold": 5, "isBadThreshold": 15, - "isAbnormalThreshold": 30 + "isAbnormalThreshold": 30, + "hasOffenders": true, + "offendersTransformFn": function(offenders) { + return { + count: offenders.length, + list: offenders.map(function(offender) { + return offendersHelpers.fileWithSizePattern(offender); + }) + }; + } }, "cssCount": { "tool": "phantomas", @@ -326,7 +858,16 @@ var policies = { "message": "

Reduce the number of stylesheets by concatenating them.

", "isOkThreshold": 3, "isBadThreshold": 10, - "isAbnormalThreshold": 22 + "isAbnormalThreshold": 22, + "hasOffenders": true, + "offendersTransformFn": function(offenders) { + return { + count: offenders.length, + list: offenders.map(function(offender) { + return offendersHelpers.fileWithSizePattern(offender); + }) + }; + } }, "imageCount": { "tool": "phantomas", @@ -334,23 +875,50 @@ var policies = { "message": "

Reduce the number of images by lazyloading them, by spriting them or by creating an icons font.

", "isOkThreshold": 15, "isBadThreshold": 40, - "isAbnormalThreshold": 70 + "isAbnormalThreshold": 70, + "hasOffenders": true, + "offendersTransformFn": function(offenders) { + return { + count: offenders.length, + list: offenders.map(function(offender) { + return offendersHelpers.fileWithSizePattern(offender); + }) + }; + } }, "webfontCount": { "tool": "phantomas", "label": "Font count", - "message": "

Fonts are loaded on the critical path of the head. Load as many as possible.

", + "message": "

Fonts are loaded on the critical path of the head. Load as few as possible.

", "isOkThreshold": 0, "isBadThreshold": 3, - "isAbnormalThreshold": 5 + "isAbnormalThreshold": 5, + "hasOffenders": true, + "offendersTransformFn": function(offenders) { + return { + count: offenders.length, + list: offenders.map(function(offender) { + return offendersHelpers.fileWithSizePattern(offender); + }) + }; + } }, "videoCount": { "tool": "phantomas", - "label": "Videos count", + "label": "Video count", "message": "

The number of videos loaded.

", "isOkThreshold": 1, "isBadThreshold": 5, - "isAbnormalThreshold": 15 + "isAbnormalThreshold": 15, + "hasOffenders": true, + "offendersTransformFn": function(offenders) { + return { + count: offenders.length, + list: offenders.map(function(offender) { + return offendersHelpers.fileWithSizePattern(offender); + }) + }; + } }, "jsonCount": { "tool": "phantomas", @@ -358,7 +926,16 @@ var policies = { "message": "

The number of AJAX requests to JSON files or webservices.

", "isOkThreshold": 2, "isBadThreshold": 10, - "isAbnormalThreshold": 25 + "isAbnormalThreshold": 25, + "hasOffenders": true, + "offendersTransformFn": function(offenders) { + return { + count: offenders.length, + list: offenders.map(function(offender) { + return offendersHelpers.fileWithSizePattern(offender); + }) + }; + } }, "otherCount": { "tool": "phantomas", @@ -366,7 +943,16 @@ var policies = { "message": "

They can be Flash, XML, music or any unknown format.

", "isOkThreshold": 5, "isBadThreshold": 20, - "isAbnormalThreshold": 40 + "isAbnormalThreshold": 40, + "hasOffenders": true, + "offendersTransformFn": function(offenders) { + return { + count: offenders.length, + list: offenders.map(function(offender) { + return offendersHelpers.fileWithSizePattern(offender); + }) + }; + } }, "smallJsFiles": { "tool": "phantomas", @@ -374,7 +960,16 @@ var policies = { "message": "

Number of JS assets smaller than 2 KB that could probably be inlined or merged.

", "isOkThreshold": 2, "isBadThreshold": 10, - "isAbnormalThreshold": 16 + "isAbnormalThreshold": 16, + "hasOffenders": true, + "offendersTransformFn": function(offenders) { + return { + count: offenders.length, + list: offenders.map(function(offender) { + return offendersHelpers.fileWithSizePattern(offender); + }) + }; + } }, "smallCssFiles": { "tool": "phantomas", @@ -382,7 +977,16 @@ var policies = { "message": "

Number of CSS assets smaller than 2 KB that could probably be inlined or merged.

", "isOkThreshold": 0, "isBadThreshold": 8, - "isAbnormalThreshold": 12 + "isAbnormalThreshold": 12, + "hasOffenders": true, + "offendersTransformFn": function(offenders) { + return { + count: offenders.length, + list: offenders.map(function(offender) { + return offendersHelpers.fileWithSizePattern(offender); + }) + }; + } }, "smallImages": { "tool": "phantomas", @@ -390,31 +994,43 @@ var policies = { "message": "

Images smaller than 2 KB that could be base64 encoded or merged into a sprite.

", "isOkThreshold": 2, "isBadThreshold": 17, - "isAbnormalThreshold": 30 + "isAbnormalThreshold": 30, + "hasOffenders": true, + "offendersTransformFn": function(offenders) { + return { + count: offenders.length, + list: offenders.map(function(offender) { + return offendersHelpers.fileWithSizePattern(offender); + }) + }; + } }, "notFound": { "tool": "phantomas", "label": "404 not found", - "message": "

404 errors are never cached, so each time a page ask for it, it hits se server. Even if it is behind a CDN or a reverse-proxy cache.

", + "message": "

404 errors are never cached, so each time a page ask for it, it hits the server. Even if it is behind a CDN or a reverse-proxy cache.

", "isOkThreshold": 0, "isBadThreshold": 1, - "isAbnormalThreshold": 1 + "isAbnormalThreshold": 1, + "hasOffenders": true }, "closedConnections": { "tool": "phantomas", "label": "Connections closed", - "message": "

This counts the number of requests not keeping the connection alive (specifying \"Connection: close\" in the response headers). It is only counting a request if it is followed by another request on the same domain.

This is slowing down the next request, because the brower needs to open a new connection to the server, which means a additional round-trip.

Correct the problem by setting a Keep-Alive header on the guilty server.

", + "message": "

This counts the number of requests not keeping the connection alive (specifying \"Connection: close\" in the response headers). It is only counting a request if it is followed by another request on the same domain.

This is slowing down the next request, because the brower needs to open a new connection to the server, which means an additional round-trip.

Correct the problem by setting a Keep-Alive header on the guilty server.

", "isOkThreshold": 0, "isBadThreshold": 8, - "isAbnormalThreshold": 20 + "isAbnormalThreshold": 20, + "hasOffenders": true }, "multipleRequests": { "tool": "phantomas", "label": "Duplicated requests", "message": "

This only happens when the asset has no cache and is requested more than once on the same page. Be very careful about it.

", "isOkThreshold": 0, - "isBadThreshold": 5, - "isAbnormalThreshold": 10 + "isBadThreshold": 3, + "isAbnormalThreshold": 10, + "hasOffenders": true }, "cachingDisabled": { "tool": "phantomas", @@ -422,7 +1038,8 @@ var policies = { "message": "

Counts responses with caching disabled (max-age=0)

Fix immediatly if on static assets.

", "isOkThreshold": 0, "isBadThreshold": 12, - "isAbnormalThreshold": 25 + "isAbnormalThreshold": 25, + "hasOffenders": true }, "cachingNotSpecified": { "tool": "phantomas", @@ -430,7 +1047,8 @@ var policies = { "message": "

When no caching is specified, each browser will handle it differently. Most of the time, it will automatically add a cache for you, but a poor one. You'd better handle it yourself.

", "isOkThreshold": 5, "isBadThreshold": 20, - "isAbnormalThreshold": 40 + "isAbnormalThreshold": 40, + "hasOffenders": true }, "cachingTooShort": { "tool": "phantomas", @@ -438,7 +1056,58 @@ var policies = { "message": "

Responses with too short caching time (less than a week).

The longer you cache, the better. Add versionning to your static assets, if it's not already done, and set their cache time to one year.

", "isOkThreshold": 5, "isBadThreshold": 20, - "isAbnormalThreshold": 40 + "isAbnormalThreshold": 40, + "hasOffenders": true, + "offendersTransformFn": function(offenders) { + return { + count: offenders.length, + list: offenders + .map(function(offender) { + var parts = /^([^ ]*) cached for (-?\d+(\.\d+)?) s$/.exec(offender); + + if (!parts) { + debug('cachingTooShort offenders transform function error with "%s"', offender); + return { + file: offender + }; + } + + return { + file: parts[1], + ttl: Math.round(parseFloat(parts[2])) + }; + + }).sort(function(a, b) { + + return a.ttl - b.ttl; + + }).map(function(obj) { + + var duration = obj.ttl; + var unit = 'seconds'; + + if (duration >= 120) { + duration = Math.round(duration / 60); + unit = 'minutes'; + } + + if (duration >= 120) { + duration = Math.round(duration / 60); + unit = 'hours'; + } + + if (duration >= 48) { + duration = Math.round(duration / 24); + unit = 'days'; + } + + obj.ttlWithUnit = duration; + obj.unit = unit; + + return obj; + }) + }; + } }, "domains": { "tool": "phantomas", @@ -446,7 +1115,29 @@ var policies = { "message": "

For each domain met, the browser needs to make a DNS look-up, which is slow. Avoid having to many different domains and the page should render faster.

By the way, domain sharding is not a good practice anymore.

", "isOkThreshold": 10, "isBadThreshold": 25, - "isAbnormalThreshold": 50 + "isAbnormalThreshold": 50, + "hasOffenders": true, + "offendersTransformFn": function(offenders) { + return { + count: offenders.length, + list: offenders.map(function(offender) { + var parts = /^([^ ]*): (\d+) request\(s\)$/.exec(offender); + + if (!parts) { + debug('domains offenders transform function error with "%s"', offender); + return { + file: offender + }; + } + + return { + domain: parts[1], + requests: parseInt(parts[2]) + }; + + }) + }; + } } }; diff --git a/lib/metadata/scoreProfileGeneric.json b/lib/metadata/scoreProfileGeneric.json index 48a81bb..044db52 100644 --- a/lib/metadata/scoreProfileGeneric.json +++ b/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": { diff --git a/lib/offendersHelpers.js b/lib/offendersHelpers.js new file mode 100644 index 0000000..f2dde54 --- /dev/null +++ b/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 110) ? url.substr(0, 47) + ' ... ' + url.substr(-48) : url; + return '' + shortUrl + ''; + }; + + 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(); \ No newline at end of file diff --git a/lib/rulesChecker.js b/lib/rulesChecker.js index f8eabd1..bc570a7 100644 --- a/lib/rulesChecker.js +++ b/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) { - data.toolsResults[policy.tool].offenders[metricName] = offenders; + 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 + }; + } + + 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; diff --git a/lib/runner.js b/lib/runner.js index ab6ea6d..adb8864 100644 --- a/lib/runner.js +++ b/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) { diff --git a/lib/screenshotHandler.js b/lib/screenshotHandler.js new file mode 100644 index 0000000..5651a12 --- /dev/null +++ b/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(); \ No newline at end of file diff --git a/lib/server/controllers/apiController.js b/lib/server/controllers/apiController.js index 4ab4142..a74d359 100644 --- a/lib/server/controllers/apiController.js +++ b/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) { - // Save result in datastore - data.runId = run.runId; - resultsDatastore.saveResult(data) - .then(function() { + debug('Success'); + data.runId = run.runId; - runsDatastore.markAsComplete(run.runId); - - // Send result if the user was waiting - if (run.params.waitForResponse) { + + // Some conditional steps are made if there is a screenshot + var screenshotPromise = Q.resolve(); - // 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); + 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; - res.status(500).send('Saving results failed'); - }); + // Official path to get the image + data.screenshotUrl = '/api/results/' + data.runId + '/screenshot.jpg'; + } + }) + // Delete screenshot temporary file + .then(screenshot.deleteTmpFile); + + } + + // Let's continue + screenshotPromise + + // Save results + .then(function() { + delete data.params.options.screenshot; + return resultsDatastore.saveResult(data); }) + // Mark as the run as complete and send the response if the request is still waiting + .then(function() { + + debug('Result saved in datastore'); + + runsDatastore.markAsComplete(run.runId); + + if (run.params.waitForResponse) { + + // If the user only wants a portion of the result (partialResult option) + switch(run.params.partialResult) { + case 'generalScores': + res.redirect(302, '/api/results/' + run.runId + '/generalScores'); + break; + case 'rules': + res.redirect(302, '/api/results/' + run.runId + '/rules'); + break; + case 'javascriptExecutionTree': + res.redirect(302, '/api/results/' + run.runId + '/javascriptExecutionTree'); + break; + case 'phantomas': + res.redirect(302, '/api/results/' + run.runId + '/toolsResults/phantomas'); + break; + default: + res.redirect(302, '/api/results/' + run.runId); + } + } + + }) .fail(function(err) { - 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'); - - }) - - .finally(function() { - queue.remove(run.runId); + res.status(500).send('An error occured'); }); - }).fail(function(err) { - console.error('Error or YLT\'s core instanciation'); - console.error(err); - console.error(err.stack); + }) + + .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'); + + }) + + .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; \ No newline at end of file diff --git a/lib/server/controllers/frontController.js b/lib/server/controllers/frontController.js index 9a19a67..4d5f531 100644 --- a/lib/server/controllers/frontController.js +++ b/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'); diff --git a/lib/server/datastores/resultsDatastore.js b/lib/server/datastores/resultsDatastore.js index 3392337..a8119f9 100644 --- a/lib/server/datastores/resultsDatastore.js +++ b/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; \ No newline at end of file diff --git a/lib/server/datastores/runsDatastore.js b/lib/server/datastores/runsDatastore.js index da9b5bb..15b4ada 100644 --- a/lib/server/datastores/runsDatastore.js +++ b/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"; diff --git a/lib/server/datastores/runsQueue.js b/lib/server/datastores/runsQueue.js index 26a6717..e99f1fb 100644 --- a/lib/server/datastores/runsQueue.js +++ b/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 diff --git a/lib/tools/phantomas/custom_modules/modules/cachYLT/cachYLT.js b/lib/tools/phantomas/custom_modules/modules/cachYLT/cachYLT.js new file mode 100644 index 0000000..213edfe --- /dev/null +++ b/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'); + } + } + }); +}; diff --git a/lib/tools/phantomas/custom_modules/modules/domQYLT/domQYLT.js b/lib/tools/phantomas/custom_modules/modules/domQYLT/domQYLT.js index 81985f4..fe19cdc 100644 --- a/lib/tools/phantomas/custom_modules/modules/domQYLT/domQYLT.js +++ b/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) { diff --git a/lib/tools/phantomas/custom_modules/modules/jQYLT/jQYLT.js b/lib/tools/phantomas/custom_modules/modules/jQYLT/jQYLT.js index a850312..c4a962d 100644 --- a/lib/tools/phantomas/custom_modules/modules/jQYLT/jQYLT.js +++ b/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); - phantomas.on('report', function() { - sizzleCalls.sort().forEach(function(id, cnt) { - if (cnt > 1) { - phantomas.incrMetric('jQuerySizzleCallsDuplicated'); - phantomas.addOffender('jQuerySizzleCallsDuplicated', '%s: %d', id, cnt); - } - }); + // report multiple jQuery "instances" (issue #435) + phantomas.incrMetric('jQueryVersionsLoaded'); + phantomas.addOffender('jQueryVersionsLoaded', 'v%s', version); - jQueryLoading.forEach(function(version) { - phantomas.incrMetric('jQueryDifferentVersions'); - phantomas.addOffender('jQueryDifferentVersions', '%s', version); - }); + phantomas.log('jQuery: v%s (probably loaded from <%s>)', version, lastUrl); }); }; diff --git a/lib/tools/phantomas/custom_modules/modules/jsFileLoadYLT/jsFileLoadYLT.js b/lib/tools/phantomas/custom_modules/modules/jsFileLoadYLT/jsFileLoadYLT.js deleted file mode 100644 index 4aa6c7d..0000000 --- a/lib/tools/phantomas/custom_modules/modules/jsFileLoadYLT/jsFileLoadYLT.js +++ /dev/null @@ -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); - });*/ -}; diff --git a/lib/tools/phantomas/custom_modules/util/collection.js b/lib/tools/phantomas/custom_modules/util/collection.js new file mode 100644 index 0000000..db1ca7f --- /dev/null +++ b/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; diff --git a/lib/tools/phantomas/phantomasWrapper.js b/lib/tools/phantomas/phantomasWrapper.js index bdef3d7..0202d08 100644 --- a/lib/tools/phantomas/phantomasWrapper.js +++ b/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); + } }); diff --git a/package.json b/package.json index fa04850..6b29c50 100644 --- a/package.json +++ b/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" diff --git a/screenshot.png b/screenshot.png deleted file mode 100644 index b95be34..0000000 Binary files a/screenshot.png and /dev/null differ diff --git a/test/api/apiTest.js b/test/api/apiTest.js index bb20128..4e960d2 100644 --- a/test/api/apiTest.js +++ b/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); + } + }); + }); + }); diff --git a/test/api/resultsDatastoreTest.js b/test/api/resultsDatastoreTest.js index 4e8bdaf..da181e7 100644 --- a/test/api/resultsDatastoreTest.js +++ b/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); + }); + }); }); diff --git a/test/api/runsQueueTest.js b/test/api/runsQueueTest.js index 1e8798d..df306cd 100644 --- a/test/api/runsQueueTest.js +++ b/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() { diff --git a/test/api/screenshotHandlerTest.js b/test/api/screenshotHandlerTest.js new file mode 100644 index 0000000..c19d0e0 --- /dev/null +++ b/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); + }); + }); +}); \ No newline at end of file diff --git a/test/core/customPoliciesTest.js b/test/core/customPoliciesTest.js index 5593f2f..abfb601 100644 --- a/test/core/customPoliciesTest.js +++ b/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": [ + " (Error: CSS parsing failed: missing '}' @ 4:1)", + " (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... + }); diff --git a/test/core/indexTest.js b/test/core/indexTest.js index 6207241..93b0824 100644 --- a/test/core/indexTest.js +++ b/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": "

A deep DOM makes the CSS matching with DOM elements difficult.

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.

", "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); + }); + }); + }); \ No newline at end of file diff --git a/test/core/offendersHelpersTest.js b/test/core/offendersHelpersTest.js new file mode 100644 index 0000000..6bf59c9 --- /dev/null +++ b/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('http://www.google.com/js/main.js'); + }); + + it('should ellypsis the url if too long', function() { + var result = offendersHelpers.urlToLink('http://www.google.com/js/longlonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglong/main.js'); + + result.should.equal('http://www.google.com/js/longlonglonglonglonglo ... longlonglonglonglonglonglonglonglonglong/main.js'); + }); + + }); + + + describe('cssOffenderPattern', function() { + + it('should transform a css offender into an object', function() { + var result = offendersHelpers.cssOffenderPattern('.pagination .plus ul li @ 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 + }); + }); + + }); + +}); diff --git a/test/core/rulesCheckerTest.js b/test/core/rulesCheckerTest.js index 841c1ab..f979109 100644 --- a/test/core/rulesCheckerTest.js +++ b/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); diff --git a/test/fixtures/logo-large.png b/test/fixtures/logo-large.png new file mode 100644 index 0000000..f739504 Binary files /dev/null and b/test/fixtures/logo-large.png differ diff --git a/test/fixtures/rulesCheckerOutput.json b/test/fixtures/rulesCheckerOutput.json index 5327aa5..e12ad22 100644 --- a/test/fixtures/rulesCheckerOutput.json +++ b/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": "

This is from another tool!

", "isOkThreshold": 0, "isBadThreshold": 3, - "isAbnormalThreshold": 11 + "isAbnormalThreshold": 11, + "hasOffenders": false }, "value": 22, "bad": true, diff --git a/test/fixtures/rulesCheckerPolicies.json b/test/fixtures/rulesCheckerPolicies.js similarity index 68% rename from test/fixtures/rulesCheckerPolicies.json rename to test/fixtures/rulesCheckerPolicies.js index 9147e01..2391781 100644 --- a/test/fixtures/rulesCheckerPolicies.json +++ b/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": "

This is from another tool!

", "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 } -} \ No newline at end of file +}; + +module.exports = policies; \ No newline at end of file