Bläddra i källkod

Merge pull request #84 from gmetais/reorder

New rules and rules reordering
Gaël Métais 10 år sedan
förälder
incheckning
dd912ecc8f

+ 4 - 2
.travis.yml

@@ -1,6 +1,8 @@
 language: node_js
 node_js:
-    - "0.10.33"
+    - "0.12.4"
 before_install:
+    - "npm install -g npm"
     - "npm install -g grunt-cli"
-install: npm install
+install:
+    - "npm install"

+ 1 - 1
Gruntfile.js

@@ -143,7 +143,7 @@ module.exports = function(grunt) {
                 options: {
                     reporter: 'spec',
                 },
-                src: ['test/core/phantomasWrapperTest.js']
+                src: ['test/core/imageOptimizerTest.js']
             },
             coverage: {
                 options: {

+ 8 - 9
bower.json

@@ -1,14 +1,13 @@
 {
   "name": "yellowlabtools",
   "dependencies": {
-    "angular": "~1.3.15",
-    "angular-route": "~1.3.15",
-    "angular-resource": "~1.3.15",
-    "angular-sanitize": "~1.3.15",
-    "angular-animate": "~1.3.15",
-    "angular-local-storage": "~0.2.0"
-  },
-  "resolutions": {
-    "angular": "~1.3.8"
+    "angular": "1.3.15",
+    "angular-route": "1.3.15",
+    "angular-resource": "1.3.15",
+    "angular-sanitize": "1.3.15",
+    "angular-animate": "1.3.15",
+    "angular-local-storage": "0.2.2",
+    "angular-chart.js": "0.7.1",
+    "Chart.js": "~1.0.2"
   }
 }

+ 41 - 4
front/src/css/rule.css

@@ -164,16 +164,18 @@
   font-style: italic;
   font-weight: normal;
 }
-.colorPalette {
-  width: 30em;
-  border: 2px solid #000;
-  text-align: left;
+.checker {
   /* 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 {
+  width: 30em;
+  border: 2px solid #000;
+  text-align: left;
+}
 .colorPalette > div {
   display: inline-block;
   height: 2em;
@@ -205,3 +207,38 @@
   z-index: 2;
   border: 0.2em solid #f1c40f;
 }
+.totalWeightPie {
+  max-width: 39em;
+  margin: 2em auto 4em;
+}
+.totalWeightPie canvas {
+  max-width: inherit;
+}
+.hugeFile {
+  font-weight: bold;
+  color: #e74c3c;
+}
+.imageOffenders {
+  display: table;
+  border-spacing: 3em;
+  width: 90%;
+}
+.imageOffenders > div {
+  display: table-row;
+}
+.imageOffenders > div > div {
+  display: table-cell;
+  vertical-align: middle;
+}
+.imageOffenders img {
+  max-height: 10em;
+  max-width: 40em;
+  border: 1px solid #000;
+  margin-top: 0.5em;
+}
+.smallPreview {
+  max-height: 1.6em;
+  max-width: 4em;
+  border: 1px solid #000;
+  margin-top: 0.2em;
+}

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

@@ -1,4 +1,13 @@
-var ruleCtrl = angular.module('ruleCtrl', []);
+var ruleCtrl = angular.module('ruleCtrl', ['chart.js']);
+
+ruleCtrl.config(['ChartJsProvider', function (ChartJsProvider) {
+    // Configure all charts
+    ChartJsProvider.setOptions({
+        animation: false,
+        colours: ['#FF5252', '#FF8A80'],
+        responsive: true
+    });
+}]);
 
 ruleCtrl.controller('RuleCtrl', ['$scope', '$rootScope', '$routeParams', '$location', '$sce', 'Menu', 'Results', 'API', function($scope, $rootScope, $routeParams, $location, $sce, Menu, Results, API) {
     $scope.runId = $routeParams.runId;
@@ -22,6 +31,23 @@ ruleCtrl.controller('RuleCtrl', ['$scope', '$rootScope', '$routeParams', '$locat
 
     function init() {
         $scope.rule = $scope.result.rules[$scope.policyName];
+
+        // Init "Total Weight" chart
+        if ($scope.policyName === 'totalWeight') {
+            $scope.weightLabels = [];
+            $scope.weightColours = ['#7ECCCC', '#A7E846', '#FF944D', '#FFE74A', '#C2A3FF', '#5A9AED', '#FF6452', '#C1C1C1'];
+            $scope.weightData = [];
+
+            var types = ['html', 'css', 'js', 'json', 'image', 'video', 'webfont', 'other'];
+            types.forEach(function(type) {
+                $scope.weightLabels.push(type);
+                $scope.weightData.push(Math.round($scope.rule.offendersObj.list.byType[type].totalWeight / 1024));
+            });
+
+            $scope.weightOptions = {
+                tooltipTemplate: '<%=label%>: <%=value%> KB'
+            };
+        }
     }
 
     $scope.backToDashboard = function() {

+ 6 - 6
front/src/js/controllers/timelineCtrl.js

@@ -44,19 +44,19 @@ timelineCtrl.controller('TimelineCtrl', ['$scope', '$rootScope', '$routeParams',
     }
 
     function initScriptFiltering() {
-        var offenders = $scope.result.rules.jsCount.offendersObj.list;
+        var offenders = $scope.result.rules.totalRequests.offendersObj.list.byType.js;
         $scope.scripts = [];
 
-        offenders.forEach(function(script) {
-            var filePath = script.file;
+        offenders.forEach(function(filePath) {
+            var shortPath = filePath;
 
             if (filePath.length > 100) {
-                filePath = filePath.substr(0, 98) + '...';
+                shortPath = filePath.substr(0, 98) + '...';
             }
 
             var scriptObj = {
-                fullPath: script.file, 
-                shortPath: filePath
+                fullPath: filePath,
+                shortPath: shortPath
             };
 
             $scope.scripts.push(scriptObj);

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

@@ -856,4 +856,34 @@
         };
     });
 
+    offendersDirectives.filter('bytes', function() {
+        return function(bytes) {
+            if (isNaN(parseFloat(bytes)) || !isFinite(bytes)) {
+                return '-';
+            }
+            
+            var kilo = bytes / 1024;
+
+            if (kilo < 1) {
+                return bytes + ' bytes';
+            }
+
+            if (kilo < 100) {
+                return kilo.toFixed(1) + ' KB';
+            }
+
+            if (kilo < 1024) {
+                return kilo.toFixed(0) + ' KB';
+            }
+
+            var mega = kilo / 1024;
+
+            if (mega < 10) {
+                return mega.toFixed(2) + ' MB';
+            }
+
+            return mega.toFixed(1) + ' MB';
+        };
+    });
+
 })();

+ 50 - 5
front/src/less/rule.less

@@ -176,16 +176,18 @@
     }
 }
 
-.colorPalette {
-    width: 30em;
-    border: 2px solid #000;
-    text-align: left;
-
+.checker {
     /* 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;
+}
+
+.colorPalette {
+    width: 30em;
+    border: 2px solid #000;
+    text-align: left;
 
     > div {
         display: inline-block;
@@ -221,4 +223,47 @@
             border: 0.2em solid #f1c40f;
         }
     }
+}
+
+.totalWeightPie {
+    max-width: 39em;
+    margin: 2em auto 4em;
+
+    canvas {
+        max-width: inherit;
+    }
+}
+
+.hugeFile {
+    font-weight: bold;
+    color: #e74c3c;
+}
+
+.imageOffenders {
+    display: table;
+    border-spacing: 3em;
+    width: 90%;
+
+    > div {
+        display: table-row;
+
+        > div {
+            display: table-cell;
+            vertical-align: middle;
+        }
+    }
+
+    img {
+        max-height: 10em;
+        max-width: 40em;
+        border: 1px solid #000;
+        margin-top: 0.5em;
+    }
+}
+
+.smallPreview {
+    max-height: 1.6em;
+    max-width: 4em;
+    border: 1px solid #000;
+    margin-top: 0.2em;
 }

+ 3 - 0
front/src/main.html

@@ -16,15 +16,18 @@
     <link rel="stylesheet" type="text/css" href="/css/screenshot.css">
     <link rel="stylesheet" type="text/css" href="/css/timeline.css">
     <link rel="stylesheet" type="text/css" href="/css/about.css">
+    <link rel="stylesheet" type="text/css" href="/bower_components/angular-chart.js/dist/angular-chart.css">
     <!-- endbuild -->
 
     <!-- build:js /js/all.js -->
     <script src="/bower_components/angular/angular.min.js"></script>
+    <script src="/bower_components/Chart.js/Chart.min.js"></script>
     <script src="/bower_components/angular-route/angular-route.min.js"></script>
     <script src="/bower_components/angular-resource/angular-resource.min.js"></script>
     <script src="/bower_components/angular-sanitize/angular-sanitize.min.js"></script>
     <script src="/bower_components/angular-animate/angular-animate.min.js"></script>
     <script src="/bower_components/angular-local-storage/dist/angular-local-storage.min.js"></script>
+    <script src="/bower_components/angular-chart.js/dist/angular-chart.min.js"></script>
     <script src="/js/app.js"></script>
     <script src="/js/controllers/indexCtrl.js"></script>
     <script src="/js/controllers/dashboardCtrl.js"></script>

+ 2 - 1
front/src/views/dashboard.html

@@ -35,7 +35,8 @@
                         </div>
                         <div class="label">{{rule.policy.label}}</div>
                         <div class="result">
-                            {{rule.value}}
+                            <span ng-if="rule.policy.unit == 'bytes'">{{rule.value | bytes}}</span>
+                            <span ng-if="rule.policy.unit != 'bytes'">{{rule.value}}</span>
                             <span ng-if="rule.abnormal" class="icon-warning"></span>
                             <span ng-if="rule.abnormalityScore <= -100" class="icon-warning"></span>
                             <span ng-if="rule.abnormalityScore <= -300" class="icon-warning"></span>

+ 125 - 11
front/src/views/rule.html

@@ -9,7 +9,11 @@
             <div>{{rule.score}}/100</div>
         </div>
         <div class="right">
-            <h3>Value: {{rule.value}}</h3>
+            <h3>
+                Value:
+                <span ng-if="rule.policy.unit == 'bytes'">{{rule.value | bytes}}</span>
+                <span ng-if="rule.policy.unit != 'bytes'">{{rule.value}}</span>
+            </h3>
             <div ng-bind-html="rule.policy.message" class="message"></div>
         </div>
     </div>
@@ -133,20 +137,15 @@
                         <file-and-line-button file="offender.file" line="offender.line" column="offender.column"></file-and-line-button>
                     </div>
 
-                    <div ng-if="policyName === 'requests' || policyName === 'htmlCount' || policyName === 'jsCount' || policyName === 'cssCount' || policyName === 'imageCount' || policyName === 'webfontCount' || policyName === 'videoCount' || policyName === 'jsonCount' || policyName === 'otherCount' || policyName === 'smallJsFiles' || policyName === 'smallCssFiles' || policyName === 'smallImages'">
-                        <url-link url="offender.file" max-length="100"></url-link>
-                        <span ng-if="offender.size || offender.size === 0">({{offender.size}} kB)</span>
+                    <div ng-if="policyName === 'lazyLoadableImagesBelowTheFold'">
+                        <url-link url="offender" max-length="100"></url-link>
+                        <img ng-src="{{offender}}" class="smallPreview checker"></img>
                     </div>
 
                     <div ng-if="policyName === 'notFound' || policyName === 'closedConnections' || policyName === 'multipleRequests' || policyName === 'cachingDisabled' || policyName === 'cachingNotSpecified'">
                         <url-link url="offender" max-length="100"></url-link>
                     </div>
 
-                    <div ng-if="policyName === 'assetsNotGzipped'">
-                        <url-link url="offender.file" max-length="100"></url-link>
-                        ({{offender.type}})
-                    </div>
-
                     <div ng-if="policyName === 'cachingTooShort'">
                         <url-link url="offender.file" max-length="100"></url-link>
                         cached for <b>{{offender.ttlWithUnit}} {{offender.unit}}</b>
@@ -165,7 +164,6 @@
             </div>
         </div>
 
-
         <div ng-if="!rule.offendersObj.list" class="offendersHtml">
             
             <div ng-if="policyName === 'DOMelementMaxDepth'">
@@ -187,13 +185,129 @@
 
             <div ng-if="policyName === 'cssColors' && rule.offendersObj.count > 0">
                 <p>This is the colors palette, sized by total occurrences:</p>
-                <div class="colorPalette">
+                <div class="colorPalette checker">
                     <div ng-repeat="offender in rule.offendersObj.palette" style="background-color: {{offender.color}}; width: {{offender.occurrences * 100 / rule.offendersObj.palette[0].occurrences}}%"><div>{{offender.color}} ({{offender.occurrences}} times)</div></div>
                 </div>
             </div>
         </div>
     </div>
 
+    <div ng-if="policyName === 'totalWeight'">
+        <h3>Weight by MIME type</h3>
+        <div class="totalWeightPie">
+            <canvas class="chart chart-doughnut" data="weightData" labels="weightLabels" options="weightOptions" colours="weightColours" legend="true"></canvas>
+        </div>
+        <div ng-repeat="type in weightLabels">
+            <h3>{{rule.offendersObj.list.byType[type].totalWeight | bytes}} of {{type}}</h3>
+            <div class="offendersTable">
+                <div ng-repeat="request in rule.offendersObj.list.byType[type].requests | orderBy:'-weight'" ng-if="request.weight > 0">
+                    <div><url-link url="request.url" max-length="60"></url-link></div>
+                    <div ng-class="{hugeFile: request.weight > 102400}">{{request.weight | bytes}}</div>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <div ng-if="policyName === 'imageOptimization'">
+        <h3 ng-if="rule.value > 0">{{rule.value | bytes}} could be saved on <ng-pluralize count="rule.offendersObj.list.images.length" when="{'one': '1 image', 'other': '{} images'}"></ng-pluralize></h3>
+        <div class="imageOffenders">
+            <div ng-repeat="image in rule.offendersObj.list.images | orderBy:'-gain'">
+                <div>
+                    Current file: <url-link url="image.url" max-length="50"></url-link>
+                    <div><a href="{{image.url}}" target="_blank"><img ng-src="{{image.url}}" class="checker" /></a></div>
+                </div>
+                <div>
+                    <p ng-if="!image.afterCompression">Current weight: {{image.original | bytes}}</p>
+                    <p ng-if="image.afterCompression">Current weight: {{image.original | bytes}} ({{image.afterCompression | bytes}} gzipped)</p>
+                    <p ng-if="image.lossless && image.afterOptimizationAndCompression">With a lossless optimization:<br/>{{image.afterOptimizationAndCompression | bytes}} gzipped (<b>-{{image.gain | bytes}}</b> gzipped)</p>
+                    <p ng-if="image.lossless && !image.afterOptimizationAndCompression">With a lossless optimization:<br/>{{image.lossless | bytes}} <span ng-if="!image.lossy">(<b>-{{image.gain | bytes}}</b>)</span></p>
+                    <p ng-if="image.lossy && image.afterOptimizationAndCompression">With a lossy optimization:<br/>{{image.afterOptimizationAndCompression | bytes}} gzipped (<b>-{{image.gain | bytes}} gzipped</b>)</p>
+                    <p ng-if="image.lossy && !image.afterOptimizationAndCompression">With a lossy optimization:<br/>{{image.lossy | bytes}} (<b>-{{image.gain | bytes}}</b>)</p>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <div ng-if="policyName === 'gzipCompression'">
+        <h3 ng-if="rule.value > 0">{{rule.value | bytes}} could be saved on <ng-pluralize count="rule.offendersObj.list.images.length" when="{'one': '1 file', 'other': '{} files'}"></ng-pluralize></h3>
+        <div class="table">
+            <div class="headers">
+                <div>File</div>
+                <div>Current weight</div>
+                <div>Gzipped</div>
+                <div>Gain</div>
+            </div>
+            <div ng-repeat="file in rule.offendersObj.list.files | orderBy:'-gain'">
+                <div>
+                    <url-link url="file.url" max-length="70"></url-link>
+                </div>
+                <div>{{file.original | bytes}}</div>
+                <div>{{file.gzipped | bytes}}</div>
+                <div><b>-{{file.gain | bytes}}</b></div>
+            </div>
+        </div>
+    </div>
+
+    <div ng-if="policyName === 'fileMinification'">
+        <h3 ng-if="rule.value > 0">{{rule.value | bytes}} could be saved on <ng-pluralize count="rule.offendersObj.list.files.length" when="{'one': '1 file', 'other': '{} files'}"></ng-pluralize></h3>
+        <div class="table">
+            <div class="headers">
+                <div>File</div>
+                <div>Current weight</div>
+                <div>Minified</div>
+                <div>Gain</div>
+            </div>
+            <div ng-repeat="file in rule.offendersObj.list.files | orderBy:'-gain'">
+                <div>
+                    <url-link url="file.url" max-length="70"></url-link>
+                </div>
+                <div ng-if="!file.afterCompression">{{file.original | bytes}} (gzipped)</div>
+                <div ng-if="file.afterCompression">{{file.original | bytes}} ({{file.afterCompression | bytes}} gzipped)</div>
+                <div ng-if="!file.afterCompression">{{file.afterOptimizationAndCompression | bytes}} (gzipped)</div>
+                <div ng-if="file.afterCompression">{{file.optimized | bytes}} ({{file.afterOptimizationAndCompression | bytes}} gzipped)</div>
+                <div><b>-{{file.gain | bytes}}</b></div>
+            </div>
+        </div>
+    </div>
+
+    <div ng-if="policyName === 'totalRequests'">
+        <h3>Requests by MIME type</h3>
+        <div ng-repeat="(type, requests) in rule.offendersObj.list.byType">
+            <h3><ng-pluralize count="requests.length" when="{'0': 'No ' + type + ' request', 'one': '1 ' + type + ' request', 'other': '{} ' + type + ' requests'}"></ng-pluralize></h3>
+            <p ng-if="type == 'css' && requests.length > 2">Reduce the number of stylesheets by concatenating them.</p>
+            <p ng-if="type == 'js' && requests.length > 3">Reduce the number of scripts by concatenating them.</p>
+            <p ng-if="type == 'image' && requests.length > 5">Reduce the number of images by lazyloading them or by spriting them.</p>
+            <p ng-if="type == 'webfont' && requests.length > 1">Fonts are generally loaded on the critical path of the head. Load as few as possible.</p>
+            <p ng-if="type == 'other' && requests.length > 0">They can be Flash, XML, music or any undetected format.</p>
+            <div class="offendersTable">
+                <div ng-repeat="request in requests track by $index">
+                    <div><url-link url="request" max-length="100"></url-link></div>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <div ng-if="policyName === 'smallRequests'">
+        <div ng-repeat="(type, requests) in rule.offendersObj.list.byType">
+            <h3><ng-pluralize count="requests.length" when="{'0': 'small ' + type + ' file', 'one': '1 small ' + type + ' file', 'other': '{} small ' + type + ' files'}"></ng-pluralize></h3>
+            <p ng-if="type == 'css' && requests.length > 0">Try to inline these styles in the head of the HTML or to merge them with other files.</p>
+            <p ng-if="type == 'js' && requests.length > 0">Try to inline these scripts in the HTML or merge them with other files.</p>
+            <p ng-if="type == 'image' && requests.length > 0">Try to inline these images (with base64 encoding for most image types except SVG that don't need base64 encoding). You can also create sprites.</p>
+            <div class="table">
+                <div class="headers">
+                    <div ng-if="type == 'image'">Preview</div>
+                    <div>File</div>
+                    <div>Weight (bytes)</div>
+                </div>
+                <div ng-repeat="request in requests track by $index">
+                    <div ng-if="type == 'image'"><img ng-src="{{request.url}}" class="smallPreview checker" /></div>
+                    <div><url-link url="request.url" max-length="100"></url-link></div>
+                    <div>{{request.size}}</div>
+                </div>
+            </div>
+        </div>
+    </div>
+
     <div ng-if="policyName === 'DOMaccesses'">
         <h3>{{rule.value}} offenders</h3>
         Please open the <a href="/result/{{runId}}/timeline">JS timeline</a>

+ 83 - 262
lib/metadata/policies.js

@@ -43,7 +43,7 @@ var policies = {
         "message": "<p>IDs of HTML elements must be document-wide unique. This can cause problems with getElementById returning the wrong element.</p>",
         "isOkThreshold": 0,
         "isBadThreshold": 5,
-        "isAbnormalThreshold": 10,
+        "isAbnormalThreshold": 50,
         "hasOffenders": true,
         "offendersTransformFn": function(offenders) {
             return {
@@ -122,7 +122,7 @@ var policies = {
         "message": "<p>Number of 'scroll' event listeners binded to 'window' or 'document'.</p><p>Asking too much work to the browser on scroll hurts the smoothness of the scroll. Merging all your event listeners into an unique listener can help you factorize their code and reduce their footprint on scroll.</p>",
         "isOkThreshold": 1,
         "isBadThreshold": 7,
-        "isAbnormalThreshold": 12,
+        "isAbnormalThreshold": 15,
         "hasOffenders": true,
         "offendersTransformFn": function(offenders) {
             return {
@@ -190,15 +190,6 @@ var policies = {
             };
         }
     },
-    /*"evalCalls": {
-        "tool": "phantomas",
-        "label": "eval calls",
-        "message": "<p>The 'eval' function is slow and is a bad coding practice. Try to get rid of it.</p>",
-        "isOkThreshold": 0,
-        "isBadThreshold": 10,
-        "isAbnormalThreshold": 20,
-        "hasOffenders": false
-    },*/
     "documentWriteCalls": {
         "tool": "phantomas",
         "label": "document.write calls",
@@ -256,7 +247,7 @@ var policies = {
         "message": "<p>Try to keep your console clean when in production. Debugging is good for development only.</p><p>Writing in the console has a cost, especially when dumping large object variables.</p><p>There is also a problem with Internet Explorer 8, not knowing the console object.</p>",
         "isOkThreshold": 0,
         "isBadThreshold": 10,
-        "isAbnormalThreshold": 25,
+        "isAbnormalThreshold": 50,
         "hasOffenders": false
     },
     "globalVariables": {
@@ -264,8 +255,8 @@ var policies = {
         "label": "Global variables",
         "message": "<p>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.</p><p>Global variables also take a (very) little bit longer to be accessed than variables in the local scope of a function.</p>",
         "isOkThreshold": 30,
-        "isBadThreshold": 100,
-        "isAbnormalThreshold": 400,
+        "isBadThreshold": 150,
+        "isAbnormalThreshold": 700,
         "hasOffenders": true,
         "offendersTransformFn": function(offenders) {
             return {
@@ -375,7 +366,7 @@ var policies = {
         "message": "<p>Yellow Lab Tools failed to parse a CSS file. I doubt the problem comes from the css parser.</p><p>Maybe a <a href=\"http://jigsaw.w3.org/css-validator\" target=\"_blank\">CSS validator</a> can help you.</p>",
         "isOkThreshold": 0,
         "isBadThreshold": 1,
-        "isAbnormalThreshold": 8,
+        "isAbnormalThreshold": 20,
         "hasOffenders": true,
         "offendersTransformFn": function(offenders) {
             return {
@@ -852,258 +843,99 @@ var policies = {
             };
         }
     },
-    "requests": {
-        "tool": "phantomas",
-        "label": "Total requests number",
-        "message": "<p>This is one of the most important performance rule. Every request is slowing down the page loading.</p><p>There are several technics to reduce their number:<ul><li>Concatenate JS files</li><li>Concatenate CSS files</li><li>Embed or inline small JS or CSS files in the HTML</li><li>Create sprites or icon fonts</li><li>Base64 encode small images in HTML or stylesheets</li><li>Use lazyloading for images</li></ul></p>",
-        "isOkThreshold": 15,
-        "isBadThreshold": 100,
-        "isAbnormalThreshold": 200,
+    "totalWeight": {
+        "tool": "weightChecker",
+        "label": "Total weight",
+        "message": "<p>The weight is of course very important if you want the page to load fast. Try to stay under 1MB, which is alreay very long to download over a slow connection.</p>",
+        "isOkThreshold": 716800,
+        "isBadThreshold": 2097152,
+        "isAbnormalThreshold": 3145728,
         "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;
-                    })
-            };
-        }
+        "unit": 'bytes'
     },
-    "htmlCount": {
-        "tool": "phantomas",
-        "label": "Document count",
-        "message": "<p>The number of HTML pages requests, HTML fragments or iframes.</p>",
-        "isOkThreshold": 10,
-        "isBadThreshold": 20,
-        "isAbnormalThreshold": 30,
+    "imageOptimization": {
+        "tool": "weightChecker",
+        "label": "Image optimization",
+        "message": "<p>This metric mesures the number of bytes that could be saved by optimizing images.</p><p>Image optimization is generally one of the easiest way to reduce a page weight, and as a result, the page load time. Don't use Photoshop or other image editing tools, they're not very good for optimization. Use specialized tools such as <a href=\"https://kraken.io/\" target=\"_blank\">Kraken.io</a> or the excellent <a href=\"https://imageoptim.com/\" target=\"_blank\">ImageOption</a> on Mac. For SVG images, you can use <a href=\"https://jakearchibald.github.io/svgomg/\" target=\"_blank\">SVGOMG</a></p><p>The tools in use in YellowLabTools are not set to their maximum optimization power, so you might be able to compress even more!</p>",
+        "isOkThreshold": 10240,
+        "isBadThreshold": 122880,
+        "isAbnormalThreshold": 307200,
         "hasOffenders": true,
-        "offendersTransformFn": function(offenders) {
-            return {
-                count: offenders.length,
-                list: offenders.map(function(offender) {
-                        return offendersHelpers.fileWithSizePattern(offender);
-                    })
-            };
-        }
+        "unit": 'bytes'
     },
-    "jsCount": {
-        "tool": "phantomas",
-        "label": "Script count",
-        "message": "<p>Reduce the number of scripts by concatenating them.</p>",
-        "isOkThreshold": 5,
-        "isBadThreshold": 15,
-        "isAbnormalThreshold": 30,
+    "gzipCompression": {
+        "tool": "weightChecker",
+        "label": "Gzip compression",
+        "message": "<p>Mesures the number of bytes that could be saved by compressing file transfers.</p><p>Gzip is a powerfull weight reducer and should be enabled on text-based assets in your server's configuration. Note that gzipping small files (< 1 KB) is arguable, and that some assets such as images should not be gzipped as they are already compressed. <a href=\"https://gist.github.com/gmetais/971ce13a1fbeebd88445\" target=\"_blank\">Here</a> is a list of Content-Types that should be gzipped.</p>",
+        "isOkThreshold": 5125,
+        "isBadThreshold": 81920,
+        "isAbnormalThreshold": 153600,
         "hasOffenders": true,
-        "offendersTransformFn": function(offenders) {
-            return {
-                count: offenders.length,
-                list: offenders.map(function(offender) {
-                        return offendersHelpers.fileWithSizePattern(offender);
-                    })
-            };
-        }
+        "unit": 'bytes'
     },
-    "cssCount": {
-        "tool": "phantomas",
-        "label": "CSS count",
-        "message": "<p>Reduce the number of stylesheets by concatenating them.</p>",
-        "isOkThreshold": 3,
-        "isBadThreshold": 10,
-        "isAbnormalThreshold": 22,
+    "fileMinification": {
+        "tool": "weightChecker",
+        "label": "File minification",
+        "message": "<p>This is the weight that could be saved if all text resources were correctly minified.</p><p>The tools in use here are <b>UglifyJS</b>, <b>clean-css</b> and <b>HTMLMinifier</b>. These tools are so good that some of your minified files can be marked as unminified. Change your tool it this happens :)</p><p>The gains of minification are generally small, but the impact can be high when these text files are loaded on the critical path.</p>",
+        "isOkThreshold": 5125,
+        "isBadThreshold": 61440,
+        "isAbnormalThreshold": 122880,
         "hasOffenders": true,
-        "offendersTransformFn": function(offenders) {
-            return {
-                count: offenders.length,
-                list: offenders.map(function(offender) {
-                        return offendersHelpers.fileWithSizePattern(offender);
-                    })
-            };
-        }
+        "unit": 'bytes'
     },
-    "imageCount": {
-        "tool": "phantomas",
-        "label": "Image count",
-        "message": "<p>Reduce the number of images by lazyloading them, by spriting them or by creating an icons font.</p>",
+    "totalRequests": {
+        "tool": "weightChecker",
+        "label": "Requests number",
+        "message": "<p>This is one of the most important performance rule. Every request is slowing down the page loading.</p><p>There are several technics to reduce their number:<ul><li>Concatenate JS files</li><li>Concatenate CSS files</li><li>Embed or inline small JS or CSS files in the HTML</li><li>Create sprites or icon fonts</li><li>Base64 encode small images in HTML or stylesheets</li><li>Use lazyloading for images</li></ul></p>",
         "isOkThreshold": 15,
-        "isBadThreshold": 40,
-        "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": "<p>Fonts are loaded on the critical path of the head. Load as few as possible.</p>",
-        "isOkThreshold": 1,
-        "isBadThreshold": 3,
-        "isAbnormalThreshold": 5,
-        "hasOffenders": true,
-        "offendersTransformFn": function(offenders) {
-            return {
-                count: offenders.length,
-                list: offenders.map(function(offender) {
-                        return offendersHelpers.fileWithSizePattern(offender);
-                    })
-            };
-        }
-    },
-    "videoCount": {
-        "tool": "phantomas",
-        "label": "Video count",
-        "message": "<p>The number of videos loaded.</p>",
-        "isOkThreshold": 1,
-        "isBadThreshold": 5,
-        "isAbnormalThreshold": 15,
-        "hasOffenders": true,
-        "offendersTransformFn": function(offenders) {
-            return {
-                count: offenders.length,
-                list: offenders.map(function(offender) {
-                        return offendersHelpers.fileWithSizePattern(offender);
-                    })
-            };
-        }
-    },
-    "jsonCount": {
-        "tool": "phantomas",
-        "label": "JSON count",
-        "message": "<p>The number of AJAX requests to JSON files or webservices.</p>",
-        "isOkThreshold": 2,
-        "isBadThreshold": 10,
-        "isAbnormalThreshold": 25,
-        "hasOffenders": true,
-        "offendersTransformFn": function(offenders) {
-            return {
-                count: offenders.length,
-                list: offenders.map(function(offender) {
-                        return offendersHelpers.fileWithSizePattern(offender);
-                    })
-            };
-        }
-    },
-    "otherCount": {
-        "tool": "phantomas",
-        "label": "Other types of requests",
-        "message": "<p>They can be Flash, XML, music or any unknown format.</p>",
-        "isOkThreshold": 5,
-        "isBadThreshold": 20,
-        "isAbnormalThreshold": 40,
-        "hasOffenders": true,
-        "offendersTransformFn": function(offenders) {
-            return {
-                count: offenders.length,
-                list: offenders.map(function(offender) {
-                        return offendersHelpers.fileWithSizePattern(offender);
-                    })
-            };
-        }
-    },
-    "smallJsFiles": {
-        "tool": "phantomas",
-        "label": "Small JS files",
-        "message": "<p>Number of JS assets smaller than 2 KB that could probably be inlined or merged.</p>",
-        "isOkThreshold": 2,
-        "isBadThreshold": 10,
-        "isAbnormalThreshold": 16,
-        "hasOffenders": true,
-        "offendersTransformFn": function(offenders) {
-            return {
-                count: offenders.length,
-                list: offenders.map(function(offender) {
-                        return offendersHelpers.fileWithSizePattern(offender);
-                    })
-            };
-        }
-    },
-    "smallCssFiles": {
-        "tool": "phantomas",
-        "label": "Small CSS files",
-        "message": "<p>Number of CSS assets smaller than 2 KB that could probably be inlined or merged.</p>",
-        "isOkThreshold": 0,
-        "isBadThreshold": 8,
-        "isAbnormalThreshold": 12,
-        "hasOffenders": true,
-        "offendersTransformFn": function(offenders) {
-            return {
-                count: offenders.length,
-                list: offenders.map(function(offender) {
-                        return offendersHelpers.fileWithSizePattern(offender);
-                    })
-            };
-        }
-    },
-    "smallImages": {
-        "tool": "phantomas",
-        "label": "Small images",
-        "message": "<p>Images smaller than 2 KB that could be base64 encoded or merged into a sprite.</p>",
-        "isOkThreshold": 2,
-        "isBadThreshold": 17,
-        "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": "<p>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.</p>",
-        "isOkThreshold": 0,
-        "isBadThreshold": 1,
-        "isAbnormalThreshold": 1,
+        "isBadThreshold": 100,
+        "isAbnormalThreshold": 180,
         "hasOffenders": true
     },
-    "assetsNotGzipped": {
+    "domains": {
         "tool": "phantomas",
-        "label": "Not gzipped",
-        "message": "<p>This is the number of requests that should be compressed with gzip but aren't.</p><p>Gzip is a powerfull weight reducer and should be enabled on text-based assets in your server's configuration. Note that gzipping small files (< 1 KB) is arguable, and that some assets such as images should not be gzipped as they are already compressed. <a href=\"https://gist.github.com/gmetais/971ce13a1fbeebd88445\" target=\"_blank\">Here</a> is a list of Content-Types that should be gzipped.</p>",
-        "isOkThreshold": 0,
-        "isBadThreshold": 12,
-        "isAbnormalThreshold": 20,
+        "label": "Different domains",
+        "message": "<p>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.</p><p>By the way, domain sharding is not a good practice anymore.</p>",
+        "isOkThreshold": 10,
+        "isBadThreshold": 25,
+        "isAbnormalThreshold": 50,
         "hasOffenders": true,
         "offendersTransformFn": function(offenders) {
             return {
                 count: offenders.length,
                 list: offenders.map(function(offender) {
-                    var parts = /^([^ ]*) \((.+)\)$/.exec(offender);
+                    var parts = /^([^ ]*): (\d+) request\(s\)$/.exec(offender);
 
                     if (!parts) {
-                        debug('assetsNotGzipped offenders transform function error with "%s"', offender);
+                        debug('domains offenders transform function error with "%s"', offender);
                         return {
-                            parseError: offender
+                            file: offender
                         };
                     }
 
                     return {
-                        file: parts[1],
-                        type: parts[2]
+                        domain: parts[1],
+                        requests: parseInt(parts[2])
                     };
                 })
             };
         }
     },
+    "notFound": {
+        "tool": "phantomas",
+        "label": "404 not found",
+        "message": "<p>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.</p>",
+        "isOkThreshold": 0,
+        "isBadThreshold": 1,
+        "isAbnormalThreshold": 1,
+        "hasOffenders": true
+    },
     "closedConnections": {
         "tool": "phantomas",
         "label": "Connections closed",
         "message": "<p>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.</p><p>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.</p><p>Correct the problem by setting a Keep-Alive header on the guilty server.</p>",
         "isOkThreshold": 0,
-        "isBadThreshold": 8,
+        "isBadThreshold": 7,
         "isAbnormalThreshold": 20,
         "hasOffenders": true
     },
@@ -1116,6 +948,24 @@ var policies = {
         "isAbnormalThreshold": 10,
         "hasOffenders": true
     },
+    "smallRequests": {
+        "tool": "weightChecker",
+        "label": "Small requests",
+        "message": "<p>List of all requests that are less than 2 KB. Try to merge them with other files.</p>",
+        "isOkThreshold": 4,
+        "isBadThreshold": 30,
+        "isAbnormalThreshold": 50,
+        "hasOffenders": true
+    },
+    "lazyLoadableImagesBelowTheFold": {
+        "tool": "phantomas",
+        "label": "Not lazyloaded images",
+        "message": "<p>This is the number of images displayed below the fold that could be lazy-loaded. This is an excellent way to accelerate the loading time of an heavy page.</p><p>I recommend using <a href=\"https://github.com/vvo/lazyload\" target=\"_blank\">this lazyloader</a>.</p>",
+        "isOkThreshold": 1,
+        "isBadThreshold": 12,
+        "isAbnormalThreshold": 30,
+        "hasOffenders": true
+    },
     "cachingDisabled": {
         "tool": "phantomas",
         "label": "Caching disabled",
@@ -1192,35 +1042,6 @@ var policies = {
                     })
             };
         }
-    },
-    "domains": {
-        "tool": "phantomas",
-        "label": "Different domains",
-        "message": "<p>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.</p><p>By the way, domain sharding is not a good practice anymore.</p>",
-        "isOkThreshold": 10,
-        "isBadThreshold": 25,
-        "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])
-                    };
-                })
-            };
-        }
     }
 };
 

+ 28 - 34
lib/metadata/scoreProfileGeneric.json

@@ -1,9 +1,29 @@
 {
     "categories": {
+        "pageWeight": {
+            "label": "Page weight",
+            "policies": {
+                "totalWeight": 5,
+                "imageOptimization": 2,
+                "gzipCompression": 2,
+                "fileMinification": 1
+            }
+        },
+        "requests": {
+            "label": "Requests",
+            "policies": {
+                "totalRequests": 5,
+                "domains": 3,
+                "notFound": 3,
+                "multipleRequests": 2,
+                "smallRequests": 1,
+                "lazyLoadableImagesBelowTheFold": 2
+            }
+        },
         "domComplexity": {
             "label": "DOM complexity",
             "policies": {
-                "DOMelementsCount": 1,
+                "DOMelementsCount": 3,
                 "DOMelementMaxDepth": 1,
                 "iframesCount": 1,
                 "DOMidDuplicated": 1
@@ -69,44 +89,20 @@
                 "cssRedundantChildNodesSelectors": 1
             }
         },
-        "requests": {
-            "label": "Requests number",
-            "policies": {
-                "requests": 5,
-                "htmlCount": 0,
-                "jsCount": 1,
-                "cssCount": 1,
-                "imageCount": 0,
-                "webfontCount": 2,
-                "videoCount": 0,
-                "jsonCount": 0,
-                "otherCount": 0
-            }
-        },
-        "smallRequests": {
-            "label": "Small requests",
+        "serverConfig": {
+            "label": "Server config",
             "policies": {
-                "smallJsFiles": 1,
-                "smallCssFiles": 1,
-                "smallImages": 1
-            }
-        },
-        "network": {
-            "label": "Network",
-            "policies": {
-                "notFound": 2,
-                "assetsNotGzipped": 1.5,
                 "closedConnections": 2,
-                "multipleRequests": 2,
                 "cachingNotSpecified": 1,
                 "cachingDisabled": 1,
-                "cachingTooShort": 1,
-                "domains": 1
+                "cachingTooShort": 1
             }
         }
     },
     "globalScore": {
-        "domComplexity": 1,
+        "pageWeight": 3,
+        "requests": 3,
+        "domComplexity": 2,
         "domManipulations": 2,
         "scroll": 1,
         "badJavascript": 1,
@@ -114,8 +110,6 @@
         "cssSyntaxError": 1,
         "cssComplexity": 1,
         "badCSS": 1,
-        "requests": 3,
-        "smallRequests": 1,
-        "network": 2
+        "serverConfig": 1
     }
 }

+ 1 - 1
lib/offendersHelpers.js

@@ -168,7 +168,7 @@ var OffendersHelpers = function() {
     };
 
     this.fileWithSizePattern = function(fileWithSize) {
-        var parts = /^([^ ]*) \((\d+\.\d{2}) kB\)$/.exec(fileWithSize);
+        var parts = /^([^ ]*) \((\d+\.\d{2}|NaN) kB\)$/.exec(fileWithSize);
 
         if (!parts) {
             return {

+ 13 - 5
lib/runner.js

@@ -3,7 +3,7 @@ var debug                   = require('debug')('ylt:runner');
 
 var phantomasWrapper        = require('./tools/phantomas/phantomasWrapper');
 var jsExecutionTransformer  = require('./tools/jsExecutionTransformer');
-var weightChecker           = require('./tools/weightChecker');
+var weightChecker           = require('./tools/weightChecker/weightChecker');
 var rulesChecker            = require('./rulesChecker');
 var scoreCalculator         = require('./scoreCalculator');
 
@@ -20,7 +20,9 @@ var Runner = function(params) {
     };
 
     // Execute Phantomas first
-    phantomasWrapper.execute(data).then(function(phantomasResults) {
+    phantomasWrapper.execute(data)
+
+    .then(function(phantomasResults) {
         data.toolsResults.phantomas = phantomasResults;
 
         // Treat the JS Execution Tree from offenders
@@ -29,7 +31,9 @@ var Runner = function(params) {
         // Redownload every file
         return weightChecker.recheckAllFiles(data);
 
-    }).then(function(data) {
+    })
+
+    .then(function(data) {
 
         // Rules checker
         var policies = require('./metadata/policies');
@@ -50,12 +54,16 @@ var Runner = function(params) {
 
         return data;
 
-    }).then(function(data) {
+    })
+
+    .then(function(data) {
 
         // Finished!
         deferred.resolve(data);
 
-    }).fail(function(err) {
+    })
+
+    .fail(function(err) {
         debug('Run failed');
         debug(err);
 

+ 0 - 279
lib/tools/weightChecker.js

@@ -1,279 +0,0 @@
-/*
- * Redownloading every files after Phantomas has finished
- * Checks weight and every kind of compression
- *
- */
-
-
-var debug = require('debug')('ylt:weightChecker');
-
-var request = require('request');
-var http    = require('http');
-var async   = require('async');
-var Q       = require('q');
-var zlib    = require('zlib');
-
-var WeightChecker = function() {
-
-    var MAX_PARALLEL_DOWNLOADS = 10;
-    var REQUEST_TIMEOUT = 10000; // 10 seconds
-
-    function recheckAllFiles(data) {
-        var deferred = Q.defer();
-
-        var requestsList = JSON.parse(data.toolsResults.phantomas.offenders.requestsList);
-        delete data.toolsResults.phantomas.metrics.requestsList;
-        delete data.toolsResults.phantomas.offenders.requestsList;
-
-        // Transform every request into a download function with a callback when done
-        var redownloadList = requestsList.map(function(entry) {
-            return function(callback) {
-                redownloadEntry(entry, callback);
-            };
-        });
-
-        // Lanch all redownload functions and wait for completion
-        async.parallelLimit(redownloadList, MAX_PARALLEL_DOWNLOADS, function(err, results) {
-            if (err) {
-                debug(err);
-                deferred.reject(err);
-            } else {
-                debug('All files checked');
-                
-                var metrics = {};
-                var offenders = {};
-
-
-                // Total weight
-                offenders.totalWeight = listRequestWeight(results);
-                metrics.totalWeight = offenders.totalWeight.totalWeight;
-
-
-                data.toolsResults.weightChecker = {
-                    metrics: metrics,
-                    offenders: offenders
-                };
-
-                deferred.resolve(data);
-            }
-        });
-
-        return deferred.promise;
-    }
-
-
-    function listRequestWeight(requests) {
-        var results = {
-            totalWeight: 0,
-            byType: {
-                html: {
-                    totalWeight: 0,
-                    requests: []
-                },
-                css: {
-                    totalWeight: 0,
-                    requests: []
-                },
-                js: {
-                    totalWeight: 0,
-                    requests: []
-                },
-                json: {
-                    totalWeight: 0,
-                    requests: []
-                },
-                image: {
-                    totalWeight: 0,
-                    requests: []
-                },
-                video: {
-                    totalWeight: 0,
-                    requests: []
-                },
-                webfont: {
-                    totalWeight: 0,
-                    requests: []
-                },
-                other: {
-                    totalWeight: 0,
-                    requests: []
-                }
-            }
-        };
-
-        requests.forEach(function(req) {
-            var weight = (typeof req.weightCheck.bodySize === 'number') ? req.weightCheck.bodySize + req.weightCheck.headersSize : req.contentLength;
-            var type = req.type || 'other';
-
-            results.totalWeight += weight;
-            results.byType[type].totalWeight += weight;
-
-            results.byType[type].requests.push({
-                url: req.url,
-                weight: weight
-            });
-        });
-
-        return results;
-    }
-
-
-    function redownloadEntry(entry, callback) {
-        
-        function onError(message) {
-            debug('Could not download %s Error: %s', entry.url, message);
-            entry.weightCheck = {
-                message: message
-            };
-            setImmediate(function() {
-                callback(null, entry);
-            });
-        }
-
-        if (entry.method !== 'GET') {
-            onError('only downloading GET');
-            return;
-        }
-
-        if (entry.status !== 200) {
-            onError('only downloading requests with status code 200');
-            return;
-        }
-
-
-        debug('Downloading %s', entry.url);
-
-        // Always add a gzip header before sending, in case the server listens to it
-        var reqHeaders = entry.requestHeaders;
-        reqHeaders['Accept-Encoding'] = 'gzip, deflate';
-
-        var requestOptions = {
-            method: entry.method,
-            url: entry.url,
-            headers: reqHeaders,
-            timeout: REQUEST_TIMEOUT
-        };
-
-        download(requestOptions, function(error, result) {
-            if (error) {
-                if (error.code === 'ETIMEDOUT') {
-                    onError('timeout after ' + REQUEST_TIMEOUT + 'ms');
-                } else {
-                    onError('error while downloading: ' + error.code);
-                }
-                return;
-            }
-                
-            debug('%s downloaded correctly', entry.url);
-
-            entry.weightCheck = result;
-            callback(null, entry);
-        });
-    }
-
-    // Inspired by https://github.com/cvan/fastHAR-api/blob/10cec585/app.js
-    function download(requestOptions, callback) {
-
-        var statusCode;
-
-        request(requestOptions)
-
-        .on('response', function(res) {
-            
-            // Raw headers were added in NodeJS v0.12
-            // (https://github.com/joyent/node/issues/4844), but let's
-            // reconstruct them for backwards compatibility.
-            var rawHeaders = ('HTTP/' + res.httpVersion + ' ' + res.statusCode +
-                              ' ' + http.STATUS_CODES[res.statusCode] + '\r\n');
-            Object.keys(res.headers).forEach(function(headerKey) {
-                rawHeaders += headerKey + ': ' + res.headers[headerKey] + '\r\n';
-            });
-            rawHeaders += '\r\n';
-
-            var uncompressedSize = 0;  // size after uncompression
-            var bodySize = 0;  // bytes size over the wire
-            var body = '';  // plain text body (after uncompressing gzip/deflate)
-            var isCompressed = false;
-
-            function tally() {
-
-                if (statusCode !== 200) {
-                    callback({code: statusCode});
-                    return;
-                }
-
-                var result = {
-                    body: body,
-                    headersSize: Buffer.byteLength(rawHeaders, 'utf8'),
-                    bodySize: bodySize,
-                    isCompressed: isCompressed,
-                    uncompressedSize: uncompressedSize
-                };
-
-                callback(null, result);
-            }
-
-            switch (res.headers['content-encoding']) {
-                case 'gzip':
-                    var gzip = zlib.createGunzip();
-
-                    gzip.on('data', function (data) {
-                        body += data;
-                        uncompressedSize += data.length;
-                    }).on('end', function () {
-                        isCompressed = true;
-                        tally();
-                    });
-
-                    res.on('data', function (data) {
-                        bodySize += data.length;
-                    }).pipe(gzip);
-
-                    break;
-                case 'deflate':
-                    var deflate = zlib.createInflate();
-
-                    deflate.on('data', function (data) {
-                        body += data;
-                        uncompressedSize += data.length;
-                    }).on('end', function () {
-                        isCompressed = true;
-                        tally();
-                    });
-
-                    res.on('data', function (data) {
-                        bodySize += data.length;
-                    }).pipe(deflate);
-
-                    break;
-                default:
-                    res.on('data', function (data) {
-                        body += data;
-                        uncompressedSize += data.length;
-                        bodySize += data.length;
-                    }).on('end', function () {
-                        tally();
-                    });
-
-                    break;
-            }
-        })
-
-        .on('response', function(response) {
-            statusCode = response.statusCode;
-        })
-
-        .on('error', function(err) {
-            callback(err);
-        });
-    }
-
-    return {
-        recheckAllFiles: recheckAllFiles,
-        listRequestWeight: listRequestWeight,
-        redownloadEntry: redownloadEntry,
-        download: download
-    };
-};
-
-module.exports = new WeightChecker();

+ 343 - 0
lib/tools/weightChecker/fileMinifier.js

@@ -0,0 +1,343 @@
+var debug = require('debug')('ylt:fileMinifier');
+
+var Q               = require('q');
+var UglifyJS        = require('uglify-js');
+var CleanCSS        = require('clean-css');
+var Minimize        = require('minimize');
+
+
+var FileMinifier = function() {
+
+    function minifyFile(entry) {
+        var deferred = Q.defer();
+
+        if (!entry.weightCheck || !entry.weightCheck.body) {
+            // No valid file available
+            deferred.resolve(entry);
+            return deferred.promise;
+        }
+
+        var fileSize = entry.weightCheck.uncompressedSize;
+        debug('Let\'s try to optimize %s', entry.url);
+        debug('Current file size is %d', fileSize);
+        var startTime = Date.now();
+
+        if (entry.isJS && !isKnownAsMinified(entry.url) && !looksAlreadyMinified(entry.weightCheck.body)) {
+
+            debug('File is a JS');
+
+            return minifyJs(entry.weightCheck.body)
+
+            .then(function(newFile) {
+                if (!newFile) {
+                    debug('Optimization didn\'t work');
+                    return entry;
+                }
+
+                var endTime = Date.now();
+
+                var newFileSize = newFile.length;
+
+                debug('JS minification complete for %s', entry.url);
+                
+                if (gainIsEnough(fileSize, newFileSize)) {
+                    entry.weightCheck.bodyAfterOptimization = newFile;
+                    entry.weightCheck.optimized = newFileSize;
+                    entry.weightCheck.isOptimized = false;
+                    debug('Filesize is %d bytes smaller (-%d%)', fileSize - newFileSize, Math.round((fileSize - newFileSize) * 100 / fileSize));
+                }
+
+                return entry;
+            })
+
+            .fail(function(err) {
+                return entry;
+            });
+
+        } else if (entry.isCSS) {
+
+            debug('File is a CSS');
+
+            return minifyCss(entry.weightCheck.body)
+
+            .then(function(newFile) {
+                if (!newFile) {
+                    debug('Optimization didn\'t work');
+                    return entry;
+                }
+
+                var endTime = Date.now();
+                debug('CSS minification took %dms', endTime - startTime);
+
+                var newFileSize = newFile.length;
+
+                debug('CSS minification complete for %s', entry.url);
+                
+                if (gainIsEnough(fileSize, newFileSize)) {
+                    entry.weightCheck.bodyAfterOptimization = newFile;
+                    entry.weightCheck.optimized = newFileSize;
+                    entry.weightCheck.isOptimized = false;
+                    debug('Filesize is %d bytes smaller (-%d%)', fileSize - newFileSize, Math.round((fileSize - newFileSize) * 100 / fileSize));
+                }
+
+                return entry;
+            })
+
+            .fail(function(err) {
+                return entry;
+            });
+
+        } else if (entry.isHTML) {
+
+            debug('File is an HTML');
+
+            return minifyHtml(entry.weightCheck.body)
+
+            .then(function(newFile) {
+                if (!newFile) {
+                    debug('Optimization didn\'t work');
+                    return entry;
+                }
+
+                var endTime = Date.now();
+                debug('HTML minification took %dms', endTime - startTime);
+
+                var newFileSize = newFile.length;
+
+                debug('HTML minification complete for %s', entry.url);
+                
+                if (gainIsEnough(fileSize, newFileSize)) {
+                    entry.weightCheck.bodyAfterOptimization = newFile;
+                    entry.weightCheck.optimized = newFileSize;
+                    entry.weightCheck.isOptimized = false;
+                    debug('Filesize is %d bytes smaller (-%d%)', fileSize - newFileSize, Math.round((fileSize - newFileSize) * 100 / fileSize));
+                }
+
+                return entry;
+            })
+
+            .fail(function(err) {
+                return entry;
+            });
+
+        } else {
+            debug('Not minfiable type or already minified');
+            deferred.resolve(entry);
+        }
+
+        return deferred.promise;
+    }
+
+    // The gain is estimated of enough value if it's over 2KB or over 20%,
+    // but it's ignored if is below 400 bytes
+    function gainIsEnough(oldWeight, newWeight) {
+        var gain = oldWeight - newWeight;
+        var ratio = gain / oldWeight;
+        return (gain > 2096 || (ratio > 0.2 && gain > 400));
+    }
+
+    // Uglify
+    function minifyJs(body) {
+        
+        // Splitting the Uglify function because it sometime takes too long (more than 10 seconds)
+        // I hope that, by splitting, it can be a little more asynchronous, so the application doesn't freeze.
+
+        return splittedUglifyStep1(body)
+        .delay(1)
+        .then(splittedUglifyStep2)
+        .delay(1)
+        .then(splittedUglifyStep3)
+        .delay(1)
+        .then(splittedUglifyStep4)
+        .delay(1)
+        .then(splittedUglifyStep5)
+        .delay(1)
+        .then(splittedUglifyStep6)
+        .delay(1)
+        .then(splittedUglifyStep7);
+
+    }
+
+    function splittedUglifyStep1(code) {
+        var deferred = Q.defer();
+        var startTime = Date.now();
+
+        try {
+            var toplevel_ast = UglifyJS.parse(code);
+
+            var endTime = Date.now();
+            debug('Uglify step 1 took %dms', endTime - startTime);
+            deferred.resolve(toplevel_ast);
+
+        } catch(err) {
+            debug('JS syntax error, Uglify\'s parser failed (step 1)');
+            deferred.reject(err);
+        }
+
+        return deferred.promise;
+    }
+
+    function splittedUglifyStep2(toplevel) {
+        var deferred = Q.defer();
+        var startTime = Date.now();
+
+        toplevel.figure_out_scope();
+
+        var endTime = Date.now();
+        debug('Uglify step 2 took %dms', endTime - startTime);
+        deferred.resolve(toplevel);
+        return deferred.promise;
+    }
+
+    function splittedUglifyStep3(toplevel) {
+        var deferred = Q.defer();
+        var startTime = Date.now();
+
+        var compressor = UglifyJS.Compressor({warnings: false});
+        var compressed_ast = toplevel.transform(compressor);
+
+        var endTime = Date.now();
+        debug('Uglify step 3 took %dms', endTime - startTime);
+        deferred.resolve(compressed_ast);
+        return deferred.promise;
+    }
+
+    function splittedUglifyStep4(compressed_ast) {
+        var deferred = Q.defer();
+        var startTime = Date.now();
+
+        compressed_ast.figure_out_scope();
+
+        var endTime = Date.now();
+        debug('Uglify step 4 took %dms', endTime - startTime);
+        deferred.resolve(compressed_ast);
+        return deferred.promise;
+    }
+
+    function splittedUglifyStep5(compressed_ast) {
+        var deferred = Q.defer();
+        var startTime = Date.now();
+
+        compressed_ast.compute_char_frequency();
+
+        var endTime = Date.now();
+        debug('Uglify step 5 took %dms', endTime - startTime);
+        deferred.resolve(compressed_ast);
+        return deferred.promise;
+    }
+
+    function splittedUglifyStep6(compressed_ast) {
+        var deferred = Q.defer();
+        var startTime = Date.now();
+
+        compressed_ast.mangle_names();
+
+        var endTime = Date.now();
+        debug('Uglify step 6 took %dms', endTime - startTime);
+        deferred.resolve(compressed_ast);
+        return deferred.promise;
+    }
+
+    function splittedUglifyStep7(compressed_ast) {
+        var deferred = Q.defer();
+        var startTime = Date.now();
+
+        var code = compressed_ast.print_to_string();
+
+        var endTime = Date.now();
+        debug('Uglify step 7 took %dms', endTime - startTime);
+        deferred.resolve(code);
+        return deferred.promise;
+    }
+
+    // Clear-css
+    function minifyCss(body) {
+        var deferred = Q.defer();
+
+        try {
+            var result = new CleanCSS({compatibility: 'ie8'}).minify(body);
+            deferred.resolve(result.styles);
+        } catch(err) {
+            deferred.reject(err);
+        }
+
+        return deferred.promise;
+    }
+
+    // HTMLMinifier
+    function minifyHtml(body) {
+        var deferred = Q.defer();
+
+        var minimize = new Minimize({
+            empty: true,        // KEEP empty attributes
+            conditionals: true, // KEEP conditional internet explorer comments
+            spare: true         // KEEP redundant attributes
+        });
+
+        minimize.parse(body, function (error, data) {
+            if (error) {
+                deferred.reject(error);
+            } else {
+                deferred.resolve(data);
+            }
+        });
+
+        return deferred.promise;
+    }
+
+    // Avoid loosing time trying to compress some JS libraries known as already compressed
+    function isKnownAsMinified(url) {
+        var result = false;
+
+        // Twitter
+        result = result || /^https?:\/\/platform\.twitter\.com\/widgets\.js/.test(url);
+
+        // Facebook
+        result = result || /^https:\/\/connect\.facebook\.net\/[^\/]*\/(sdk|all)\.js/.test(url);
+
+        // Google +1
+        result = result || /^https:\/\/apis\.google\.com\/js\/plusone\.js/.test(url);
+
+        // jQuery CDN
+        result = result || /^https?:\/\/code\.jquery\.com\/.*\.min.js/.test(url);
+
+        // Google Analytics
+        result = result || /^https?:\/\/(www|ssl)\.google-analytics\.com\/(.*)\.js/.test(url);
+
+        if (result === true) {
+            debug('This file is known as already minified. Skipping minification: %s', url);
+        }
+
+        return result;
+    }
+
+    // Avoid loosing tome trying to compress JS files if they alreay look minified
+    // by counting the number of lines compared to the total size.
+    // Less than 1000kb per line is suspicious
+    function looksAlreadyMinified(code) {
+        var linesCount = code.split(/\r\n|\r|\n/).length;
+        var linesRatio = code.length / linesCount;
+        var looksMinified = linesRatio > 1024;
+        
+        debug('Lines ratio is %d bytes per line', Math.round(linesRatio));
+        debug(looksMinified ? 'It looks already minified' : 'It doesn\'t look minified');
+
+        return looksMinified;
+    }
+
+    function entryTypeCanBeMinified(entry) {
+        return entry.isJS || entry.isCSS || entry.isHTML;
+    }
+
+    return {
+        minifyFile: minifyFile,
+        minifyJs: minifyJs,
+        minifyCss: minifyCss,
+        minifyHtml: minifyHtml,
+        gainIsEnough: gainIsEnough,
+        entryTypeCanBeMinified: entryTypeCanBeMinified,
+        isKnownAsMinified: isKnownAsMinified
+    };
+};
+
+module.exports = new FileMinifier();

+ 100 - 0
lib/tools/weightChecker/gzipCompressor.js

@@ -0,0 +1,100 @@
+var debug = require('debug')('ylt:gzipCompressor');
+
+var Q       = require('q');
+var zlib    = require('zlib');
+
+var GzipCompressor = function() {
+
+    function compressFile(entry) {
+        return gzipUncompressedFile(entry)
+
+        .then(gzipOptimizedFile);
+    }
+
+    // Gzip a file if it was not already gziped
+    function gzipUncompressedFile(entry) {
+        var deferred = Q.defer();
+
+        if (entryTypeCanBeGzipped(entry) && entry.weightCheck && !entry.weightCheck.isCompressed && entry.weightCheck.body) {
+            debug('Compression missing, trying to gzip file %s', entry.url);
+
+            var uncompressedSize = entry.weightCheck.uncompressedSize;
+
+            zlib.gzip(new Buffer(entry.weightCheck.body, 'utf8'), function(err, buffer) {
+                if (err) {
+                    debug('Could not compress uncompressed file with gzip');
+                    debug(err);
+
+                    deferred.reject(err);
+                } else {
+                    var compressedSize = buffer.length;
+
+                    if (gainIsEnough(uncompressedSize, compressedSize)) {
+                        debug('File correctly gziped, was %d and is now %d bytes', uncompressedSize, compressedSize);
+
+                        entry.weightCheck.afterCompression = compressedSize;
+                    } else {
+                        debug('Gzip gain is not enough, was %d and is now %d bytes', uncompressedSize, compressedSize);
+                    }
+
+                    deferred.resolve(entry);
+                }
+            });
+        } else {
+            
+            deferred.resolve(entry);
+        }
+
+        return deferred.promise;
+    }
+
+    // Gzip a file after minification or optimization if this step was successful
+    function gzipOptimizedFile(entry) {
+        var deferred = Q.defer();
+
+        if (entryTypeCanBeGzipped(entry) && entry.weightCheck && entry.weightCheck.isOptimized === false) {
+            debug('Trying to gzip file after minification: %s', entry.url);
+
+            var uncompressedSize = entry.weightCheck.optimized;
+
+            zlib.gzip(new Buffer(entry.weightCheck.bodyAfterOptimization, 'utf8'), function(err, buffer) {
+                if (err) {
+                    debug('Could not compress minified file with gzip');
+                    debug(err);
+
+                    deferred.reject(err);
+                } else {
+                    var compressedSize = buffer.length;
+
+                    debug('Correctly gziped the minified file, was %d and is now %d bytes', uncompressedSize, compressedSize);
+                    entry.weightCheck.afterOptimizationAndCompression = compressedSize;
+
+                    deferred.resolve(entry);
+                }
+            });
+        } else {
+            deferred.resolve(entry);
+        }
+
+        return deferred.promise;
+    }
+
+    // The gain is estimated of enough value if it's over 1KB or over 20%,
+    // but it's ignored if is below 100 bytes
+    function gainIsEnough(oldWeight, newWeight) {
+        var gain = oldWeight - newWeight;
+        var ratio = gain / oldWeight;
+        return (gain > 2048 || (ratio > 0.2 && gain > 100));
+    }
+
+    function entryTypeCanBeGzipped(entry) {
+        return entry.isJS || entry.isCSS || entry.isHTML || entry.isJSON || entry.isSVG || entry.isTTF || entry.isXML || entry.isFavicon;
+    }
+
+    return {
+        compressFile: compressFile,
+        entryTypeCanBeGzipped: entryTypeCanBeGzipped
+    };
+};
+
+module.exports = new GzipCompressor();

+ 245 - 0
lib/tools/weightChecker/imageOptimizer.js

@@ -0,0 +1,245 @@
+var debug = require('debug')('ylt:imageOptimizer');
+
+var Q           = require('q');
+var Imagemin    = require('imagemin');
+var jpegoptim   = require('imagemin-jpegoptim');
+
+var ImageOptimizer = function() {
+
+    var MAX_JPEG_QUALITY = 85;
+    var OPTIPNG_COMPRESSION_LEVEL = 1;
+
+    function optimizeImage(entry) {
+        var deferred = Q.defer();
+
+        if (!entry.weightCheck || !entry.weightCheck.body) {
+            // No valid file available
+            deferred.resolve(entry);
+            return deferred.promise;
+        }
+
+        var fileSize = entry.weightCheck.uncompressedSize;
+        debug('Let\'s try to optimize %s', entry.url);
+        debug('Current file size is %d', fileSize);
+
+        if (isJPEG(entry)) {
+            debug('File is a JPEG');
+
+            // Starting softly with a lossless compression
+            return compressJpegLosslessly(new Buffer(entry.weightCheck.body, 'binary'))
+
+            .then(function(newFile) {
+                if (!newFile) {
+                    debug('Optimization didn\'t work');
+                    return entry;
+                }
+
+                var newFileSize = newFile.contents.length;
+
+                debug('JPEG lossless compression complete for %s', entry.url);
+                
+                if (gainIsEnough(fileSize, newFileSize)) {
+                    entry.weightCheck.lossless = entry.weightCheck.optimized = newFileSize;
+                    entry.weightCheck.isOptimized = false;
+                    debug('Filesize is %d bytes smaller (-%d%)', fileSize - newFileSize, Math.round((fileSize - newFileSize) * 100 / fileSize));
+                }
+
+
+                // Now let's compress lossy to MAX_JPEG_QUALITY
+                return compressJpegLossly(new Buffer(entry.weightCheck.body, 'binary'));
+            })
+            
+            .then(function(newFile) {
+                if (!newFile) {
+                    debug('Optimization didn\'t work');
+                    return entry;
+                }
+
+                var newFileSize = newFile.contents.length;
+
+                debug('JPEG lossy compression complete for %s', entry.url);
+
+                if (gainIsEnough(fileSize, newFileSize)) {
+                    
+                    if (entry.weightCheck.isOptimized !== false || newFileSize < entry.weightCheck.lossless) {
+                        entry.weightCheck.optimized = newFileSize;
+                    }
+
+                    entry.weightCheck.lossy = newFileSize;
+                    entry.weightCheck.isOptimized = false;
+                    debug('Filesize is %d bytes smaller (-%d%)', fileSize - newFileSize, Math.round((fileSize - newFileSize) * 100 / fileSize));
+                }
+
+                return entry;
+            })
+
+            .fail(function() {
+                return entry;
+            });
+
+        } else if (isPNG(entry)) {
+
+            debug('File is a PNG');
+
+            // Starting softly with a lossless compression
+            return compressPngLosslessly(new Buffer(entry.weightCheck.body, 'binary'))
+
+            .then(function(newFile) {
+                if (!newFile) {
+                    debug('Optimization didn\'t work');
+                    return entry;
+                }
+                
+                var newFileSize = newFile.contents.length;
+
+                debug('PNG lossless compression complete for %s', entry.url);
+                
+                debug('Old file size: %d', fileSize);
+                debug('New file size: %d', newFileSize);
+                debug('newgainIsEnough: %s', gainIsEnough(fileSize, newFileSize) ? 'true':'false');
+
+                if (gainIsEnough(fileSize, newFileSize)) {
+                    entry.weightCheck.lossless = entry.weightCheck.optimized = newFileSize;
+                    entry.weightCheck.isOptimized = false;
+                    debug('Filesize is %d bytes smaller (-%d%)', fileSize - newFileSize, Math.round((fileSize - newFileSize) * 100 / fileSize));
+                }
+
+                return entry;
+            })
+
+            .fail(function() {
+                return entry;
+            });
+
+        } else if (isSVG(entry)) {
+
+            debug('File is an SVG');
+
+            // Starting softly with a lossless compression
+            return compressSvgLosslessly(new Buffer(entry.weightCheck.body, 'utf8'))
+
+            .then(function(newFile) {
+                if (!newFile) {
+                    debug('Optimization didn\'t work');
+                    return entry;
+                }
+
+                var newFileSize = newFile.contents.length;
+
+                debug('SVG lossless compression complete for %s', entry.url);
+                
+                if (gainIsEnough(fileSize, newFileSize)) {
+                    entry.weightCheck.bodyAfterOptimization = newFile.contents.toString();
+                    entry.weightCheck.lossless = entry.weightCheck.optimized = newFileSize;
+                    entry.weightCheck.isOptimized = false;
+                    debug('Filesize is %d bytes smaller (-%d%)', fileSize - newFileSize, Math.round((fileSize - newFileSize) * 100 / fileSize));
+                }
+
+                return entry;
+            })
+
+            .fail(function() {
+                return entry;
+            });
+
+        } else {
+            debug('File type %s is not an optimizable image', entry.contentType);
+            deferred.resolve(entry);
+        }
+
+        return deferred.promise;
+    }
+
+    // The gain is estimated of enough value if it's over 2KB or over 20%,
+    // but it's ignored if is below 100 bytes
+    function gainIsEnough(oldWeight, newWeight) {
+        var gain = oldWeight - newWeight;
+        var ratio = gain / oldWeight;
+        return (gain > 2048 || (ratio > 0.2 && gain > 100));
+    }
+
+    function isJPEG(entry) {
+        return entry.isImage && entry.contentType === 'image/jpeg';
+    }
+
+    function isPNG(entry) {
+        return entry.isImage && entry.contentType === 'image/png';
+    }
+
+    function isSVG(entry) {
+        return entry.isImage && entry.isSVG;
+    }
+
+    function compressJpegLosslessly(imageBody) {
+        return imageminLauncher(imageBody, 'jpeg', false);
+    }
+
+    function compressJpegLossly(imageBody) {
+        return imageminLauncher(imageBody, 'jpeg', true);
+    }
+
+    function compressPngLosslessly(imageBody) {
+        return imageminLauncher(imageBody, 'png', false);
+    }
+
+    function compressSvgLosslessly(imageBody) {
+        return imageminLauncher(imageBody, 'svg', false);
+    }
+
+    function imageminLauncher(imageBody, type, lossy) {
+        var deferred = Q.defer();
+        var startTime = Date.now();
+
+        debug('Starting %s %s optimization', type, lossy ? 'lossy' : 'lossless');
+
+        var engine;
+        if (type === 'jpeg' && !lossy) {
+            engine = Imagemin.jpegtran();
+        } else if (type === 'jpeg' && lossy) {
+            engine = jpegoptim({max: MAX_JPEG_QUALITY});
+        } else if (type === 'png' && !lossy) {
+            engine = Imagemin.optipng({optimizationLevel: OPTIPNG_COMPRESSION_LEVEL});
+        } else if (type === 'svg' && !lossy) {
+            engine = Imagemin.svgo();
+        } else {
+            deferred.reject('No optimization engine found for imagemin');
+        }
+
+        try {
+
+            new Imagemin()
+                .src(imageBody)
+                .use(engine)
+                .run(function (err, files) {
+                    if (err) {
+                        deferred.reject(err);
+                    } else {
+                        deferred.resolve(files[0]);
+                        var endTime = Date.now();
+                        debug('Optimization for %s took %d ms', type, endTime - startTime);
+                    }
+                });
+
+            } catch(err) {
+                deferred.reject(err);
+            }
+
+        return deferred.promise;
+    }
+
+    function entryTypeCanBeOptimized(entry) {
+        return isJPEG(entry) || isPNG(entry) || isSVG(entry);
+    }
+
+    return {
+        optimizeImage: optimizeImage,
+        compressJpegLosslessly: compressJpegLosslessly,
+        compressJpegLossly: compressJpegLossly,
+        compressPngLosslessly: compressPngLosslessly,
+        compressSvgLosslessly: compressSvgLosslessly,
+        gainIsEnough: gainIsEnough,
+        entryTypeCanBeOptimized: entryTypeCanBeOptimized
+    };
+};
+
+module.exports = new ImageOptimizer();

+ 501 - 0
lib/tools/weightChecker/weightChecker.js

@@ -0,0 +1,501 @@
+/*
+ * Redownloading every files after Phantomas has finished
+ * Checks weight and every kind of compression
+ *
+ */
+
+
+var debug           = require('debug')('ylt:weightChecker');
+var Q               = require('q');
+var http            = require('http');
+var zlib            = require('zlib');
+var async           = require('async');
+var request         = require('request');
+
+var imageOptimizer  = require('./imageOptimizer');
+var fileMinifier    = require('./fileMinifier');
+var gzipCompressor  = require('./gzipCompressor');
+
+
+var WeightChecker = function() {
+
+    var MAX_PARALLEL_DOWNLOADS = 10;
+    var REQUEST_TIMEOUT = 15000; // 15 seconds
+
+
+    // This function will re-download every asset and check if it could be optimized
+    function recheckAllFiles(data) {
+        var startTime = Date.now();
+        debug('Redownload started');
+        var deferred = Q.defer();
+
+        var requestsList = JSON.parse(data.toolsResults.phantomas.offenders.requestsList);
+        delete data.toolsResults.phantomas.metrics.requestsList;
+        delete data.toolsResults.phantomas.offenders.requestsList;
+
+        // Transform every request into a download function with a callback when done
+        var redownloadList = requestsList.map(function(entry) {
+            return function(callback) {
+                
+                redownloadEntry(entry)
+
+                .then(imageOptimizer.optimizeImage)
+
+                .then(fileMinifier.minifyFile)
+
+                .then(gzipCompressor.compressFile)
+
+                .then(function(newEntry) {
+                    callback(null, newEntry);
+                })
+
+                .fail(function(err) {
+                    callback(err);
+                });
+            };
+        });
+
+        // Lanch all redownload functions and wait for completion
+        async.parallelLimit(redownloadList, MAX_PARALLEL_DOWNLOADS, function(err, results) {
+            
+            if (err) {
+                debug(err);
+                deferred.reject(err);
+            } else {
+
+                debug('All files checked');
+                endTime = Date.now();
+                debug('Redownload took %d ms', endTime - startTime);
+                
+                var metrics = {};
+                var offenders = {};
+
+                // Count requests
+                offenders.totalRequests = listRequestsByType(results);
+                metrics.totalRequests = offenders.totalRequests.total;
+
+                // Remove unwanted requests (redirections, about:blank)
+                results = results.filter(function(result) {
+                    return (result !== null && result.weightCheck && result.weightCheck.bodySize > 0);
+                });
+
+
+                // Total weight
+                offenders.totalWeight = listRequestWeight(results);
+                metrics.totalWeight = offenders.totalWeight.totalWeight;
+
+                // Image compression
+                offenders.imageOptimization = listImageNotOptimized(results);
+                metrics.imageOptimization = offenders.imageOptimization.totalGain;
+
+                // File minification
+                offenders.fileMinification = listFilesNotMinified(results);
+                metrics.fileMinification = offenders.fileMinification.totalGain;
+
+                // Gzip compression
+                offenders.gzipCompression = listFilesNotGzipped(results);
+                metrics.gzipCompression = offenders.gzipCompression.totalGain;
+
+                // Small requests
+                offenders.smallRequests = listSmallRequests(results);
+                metrics.smallRequests = offenders.smallRequests.total;
+
+                data.toolsResults.weightChecker = {
+                    metrics: metrics,
+                    offenders: offenders
+                };
+
+                deferred.resolve(data);
+            }
+        });
+
+        return deferred.promise;
+    }
+
+
+    function listRequestWeight(requests) {
+        var results = {
+            totalWeight: 0,
+            byType: {
+                html: {
+                    totalWeight: 0,
+                    requests: []
+                },
+                css: {
+                    totalWeight: 0,
+                    requests: []
+                },
+                js: {
+                    totalWeight: 0,
+                    requests: []
+                },
+                json: {
+                    totalWeight: 0,
+                    requests: []
+                },
+                image: {
+                    totalWeight: 0,
+                    requests: []
+                },
+                video: {
+                    totalWeight: 0,
+                    requests: []
+                },
+                webfont: {
+                    totalWeight: 0,
+                    requests: []
+                },
+                other: {
+                    totalWeight: 0,
+                    requests: []
+                }
+            }
+        };
+
+        requests.forEach(function(req) {
+            var weight = ((typeof req.weightCheck.bodySize === 'number') ? req.weightCheck.bodySize + req.weightCheck.headersSize : req.contentLength) || 0;
+            var type = req.type || 'other';
+
+            results.totalWeight += weight;
+            results.byType[type].totalWeight += weight;
+
+            results.byType[type].requests.push({
+                url: req.url,
+                weight: weight
+            });
+        });
+
+        return results;
+    }
+
+
+    function listImageNotOptimized(requests) {
+        var results = {
+            totalGain: 0,
+            images: []
+        };
+
+        requests.forEach(function(req) {
+            if (req.weightCheck.bodySize > 0 && imageOptimizer.entryTypeCanBeOptimized(req) && req.weightCheck.isOptimized === false) {
+                var before = req.weightCheck.afterCompression || req.weightCheck.bodySize;
+                var after = req.weightCheck.afterOptimizationAndCompression || req.weightCheck.optimized;
+                var gain = before - after;
+
+                if (gain > 200) {
+                    results.totalGain += gain;
+
+                    results.images.push({
+                        url: req.url,
+                        original: req.weightCheck.bodySize,
+                        isCompressed: req.weightCheck.isCompressed,
+                        afterCompression: req.weightCheck.afterCompression,
+                        afterOptimizationAndCompression: req.weightCheck.afterOptimizationAndCompression,
+                        lossless: req.weightCheck.lossless,
+                        lossy: req.weightCheck.lossy,
+                        gain: gain
+                    });
+                }
+            }
+        });
+
+        return results;
+    }
+
+
+    function listFilesNotMinified(requests) {
+        var results = {
+            totalGain: 0,
+            files: []
+        };
+
+        requests.forEach(function(req) {
+            if (req.weightCheck.bodySize > 0 && fileMinifier.entryTypeCanBeMinified(req) && req.weightCheck.isOptimized === false) {
+                var before = req.weightCheck.afterCompression || req.weightCheck.bodySize;
+                var after = req.weightCheck.afterOptimizationAndCompression || req.weightCheck.optimized;
+                var gain = before - after;
+
+                if (gain > 200) {
+                    results.totalGain += gain;
+
+                    results.files.push({
+                        url: req.url,
+                        original: req.weightCheck.bodySize,
+                        isCompressed: req.weightCheck.isCompressed,
+                        afterCompression: req.weightCheck.afterCompression,
+                        afterOptimizationAndCompression: req.weightCheck.afterOptimizationAndCompression,
+                        optimized: req.weightCheck.optimized,
+                        gain: gain
+                    });
+                }
+            }
+        });
+
+        return results;
+    }
+
+    function listFilesNotGzipped(requests) {
+        var results = {
+            totalGain: 0,
+            files: []
+        };
+
+        requests.forEach(function(req) {
+            if (req.weightCheck.uncompressedSize && req.weightCheck.isCompressed === false && req.weightCheck.afterCompression) {
+                var gain = req.weightCheck.uncompressedSize - req.weightCheck.afterCompression;
+
+                results.totalGain += gain;
+
+                results.files.push({
+                    url: req.url,
+                    original: req.weightCheck.uncompressedSize,
+                    gzipped: req.weightCheck.afterCompression,
+                    gain: gain
+                });
+            }
+        });
+
+        return results;
+    }
+
+    function listRequestsByType(requests) {
+        var results = {
+            total: 0,
+            byType: {
+                html: [],
+                css: [],
+                js: [],
+                json: [],
+                image: [],
+                video: [],
+                webfont: [],
+                other: []
+            }
+        };
+
+        requests.forEach(function(req) {
+            if (req.url !== 'about:blank') {
+                var type = req.type || 'other';
+                results.byType[type].push(req.url);
+                results.total ++;
+            }
+        });
+
+        return results;
+    }
+
+    function listSmallRequests(requests) {
+        var results = {
+            total: 0,
+            byType: {
+                css: [],
+                js: [],
+                image: []
+            }
+        };
+
+        requests.forEach(function(req) {
+            if (req.weightCheck.bodySize > 0 && req.weightCheck.bodySize < 2048) {
+                if (req.isCSS || req.isJS || req.isImage) {
+                    results.byType[req.type].push({
+                        url: req.url,
+                        size: req.weightCheck.bodySize
+                    });
+                    results.total ++;
+                }
+            }
+        });
+
+        return results;
+    }
+
+
+    function redownloadEntry(entry) {
+        var deferred = Q.defer();
+        
+        function downloadError(message) {
+            debug('Could not download %s Error: %s', entry.url, message);
+            entry.weightCheck = {
+                message: message
+            };
+            deferred.resolve(entry);
+        }
+
+        // Not downloaded again but will be counted in totalWeight
+        function notDownloadableFile(message) {
+            entry.weightCheck = {
+                message: message
+            };
+            deferred.resolve(entry);
+        }
+
+        // Not counted in totalWeight
+        function unwantedFile(message) {
+            debug(message);
+            deferred.resolve(entry);
+        }
+
+        if (entry.method !== 'GET') {
+            notDownloadableFile('only downloading GET');
+            return deferred.promise;
+        }
+
+        if (entry.status !== 200) {
+            unwantedFile('only downloading requests with status code 200');
+            return deferred.promise;
+        }
+
+        if (entry.url === 'about:blank') {
+            unwantedFile('not downloading about:blank');
+            return deferred.promise;
+        }
+
+
+        debug('Downloading %s', entry.url);
+
+        // Always add a gzip header before sending, in case the server listens to it
+        var reqHeaders = entry.requestHeaders;
+        reqHeaders['Accept-Encoding'] = 'gzip, deflate';
+
+        var requestOptions = {
+            method: entry.method,
+            url: entry.url,
+            headers: reqHeaders,
+            timeout: REQUEST_TIMEOUT
+        };
+
+        download(requestOptions, entry.contentType, function(error, result) {
+            if (error) {
+                if (error.code === 'ETIMEDOUT') {
+                    downloadError('timeout after ' + REQUEST_TIMEOUT + 'ms');
+                } else {
+                    downloadError('error while downloading: ' + error.code);
+                }
+                return;
+            }
+                
+            debug('%s downloaded correctly', entry.url);
+
+            entry.weightCheck = result;
+            deferred.resolve(entry);
+        });
+
+        return deferred.promise;
+    }
+
+    // Inspired by https://github.com/cvan/fastHAR-api/blob/10cec585/app.js
+    function download(requestOptions, contentType, callback) {
+
+        var statusCode;
+
+        request(requestOptions)
+
+        .on('response', function(res) {
+            
+            // Raw headers were added in NodeJS v0.12
+            // (https://github.com/joyent/node/issues/4844), but let's
+            // reconstruct them for backwards compatibility.
+            var rawHeaders = ('HTTP/' + res.httpVersion + ' ' + res.statusCode +
+                              ' ' + http.STATUS_CODES[res.statusCode] + '\r\n');
+            Object.keys(res.headers).forEach(function(headerKey) {
+                rawHeaders += headerKey + ': ' + res.headers[headerKey] + '\r\n';
+            });
+            rawHeaders += '\r\n';
+
+            var uncompressedSize = 0;  // size after uncompression
+            var bodySize = 0;  // bytes size over the wire
+            var body = '';  // plain text body (after uncompressing gzip/deflate)
+            var isCompressed = false;
+
+            function tally() {
+
+                if (statusCode !== 200) {
+                    callback({code: statusCode});
+                    return;
+                }
+
+                var result = {
+                    body: body,
+                    headersSize: Buffer.byteLength(rawHeaders, 'utf8'),
+                    bodySize: bodySize,
+                    isCompressed: isCompressed,
+                    uncompressedSize: uncompressedSize
+                };
+
+                callback(null, result);
+            }
+
+            switch (res.headers['content-encoding']) {
+                case 'gzip':
+
+                    var gzip = zlib.createGunzip();
+
+                    gzip.on('data', function (data) {
+                        body += data;
+                        uncompressedSize += data.length;
+                    }).on('end', function () {
+                        isCompressed = true;
+                        tally();
+                    }).on('error', function(err) {
+                        debug(err);
+                    });
+
+                    res.on('data', function (data) {
+                        bodySize += data.length;
+                    }).pipe(gzip);
+
+                    break;
+                case 'deflate':
+                    res.setEncoding('utf8');
+
+                    var deflate = zlib.createInflate();
+
+                    deflate.on('data', function (data) {
+                        body += data;
+                        uncompressedSize += data.length;
+                    }).on('end', function () {
+                        isCompressed = true;
+                        tally();
+                    }).on('error', function(err) {
+                        debug(err);
+                    });
+
+                    res.on('data', function (data) {
+                        bodySize += data.length;
+                    }).pipe(deflate);
+
+                    break;
+                default:
+                    if (contentType === 'image/jpeg' || contentType === 'image/png') {
+                        res.setEncoding('binary');
+                    }
+
+                    res.on('data', function (data) {
+                        body += data;
+                        uncompressedSize += data.length;
+                        bodySize += data.length;
+                    }).on('end', function () {
+                        tally();
+                    });
+
+                    break;
+            }
+        })
+
+        .on('response', function(response) {
+            statusCode = response.statusCode;
+        })
+
+        .on('error', function(err) {
+            callback(err);
+        });
+    }
+
+    return {
+        recheckAllFiles: recheckAllFiles,
+        listRequestWeight: listRequestWeight,
+        redownloadEntry: redownloadEntry,
+        download: download
+    };
+};
+
+module.exports = new WeightChecker();

+ 22 - 17
package.json

@@ -1,6 +1,6 @@
 {
   "name": "yellowlabtools",
-  "version": "1.6.4",
+  "version": "1.7.0",
   "description": "Online tool to audit a webpage for performance and front-end quality issues",
   "repository": {
     "type": "git",
@@ -11,22 +11,27 @@
   },
   "main": "./lib/index.js",
   "dependencies": {
-    "async": "~1.0.0",
-    "body-parser": "~1.12.4",
-    "compression": "~1.4.4",
-    "cors": "^2.6.0",
-    "debug": "~2.2.0",
-    "express": "~4.12.4",
-    "lwip": "0.0.6",
-    "meow": "^3.1.0",
-    "phantomas": "1.10.2",
+    "async": "1.2.1",
+    "body-parser": "1.13.1",
+    "clean-css": "3.3.0",
+    "compression": "1.5.0",
+    "cors": "2.7.1",
+    "debug": "2.2.0",
+    "express": "4.12.4",
+    "imagemin": "3.2.0",
+    "imagemin-jpegoptim": "4.0.0",
+    "lwip": "0.0.7",
+    "meow": "3.3.0",
+    "minimize": "1.4.1",
+    "phantomas": "1.11.0",
     "ps-node": "0.0.4",
-    "q": "~1.4.1",
-    "rimraf": "~2.3.4",
-    "temporary": "0.0.8"
+    "q": "1.4.1",
+    "rimraf": "2.4.0",
+    "temporary": "0.0.8",
+    "uglify-js": "2.4.23"
   },
   "devDependencies": {
-    "chai": "^2.3.0",
+    "chai": "^3.0.0",
     "grunt": "^0.4.5",
     "grunt-blanket": "^0.0.8",
     "grunt-contrib-clean": "^0.6.0",
@@ -48,9 +53,9 @@
     "grunt-webfont": "^0.5.3",
     "matchdep": "^0.3.0",
     "mocha": "^2.2.5",
-    "request": "^2.55.0",
-    "sinon": "^1.14.1",
-    "sinon-chai": "^2.7.0"
+    "request": "^2.58.0",
+    "sinon": "^1.15.3",
+    "sinon-chai": "^2.8.0"
   },
   "scripts": {
     "test": "grunt test"

+ 244 - 0
test/core/fileMinifierTest.js

@@ -0,0 +1,244 @@
+var should = require('chai').should();
+var fileMinifier = require('../../lib/tools/weightChecker/fileMinifier');
+var fs = require('fs');
+var path = require('path');
+
+describe('fileMinifier', function() {
+    
+    it('should minify a JS file with minifyJs', function(done) {
+        var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/unminified-script.js'));
+
+        var fileSize = fileContent.length;
+
+        fileMinifier.minifyJs(fileContent.toString()).then(function(newFile) {
+            var newFileSize = newFile.length;
+            newFileSize.should.be.below(fileSize);
+            done();
+        }).fail(function(err) {
+            done(err);
+        });
+    });
+
+    it('should minify a JS file with minifyFile', function(done) {
+        var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/unminified-script.js'));
+        var fileSize = fileContent.length;
+
+        var entry = {
+            method: 'GET',
+            url: 'http://localhost:8388/unminified-script.js',
+            requestHeaders: {
+                'User-Agent': 'something',
+                Referer: 'http://www.google.fr/',
+                Accept: '*/*',
+                'Accept-Encoding': 'gzip, deflate'
+            },
+            status: 200,
+            isJS: true,
+            type: 'js',
+            contentLength: 999,
+            weightCheck: {
+                body: fileContent.toString('utf8'),
+                totalWeight: fileSize + 200,
+                headersSize: 200,
+                bodySize: fileSize,
+                isCompressed: false,
+                uncompressedSize: fileSize
+            }
+        };
+
+        fileMinifier.minifyFile(entry)
+
+        .then(function(newEntry) {
+            newEntry.weightCheck.should.have.a.property('isOptimized').that.equals(false);
+            newEntry.weightCheck.should.have.a.property('optimized').that.is.below(fileSize);
+            newEntry.weightCheck.should.have.a.property('bodyAfterOptimization').that.is.a('string');
+
+            done();
+        })
+
+        .fail(function(err) {
+            done(err);
+        });
+    });
+
+    it('should fail minifying an already minified JS', function(done) {
+        this.timeout(5000);
+
+        var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/jquery1.8.3.js'));
+        var fileSize = fileContent.length;
+
+        var entry = {
+            method: 'GET',
+            url: 'http://localhost:8388/jquery1.8.3.js',
+            requestHeaders: {
+                'User-Agent': 'something',
+                Referer: 'http://www.google.fr/',
+                Accept: '*/*',
+                'Accept-Encoding': 'gzip, deflate'
+            },
+            status: 200,
+            isJS: true,
+            type: 'js',
+            contentLength: 999,
+            weightCheck: {
+                body: fileContent.toString('utf8'),
+                totalWeight: fileSize + 200,
+                headersSize: 200,
+                bodySize: fileSize,
+                isCompressed: false,
+                uncompressedSize: fileSize
+            }
+        };
+
+        fileMinifier.minifyFile(entry)
+
+        .then(function(newEntry) {
+            newEntry.weightCheck.should.not.have.a.property('isOptimized');
+            newEntry.weightCheck.should.not.have.a.property('optimized');
+            newEntry.weightCheck.should.not.have.a.property('bodyAfterOptimization');
+
+            done();
+        })
+
+        .fail(function(err) {
+            done(err);
+        });
+    });
+
+    it('should fail minifying a JS with syntax errors', function(done) {
+        var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/svg-image.svg'));
+        var fileSize = fileContent.length;
+
+        var entry = {
+            method: 'GET',
+            url: 'http://localhost:8388/svg-image.svg',
+            requestHeaders: {
+                'User-Agent': 'something',
+                Referer: 'http://www.google.fr/',
+                Accept: '*/*',
+                'Accept-Encoding': 'gzip, deflate'
+            },
+            status: 200,
+            isJS: true,
+            type: 'js',
+            contentLength: 999,
+            weightCheck: {
+                body: fileContent.toString('utf8'),
+                totalWeight: fileSize + 200,
+                headersSize: 200,
+                bodySize: fileSize,
+                isCompressed: false,
+                uncompressedSize: fileSize
+            }
+        };
+
+        fileMinifier.minifyFile(entry)
+
+        .then(function(newEntry) {
+            newEntry.weightCheck.should.not.have.a.property('isOptimized');
+            newEntry.weightCheck.should.not.have.a.property('optimized');
+            newEntry.weightCheck.should.not.have.a.property('bodyAfterOptimization');
+
+            done();
+        })
+
+        .fail(function(err) {
+            done(err);
+        });
+    });
+
+    it('should minify a CSS file with clean-css', function(done) {
+        var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/unminified-stylesheet.css'));
+
+        var fileSize = fileContent.length;
+
+        fileMinifier.minifyCss(fileContent.toString()).then(function(newFile) {
+            var newFileSize = newFile.length;
+            newFileSize.should.be.below(fileSize);
+            done();
+        }).fail(function(err) {
+            done(err);
+        });
+    });
+
+    it('should minify a CSS file with minifyFile', function(done) {
+        var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/unminified-stylesheet.css'));
+        var fileSize = fileContent.length;
+
+        var entry = {
+            method: 'GET',
+            url: 'http://localhost:8388/unminified-stylesheet.css',
+            requestHeaders: {
+                'User-Agent': 'something',
+                Referer: 'http://www.google.fr/',
+                Accept: '*/*',
+                'Accept-Encoding': 'gzip, deflate'
+            },
+            status: 200,
+            isCSS: true,
+            type: 'css',
+            contentLength: 999,
+            weightCheck: {
+                body: fileContent.toString('utf8'),
+                totalWeight: fileSize + 200,
+                headersSize: 200,
+                bodySize: fileSize,
+                isCompressed: false,
+                uncompressedSize: fileSize
+            }
+        };
+
+        fileMinifier.minifyFile(entry)
+
+        .then(function(newEntry) {
+            newEntry.weightCheck.should.have.a.property('isOptimized').that.equals(false);
+            newEntry.weightCheck.should.have.a.property('optimized').that.is.below(fileSize);
+            newEntry.weightCheck.should.have.a.property('bodyAfterOptimization').that.is.a('string');
+
+            done();
+        })
+
+        .fail(function(err) {
+            done(err);
+        });
+    });
+
+    it('should minify an HTML file with ', function(done) {
+        var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/jquery-page.html'));
+
+        var fileSize = fileContent.length;
+
+        fileMinifier.minifyHtml(fileContent.toString()).then(function(newFile) {
+            var newFileSize = newFile.length;
+            newFileSize.should.be.below(fileSize);
+            done();
+        }).fail(function(err) {
+            done(err);
+        });
+    });
+
+    it('should avoid minifying some JS files known as minified', function() {
+        fileMinifier.isKnownAsMinified('https://platform.twitter.com/widgets.js').should.equal(true);
+        fileMinifier.isKnownAsMinified('http://platform.twitter.com/widgets.js').should.equal(true);
+
+        fileMinifier.isKnownAsMinified('https://connect.facebook.net/fr_FR/sdk.js').should.equal(true);
+        fileMinifier.isKnownAsMinified('https://connect.facebook.net/en_EN/sdk.js').should.equal(true);
+        fileMinifier.isKnownAsMinified('https://connect.facebook.net/fr_FR/all.js').should.equal(true);
+        fileMinifier.isKnownAsMinified('https://connect.facebook.net/en_EN/all.js').should.equal(true);
+
+        fileMinifier.isKnownAsMinified('https://apis.google.com/js/plusone.js').should.equal(true);
+
+        fileMinifier.isKnownAsMinified('https://code.jquery.com/jquery-2.1.4.min.js').should.equal(true);
+        fileMinifier.isKnownAsMinified('http://code.jquery.com/jquery-2.1.4.min.js').should.equal(true);
+        fileMinifier.isKnownAsMinified('https://code.jquery.com/jquery-2.1.4.js').should.equal(false);
+        fileMinifier.isKnownAsMinified('http://code.jquery.com/jquery-2.1.4.js').should.equal(false);
+
+        fileMinifier.isKnownAsMinified('https://ssl.google-analytics.com/ga.js').should.equal(true);
+        fileMinifier.isKnownAsMinified('http://www.google-analytics.com/ga.js').should.equal(true);
+        fileMinifier.isKnownAsMinified('https://www.google-analytics.com/analytics.js').should.equal(true);
+        fileMinifier.isKnownAsMinified('http://www.google-analytics.com/analytics.js').should.equal(true);
+
+        fileMinifier.isKnownAsMinified('http://anydomain.com/anyurl').should.equal(false);
+    });
+
+});

+ 583 - 0
test/core/gzipCompressorTest.js

@@ -0,0 +1,583 @@
+var should = require('chai').should();
+var gzipCompressor = require('../../lib/tools/weightChecker/gzipCompressor');
+var fileMinifier = require('../../lib/tools/weightChecker/fileMinifier');
+var fs = require('fs');
+var path = require('path');
+
+describe('gzipCompressor', function() {
+
+    var minifiedJSContent = fs.readFileSync(path.resolve(__dirname, '../www/minified-script.js'));
+    var notMinifiedJSContent = fs.readFileSync(path.resolve(__dirname, '../www/unminified-script.js'));
+    var someTextFileContent = fs.readFileSync(path.resolve(__dirname, '../www/svg-image.svg'));
+
+    
+    it('should gzip a JS file that was not gziped but was minified', function(done) {
+        var fileContent = minifiedJSContent;
+        var fileSize = fileContent.length;
+
+        var entry = {
+            method: 'GET',
+            url: 'http://localhost:8388/minified-script.js',
+            status: 200,
+            isJS: true,
+            type: 'js',
+            contentLength: 999,
+            weightCheck: {
+                body: fileContent.toString('utf8'),
+                totalWeight: fileSize + 200,
+                headersSize: 200,
+                bodySize: fileSize,
+                isCompressed: false,
+                uncompressedSize: fileSize,
+                isOptimized: true
+            }
+        };
+
+        gzipCompressor.compressFile(entry)
+
+        .then(function(newEntry) {
+            newEntry.weightCheck.should.have.a.property('afterCompression').that.is.below(fileSize);
+            newEntry.weightCheck.should.not.have.a.property('afterOptimizationAndCompression');
+
+            done();
+        })
+
+        .fail(function(err) {
+            done(err);
+        });
+    });
+
+    it('should gzip a JS file that was not gziped and not minified', function(done) {
+        /*jshint expr: true*/
+
+        var fileContent = notMinifiedJSContent;
+        var minifiedContent = minifiedJSContent;
+
+        var fileSize = fileContent.length;
+        var minifiedSize = minifiedContent.length;
+
+        var entry = {
+            method: 'GET',
+            url: 'http://localhost:8388/unminified-script.js',
+            status: 200,
+            isJS: true,
+            type: 'js',
+            contentLength: 999,
+            weightCheck: {
+                body: fileContent.toString('utf8'),
+                bodyAfterOptimization: minifiedContent.toString('utf8'),
+                totalWeight: fileSize + 200,
+                headersSize: 200,
+                bodySize: fileSize,
+                isCompressed: false,
+                uncompressedSize: fileSize,
+                isOptimized: false,
+                optimized: minifiedSize
+            }
+        };
+
+        gzipCompressor.compressFile(entry)
+
+        .then(function(newEntry) {
+            newEntry.weightCheck.should.have.a.property('afterCompression').that.is.below(fileSize);
+            newEntry.weightCheck.should.have.a.property('afterOptimizationAndCompression').that.is.not.undefined;
+            newEntry.weightCheck.should.have.a.property('afterOptimizationAndCompression').that.is.below(newEntry.weightCheck.afterCompression);
+
+            done();
+        })
+
+        .fail(function(err) {
+            done(err);
+        });
+    });
+
+    it('should gzip a JS file that is gziped but not minified', function(done) {
+        /*jshint expr: true*/
+
+        var fileContent = notMinifiedJSContent;
+        var minifiedContent = minifiedJSContent;
+        var fileSize = 6436;
+        var gzipedSize = 2646;
+        var minifiedSize = 1954;
+
+        var entry = {
+            method: 'GET',
+            url: 'http://localhost:8388/unminified-script.js',
+            status: 200,
+            isJS: true,
+            type: 'js',
+            contentLength: 999,
+            weightCheck: {
+                body: fileContent.toString('utf8'),
+                bodyAfterOptimization: minifiedContent.toString('utf8'),
+                totalWeight: gzipedSize + 200,
+                headersSize: 200,
+                bodySize: gzipedSize,
+                isCompressed: true,
+                uncompressedSize: fileSize,
+                isOptimized: false,
+                optimized: minifiedSize
+            }
+        };
+
+        gzipCompressor.compressFile(entry)
+
+        .then(function(newEntry) {
+            newEntry.weightCheck.should.have.a.property('afterOptimizationAndCompression').that.is.not.undefined;
+            newEntry.weightCheck.should.have.a.property('afterOptimizationAndCompression').that.is.below(gzipedSize);
+            newEntry.weightCheck.should.have.a.property('afterOptimizationAndCompression').that.is.below(minifiedSize);
+
+            done();
+        })
+
+        .fail(function(err) {
+            done(err);
+        });
+    });
+
+    it('should not gzip a JS file that was gziped and minified', function(done) {
+        /*jshint expr: true*/
+
+        var fileContent = notMinifiedJSContent;
+        var fileSize = 6436;
+        var gzipedSize = 2646;
+
+        var entry = {
+            method: 'GET',
+            url: 'http://localhost:8388/unminified-script.js',
+            status: 200,
+            isJS: true,
+            type: 'js',
+            contentLength: 999,
+            weightCheck: {
+                body: fileContent.toString('utf8'),
+                totalWeight: gzipedSize + 200,
+                headersSize: 200,
+                bodySize: gzipedSize,
+                isCompressed: true,
+                uncompressedSize: fileSize,
+                isOptimized: true
+            }
+        };
+
+        gzipCompressor.compressFile(entry)
+
+        .then(function(newEntry) {
+            newEntry.weightCheck.should.not.have.a.property('minified');
+            newEntry.weightCheck.should.not.have.a.property('bodyAfterOptimization');
+            newEntry.weightCheck.should.not.have.a.property('afterCompression');
+            newEntry.weightCheck.should.not.have.a.property('afterOptimizationAndCompression');
+
+            done();
+        })
+
+        .fail(function(err) {
+            done(err);
+        });
+    });
+
+    it('should gzip a CSS file', function(done) {
+        var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/unminified-stylesheet.css'));
+        var fileSize = fileContent.length;
+
+        var entry = {
+            method: 'GET',
+            url: 'http://localhost:8388/unminified-stylesheet.css',
+            status: 200,
+            isCSS: true,
+            type: 'css',
+            contentLength: 999,
+            weightCheck: {
+                body: fileContent.toString('utf8'),
+                totalWeight: fileSize + 200,
+                headersSize: 200,
+                bodySize: fileSize,
+                isCompressed: false,
+                uncompressedSize: fileSize,
+                isOptimized: true
+            }
+        };
+
+        gzipCompressor.compressFile(entry)
+
+        .then(function(newEntry) {
+            newEntry.weightCheck.should.have.a.property('afterCompression').that.is.below(fileSize);
+            newEntry.weightCheck.should.not.have.a.property('afterOptimizationAndCompression');
+
+            done();
+        })
+
+        .fail(function(err) {
+            done(err);
+        });
+    });
+
+    it('should gzip an HTML file', function(done) {
+        var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/jquery-page.html'));
+        var fileSize = fileContent.length;
+
+        var entry = {
+            method: 'GET',
+            url: 'http://localhost:8388/jquery-page.html',
+            status: 200,
+            isHTML: true,
+            type: 'html',
+            contentLength: 999,
+            weightCheck: {
+                body: fileContent.toString('utf8'),
+                totalWeight: fileSize + 200,
+                headersSize: 200,
+                bodySize: fileSize,
+                isCompressed: false,
+                uncompressedSize: fileSize,
+                isOptimized: true
+            }
+        };
+
+        gzipCompressor.compressFile(entry)
+
+        .then(function(newEntry) {
+            newEntry.weightCheck.should.have.a.property('afterCompression').that.is.below(fileSize);
+            newEntry.weightCheck.should.not.have.a.property('afterOptimizationAndCompression');
+
+            done();
+        })
+
+        .fail(function(err) {
+            done(err);
+        });
+    });
+
+    it('should gzip an SVG file', function(done) {
+        var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/svg-image.svg'));
+        var fileSize = fileContent.length;
+
+        var entry = {
+            method: 'GET',
+            url: 'http://localhost:8388/svg-image.svg',
+            status: 200,
+            isImage: true,
+            isSVG: true,
+            type: 'image',
+            contentLength: 999,
+            weightCheck: {
+                body: fileContent.toString('utf8'),
+                totalWeight: fileSize + 200,
+                headersSize: 200,
+                bodySize: fileSize,
+                isCompressed: false,
+                uncompressedSize: fileSize,
+                isOptimized: true
+            }
+        };
+
+        gzipCompressor.compressFile(entry)
+
+        .then(function(newEntry) {
+            newEntry.weightCheck.should.have.a.property('afterCompression').that.is.below(fileSize);
+            newEntry.weightCheck.should.not.have.a.property('afterOptimizationAndCompression');
+
+            done();
+        })
+
+        .fail(function(err) {
+            done(err);
+        });
+    });
+
+    it('should gzip an XML file', function(done) {
+        var fileContent = someTextFileContent; // it dosn't matter if it's not the correct file type
+        var fileSize = fileContent.length;
+
+        var entry = {
+            method: 'GET',
+            url: 'http://localhost:8388/someTextFile.xml',
+            status: 200,
+            isXML: true,
+            type: 'xml',
+            contentLength: 999,
+            weightCheck: {
+                body: fileContent.toString('utf8'),
+                totalWeight: fileSize + 200,
+                headersSize: 200,
+                bodySize: fileSize,
+                isCompressed: false,
+                uncompressedSize: fileSize,
+                isOptimized: true
+            }
+        };
+
+        gzipCompressor.compressFile(entry)
+
+        .then(function(newEntry) {
+            newEntry.weightCheck.should.have.a.property('afterCompression').that.is.below(fileSize);
+            newEntry.weightCheck.should.not.have.a.property('afterOptimizationAndCompression');
+
+            done();
+        })
+
+        .fail(function(err) {
+            done(err);
+        });
+    });
+
+    it('should gzip a JSON file', function(done) {
+        var fileContent = someTextFileContent; // it dosn't matter if it's not the correct file type
+        var fileSize = fileContent.length;
+
+        var entry = {
+            method: 'GET',
+            url: 'http://localhost:8388/someTextFile.json',
+            status: 200,
+            isJSON: true,
+            type: 'json',
+            contentLength: 999,
+            weightCheck: {
+                body: fileContent.toString('utf8'),
+                totalWeight: fileSize + 200,
+                headersSize: 200,
+                bodySize: fileSize,
+                isCompressed: false,
+                uncompressedSize: fileSize,
+                isOptimized: true
+            }
+        };
+
+        gzipCompressor.compressFile(entry)
+
+        .then(function(newEntry) {
+            newEntry.weightCheck.should.have.a.property('afterCompression').that.is.below(fileSize);
+            newEntry.weightCheck.should.not.have.a.property('afterOptimizationAndCompression');
+
+            done();
+        })
+
+        .fail(function(err) {
+            done(err);
+        });
+    });
+
+    it('should gzip a TTF file', function(done) {
+        var fileContent = someTextFileContent; // it dosn't matter if it's not the correct file type
+        var fileSize = fileContent.length;
+
+        var entry = {
+            method: 'GET',
+            url: 'http://localhost:8388/someTextFile.ttf',
+            status: 200,
+            isWebFont: true,
+            isTTF: true,
+            type: 'webfont',
+            contentLength: 999,
+            weightCheck: {
+                body: fileContent.toString('utf8'),
+                totalWeight: fileSize + 200,
+                headersSize: 200,
+                bodySize: fileSize,
+                isCompressed: false,
+                uncompressedSize: fileSize,
+                isOptimized: true
+            }
+        };
+
+        gzipCompressor.compressFile(entry)
+
+        .then(function(newEntry) {
+            newEntry.weightCheck.should.have.a.property('afterCompression').that.is.below(fileSize);
+            newEntry.weightCheck.should.not.have.a.property('afterOptimizationAndCompression');
+
+            done();
+        })
+
+        .fail(function(err) {
+            done(err);
+        });
+    });
+
+
+    it('should gzip a favicon file', function(done) {
+        var fileContent = someTextFileContent; // it dosn't matter if it's not the correct file type
+        var fileSize = fileContent.length;
+
+        var entry = {
+            method: 'GET',
+            url: 'http://localhost:8388/someTextFile.ico',
+            status: 200,
+            isFavicon: true,
+            type: 'favicon',
+            contentLength: 999,
+            weightCheck: {
+                body: fileContent.toString('utf8'),
+                totalWeight: fileSize + 200,
+                headersSize: 200,
+                bodySize: fileSize,
+                isCompressed: false,
+                uncompressedSize: fileSize,
+                isOptimized: true
+            }
+        };
+
+        gzipCompressor.compressFile(entry)
+
+        .then(function(newEntry) {
+            newEntry.weightCheck.should.have.a.property('afterCompression').that.is.below(fileSize);
+            newEntry.weightCheck.should.not.have.a.property('afterOptimizationAndCompression');
+
+            done();
+        })
+
+        .fail(function(err) {
+            done(err);
+        });
+    });
+
+    it('should not gzip a JPEG file', function(done) {
+        var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/jpeg-image.jpg'));
+        var fileSize = fileContent.length;
+
+        var entry = {
+            method: 'GET',
+            url: 'http://localhost:8388/jpeg-image.jpg',
+            status: 200,
+            isImage: true,
+            type: 'image',
+            contentType: 'image/jpeg',
+            contentLength: 999,
+            weightCheck: {
+                body: fileContent.toString('utf8'),
+                totalWeight: fileSize + 200,
+                headersSize: 200,
+                bodySize: fileSize,
+                isCompressed: false,
+                uncompressedSize: fileSize,
+                isOptimized: true
+            }
+        };
+
+        gzipCompressor.compressFile(entry)
+
+        .then(function(newEntry) {
+            newEntry.weightCheck.should.not.have.a.property('afterCompression');
+            newEntry.weightCheck.should.not.have.a.property('afterOptimizationAndCompression');
+
+            done();
+        })
+
+        .fail(function(err) {
+            done(err);
+        });
+    });
+
+
+    it('should not gzip a PNG file', function(done) {
+        var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/png-image.png'));
+        var fileSize = fileContent.length;
+
+        var entry = {
+            method: 'GET',
+            url: 'http://localhost:8388/png-image.png',
+            status: 200,
+            isImage: true,
+            type: 'image',
+            contentType: 'image/png',
+            contentLength: 999,
+            weightCheck: {
+                body: fileContent.toString('utf8'),
+                totalWeight: fileSize + 200,
+                headersSize: 200,
+                bodySize: fileSize,
+                isCompressed: false,
+                uncompressedSize: fileSize,
+                isOptimized: true
+            }
+        };
+
+        gzipCompressor.compressFile(entry)
+
+        .then(function(newEntry) {
+            newEntry.weightCheck.should.not.have.a.property('afterCompression');
+            newEntry.weightCheck.should.not.have.a.property('afterOptimizationAndCompression');
+
+            done();
+        })
+
+        .fail(function(err) {
+            done(err);
+        });
+    });
+
+    it('should not gzip a GIF file', function(done) {
+        var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/png-image.png')); // Fake gif, don't tell anyone...
+        var fileSize = fileContent.length;
+
+        var entry = {
+            method: 'GET',
+            url: 'http://localhost:8388/gif-image.gif',
+            status: 200,
+            isImage: true,
+            type: 'image',
+            contentType: 'image/gif',
+            contentLength: 999,
+            weightCheck: {
+                body: fileContent.toString('utf8'),
+                totalWeight: fileSize + 200,
+                headersSize: 200,
+                bodySize: fileSize,
+                isCompressed: false,
+                uncompressedSize: fileSize,
+                isOptimized: true
+            }
+        };
+
+        gzipCompressor.compressFile(entry)
+
+        .then(function(newEntry) {
+            newEntry.weightCheck.should.not.have.a.property('afterCompression');
+            newEntry.weightCheck.should.not.have.a.property('afterOptimizationAndCompression');
+
+            done();
+        })
+
+        .fail(function(err) {
+            done(err);
+        });
+    });
+
+    it('should not gzip a WEBP file', function(done) {
+        var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/png-image.png')); // Fake webp, don't tell anyone...
+        var fileSize = fileContent.length;
+
+        var entry = {
+            method: 'GET',
+            url: 'http://localhost:8388/webp-image.webp',
+            status: 200,
+            isImage: true,
+            type: 'image',
+            contentType: 'image/webp',
+            contentLength: 999,
+            weightCheck: {
+                body: fileContent.toString('utf8'),
+                totalWeight: fileSize + 200,
+                headersSize: 200,
+                bodySize: fileSize,
+                isCompressed: false,
+                uncompressedSize: fileSize,
+                isOptimized: true
+            }
+        };
+
+        gzipCompressor.compressFile(entry)
+
+        .then(function(newEntry) {
+            newEntry.weightCheck.should.not.have.a.property('afterCompression');
+            newEntry.weightCheck.should.not.have.a.property('afterOptimizationAndCompression');
+
+            done();
+        })
+
+        .fail(function(err) {
+            done(err);
+        });
+    });
+
+});

+ 321 - 0
test/core/imageOptimizerTest.js

@@ -0,0 +1,321 @@
+var should = require('chai').should();
+var imageOptimizer = require('../../lib/tools/weightChecker/imageOptimizer');
+var fs = require('fs');
+var path = require('path');
+
+describe('imageOptimizer', function() {
+    
+    it('should optimize a JPEG image losslessly', function(done) {
+        var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/jpeg-image.jpg'));
+
+        var fileSize = fileContent.length;
+
+        imageOptimizer.compressJpegLosslessly(fileContent).then(function(newFile) {
+            var newFileSize = newFile.contents.length;
+            newFileSize.should.be.below(fileSize);
+            done();
+        }).fail(function(err) {
+            done(err);
+        });
+    });
+
+    it('should optimize a JPEG image lossly', function(done) {
+        var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/jpeg-image.jpg'));
+
+        var fileSize = fileContent.length;
+
+        imageOptimizer.compressJpegLossly(fileContent).then(function(newFile) {
+            var newFileSize = newFile.contents.length;
+            newFileSize.should.be.below(fileSize);
+            done();
+        }).fail(function(err) {
+            done(err);
+        });
+    });
+
+    it('should find the best optimization for a jpeg', function(done) {
+        var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/jpeg-image.jpg'));
+        var fileSize = fileContent.length;
+
+        var entry = {
+            method: 'GET',
+            url: 'http://localhost:8388/an-image.jpg',
+            requestHeaders: {
+                'User-Agent': 'something',
+                Referer: 'http://www.google.fr/',
+                Accept: '*/*',
+                'Accept-Encoding': 'gzip, deflate'
+            },
+            status: 200,
+            isImage: true,
+            type: 'image',
+            contentType: 'image/jpeg',
+            contentLength: 999,
+            weightCheck: {
+                body: fileContent,
+                totalWeight: fileSize + 200,
+                headersSize: 200,
+                bodySize: fileSize,
+                isCompressed: false,
+                uncompressedSize: fileSize
+            }
+        };
+
+        imageOptimizer.optimizeImage(entry)
+
+        .then(function(newEntry) {
+            newEntry.weightCheck.should.have.a.property('isOptimized').that.equals(false);
+            newEntry.weightCheck.should.have.a.property('lossless').that.is.below(fileSize);
+            newEntry.weightCheck.should.have.a.property('lossy').that.is.below(newEntry.weightCheck.lossless);
+
+            done();
+        })
+
+        .fail(function(err) {
+            done(err);
+        });
+    });
+
+    it('should optimize a PNG image losslessly', function(done) {
+        var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/png-image.png'));
+
+        var fileSize = fileContent.length;
+
+        imageOptimizer.compressPngLosslessly(fileContent).then(function(newFile) {
+            var newFileSize = newFile.contents.length;
+            newFileSize.should.be.below(fileSize);
+            done();
+        }).fail(function(err) {
+            done(err);
+        });
+    });
+
+    it('should fail to optimize an already optimized PNG', function(done) {
+        var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/logo-large.png'));
+
+        var fileSize = fileContent.length;
+
+        imageOptimizer.compressPngLosslessly(fileContent).then(function(newFile) {
+            var newFileSize = newFile.contents.length;
+            newFileSize.should.equal(fileSize);
+            done();
+        }).fail(function(err) {
+            done(err);
+        });
+    });
+
+    it('should fail to optimize a non-PNG', function(done) {
+        var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/svg-image.svg'));
+
+        var fileSize = fileContent.length;
+
+        imageOptimizer.compressPngLosslessly(fileContent).then(function(newFile) {
+            var newFileSize = newFile.contents.length;
+            newFileSize.should.equal(fileSize);
+            done();
+        }).fail(function(err) {
+            done(err);
+        });
+    });
+
+    it('should optimize a png', function(done) {
+        var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/png-image.png'));
+        var fileSize = fileContent.length;
+
+        var entry = {
+            method: 'GET',
+            url: 'http://localhost:8388/an-image.png',
+            requestHeaders: {
+                'User-Agent': 'something',
+                Referer: 'http://www.google.fr/',
+                Accept: '*/*',
+                'Accept-Encoding': 'gzip, deflate'
+            },
+            status: 200,
+            isImage: true,
+            type: 'image',
+            contentType: 'image/png',
+            contentLength: 999,
+            weightCheck: {
+                body: fileContent,
+                totalWeight: fileSize + 200,
+                headersSize: 200,
+                bodySize: fileSize,
+                isCompressed: false,
+                uncompressedSize: fileSize
+            }
+        };
+
+        imageOptimizer.optimizeImage(entry)
+
+        .then(function(newEntry) {
+            newEntry.weightCheck.should.have.a.property('isOptimized').that.equals(false);
+            newEntry.weightCheck.should.have.a.property('lossless').that.is.below(fileSize);
+
+            done();
+        })
+
+        .fail(function(err) {
+            done(err);
+        });
+    });
+
+    it('should optimize an SVG image losslessly', function(done) {
+        var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/svg-image.svg'));
+
+        var fileSize = fileContent.length;
+
+        imageOptimizer.compressSvgLosslessly(fileContent).then(function(newFile) {
+            var newFileSize = newFile.contents.length;
+            newFileSize.should.be.below(fileSize);
+            done();
+        }).fail(function(err) {
+            done(err);
+        });
+    });
+
+    it('should optimize an SVG', function(done) {
+        var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/svg-image.svg'));
+        var fileSize = fileContent.length;
+
+        var entry = {
+            method: 'GET',
+            url: 'http://localhost:8388/an-image.svg',
+            requestHeaders: {
+                'User-Agent': 'something',
+                Referer: 'http://www.google.fr/',
+                Accept: '*/*',
+                'Accept-Encoding': 'gzip, deflate'
+            },
+            status: 200,
+            isImage: true,
+            isSVG: true,
+            type: 'image',
+            contentType: 'image/svg+xml',
+            contentLength: 999,
+            weightCheck: {
+                body: fileContent,
+                totalWeight: fileSize + 200,
+                headersSize: 200,
+                bodySize: fileSize,
+                isCompressed: false,
+                uncompressedSize: fileSize
+            }
+        };
+
+        imageOptimizer.optimizeImage(entry)
+
+        .then(function(newEntry) {
+            newEntry.weightCheck.should.have.a.property('isOptimized').that.equals(false);
+            newEntry.weightCheck.should.have.a.property('lossless').that.is.below(fileSize);
+            newEntry.weightCheck.should.have.a.property('optimized').that.equals(newEntry.weightCheck.lossless);
+            newEntry.weightCheck.should.have.a.property('bodyAfterOptimization').that.is.a('string');
+
+            done();
+        })
+
+        .fail(function(err) {
+            done(err);
+        });
+    });
+
+
+    it('shouldn\'t fail optimizing a corrupted jpeg', function(done) {
+
+        // In this test, we try to optimize a PNG but with a falsy "image/jpeg" content type
+
+        var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/png-image.png'));
+        var fileSize = fileContent.length;
+
+        var entry = {
+            method: 'GET',
+            url: 'http://localhost:8388/an-image.png',
+            requestHeaders: {
+                'User-Agent': 'something',
+                Referer: 'http://www.google.fr/',
+                Accept: '*/*',
+                'Accept-Encoding': 'gzip, deflate'
+            },
+            status: 200,
+            isImage: true,
+            type: 'image',
+            contentType: 'image/jpeg',
+            contentLength: 999,
+            weightCheck: {
+                body: fileContent,
+                totalWeight: fileSize + 200,
+                headersSize: 200,
+                bodySize: fileSize,
+                isCompressed: false,
+                uncompressedSize: fileSize
+            }
+        };
+
+        imageOptimizer.optimizeImage(entry)
+
+        .then(function(newEntry) {
+            newEntry.weightCheck.should.not.have.a.property('isOptimized');
+            done();
+        })
+
+        .fail(function(err) {
+            done(err);
+        });
+    });
+
+    it('shouldn\'t fail optimizing a corrupted png', function(done) {
+
+        // In this test, we try to optimize a JPEG but with a falsy "image/png" content type
+
+        var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/jpeg-image.jpg'));
+        var fileSize = fileContent.length;
+
+        var entry = {
+            method: 'GET',
+            url: 'http://localhost:8388/an-image.jpg',
+            requestHeaders: {
+                'User-Agent': 'something',
+                Referer: 'http://www.google.fr/',
+                Accept: '*/*',
+                'Accept-Encoding': 'gzip, deflate'
+            },
+            status: 200,
+            isImage: true,
+            type: 'image',
+            contentType: 'image/png',
+            contentLength: 999,
+            weightCheck: {
+                body: fileContent,
+                totalWeight: fileSize + 200,
+                headersSize: 200,
+                bodySize: fileSize,
+                isCompressed: false,
+                uncompressedSize: fileSize
+            }
+        };
+
+        imageOptimizer.optimizeImage(entry)
+
+        .then(function(newEntry) {
+            newEntry.weightCheck.should.not.have.a.property('isOptimized');
+            done();
+        })
+
+        .fail(function(err) {
+            done(err);
+        });
+    });
+
+    it('should determine if gain is enough', function() {
+        imageOptimizer.gainIsEnough(20000, 10000).should.equal(true);
+        imageOptimizer.gainIsEnough(2000, 1000).should.equal(true);
+        imageOptimizer.gainIsEnough(20000, 21000).should.equal(false);
+        imageOptimizer.gainIsEnough(20000, 40000).should.equal(false);
+        imageOptimizer.gainIsEnough(20000, 19500).should.equal(false);
+        imageOptimizer.gainIsEnough(250, 120).should.equal(true);
+        imageOptimizer.gainIsEnough(200, 120).should.equal(false);
+        imageOptimizer.gainIsEnough(2000, 1900).should.equal(false);
+        imageOptimizer.gainIsEnough(200000, 197000).should.equal(true);
+    });
+
+});

+ 2 - 0
test/core/indexTest.js

@@ -101,8 +101,10 @@ describe('index.js', function() {
                 /*jshint expr: true*/
                 console.log.should.not.have.been.called;
 
+                console.log.restore();
                 done();
             }).fail(function(err) {
+                console.log.restore();
                 done(err);
             });
     });

+ 0 - 1
test/core/phantomasWrapperTest.js

@@ -48,7 +48,6 @@ describe('phantomasWrapper', function() {
             }
         }).then(function(data) {
 
-            console.log(data);
             done('Error: unwanted success');
 
         }).fail(function(err) {

+ 170 - 13
test/core/weightCheckerTest.js

@@ -1,9 +1,13 @@
 var should = require('chai').should();
-var weightChecker = require('../../lib/tools/weightChecker');
+var weightChecker = require('../../lib/tools/weightChecker/weightChecker');
+var fs = require('fs');
+var path = require('path');
 
 describe('weightChecker', function() {
     
     it('should download a list of files', function(done) {
+        this.timeout(10000);
+
         var requestsList = [
             {
                 method: 'GET',
@@ -14,7 +18,8 @@ describe('weightChecker', function() {
                    Accept: '*/*'
                 },
                 status: 200,
-                isHTML: true
+                isHTML: true,
+                type: 'html'
             },
             {
                 method: 'GET',
@@ -25,7 +30,69 @@ describe('weightChecker', function() {
                    Accept: '*/*'
                 },
                 status: 200,
-                isJS: true
+                isJS: true,
+                type: 'js'
+            },
+            {
+                method: 'GET',
+                url: 'http://localhost:8388/jpeg-image.jpg',
+                requestHeaders: {
+                    'User-Agent': 'something',
+                   Referer: 'http://www.google.fr/',
+                   Accept: '*/*'
+                },
+                status: 200,
+                isImage: true,
+                type: 'image',
+                contentType: 'image/jpeg'
+            },
+            {
+                method: 'GET',
+                url: 'http://localhost:8388/svg-image.svg',
+                requestHeaders: {
+                    'User-Agent': 'something',
+                   Referer: 'http://www.google.fr/',
+                   Accept: '*/*'
+                },
+                status: 200,
+                isImage: true,
+                isSVG: true,
+                type: 'image',
+                contentType: 'image/svg+xml'
+            },
+            {
+                method: 'GET',
+                url: 'http://localhost:8388/unminified-script.js',
+                requestHeaders: {
+                    'User-Agent': 'something',
+                   Referer: 'http://www.google.fr/',
+                   Accept: '*/*'
+                },
+                status: 200,
+                isJS: true,
+                type: 'js'
+            },
+            {
+                method: 'GET',
+                url: 'http://localhost:8388/unminified-stylesheet.css',
+                requestHeaders: {
+                    'User-Agent': 'something',
+                   Referer: 'http://www.google.fr/',
+                   Accept: '*/*'
+                },
+                status: 200,
+                isCSS: true,
+                type: 'css'
+            },
+            {
+                method: 'GET',
+                url: 'about:blank',
+                requestHeaders: {
+                    'User-Agent': 'something',
+                   Referer: 'http://www.google.fr/',
+                   Accept: '*/*'
+                },
+                status: 200
             }
         ];
 
@@ -48,6 +115,44 @@ describe('weightChecker', function() {
             data.toolsResults.should.have.a.property('weightChecker');
             data.toolsResults.weightChecker.should.have.a.property('metrics');
             data.toolsResults.weightChecker.should.have.a.property('offenders');
+
+            data.toolsResults.weightChecker.offenders.should.have.a.property('totalWeight');
+            data.toolsResults.weightChecker.offenders.totalWeight.totalWeight.should.be.above(0);
+            data.toolsResults.weightChecker.offenders.totalWeight.byType.html.requests.length.should.equal(1);
+            data.toolsResults.weightChecker.offenders.totalWeight.byType.js.requests.length.should.equal(2);
+            data.toolsResults.weightChecker.offenders.totalWeight.byType.css.requests.length.should.equal(1);
+            data.toolsResults.weightChecker.offenders.totalWeight.byType.image.requests.length.should.equal(2);
+            data.toolsResults.weightChecker.offenders.totalWeight.byType.other.requests.length.should.equal(0);
+
+            data.toolsResults.weightChecker.offenders.should.have.a.property('imageOptimization');
+            data.toolsResults.weightChecker.offenders.imageOptimization.totalGain.should.be.above(0);
+            data.toolsResults.weightChecker.offenders.imageOptimization.images.length.should.equal(2);
+
+            data.toolsResults.weightChecker.offenders.should.have.a.property('gzipCompression');
+            data.toolsResults.weightChecker.offenders.gzipCompression.totalGain.should.be.above(0);
+            data.toolsResults.weightChecker.offenders.gzipCompression.files.length.should.equal(4);
+
+            data.toolsResults.weightChecker.offenders.should.have.a.property('fileMinification');
+            data.toolsResults.weightChecker.offenders.fileMinification.totalGain.should.be.above(0);
+            data.toolsResults.weightChecker.offenders.fileMinification.files.length.should.equal(2);
+
+            data.toolsResults.weightChecker.metrics.should.have.a.property('totalRequests').that.equals(6);
+            data.toolsResults.weightChecker.offenders.should.have.a.property('totalRequests');
+            data.toolsResults.weightChecker.offenders.totalRequests.byType.html.length.should.equal(1);
+            data.toolsResults.weightChecker.offenders.totalRequests.byType.js.length.should.equal(2);
+            data.toolsResults.weightChecker.offenders.totalRequests.byType.css.length.should.equal(1);
+            data.toolsResults.weightChecker.offenders.totalRequests.byType.image.length.should.equal(2);
+            data.toolsResults.weightChecker.offenders.totalRequests.byType.json.length.should.equal(0);
+            data.toolsResults.weightChecker.offenders.totalRequests.byType.webfont.length.should.equal(0);
+            data.toolsResults.weightChecker.offenders.totalRequests.byType.video.length.should.equal(0);
+            data.toolsResults.weightChecker.offenders.totalRequests.byType.other.length.should.equal(0);
+
+            data.toolsResults.weightChecker.metrics.should.have.a.property('smallRequests').that.equals(0);
+            data.toolsResults.weightChecker.offenders.should.have.a.property('smallRequests');
+            data.toolsResults.weightChecker.offenders.smallRequests.byType.js.length.should.equal(0);
+            data.toolsResults.weightChecker.offenders.smallRequests.byType.css.length.should.equal(0);
+            data.toolsResults.weightChecker.offenders.smallRequests.byType.image.length.should.equal(0);
+
             done();
         })
 
@@ -66,19 +171,64 @@ describe('weightChecker', function() {
                Accept: '*/*'
             },
             status: 200,
-            isJS: true
+            isJS: true,
+            type: 'js'
         };
 
-        weightChecker.redownloadEntry(entry, function(err, newEntry) {
-            should.not.exist(err);
+        weightChecker.redownloadEntry(entry)
+
+        .then(function(newEntry) {
 
             newEntry.weightCheck.bodySize.should.equal(93636);
             newEntry.weightCheck.uncompressedSize.should.equal(newEntry.weightCheck.bodySize);
             newEntry.weightCheck.isCompressed.should.equal(false);
             newEntry.weightCheck.headersSize.should.be.above(200).and.below(400);
-            newEntry.weightCheck.body.should.have.string('1.8.3');
+            newEntry.weightCheck.body.toString().should.have.string('1.8.3');
 
             done();
+        })
+
+        .fail(function(err) {
+            done(err);
+        });
+    });
+
+    it('should download a PNG image and find the same body as fs.readFile', function(done) {
+        var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/logo-large.png'));
+
+        var entry = {
+            method: 'GET',
+            url: 'http://localhost:8388/logo-large.png',
+            requestHeaders: {
+                'User-Agent': 'something',
+               Referer: 'http://www.google.fr/',
+               Accept: '*/*'
+            },
+            status: 200,
+            isImage: true,
+            contentType: 'image/png'
+        };
+
+        weightChecker.redownloadEntry(entry)
+
+        .then(function(newEntry) {
+
+            newEntry.weightCheck.bodySize.should.equal(4193);
+            newEntry.weightCheck.body.should.equal(fileContent.toString('binary'));
+
+            // Opening the image in lwip to check if the format is good
+            var lwip = require('lwip');
+            var buffer = new Buffer(newEntry.weightCheck.body, 'binary');
+            lwip.open(buffer, 'png', function(err, image) {
+                image.width().should.equal(620);
+                image.height().should.equal(104);
+                done(err);
+            });
+            
+        })
+
+        .fail(function(err) {
+            done(err);
         });
     });
 
@@ -96,12 +246,16 @@ describe('weightChecker', function() {
             contentLength: 999
         };
 
-        weightChecker.redownloadEntry(entry, function(err, newEntry) {
-            should.not.exist(err);
+        weightChecker.redownloadEntry(entry)
 
+        .then(function(newEntry) {
             newEntry.weightCheck.should.have.a.property('message').that.equals('error while downloading: 404');
 
             done();
+        })
+
+        .fail(function(err) {
+            done(err);
         });
     });
 
@@ -119,12 +273,15 @@ describe('weightChecker', function() {
             contentLength: 999
         };
 
-        weightChecker.redownloadEntry(entry, function(err, newEntry) {
-            should.not.exist(err);
-
-            newEntry.weightCheck.should.have.a.property('message').that.equals('only downloading requests with status code 200');
+        weightChecker.redownloadEntry(entry)
 
+        .then(function(newEntry) {
+            newEntry.should.not.have.a.property('weightCheck');
             done();
+        })
+
+        .fail(function(err) {
+            done(err);
         });
     });
 

BIN
test/www/jpeg-image.jpg


BIN
test/www/logo-large.png


+ 14 - 0
test/www/minified-script.js

@@ -0,0 +1,14 @@
+var timelineCtrl=angular.module("timelineCtrl",[])
+timelineCtrl.controller("TimelineCtrl",["$scope","$rootScope","$routeParams","$location","$timeout","Menu","Results","API",function(e,t,n,r,i,a,l,u){function o(){t.loadedResult&&t.loadedResult.runId===n.runId?(e.result=t.loadedResult,c()):l.get({runId:n.runId,exclude:"toolsResults"},function(n){t.loadedResult=n,e.result=n,c()})}function c(){s(),d(),f(),m(),i(p,100)}function s(){var t=r.hash(),n=null
+0===t.indexOf("filter=")&&(n=t.substr(7)),e.warningsFilterOn=null!==n,e.warningsFilters={queryWithoutResults:null===n||"queryWithoutResults"===n,jQueryCallOnEmptyObject:null===n||"jQueryCallOnEmptyObject"===n,eventNotDelegated:null===n||"eventNotDelegated"===n,jsError:null===n||"jsError"===n}}function d(){var t=e.result.rules.jsCount.offendersObj.list
+e.scripts=[],t.forEach(function(t){var n=t.file
+n.length>100&&(n=n.substr(0,98)+"...")
+var r={fullPath:t.file,shortPath:n}
+e.scripts.push(r)})}function f(){var t=e.result.javascriptExecutionTree.children||[],n=t[t.length-1]
+e.endTime=n.data.timestamp+(n.data.time||0),e.executionTree=[],t.forEach(function(t){if(e.selectedScript){if(t.data.backtrace&&-1===t.data.backtrace.indexOf(e.selectedScript.fullPath+":"))return
+if("jQuery loaded"===t.data.type||"jQuery version change"===t.data.type)return}e.executionTree.push(t)})}function m(){var t=199
+e.timelineIntervalDuration=e.endTime/t
+var n=Array.apply(null,Array(e.endTime+1)).map(Number.prototype.valueOf,0)
+e.executionTree.forEach(function(e){if(void 0!==e.data.time)for(var t=Math.min(e.data.time,100)||1,r=e.data.timestamp,i=e.data.timestamp+t;i>r;r++)n[r]|=1}),e.timeline=Array.apply(null,Array(t+1)).map(Number.prototype.valueOf,0),n.forEach(function(t,n){1===t&&(e.timeline[Math.floor(n/e.timelineIntervalDuration)]+=1)}),e.timelineMax=Math.max.apply(Math,e.timeline)}function p(){e.profilerData=e.executionTree}e.runId=n.runId,e.Menu=a.setCurrentPage("timeline",e.runId),e.changeScript=function(){f(),m(),p()},e.findLineIndexByTimestamp=function(t){for(var n=0,r=0;r<e.executionTree.length;r++){var i=e.executionTree[r].data.timestamp-t
+if(i<e.timelineIntervalDuration&&(n=r),i>0)break}return n},e.backToDashboard=function(){r.path("/result/"+e.runId)},e.testAgain=function(){u.relaunchTest(e.result)},o()}]),timelineCtrl.directive("scrollOnClick",["$animate","$timeout",function(e,t){return{restrict:"A",link:function(n,r,i){r.on("click",function(){var r=n.findLineIndexByTimestamp(i.scrollOnClick),a=angular.element(document.getElementById("line_"+r))
+a.addClass("highlight"),t(function(){e.removeClass(a,"highlight"),n.$digest()},50),window.scrollTo(0,a[0].offsetTop)})}}}])

BIN
test/www/png-image.png


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 627 - 0
test/www/svg-image.svg


+ 190 - 0
test/www/unminified-script.js

@@ -0,0 +1,190 @@
+var timelineCtrl = angular.module('timelineCtrl', []);
+
+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);
+
+    function loadResults() {
+        // Load result if needed
+        if (!$rootScope.loadedResult || $rootScope.loadedResult.runId !== $routeParams.runId) {
+            Results.get({runId: $routeParams.runId, exclude: 'toolsResults'}, function(result) {
+                $rootScope.loadedResult = result;
+                $scope.result = result;
+                render();
+            });
+        } else {
+            $scope.result = $rootScope.loadedResult;
+            render();
+        }
+    }
+
+    function render() {
+        initFilters();
+        initScriptFiltering();
+        initExecutionTree();
+        initTimeline();
+        $timeout(initProfiler, 100);
+    }
+
+    function initFilters() {
+        var hash = $location.hash();
+        var filter = null;
+        
+        if (hash.indexOf('filter=') === 0) {
+            filter = hash.substr(7);
+        }
+
+        $scope.warningsFilterOn = (filter !== null);
+        $scope.warningsFilters = {
+            queryWithoutResults: (filter === null || filter === 'queryWithoutResults'),
+            jQueryCallOnEmptyObject: (filter === null || filter === 'jQueryCallOnEmptyObject'),
+            eventNotDelegated: (filter === null || filter === 'eventNotDelegated'),
+            jsError: (filter === null || filter === 'jsError')
+        };
+    }
+
+    function initScriptFiltering() {
+        var offenders = $scope.result.rules.jsCount.offendersObj.list;
+        $scope.scripts = [];
+
+        offenders.forEach(function(script) {
+            var filePath = script.file;
+
+            if (filePath.length > 100) {
+                filePath = filePath.substr(0, 98) + '...';
+            }
+
+            var scriptObj = {
+                fullPath: script.file, 
+                shortPath: filePath
+            };
+
+            $scope.scripts.push(scriptObj);
+        });
+    }
+
+    function initExecutionTree() {
+        var originalExecutions = $scope.result.javascriptExecutionTree.children || [];
+        
+        // Detect the last event of all (before filtering) and read time
+        var lastEvent = originalExecutions[originalExecutions.length - 1];
+        $scope.endTime =  lastEvent.data.timestamp + (lastEvent.data.time || 0);
+
+        // Filter
+        $scope.executionTree = [];
+        originalExecutions.forEach(function(node) {
+            
+            // Filter by script (if enabled)
+            if ($scope.selectedScript) {
+                if (node.data.backtrace && node.data.backtrace.indexOf($scope.selectedScript.fullPath + ':') === -1) {
+                    return;
+                }
+                if (node.data.type === "jQuery loaded" || node.data.type === "jQuery version change") {
+                    return;
+                }
+            }
+
+            $scope.executionTree.push(node);
+        });
+    }
+
+    function initTimeline() {
+
+        // Split the timeline into 200 intervals
+        var numberOfIntervals = 199;
+        $scope.timelineIntervalDuration = $scope.endTime / numberOfIntervals;
+        
+        // Pre-fill array of as many elements as there are milleseconds
+        var millisecondsArray = Array.apply(null, new Array($scope.endTime + 1)).map(Number.prototype.valueOf,0);
+        
+        // Create the milliseconds array from the execution tree
+        $scope.executionTree.forEach(function(node) {
+            if (node.data.time !== undefined) {
+
+                // Ignore artefacts (durations > 100ms)
+                var time = Math.min(node.data.time, 100) || 1;
+
+                for (var i=node.data.timestamp, max=node.data.timestamp + time ; i<max ; i++) {
+                    millisecondsArray[i] |= 1;
+                }
+            }
+        });
+
+        // Pre-fill array of 200 elements
+        $scope.timeline = Array.apply(null, new Array(numberOfIntervals + 1)).map(Number.prototype.valueOf,0);
+
+        // Create the timeline from the milliseconds array
+        millisecondsArray.forEach(function(value, timestamp) {
+            if (value === 1) {
+                $scope.timeline[Math.floor(timestamp / $scope.timelineIntervalDuration)] += 1;
+            }
+        });
+        
+        // Get the maximum value of the array (needed for display)
+        $scope.timelineMax = Math.max.apply(Math, $scope.timeline);
+    }
+
+
+    function initProfiler() {
+        $scope.profilerData = $scope.executionTree;
+    }
+
+    $scope.changeScript = function() {
+        initExecutionTree();
+        initTimeline();
+        initProfiler();
+    };
+
+    $scope.findLineIndexByTimestamp = function(timestamp) {
+        var lineIndex = 0;
+
+        for (var i = 0; i < $scope.executionTree.length; i ++) {
+            var delta = $scope.executionTree[i].data.timestamp - timestamp;
+            
+            if (delta < $scope.timelineIntervalDuration) {
+                lineIndex = i;
+            }
+
+            if (delta > 0) {
+                break;
+            }
+        }
+
+        return lineIndex;
+    };
+
+
+    $scope.backToDashboard = function() {
+        $location.path('/result/' + $scope.runId);
+    };
+
+    $scope.testAgain = function() {
+        API.relaunchTest($scope.result);
+    };
+
+    loadResults();
+
+}]);
+
+timelineCtrl.directive('scrollOnClick', ['$animate', '$timeout', function($animate, $timeout) {
+    return {
+        restrict: 'A',
+        link: function (scope, element, attributes) {            
+            // When the user clicks on the timeline, find the right profiler line and scroll to it
+            element.on('click', function() {
+                var lineIndex = scope.findLineIndexByTimestamp(attributes.scrollOnClick);
+                var lineElement = angular.element(document.getElementById('line_' + lineIndex));
+                
+                // Animate the background color to "flash" the row
+                lineElement.addClass('highlight');
+                $timeout(function() {
+                    $animate.removeClass(lineElement, 'highlight');
+                    scope.$digest();
+                }, 50);
+
+
+                window.scrollTo(0, lineElement[0].offsetTop);
+            });
+        }
+    };
+}]);

+ 772 - 0
test/www/unminified-stylesheet.css

@@ -0,0 +1,772 @@
+/* Timeline colors, related to Window Performances */
+.execution {
+  text-align: center;
+}
+.selectScript {
+  padding-bottom: 2em;
+  font-size: 0.9em;
+}
+.selectScript select {
+  max-width: 30em;
+}
+.selectScript.empty {
+  font-size: 0.8em;
+}
+.selectScript.empty select {
+  width: 10em;
+}
+.timeline {
+  margin: 2em 0 5em;
+}
+.timeline .chart {
+  position: relative;
+  width: 100%;
+  border-bottom: 1px solid #000;
+}
+.timeline .startTime,
+.timeline .endTime {
+  position: absolute;
+  bottom: 0.5em;
+  font-size: 0.8em;
+}
+.timeline .startTime {
+  left: 0em;
+}
+.timeline .endTime {
+  right: 0em;
+}
+.timeline .chartPoints {
+  display: table;
+  height: 100px;
+  width: 99%;
+  margin: 0 auto;
+}
+.timeline .interval {
+  display: table-cell;
+  position: relative;
+  height: 100px;
+  width: 0.5%;
+}
+.timeline .interval .color {
+  position: absolute;
+  bottom: 0;
+  width: 100%;
+}
+.timeline .interval .color.clickable {
+  cursor: pointer;
+}
+.timeline div.interval:hover {
+  background: #9C4274;
+}
+.timeline .interval:hover .color {
+  background: #F04DA7;
+}
+.timeline .domComplete.interval {
+  background: #ede3ff;
+}
+.timeline .domComplete .color {
+  background: #c2a3ff;
+}
+.timeline .domContentLoadedEnd.interval {
+  background: #d8f0f0;
+}
+.timeline .domContentLoadedEnd .color {
+  background: #7ecccc;
+}
+.timeline .domContentLoaded.interval {
+  background: #e0ffd1;
+}
+.timeline .domContentLoaded .color {
+  background: #a7e846;
+}
+.timeline .domInteractive.interval {
+  background: #fffccc;
+}
+.timeline .domInteractive .color {
+  background: #ffe433;
+}
+.timeline .domCreation.interval {
+  background: #ffe0cc;
+}
+.timeline .domCreation .color {
+  background: #ff6600;
+}
+.timeline .tooltip.detailsOverlay {
+  position: absolute;
+  display: none;
+  width: auto;
+  padding: 0.5em 1em;
+  top: -1.5em;
+  right: 1em;
+}
+.timeline .interval:hover .tooltip {
+  display: block;
+}
+.timeline .legend {
+  display: table;
+  width: 100%;
+  margin-top: 1em;
+}
+.timeline .legend > div {
+  display: table-row;
+}
+.timeline .legend > div > div {
+  position: relative;
+  display: table-cell;
+  margin-top: 1em;
+}
+.timeline .titles {
+  font-weight: bold;
+}
+.timeline .titles > div {
+  padding: 0 1em 0 2em;
+}
+.timeline .tips {
+  font-size: 0.7em;
+}
+.timeline .tips > div {
+  padding: 1em 1em 0 0;
+}
+.timeline .legend .color {
+  display: block;
+  position: absolute;
+  left: 0;
+  height: 1.5em;
+  width: 1.5em;
+  border-radius: 0.2em;
+}
+.filters {
+  margin: 1em auto;
+  padding: 0.5em;
+  min-width: 30em;
+  width: 30%;
+  border: 1px dotted #aaa;
+  text-align: left;
+}
+.subFilters {
+  margin-left: 3em;
+  font-size: 0.9em;
+}
+.table {
+  display: table;
+  width: 100%;
+  border-spacing: 0.25em;
+}
+.table > div {
+  display: table-row;
+}
+.table > .headers > div {
+  font-weight: bold;
+  padding: 0.5em 1em;
+}
+.table > div > div {
+  padding: 0.1em 1em;
+  background: #f2f2f2;
+  display: table-cell;
+  text-align: left;
+}
+.table > div.jsError > .type,
+.table > div.jsError > .value {
+  color: #e74c3c;
+  font-weight: bold;
+}
+.table > div.windowPerformance > div,
+.table > div.windowPerformance > div.startTime {
+  background: #EBD8E2;
+}
+.table > div.showingDetails > div {
+  background: #f1c40f;
+}
+.table > div.highlight > div.startTime {
+  background-color: #C0F090;
+}
+.table > div.highlight-remove {
+  transition: 3s;
+}
+.table > div.highlight-remove > div.startTime {
+  transition: background-color 3s ease-in;
+}
+.table > div > .index {
+  color: #bbb;
+  word-break: normal;
+}
+.table > div > .type {
+  white-space: nowrap;
+}
+.table .children {
+  margin-top: 0.2em;
+  font-size: 0.8em;
+  line-height: 1.6em;
+}
+.table .child {
+  margin-left: 0.5em;
+}
+.table .child > .child {
+  margin-left: 1em;
+}
+.table .child:before {
+  content: "↳";
+}
+.table .child .childArgs {
+  display: none;
+}
+.table .child span {
+  position: relative;
+}
+.table .child span:hover {
+  background: #EBD8E2;
+}
+.table .child span:hover div {
+  display: inline-block;
+}
+.table .child span:hover .childArgs {
+  display: block;
+  position: absolute;
+  padding: 0 1em 0 2em;
+  left: 100%;
+  top: 0;
+  background: #EBD8E2;
+  line-height: 1.3em;
+  height: 1.3em;
+  z-index: 2;
+}
+.table .showingDetails .child span:hover {
+  background: inherit;
+}
+.table .showingDetails .child span:hover .childArgs {
+  display: none;
+}
+.table > div > .value {
+  width: 70%;
+  word-break: break-all;
+}
+.table > div > .details {
+  position: relative;
+}
+.table .details .icon-question {
+  color: #f1c40f;
+  cursor: pointer;
+}
+.table .icon-warning {
+  display: inline-block;
+  width: 0.8em;
+}
+.detailsOverlay {
+  display: none;
+  position: absolute;
+  right: 3em;
+  top: -3em;
+  width: 45em;
+  min-height: 1em;
+  padding: 0 1em 1em;
+  background: #fff;
+  border: 2px solid #f1c40f;
+  border-radius: 0.5em;
+  z-index: 2;
+}
+@media screen and (max-width: 1024px) {
+  .detailsOverlay {
+    width: 25em;
+  }
+}
+.showDetails .detailsOverlay {
+  display: block;
+}
+.detailsOverlay .closeBtn {
+  position: absolute;
+  top: 0.5em;
+  right: 0.5em;
+  color: #f1c40f;
+  cursor: pointer;
+}
+.detailsOverlay .advice {
+  color: #e74c3c;
+  font-weight: bold;
+}
+.detailsOverlay .trace {
+  word-break: break-all;
+}
+.table > div > .duration,
+.table > div > .startTime {
+  text-align: center;
+  white-space: nowrap;
+}
+.table > div > .startTime.domComplete {
+  background: #ede3ff;
+}
+.table > div > .startTime.domContentLoadedEnd {
+  background: #d8f0f0;
+}
+.table > div > .startTime.domContentLoaded {
+  background: #e0ffd1;
+}
+.table > div > .startTime.domInteractive {
+  background: #fffccc;
+}
+.table > div > .startTime.domCreation {
+  background: #ffe0cc;
+}
+.execution .icon-warning {
+  color: #e74c3c;
+  cursor: pointer;
+}
+.queryWithoutResultsFilterOn > div {
+  display: none;
+}
+.queryWithoutResultsFilterOn > div.queryWithoutResults {
+  display: table-row;
+}
+.jQueryCallOnEmptyObjectFilterOn > div {
+  display: none;
+}
+.jQueryCallOnEmptyObjectFilterOn > div.jQueryCallOnEmptyObject {
+  display: table-row;
+}
+.eventNotDelegatedFilterOn > div {
+  display: none;
+}
+.eventNotDelegatedFilterOn > div.eventNotDelegated {
+  display: table-row;
+}
+.jsErrorFilterOn > div {
+  display: none;
+}
+.jsErrorFilterOn > div.jsError {
+  display: table-row;
+}
+.testedUrl {
+  color: inherit;
+}
+.summary {
+  text-align: center;
+}
+.summary .globalScore {
+  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;
+  width: 2.5em;
+  height: 2.5em;
+  line-height: 2.5em;
+  border-radius: 0.5em;
+  font-size: 3em;
+  font-weight: bold;
+  vertical-align: middle;
+}
+.summary .globalScore .on100 {
+  font-size: 1.2em;
+  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 .globalScore .screenshotWrapper.phone:hover:after {
+  top: 1.7em;
+  left: 0.64em;
+}
+.summary .globalScore .screenshotWrapper.tablet:hover:after {
+  top: 1.5em;
+  left: 0.9em;
+}
+.summary .notations {
+  display: table;
+  width: 80%;
+  margin: 0 10% 1.5em;
+  border-spacing: 1em;
+}
+.summary .notations > div {
+  display: table-row;
+}
+.summary .notations > div > div {
+  display: table-cell;
+  height: 2.5em;
+  vertical-align: middle;
+}
+.summary .notations .category {
+  font-weight: bold;
+  text-align: center;
+  width: 20%;
+}
+.summary .notations .criteria {
+  font-weight: normal;
+  width: 75%;
+}
+.summary .notations .A.categoryScore,
+.summary .notations .B.categoryScore,
+.summary .notations .C.categoryScore,
+.summary .notations .D.categoryScore,
+.summary .notations .E.categoryScore,
+.summary .notations .F.categoryScore,
+.summary .notations .NA.categoryScore {
+  width: 2.5em;
+  max-width: 2.5em;
+  min-width: 2.5em;
+  font-size: 2em;
+  text-align: center;
+  border-radius: 0.5em;
+  font-weight: bold;
+}
+.summary .notations .grade .A,
+.summary .notations .grade .B,
+.summary .notations .grade .C,
+.summary .notations .grade .D,
+.summary .notations .grade .E,
+.summary .notations .grade .F,
+.summary .notations .grade .NA {
+  width: 1em;
+  height: 1em;
+  font-size: 1em;
+  color: transparent;
+  margin: 0 auto;
+  border-radius: 0.5em;
+}
+.summary .notations .criteria .table {
+  width: 100%;
+}
+.summary .notations .criteria .table > div:hover > div {
+  background: #EBD8E2;
+  cursor: pointer;
+}
+.summary .notations .criteria .table > div:hover > div.info {
+  background: #FFF;
+}
+.summary .notations .criteria .table > div:hover > div.info .icon-question {
+  color: #EBD8E2;
+}
+.summary .notations .criteria .grade {
+  width: 10%;
+  padding-left: 0.5em;
+  padding-right: 0.5em;
+  vertical-align: middle;
+}
+.summary .notations .criteria .label {
+  width: 70%;
+}
+.summary .notations .criteria .result {
+  width: 18%;
+  font-weight: bold;
+  white-space: nowrap;
+  text-align: center;
+  vertical-align: middle;
+}
+.summary .notations .warning .label,
+.summary .notations .warning .result,
+.summary .notations .icon-warning {
+  color: #FF1919;
+}
+.summary .notations .criteria .info {
+  width: 2%;
+  text-align: center;
+  vertical-align: middle;
+  background: #FFF;
+  padding-left: 0.1em;
+  padding-right: 0.1em;
+}
+.summary .notations .criteria .icon-question {
+  color: transparent;
+}
+.summary .fromShare {
+  margin-bottom: 3em;
+}
+.summary .apiTip {
+  font-size: 0.8em;
+  margin-bottom: 4em;
+  color: #413;
+}
+.summary .apiTip a {
+  color: inherit;
+}
+.summary .tweet .tweetText {
+  color: #413;
+  background: #F2F2F2;
+  border: none;
+  width: 25em;
+  padding: 0.4em;
+  border-radius: 0.5em;
+  box-shadow: 0.05em 0.1em 0 0 #999;
+}
+.summary .tweet .tweetButton,
+.summary .tweet .linkedinButton {
+  color: #413;
+  background: #F2F2F2;
+  margin-right: 0;
+}
+.summary .tweet .tweetButton:hover,
+.summary .tweet .linkedinButton:hover {
+  color: #F2F2F2;
+  background: #e74c3c;
+}
+.summary .tweet input {
+  font-size: 0.9em;
+}
+
+
+.promess {
+  padding: 0em 2em;
+  margin-bottom: 0.5em;
+  font-weight: normal;
+  font-size: 1.2em;
+}
+.price {
+  padding: 0em 2em 3em;
+  margin-top: 0em;
+  font-size: 0.9em;
+}
+.url {
+  width: 50%;
+}
+.launchBtn {
+  background: #ffa319;
+  color: #fff;
+}
+.launchBtn:focus {
+  background: #e74c3c;
+}
+.launchBtn.disabled {
+  background: #deaca6;
+}
+.launchBtn.disabled:focus {
+  color: #ddd;
+}
+.settings {
+  width: 50%;
+  margin: 0 auto;
+}
+.settings input,
+.settings select {
+  font-size: 1em;
+}
+.settings input[type=text] {
+  width: 100%;
+  min-width: 4em;
+}
+.device {
+  margin-top: 3em;
+}
+.device .item {
+  display: inline-block;
+  margin: 1em 0.75em;
+  width: 5.5em;
+  height: 5.5em;
+  color: #FFF;
+  border: 1px solid #FFF;
+  padding: 1px;
+  border-radius: 0.5em;
+  cursor: pointer;
+  text-decoration: none;
+  font-size: 0.8em;
+}
+                                                                                       
+.device .item.active {                             
+  color: #ffa319;                             
+  border: 2px solid #ffa319;                             
+  padding: 0;                             
+}                             
+.device .item:hover {                             
+  color: #ffa319;                             
+}                             
+.device .item > div {                             
+  margin: 0.2em 0 0.1em;                             
+  font-size: 3em;                             
+}                             
+.settingsTooltip {
+  position: relative;
+}
+.settingsTooltip span {
+  font-size: 0.8em;
+  vertical-align: text-top;
+}
+.settingsTooltip div {
+  display: none;
+  position: absolute;
+  padding: 0.5em;
+  width: 25em;
+  background: #FFF;
+  color: #000;
+  font-size: 0.8em;
+  border-radius: 1em;
+  border: 2px solid #ffa319;
+  white-space: normal;
+  z-index: 2;
+}
+.settingsTooltip:hover div {
+  display: block;
+}
+.showAdvanced {
+  display: inline-block;
+  margin-top: 2em;
+  color: #FFF;
+  text-decoration: none;
+  font-size: 0.9em;
+}
+.showAdvanced:hover {
+  color: #ffa319;
+}
+.currentSettings {
+  font-size: 0.9em;
+}
+.currentSettings span {
+  color: #ffa319;
+}
+.currentSettings span:after {
+  color: #FFF;
+  content: ",";
+}
+.currentSettings span:last-child:after {
+  content: "";
+}
+.advanced {
+  margin: 1em 0 0;
+  display: table;
+  width: 100%;
+  text-align: left;
+  border-spacing: 0.75em;
+}
+.advanced > div {
+  display: table-row;
+}
+.advanced > div > div {
+  display: table-cell;
+  width: 75%;
+}
+.advanced > div > div.label {
+  width: 25%;
+  white-space: nowrap;
+}
+.advanced .subTable {
+  display: table;
+  border-spacing: 0;
+  width: 100%;
+}
+.advanced .subTable > div {
+  display: table-row;
+}
+.advanced .subTable > div > div {
+  display: table-cell;
+  padding: 0 0 0.75em;
+}
+.features {
+  display: table;
+  width: 50%;
+  margin: 6em 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;
+  margin: 0.5em;
+  font-size: 1.2em;
+  height: 2em;
+  border: 0 solid;
+  border-radius: 0.5em;
+  box-shadow: 0.1em 0.2em 0 0 #5e2846;
+  outline: none;
+}
+input[type=submit]:hover {
+  color: #ddd;
+}
+input[type=submit].clicked {
+  color: #ddd;
+  position: relative;
+  left: 0.1em;
+  top: 0.2em;
+  box-shadow: none;
+}
+/* 
+ *  Core Owl Carousel CSS File
+ *  v1.3.2
+ */
+
+/* clearfix */
+.owl-carousel .owl-wrapper:after {
+  content: ".";
+  display: block;
+  clear: both;
+  visibility: hidden;
+  line-height: 0;
+  height: 0;
+}
+/* display none until init */
+.owl-carousel{
+  display: none;
+  position: relative;
+  width: 100%;
+  -ms-touch-action: pan-y;
+}
+.owl-carousel .owl-wrapper{
+  display: none;
+  position: relative;
+  -webkit-transform: translate3d(0px, 0px, 0px);
+}
+.owl-carousel .owl-wrapper-outer{
+  overflow: hidden;
+  position: relative;
+  width: 100%;
+}
+.owl-carousel .owl-wrapper-outer.autoHeight{
+  -webkit-transition: height 500ms ease-in-out;
+  -moz-transition: height 500ms ease-in-out;
+  -ms-transition: height 500ms ease-in-out;
+  -o-transition: height 500ms ease-in-out;
+  transition: height 500ms ease-in-out;
+}
+  
+.owl-carousel .owl-item{
+  float: left;
+}
+.owl-controls .owl-page,
+.owl-controls .owl-buttons div{
+  cursor: pointer;
+}
+.owl-controls {
+  -webkit-user-select: none;
+  -khtml-user-select: none;
+  -moz-user-select: none;
+  -ms-user-select: none;
+  user-select: none;
+  -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
+}
+
+/* mouse grab icon */
+.grabbing { 
+    cursor:url(grabbing.png) 8 8, move;
+}
+
+/* fix */
+.owl-carousel  .owl-wrapper,
+.owl-carousel  .owl-item{
+  -webkit-backface-visibility: hidden;
+  -moz-backface-visibility:    hidden;
+  -ms-backface-visibility:     hidden;
+  -webkit-transform: translate3d(0,0,0);
+  -moz-transform: translate3d(0,0,0);
+  -ms-transform: translate3d(0,0,0);
+}

Vissa filer visades inte eftersom för många filer har ändrats