From d7ddae38e5976c0b4226eca346b9ca9713300105 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Sat, 30 May 2015 12:52:50 +0200 Subject: [PATCH 01/39] Total Weight offenders --- bower.json | 17 +++--- front/src/css/rule.css | 11 ++++ front/src/js/controllers/ruleCtrl.js | 28 +++++++++- .../src/js/directives/offendersDirectives.js | 26 ++++++++++ front/src/less/rule.less | 14 +++++ front/src/main.html | 3 ++ front/src/views/dashboard.html | 3 +- front/src/views/rule.html | 23 +++++++- lib/metadata/policies.js | 10 ++++ lib/metadata/scoreProfileGeneric.json | 52 ++++++++----------- lib/tools/weightChecker.js | 2 +- 11 files changed, 144 insertions(+), 45 deletions(-) diff --git a/bower.json b/bower.json index acfc53c..e5db6d7 100644 --- a/bower.json +++ b/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.0", + "angular-chart.js": "0.7.1", + "Chart.js": "~1.0.2" } } diff --git a/front/src/css/rule.css b/front/src/css/rule.css index 0903840..40f0546 100644 --- a/front/src/css/rule.css +++ b/front/src/css/rule.css @@ -205,3 +205,14 @@ 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; +} diff --git a/front/src/js/controllers/ruleCtrl.js b/front/src/js/controllers/ruleCtrl.js index 1d4c2c2..2b22bfc 100644 --- a/front/src/js/controllers/ruleCtrl.js +++ b/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() { diff --git a/front/src/js/directives/offendersDirectives.js b/front/src/js/directives/offendersDirectives.js index 0eb0693..e5c0967 100644 --- a/front/src/js/directives/offendersDirectives.js +++ b/front/src/js/directives/offendersDirectives.js @@ -856,4 +856,30 @@ }; }); + offendersDirectives.filter('bytes', function() { + return function(bytes) { + if (isNaN(parseFloat(bytes)) || !isFinite(bytes)) { + return '-'; + } + + var kilo = bytes / 1024; + + 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'; + } + }); + })(); \ No newline at end of file diff --git a/front/src/less/rule.less b/front/src/less/rule.less index b0aa189..db20709 100644 --- a/front/src/less/rule.less +++ b/front/src/less/rule.less @@ -221,4 +221,18 @@ border: 0.2em solid #f1c40f; } } +} + +.totalWeightPie { + max-width: 39em; + margin: 2em auto 4em; + + canvas { + max-width: inherit; + } +} + +.hugeFile { + font-weight: bold; + color: #e74c3c; } \ No newline at end of file diff --git a/front/src/main.html b/front/src/main.html index 357cce5..d5bdd5e 100644 --- a/front/src/main.html +++ b/front/src/main.html @@ -16,15 +16,18 @@ + + + diff --git a/front/src/views/dashboard.html b/front/src/views/dashboard.html index fa97376..5efffa0 100644 --- a/front/src/views/dashboard.html +++ b/front/src/views/dashboard.html @@ -35,7 +35,8 @@
{{rule.policy.label}}
- {{rule.value}} + {{rule.value | bytes}} + {{rule.value}} diff --git a/front/src/views/rule.html b/front/src/views/rule.html index 72635fa..c2a8c03 100644 --- a/front/src/views/rule.html +++ b/front/src/views/rule.html @@ -9,7 +9,11 @@
{{rule.score}}/100
-

Value: {{rule.value}}

+

+ Value: + {{rule.value | bytes}} + {{rule.value}} +

@@ -165,7 +169,6 @@ -
@@ -194,6 +197,22 @@
+
+

Weight by MIME type

+
+ +
+
+

{{rule.offendersObj.list.byType[type].totalWeight | bytes}} of {{type}}

+
+
+
+
{{request.weight | bytes}}
+
+
+
+
+

{{rule.value}} offenders

Please open the JS timeline diff --git a/lib/metadata/policies.js b/lib/metadata/policies.js index a2f481b..376ce7f 100644 --- a/lib/metadata/policies.js +++ b/lib/metadata/policies.js @@ -852,6 +852,16 @@ var policies = { }; } }, + "totalWeight": { + "tool": "weightChecker", + "label": "Total weight", + "message": "

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.

", + "isOkThreshold": 716800, + "isBadThreshold": 2097152, + "isAbnormalThreshold": 3145728, + "hasOffenders": true, + "unit": 'bytes' + }, "requests": { "tool": "phantomas", "label": "Total requests number", diff --git a/lib/metadata/scoreProfileGeneric.json b/lib/metadata/scoreProfileGeneric.json index c5def7f..250a178 100644 --- a/lib/metadata/scoreProfileGeneric.json +++ b/lib/metadata/scoreProfileGeneric.json @@ -3,7 +3,7 @@ "domComplexity": { "label": "DOM complexity", "policies": { - "DOMelementsCount": 1, + "DOMelementsCount": 3, "DOMelementMaxDepth": 1, "iframesCount": 1, "DOMidDuplicated": 1 @@ -69,44 +69,34 @@ "cssRedundantChildNodesSelectors": 1 } }, + "pageWeight": { + "label": "Page weight", + "policies": { + "totalWeight": 5 + } + }, "requests": { - "label": "Requests number", + "label": "Requests", "policies": { "requests": 5, - "htmlCount": 0, - "jsCount": 1, - "cssCount": 1, - "imageCount": 0, - "webfontCount": 2, - "videoCount": 0, - "jsonCount": 0, - "otherCount": 0 - } - }, - "smallRequests": { - "label": "Small requests", - "policies": { - "smallJsFiles": 1, - "smallCssFiles": 1, - "smallImages": 1 - } - }, - "network": { - "label": "Network", - "policies": { - "notFound": 2, - "assetsNotGzipped": 1.5, - "closedConnections": 2, + "domains": 3, + "notFound": 3, "multipleRequests": 2, + "smallRequests": 1 + } + }, + "serverConfig": { + "label": "Server config", + "policies": { + "closedConnections": 2, "cachingNotSpecified": 1, "cachingDisabled": 1, - "cachingTooShort": 1, - "domains": 1 + "cachingTooShort": 1 } } }, "globalScore": { - "domComplexity": 1, + "domComplexity": 2, "domManipulations": 2, "scroll": 1, "badJavascript": 1, @@ -114,8 +104,8 @@ "cssSyntaxError": 1, "cssComplexity": 1, "badCSS": 1, + "pageWeight": 3, "requests": 3, - "smallRequests": 1, - "network": 2 + "serverConfig": 1 } } \ No newline at end of file diff --git a/lib/tools/weightChecker.js b/lib/tools/weightChecker.js index 270517d..df2b6eb 100644 --- a/lib/tools/weightChecker.js +++ b/lib/tools/weightChecker.js @@ -102,7 +102,7 @@ var WeightChecker = function() { }; requests.forEach(function(req) { - var weight = (typeof req.weightCheck.bodySize === 'number') ? req.weightCheck.bodySize + req.weightCheck.headersSize : req.contentLength; + var weight = ((typeof req.weightCheck.bodySize === 'number') ? req.weightCheck.bodySize + req.weightCheck.headersSize : req.contentLength) || 0; var type = req.type || 'other'; results.totalWeight += weight; From 74eabd700cf10a7f2e6193a7e213e16c9701368a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Sun, 31 May 2015 18:39:52 +0200 Subject: [PATCH 02/39] Add a JPEG optimizer --- Gruntfile.js | 2 +- front/src/js/controllers/ruleCtrl.js | 2 +- .../src/js/directives/offendersDirectives.js | 2 +- lib/runner.js | 18 ++- lib/tools/weightChecker/imageOptimizer.js | 121 ++++++++++++++++++ .../{ => weightChecker}/weightChecker.js | 50 ++++++-- package.json | 2 + test/core/imageOptimizerTest.js | 79 ++++++++++++ test/core/phantomasWrapperTest.js | 1 - test/core/weightCheckerTest.js | 27 +++- test/fixtures/jpeg-image.jpg | Bin 0 -> 117389 bytes 11 files changed, 274 insertions(+), 30 deletions(-) create mode 100644 lib/tools/weightChecker/imageOptimizer.js rename lib/tools/{ => weightChecker}/weightChecker.js (90%) create mode 100644 test/core/imageOptimizerTest.js create mode 100644 test/fixtures/jpeg-image.jpg diff --git a/Gruntfile.js b/Gruntfile.js index 7bf74da..3ccabe9 100644 --- a/Gruntfile.js +++ b/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: { diff --git a/front/src/js/controllers/ruleCtrl.js b/front/src/js/controllers/ruleCtrl.js index 2b22bfc..398c17f 100644 --- a/front/src/js/controllers/ruleCtrl.js +++ b/front/src/js/controllers/ruleCtrl.js @@ -7,7 +7,7 @@ ruleCtrl.config(['ChartJsProvider', function (ChartJsProvider) { 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; diff --git a/front/src/js/directives/offendersDirectives.js b/front/src/js/directives/offendersDirectives.js index e5c0967..974e70f 100644 --- a/front/src/js/directives/offendersDirectives.js +++ b/front/src/js/directives/offendersDirectives.js @@ -879,7 +879,7 @@ } return mega.toFixed(1) + ' MB'; - } + }; }); })(); \ No newline at end of file diff --git a/lib/runner.js b/lib/runner.js index 43ead1e..473e751 100644 --- a/lib/runner.js +++ b/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); diff --git a/lib/tools/weightChecker/imageOptimizer.js b/lib/tools/weightChecker/imageOptimizer.js new file mode 100644 index 0000000..cf968df --- /dev/null +++ b/lib/tools/weightChecker/imageOptimizer.js @@ -0,0 +1,121 @@ +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; + + function optimizeImage(entry) { + var deferred = Q.defer(); + + var fileSize = entry.weightCheck.body.length; + debug('Current file size is %d', fileSize); + + if (isJpeg(entry)) { + debug('File is a JPEG'); + + // Starting softly with a lossless compression + return compressJpegLosslessly(entry.weightCheck.body) + + .then(function(newFile) { + var newFileSize = newFile.contents.length; + + debug('JPEG lossless compression complete for %s', entry.url); + + if (newFileSize < fileSize) { + 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(entry.weightCheck.body); + }) + + .then(function(newFile) { + var newFileSize = newFile.contents.length; + + debug('JPEG lossy compression complete for %s', entry.url); + + if (newFileSize < fileSize) { + entry.weightCheck.lossy = newFileSize; + entry.weightCheck.isOptimized = false; + debug('Filesize is %d bytes smaller (-%d%)', fileSize - newFileSize, Math.round((fileSize - newFileSize) * 100 / fileSize)); + + if (newFileSize < entry.weightCheck.lossless) { + entry.weightCheck.optimized = newFileSize; + } + } + + return entry; + }); + + } else { + debug('File type is not an optimizable image'); + deferred.resolve(entry); + } + + return deferred.promise; + } + + function isJpeg(entry) { + return entry.isImage && entry.contentType === 'image/jpeg'; + } + + function compressJpegLosslessly(imageBody) { + var deferred = Q.defer(); + var startTime = Date.now(); + + debug('Starting JPEG lossless compression'); + + new Imagemin() + .src(imageBody) + .use(Imagemin.jpegtran()) + .run(function (err, files) { + if (err) { + deferred.reject(err); + } else { + deferred.resolve(files[0]); + var endTime = Date.now(); + debug('compressJpegLosslessly took %d ms', endTime - startTime); + } + }); + + return deferred.promise; + } + + function compressJpegLossly(imageBody) { + var deferred = Q.defer(); + var startTime = Date.now(); + + debug('Starting JPEG lossly compression'); + + new Imagemin() + .src(imageBody) + .use(jpegoptim({max: MAX_JPEG_QUALITY})) + .run(function (err, files) { + if (err) { + deferred.reject(err); + } else { + deferred.resolve(files[0]); + var endTime = Date.now(); + debug('compressJpegLossly took %d ms', endTime - startTime); + } + }); + + return deferred.promise; + } + + return { + //recompressIfImage: recompressIfImage, + optimizeImage: optimizeImage, + compressJpegLosslessly: compressJpegLosslessly, + compressJpegLossly: compressJpegLossly + }; +}; + +module.exports = new ImageOptimizer(); \ No newline at end of file diff --git a/lib/tools/weightChecker.js b/lib/tools/weightChecker/weightChecker.js similarity index 90% rename from lib/tools/weightChecker.js rename to lib/tools/weightChecker/weightChecker.js index df2b6eb..75e1e5b 100644 --- a/lib/tools/weightChecker.js +++ b/lib/tools/weightChecker/weightChecker.js @@ -5,13 +5,12 @@ */ -var debug = require('debug')('ylt:weightChecker'); - -var request = require('request'); -var http = require('http'); -var async = require('async'); +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 WeightChecker = function() { @@ -28,7 +27,18 @@ var WeightChecker = function() { // Transform every request into a download function with a callback when done var redownloadList = requestsList.map(function(entry) { return function(callback) { - redownloadEntry(entry, callback); + + redownloadEntry(entry) + + .then(recompressIfImage) + + .then(function(entry) { + callback(null, entry); + }) + + .fail(function(err) { + callback(err); + }); }; }); @@ -118,26 +128,25 @@ var WeightChecker = function() { } - function redownloadEntry(entry, callback) { + function redownloadEntry(entry) { + var deferred = Q.defer(); function onError(message) { debug('Could not download %s Error: %s', entry.url, message); entry.weightCheck = { message: message }; - setImmediate(function() { - callback(null, entry); - }); + deferred.reject(); } if (entry.method !== 'GET') { onError('only downloading GET'); - return; + return deferred.promise; } if (entry.status !== 200) { onError('only downloading requests with status code 200'); - return; + return deferred.promise; } @@ -167,8 +176,10 @@ var WeightChecker = function() { debug('%s downloaded correctly', entry.url); entry.weightCheck = result; - callback(null, entry); + deferred.resolve(entry); }); + + return deferred.promise; } // Inspired by https://github.com/cvan/fastHAR-api/blob/10cec585/app.js @@ -268,11 +279,22 @@ var WeightChecker = function() { }); } + + function recompressIfImage(entry) { + var deferred = Q.defer(); + + deferred.resolve(entry); + + return deferred.promise; + } + + return { recheckAllFiles: recheckAllFiles, listRequestWeight: listRequestWeight, redownloadEntry: redownloadEntry, - download: download + download: download, + recompressIfImage: recompressIfImage }; }; diff --git a/package.json b/package.json index 24f8d32..03c1a33 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "cors": "^2.6.0", "debug": "~2.2.0", "express": "~4.12.4", + "imagemin": "~3.2.0", + "imagemin-jpegoptim": "~4.0.0", "lwip": "0.0.6", "meow": "^3.1.0", "phantomas": "1.10.2", diff --git a/test/core/imageOptimizerTest.js b/test/core/imageOptimizerTest.js new file mode 100644 index 0000000..d170334 --- /dev/null +++ b/test/core/imageOptimizerTest.js @@ -0,0 +1,79 @@ +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, '../fixtures/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, '../fixtures/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, '../fixtures/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); + }); + }); + +}); diff --git a/test/core/phantomasWrapperTest.js b/test/core/phantomasWrapperTest.js index 28bafc5..ef5cef8 100644 --- a/test/core/phantomasWrapperTest.js +++ b/test/core/phantomasWrapperTest.js @@ -48,7 +48,6 @@ describe('phantomasWrapper', function() { } }).then(function(data) { - console.log(data); done('Error: unwanted success'); }).fail(function(err) { diff --git a/test/core/weightCheckerTest.js b/test/core/weightCheckerTest.js index 61bf429..208ff12 100644 --- a/test/core/weightCheckerTest.js +++ b/test/core/weightCheckerTest.js @@ -1,5 +1,5 @@ var should = require('chai').should(); -var weightChecker = require('../../lib/tools/weightChecker'); +var weightChecker = require('../../lib/tools/weightChecker/weightChecker'); describe('weightChecker', function() { @@ -69,8 +69,9 @@ describe('weightChecker', function() { isJS: true }; - 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); @@ -79,6 +80,10 @@ describe('weightChecker', function() { newEntry.weightCheck.body.should.have.string('1.8.3'); done(); + }) + + .fail(function(err) { + done(err); }); }); @@ -96,12 +101,16 @@ describe('weightChecker', function() { contentLength: 999 }; - weightChecker.redownloadEntry(entry, function(err, newEntry) { - should.not.exist(err); + weightChecker.redownloadEntry(entry) + .then(function(errnewEntry) { newEntry.weightCheck.should.have.a.property('message').that.equals('error while downloading: 404'); done(); + }) + + .fail(function(err) { + done(err); }); }); @@ -119,12 +128,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('only downloading requests with status code 200'); done(); + }) + + .fail(function(err) { + done(err); }); }); diff --git a/test/fixtures/jpeg-image.jpg b/test/fixtures/jpeg-image.jpg new file mode 100644 index 0000000000000000000000000000000000000000..b77fca9f23b4a9ba886c2ca4a28daaa4a95386e9 GIT binary patch literal 117389 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 GkN?@-3v^)s literal 0 HcmV?d00001 From b52e84d1e769cc157cf8a56117dd444d579ab48d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Sun, 31 May 2015 19:25:34 +0200 Subject: [PATCH 03/39] Optimize PNG files --- lib/tools/weightChecker/imageOptimizer.js | 52 +++++++- test/core/imageOptimizerTest.js | 150 ++++++++++++++++++++++ test/fixtures/png-image.png | Bin 0 -> 56997 bytes 3 files changed, 200 insertions(+), 2 deletions(-) create mode 100644 test/fixtures/png-image.png diff --git a/lib/tools/weightChecker/imageOptimizer.js b/lib/tools/weightChecker/imageOptimizer.js index cf968df..542ee87 100644 --- a/lib/tools/weightChecker/imageOptimizer.js +++ b/lib/tools/weightChecker/imageOptimizer.js @@ -54,6 +54,27 @@ var ImageOptimizer = function() { return entry; }); + } else if (isPNG(entry)) { + + debug('File is a PNG'); + + // Starting softly with a lossless compression + return compressPngLosslessly(entry.weightCheck.body) + + .then(function(newFile) { + var newFileSize = newFile.contents.length; + + debug('PNG lossless compression complete for %s', entry.url); + + if (newFileSize < fileSize) { + 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; + }); + } else { debug('File type is not an optimizable image'); deferred.resolve(entry); @@ -92,7 +113,7 @@ var ImageOptimizer = function() { var deferred = Q.defer(); var startTime = Date.now(); - debug('Starting JPEG lossly compression'); + debug('Starting JPEG lossy compression'); new Imagemin() .src(imageBody) @@ -110,11 +131,38 @@ var ImageOptimizer = function() { return deferred.promise; } + function isPNG(entry) { + return entry.isImage && entry.contentType === 'image/png'; + } + + function compressPngLosslessly(imageBody) { + var deferred = Q.defer(); + var startTime = Date.now(); + + debug('Starting PNG losslessly compression'); + + new Imagemin() + .src(imageBody) + .use(Imagemin.optipng({optimizationLevel: 2})) + .run(function (err, files) { + if (err) { + deferred.reject(err); + } else { + deferred.resolve(files[0]); + var endTime = Date.now(); + debug('compressPngLosslessly took %d ms', endTime - startTime); + } + }); + + return deferred.promise; + } + return { //recompressIfImage: recompressIfImage, optimizeImage: optimizeImage, compressJpegLosslessly: compressJpegLosslessly, - compressJpegLossly: compressJpegLossly + compressJpegLossly: compressJpegLossly, + compressPngLosslessly: compressPngLosslessly }; }; diff --git a/test/core/imageOptimizerTest.js b/test/core/imageOptimizerTest.js index d170334..ca05d40 100644 --- a/test/core/imageOptimizerTest.js +++ b/test/core/imageOptimizerTest.js @@ -76,4 +76,154 @@ describe('imageOptimizer', function() { }); }); + it('should optimize a PNG image losslessly', function(done) { + var fileContent = fs.readFileSync(path.resolve(__dirname, '../fixtures/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 optimize a png', function(done) { + var fileContent = fs.readFileSync(path.resolve(__dirname, '../fixtures/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('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, '../fixtures/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, '../fixtures/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); + }); + }); + }); diff --git a/test/fixtures/png-image.png b/test/fixtures/png-image.png new file mode 100644 index 0000000000000000000000000000000000000000..e1d92f7a56ba174737e86d80ffd1e629e33bbd40 GIT binary patch literal 56997 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 g&#wL7OlFwuxcAbLDu!vfY)6-=k%eKUfy Date: Sun, 31 May 2015 19:36:54 +0200 Subject: [PATCH 04/39] Optimize SVG files --- lib/tools/weightChecker/imageOptimizer.js | 50 +- test/core/imageOptimizerTest.js | 50 ++ test/fixtures/svg-image.svg | 999 ++++++++++++++++++++++ 3 files changed, 1098 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/svg-image.svg diff --git a/lib/tools/weightChecker/imageOptimizer.js b/lib/tools/weightChecker/imageOptimizer.js index 542ee87..a14c434 100644 --- a/lib/tools/weightChecker/imageOptimizer.js +++ b/lib/tools/weightChecker/imageOptimizer.js @@ -75,6 +75,27 @@ var ImageOptimizer = function() { return entry; }); + } else if (isSVG(entry)) { + + debug('File is an SVG'); + + // Starting softly with a lossless compression + return compressSvgLosslessly(entry.weightCheck.body) + + .then(function(newFile) { + var newFileSize = newFile.contents.length; + + debug('SVG lossless compression complete for %s', entry.url); + + if (newFileSize < fileSize) { + 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; + }); + } else { debug('File type is not an optimizable image'); deferred.resolve(entry); @@ -157,12 +178,39 @@ var ImageOptimizer = function() { return deferred.promise; } + function isSVG(entry) { + return entry.isImage && entry.contentType === 'image/svg+xml'; + } + + function compressSvgLosslessly(imageBody) { + var deferred = Q.defer(); + var startTime = Date.now(); + + debug('Starting SVG losslessly compression'); + + new Imagemin() + .src(imageBody) + .use(Imagemin.svgo()) + .run(function (err, files) { + if (err) { + deferred.reject(err); + } else { + deferred.resolve(files[0]); + var endTime = Date.now(); + debug('compressSvgLosslessly took %d ms', endTime - startTime); + } + }); + + return deferred.promise; + } + return { //recompressIfImage: recompressIfImage, optimizeImage: optimizeImage, compressJpegLosslessly: compressJpegLosslessly, compressJpegLossly: compressJpegLossly, - compressPngLosslessly: compressPngLosslessly + compressPngLosslessly: compressPngLosslessly, + compressSvgLosslessly: compressSvgLosslessly }; }; diff --git a/test/core/imageOptimizerTest.js b/test/core/imageOptimizerTest.js index ca05d40..4a26213 100644 --- a/test/core/imageOptimizerTest.js +++ b/test/core/imageOptimizerTest.js @@ -133,11 +133,61 @@ describe('imageOptimizer', function() { }); + it('should optimize an SVG image losslessly', function(done) { + var fileContent = fs.readFileSync(path.resolve(__dirname, '../fixtures/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, '../fixtures/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, + 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); + + done(); + }) + + .fail(function(err) { + done(err); + }); + }); it('shouldn\'t fail optimizing a corrupted jpeg', function(done) { diff --git a/test/fixtures/svg-image.svg b/test/fixtures/svg-image.svg new file mode 100644 index 0000000..701b2a7 --- /dev/null +++ b/test/fixtures/svg-image.svg @@ -0,0 +1,999 @@ + + + + + Tiger - coloured + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + Tiger - coloured + 2015-05-31 + + + Frank Tremmel + + + + + tiger + animal + cat + Tiger + Tier + Katze + line art + outline + + + coloured line art tiger + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 1606cbab18598352809e24b19743bc1d74036d29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Mon, 1 Jun 2015 14:57:05 +0200 Subject: [PATCH 05/39] Add Image Optimization rule to the dashbard --- lib/metadata/policies.js | 10 + lib/metadata/scoreProfileGeneric.json | 3 +- lib/tools/weightChecker/imageOptimizer.js | 211 ++++++++++++---------- lib/tools/weightChecker/weightChecker.js | 113 +++++++++--- test/core/imageOptimizerTest.js | 58 +++++- test/core/weightCheckerTest.js | 89 ++++++++- test/{fixtures => www}/jpeg-image.jpg | Bin test/www/logo-large.png | Bin 0 -> 4193 bytes test/{fixtures => www}/png-image.png | Bin test/{fixtures => www}/svg-image.svg | 0 10 files changed, 351 insertions(+), 133 deletions(-) rename test/{fixtures => www}/jpeg-image.jpg (100%) create mode 100644 test/www/logo-large.png rename test/{fixtures => www}/png-image.png (100%) rename test/{fixtures => www}/svg-image.svg (100%) diff --git a/lib/metadata/policies.js b/lib/metadata/policies.js index 376ce7f..0701acb 100644 --- a/lib/metadata/policies.js +++ b/lib/metadata/policies.js @@ -862,6 +862,16 @@ var policies = { "hasOffenders": true, "unit": 'bytes' }, + "imageOptimization": { + "tool": "weightChecker", + "label": "Image optimization", + "message": "

This metric mesures the number of bytes that could be saved by optimizing images.

The tools in use in YellowLabTools are not set to their maximum optimization power, so you might be able to compress even more (the max JPEG quality is set to 85, which should be sufficient for any website).

", + "isOkThreshold": 30720, + "isBadThreshold": 122880, + "isAbnormalThreshold": 204800, + "hasOffenders": true, + "unit": 'bytes' + }, "requests": { "tool": "phantomas", "label": "Total requests number", diff --git a/lib/metadata/scoreProfileGeneric.json b/lib/metadata/scoreProfileGeneric.json index 250a178..81c7781 100644 --- a/lib/metadata/scoreProfileGeneric.json +++ b/lib/metadata/scoreProfileGeneric.json @@ -72,7 +72,8 @@ "pageWeight": { "label": "Page weight", "policies": { - "totalWeight": 5 + "totalWeight": 5, + "imageOptimization": 3 } }, "requests": { diff --git a/lib/tools/weightChecker/imageOptimizer.js b/lib/tools/weightChecker/imageOptimizer.js index a14c434..be0944c 100644 --- a/lib/tools/weightChecker/imageOptimizer.js +++ b/lib/tools/weightChecker/imageOptimizer.js @@ -7,25 +7,38 @@ var jpegoptim = require('imagemin-jpegoptim'); var ImageOptimizer = function() { var MAX_JPEG_QUALITY = 85; + var OPTIPNG_COMPRESSION_LEVEL = 2; function optimizeImage(entry) { var deferred = Q.defer(); - var fileSize = entry.weightCheck.body.length; + if (!entry.weightCheck.body) { + // No valid file available + deferred.resolve(entry); + return deferred.promise; + } + + var fileSize = entry.weightCheck.bodySize; + 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(entry.weightCheck.body) + 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 (newFileSize < fileSize) { + 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)); @@ -33,24 +46,34 @@ var ImageOptimizer = function() { // Now let's compress lossy to MAX_JPEG_QUALITY - return compressJpegLossly(entry.weightCheck.body); + 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 (newFileSize < fileSize) { + 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)); - - if (newFileSize < entry.weightCheck.lossless) { - entry.weightCheck.optimized = newFileSize; - } } + return entry; + }) + + .fail(function() { return entry; }); @@ -59,19 +82,32 @@ var ImageOptimizer = function() { debug('File is a PNG'); // Starting softly with a lossless compression - return compressPngLosslessly(entry.weightCheck.body) + 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); - if (newFileSize < fileSize) { + 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; }); @@ -80,137 +116,122 @@ var ImageOptimizer = function() { debug('File is an SVG'); // Starting softly with a lossless compression - return compressSvgLosslessly(entry.weightCheck.body) + 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 (newFileSize < fileSize) { + 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 { - debug('File type is not an optimizable image'); + debug('File type %s is not an (optimizable) image', entry.contentType); deferred.resolve(entry); } return deferred.promise; } + function gainIsEnough(oldWeight, newWeight) { + var gain = oldWeight - newWeight; + var ratio = gain / oldWeight; + return (gain > 2048 || ratio > 0.2); + } + function isJpeg(entry) { return entry.isImage && entry.contentType === 'image/jpeg'; } - function compressJpegLosslessly(imageBody) { - var deferred = Q.defer(); - var startTime = Date.now(); - - debug('Starting JPEG lossless compression'); - - new Imagemin() - .src(imageBody) - .use(Imagemin.jpegtran()) - .run(function (err, files) { - if (err) { - deferred.reject(err); - } else { - deferred.resolve(files[0]); - var endTime = Date.now(); - debug('compressJpegLosslessly took %d ms', endTime - startTime); - } - }); - - return deferred.promise; - } - - function compressJpegLossly(imageBody) { - var deferred = Q.defer(); - var startTime = Date.now(); - - debug('Starting JPEG lossy compression'); - - new Imagemin() - .src(imageBody) - .use(jpegoptim({max: MAX_JPEG_QUALITY})) - .run(function (err, files) { - if (err) { - deferred.reject(err); - } else { - deferred.resolve(files[0]); - var endTime = Date.now(); - debug('compressJpegLossly took %d ms', endTime - startTime); - } - }); - - return deferred.promise; - } - function isPNG(entry) { return entry.isImage && entry.contentType === 'image/png'; } - function compressPngLosslessly(imageBody) { - var deferred = Q.defer(); - var startTime = Date.now(); - - debug('Starting PNG losslessly compression'); - - new Imagemin() - .src(imageBody) - .use(Imagemin.optipng({optimizationLevel: 2})) - .run(function (err, files) { - if (err) { - deferred.reject(err); - } else { - deferred.resolve(files[0]); - var endTime = Date.now(); - debug('compressPngLosslessly took %d ms', endTime - startTime); - } - }); - - return deferred.promise; - } - function isSVG(entry) { return entry.isImage && entry.contentType === 'image/svg+xml'; } + 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 SVG losslessly compression'); + debug('Starting %s %s optimization', type, lossy ? 'lossy' : 'lossless'); - new Imagemin() - .src(imageBody) - .use(Imagemin.svgo()) - .run(function (err, files) { - if (err) { - deferred.reject(err); - } else { - deferred.resolve(files[0]); - var endTime = Date.now(); - debug('compressSvgLosslessly took %d ms', endTime - startTime); - } - }); + 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; } return { - //recompressIfImage: recompressIfImage, + recompressIfImage: optimizeImage, optimizeImage: optimizeImage, compressJpegLosslessly: compressJpegLosslessly, compressJpegLossly: compressJpegLossly, compressPngLosslessly: compressPngLosslessly, - compressSvgLosslessly: compressSvgLosslessly + compressSvgLosslessly: compressSvgLosslessly, + gainIsEnough: gainIsEnough }; }; diff --git a/lib/tools/weightChecker/weightChecker.js b/lib/tools/weightChecker/weightChecker.js index 75e1e5b..8c2fe5a 100644 --- a/lib/tools/weightChecker/weightChecker.js +++ b/lib/tools/weightChecker/weightChecker.js @@ -5,18 +5,23 @@ */ -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 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 WeightChecker = function() { var MAX_PARALLEL_DOWNLOADS = 10; var REQUEST_TIMEOUT = 10000; // 10 seconds + + // This function will re-download every asset and check if it could be optimized function recheckAllFiles(data) { var deferred = Q.defer(); @@ -30,10 +35,10 @@ var WeightChecker = function() { redownloadEntry(entry) - .then(recompressIfImage) + .then(imageOptimizer.recompressIfImage) - .then(function(entry) { - callback(null, entry); + .then(function(newEntry) { + callback(null, newEntry); }) .fail(function(err) { @@ -58,6 +63,10 @@ var WeightChecker = function() { offenders.totalWeight = listRequestWeight(results); metrics.totalWeight = offenders.totalWeight.totalWeight; + // Image compression + offenders.imageOptimization = listImageNotOptimized(results); + metrics.imageOptimization = offenders.imageOptimization.totalGain; + data.toolsResults.weightChecker = { metrics: metrics, @@ -128,6 +137,62 @@ var WeightChecker = function() { } + function listImageNotOptimized(requests) { + var results = { + totalGain: 0, + byType: { + jpeg: { + totalGain: 0, + requests: [] + }, + png: { + totalGain: 0, + requests: [] + }, + svg: { + totalGain: 0, + requests: [] + } + } + }; + + requests.forEach(function(req) { + var type = null; + + switch(req.contentType) { + case 'image/jpeg': + type = 'jpeg'; + break; + case 'image/png': + type = 'png'; + break; + case 'image/svg+xml': + type = 'svg'; + break; + } + + if (type && req.weightCheck.bodySize && req.weightCheck.isOptimized === false) { + var gain = req.weightCheck.bodySize - req.weightCheck.optimized; + + results.totalGain += gain; + + results.byType[type].totalGain += gain; + + results.byType[type].requests.push({ + url: req.url, + original: req.weightCheck.bodySize, + optimized: req.weightCheck.optimized, + lossless: req.weightCheck.lossless, + lossy: req.weightCheck.lossy, + gain: gain + }); + } + }); + + return results; + } + + function redownloadEntry(entry) { var deferred = Q.defer(); @@ -136,7 +201,7 @@ var WeightChecker = function() { entry.weightCheck = { message: message }; - deferred.reject(); + deferred.resolve(entry); } if (entry.method !== 'GET') { @@ -161,9 +226,10 @@ var WeightChecker = function() { url: entry.url, headers: reqHeaders, timeout: REQUEST_TIMEOUT + //encoding: (entry.contentType === 'image/jpeg' || entry.contentType === 'image/png') ? 'binary' : null }; - download(requestOptions, function(error, result) { + download(requestOptions, entry.contentType, function(error, result) { if (error) { if (error.code === 'ETIMEDOUT') { onError('timeout after ' + REQUEST_TIMEOUT + 'ms'); @@ -183,7 +249,7 @@ var WeightChecker = function() { } // Inspired by https://github.com/cvan/fastHAR-api/blob/10cec585/app.js - function download(requestOptions, callback) { + function download(requestOptions, contentType, callback) { var statusCode; @@ -226,6 +292,7 @@ var WeightChecker = function() { switch (res.headers['content-encoding']) { case 'gzip': + var gzip = zlib.createGunzip(); gzip.on('data', function (data) { @@ -234,6 +301,8 @@ var WeightChecker = function() { }).on('end', function () { isCompressed = true; tally(); + }).on('error', function(err) { + debug(err); }); res.on('data', function (data) { @@ -242,6 +311,8 @@ var WeightChecker = function() { break; case 'deflate': + res.setEncoding('utf8'); + var deflate = zlib.createInflate(); deflate.on('data', function (data) { @@ -250,6 +321,8 @@ var WeightChecker = function() { }).on('end', function () { isCompressed = true; tally(); + }).on('error', function(err) { + debug(err); }); res.on('data', function (data) { @@ -258,6 +331,10 @@ var WeightChecker = function() { break; default: + if (contentType === 'image/jpeg' || contentType === 'image/png') { + res.setEncoding('binary'); + } + res.on('data', function (data) { body += data; uncompressedSize += data.length; @@ -280,21 +357,11 @@ var WeightChecker = function() { } - function recompressIfImage(entry) { - var deferred = Q.defer(); - - deferred.resolve(entry); - - return deferred.promise; - } - - return { recheckAllFiles: recheckAllFiles, listRequestWeight: listRequestWeight, redownloadEntry: redownloadEntry, - download: download, - recompressIfImage: recompressIfImage + download: download }; }; diff --git a/test/core/imageOptimizerTest.js b/test/core/imageOptimizerTest.js index 4a26213..07df3c5 100644 --- a/test/core/imageOptimizerTest.js +++ b/test/core/imageOptimizerTest.js @@ -6,7 +6,7 @@ var path = require('path'); describe('imageOptimizer', function() { it('should optimize a JPEG image losslessly', function(done) { - var fileContent = fs.readFileSync(path.resolve(__dirname, '../fixtures/jpeg-image.jpg')); + var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/jpeg-image.jpg')); var fileSize = fileContent.length; @@ -20,7 +20,7 @@ describe('imageOptimizer', function() { }); it('should optimize a JPEG image lossly', function(done) { - var fileContent = fs.readFileSync(path.resolve(__dirname, '../fixtures/jpeg-image.jpg')); + var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/jpeg-image.jpg')); var fileSize = fileContent.length; @@ -34,7 +34,7 @@ describe('imageOptimizer', function() { }); it('should find the best optimization for a jpeg', function(done) { - var fileContent = fs.readFileSync(path.resolve(__dirname, '../fixtures/jpeg-image.jpg')); + var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/jpeg-image.jpg')); var fileSize = fileContent.length; var entry = { @@ -77,7 +77,7 @@ describe('imageOptimizer', function() { }); it('should optimize a PNG image losslessly', function(done) { - var fileContent = fs.readFileSync(path.resolve(__dirname, '../fixtures/png-image.png')); + var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/png-image.png')); var fileSize = fileContent.length; @@ -90,8 +90,36 @@ describe('imageOptimizer', function() { }); }); + 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, '../fixtures/png-image.png')); + var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/png-image.png')); var fileSize = fileContent.length; var entry = { @@ -132,9 +160,8 @@ describe('imageOptimizer', function() { }); }); - it('should optimize an SVG image losslessly', function(done) { - var fileContent = fs.readFileSync(path.resolve(__dirname, '../fixtures/svg-image.svg')); + var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/svg-image.svg')); var fileSize = fileContent.length; @@ -148,7 +175,7 @@ describe('imageOptimizer', function() { }); it('should optimize an SVG', function(done) { - var fileContent = fs.readFileSync(path.resolve(__dirname, '../fixtures/svg-image.svg')); + var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/svg-image.svg')); var fileSize = fileContent.length; var entry = { @@ -194,7 +221,7 @@ describe('imageOptimizer', function() { // In this test, we try to optimize a PNG but with a falsy "image/jpeg" content type - var fileContent = fs.readFileSync(path.resolve(__dirname, '../fixtures/png-image.png')); + var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/png-image.png')); var fileSize = fileContent.length; var entry = { @@ -237,7 +264,7 @@ describe('imageOptimizer', function() { // In this test, we try to optimize a JPEG but with a falsy "image/png" content type - var fileContent = fs.readFileSync(path.resolve(__dirname, '../fixtures/jpeg-image.jpg')); + var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/jpeg-image.jpg')); var fileSize = fileContent.length; var entry = { @@ -276,4 +303,15 @@ describe('imageOptimizer', function() { }); }); + 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(200, 100).should.equal(true); + imageOptimizer.gainIsEnough(2000, 1900).should.equal(false); + imageOptimizer.gainIsEnough(200000, 197000).should.equal(true); + }); + }); diff --git a/test/core/weightCheckerTest.js b/test/core/weightCheckerTest.js index 01b0ff7..eb04860 100644 --- a/test/core/weightCheckerTest.js +++ b/test/core/weightCheckerTest.js @@ -1,5 +1,7 @@ var should = require('chai').should(); var weightChecker = require('../../lib/tools/weightChecker/weightChecker'); +var fs = require('fs'); +var path = require('path'); describe('weightChecker', function() { @@ -14,7 +16,8 @@ describe('weightChecker', function() { Accept: '*/*' }, status: 200, - isHTML: true + isHTML: true, + type: 'html' }, { method: 'GET', @@ -25,7 +28,34 @@ 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, + type: 'image', + contentType: 'image/svg+xml' } ]; @@ -48,6 +78,18 @@ 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(1); + data.toolsResults.weightChecker.offenders.totalWeight.byType.image.requests.length.should.equal(2); + + data.toolsResults.weightChecker.offenders.should.have.a.property('imageOptimization'); + data.toolsResults.weightChecker.offenders.imageOptimization.totalGain.should.be.above(0); + data.toolsResults.weightChecker.offenders.imageOptimization.byType.jpeg.requests.length.should.equal(1); + data.toolsResults.weightChecker.offenders.imageOptimization.byType.svg.requests.length.should.equal(1); + done(); }) @@ -77,7 +119,7 @@ describe('weightChecker', function() { 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(); }) @@ -87,6 +129,45 @@ describe('weightChecker', function() { }); }); + 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); + }); + }); + it('should fail downloading a file in error', function(done) { var entry = { method: 'GET', @@ -103,7 +184,7 @@ describe('weightChecker', function() { weightChecker.redownloadEntry(entry) - .then(function(errnewEntry) { + .then(function(newEntry) { newEntry.weightCheck.should.have.a.property('message').that.equals('error while downloading: 404'); done(); diff --git a/test/fixtures/jpeg-image.jpg b/test/www/jpeg-image.jpg similarity index 100% rename from test/fixtures/jpeg-image.jpg rename to test/www/jpeg-image.jpg diff --git a/test/www/logo-large.png b/test/www/logo-large.png new file mode 100644 index 0000000000000000000000000000000000000000..f739504057983a007144315515a0a431463cce2e GIT binary patch literal 4193 zcmV-n5T5UeP);L}#`0@4Cuf+cS{ieu4_{r>s%_x$<$?Aqk}_xb+( z{rK|stYM1%`~2j~+41D--oVhtlCQ64kN5BM`t|tZ$=B1W!Sd$qq*8#&nY6uuqT$BX z_wMub>F@gV_w3u{_3QEa_V~=6xVwI!xOki4g*4^P+wR@w*tE!>L~z}`&(EX0{GS}T zUs%m@M%uZ|@|Gm>=Iq-Nuu!+PKT9PiMz$OwFLVxn5c5i!Scp=faDs)vm+Ttit}G8rZeT^XKjR zo*dhMInk%SD!zhhJCjw`cSUjL&R^XTsM>hSgJ@S#h4_3ZJnYm&&9vgOR$>ek`y z+~vQ7rI*|0Apigkqe(1rjvCdzD@LOO9Uz z0000000000002<39>@}(23ZefyaL3AHwyJ(nr9$3#0FwRY={lShS)f5ZCK7@T`x-; z`YpcJ-k$n({ioRGYrgN!p835t4MlPM92?zg%G_qlHaD3yI1oG8WX{que@z(T&^ax0 zRoPe(=8qNnpi|j{Afn)WQUt|^`QU%ygZQEtrTQOvV{%W^-EK*G2eIA#yq#|^G$;2H zE+;Es?c%rsc%YuYTHYJ-ZW#nik5PAp21rSiO$&pBiEQw=#w4IesfTl0Et`_<_&3G`uO_VO zO_R$eiiH_bz(XLQWRqh!&Fq>7VxE=h;UK$*q=6$u`R5dOr=s^eq;Q z`s`<*QD1$C!$7y4?5Cg~1e9!YWJj1=Q|@Gn1tVGTMe#6A5uYzO*~a|swn(ZlEX)skTtAM>jh?Rq@=!pVPYS^c;}`Q&fW4}B((D9dS#m{R~+w%^?;julkNYQ{OV5in3X*--7*#1 z9aid=ZLZQ#=i4L;PX2NJy}OodW~P_!&-Ztd(?ReVfn?kKWH0nbkn`ibPPz9&Z`m@m zacwt)PfUNAIoIDhjxvnzuy#T?*>il(vE$gzIc|vUxOQp>o6tJMaYAC_gg7R|vFp;b zI1LR+OKVD63Td096zB!DX%VzsRHPy#)EiYL?gCU5DYe$Qn7;^7pR*6U&(3(?+5N!t zas1le-TCdzJny`-=n{25Mrfr5V^F(WMbtZE16zBO3ajhBIAZfTYIUzW(BAr!^7<`d z^Jm@g?M&g_>x_YIXk940_;Y)AKpr78*s2j9;m%T&AOceF!Vb2D0@=FThXH5=wp*K*R8&WNOLxE? zAtTt1b}EuvL4Y8TVH;8rHMC1jTbHVm48TBoZ>G9SUn{7sG~&Wif0fm7A^H0egg9S-^HeY1Gp!4P@93 zloYEd9NYX}MH-EoRE7BFYoziG0GsW5LZS^yMtobkj+jH zo4P-_JNP=e1M&!IV0+N?{tSu(0h{58b&6Ac-72DcqBDHRJKngf@z}VhptDI~8&W3a zYtL@4hY6BcKUw!u*k`kWZ9ruOo7ZpW4%j2u!^X1)YqvBIutf>o6HKThzWiuIgmmw) zehc{g1GTmZ^g>H}GT5%QD5&As<82rJbi`%GF|W__?={q3rOA53v%;h(Njkb_eVctYPE#$6cvjeEy{qWd(UV1c3}2l0x|z$7ve_Yf?oL zXwPcb(xu;{HHQJQvKDMK>z7`uJBBf~B}1SDdum9CfIomNgzd)vraJ6giy94$Jmay@Kj@PfV5sj)z5Xi6rg4OI=UkR+qwBUtS{MU5J5iiuU{u1_8lfZ_% zR2}VrVge*BItauN3kQQoaV414eF<#5VrzL7%|{)kAthQ|Vhx0vqa?${`!|0~qP`k&*Tg)^OoDyBn7z zfsGBv6fJuQw)p4rBGuGO-2r<9d)T-%k#A{25Xi9gxxxLWtY|`ia^4D%ENg22PDe2mK81a6Y7`TQHfKVGG7Vut)|4&?409XV2Y@a$-C%8EljEV$sTg zVVl>zY3}|6?%*l71A|AfhpnQmpcsK~76k$}AOhUBW^!V=*c|P&vN8$FJTq+g7hEaV z4J_*HgZw$pHDDEaS^^vAh%3gr8go4x=AE;)?tndlHEi1{?M)+=+a?GM!3IR2u9*yq zzs9NL%?um(F~bHF3$(>?Ci(z|kub(E1VR=({3!u!jNxv6=?FG7=zPdUE!^Sm#CtzzaYScJAK>t4P55St*z_z1=3rh@J ztG?3**Kh{{eJ(9*?3li=0Y4H1GHlK^D#FMdwohdZ8=tdpgdL538ln=Ikjs;GL}nUb zD8#UVD8R-&!?0aq*Z{LZFMn3R8a#qEY*hpjhmt^sjmNgaj{kGmKoqo-2EB#4XDHVv zEAO8Hl07aAV#csx)}#hKY^`Q@AkuAVVVlq|gEk8S2{u$F5p9{nmVV8)Q@@+Jk!lY7 z4uD)+&IKNoGG9{IkhQ2Lh7D_3c5l;0cOcX*JJ`y!faLx=EeH(51|TCP8Os^N1`Z#p zY|`k|6Klc-v%`6*?Qkc2RvO>Fc|nC~bEibGanJC&MPI4gkTd@kxjT@11cr?b{b)4& z;ox!YO&mzDLH#QsXLvAU*g9|xX^!&Y#o4X>oYENC61WQFBk@{U!q%&;Mth85qrCz>qg(3tOb*~$6{UyG-AWTK-W`qpan(Y%iDAQaa_;dA-d90jJZ$|ziN;N` zbSBA*zqo78&5Dll^#PqZ2Riz+f~}*u6*DHV1P@}^s6g&W=qtQR0~=efPnS@KlFl8l zc1d94nP4gBK$k-%P7(;%aL>hd7!vetmasW1bowSJX8|s(?xu-wA zBToULZ`JrIuU)Bo=J4vh16I=qcs{&pseg%J<8w>y!0Pk5P*hM=5J<3LSLd;h3I8)T z4Aa8Kw+HRp*!ahbe?8WTmg093z{clY_I&iWPn0QH7h9pF2C0G$*4+pjYcrQH?3;XO z;K-I_XApX4H(mNR8c;9Adh;z?*xq_m2pbpoca4oNmFR|&1~P0oVCPOwT$$trYkJtw z$0psHO9-1eKRm5P+@0b1&)u2^wgaUs4!oFGY;Rw+*4skZV8BRl1A;(?&F|D5#>y5p z%=0XmR5NUd(5jQq;c(qzAGO)R=Il%f8&zbnUa^Pm6%lN->%wxM69qDC$0;)Nq%Kzq zvfSZ>2EqJy%Io!ebtkC}TUC~@Ij1s#t>q7nR`1)x_Pzi%DEElc>2_Hl!&cQkrtD#J z8eJW0bBPTC{~hyp3@X^)qBeIbC2Y=R5p40y6-^t_@7crlo&+}NmL(OhZ%PBXH~)?x zN_A!po3l_K zi(x~ul~ww+BM4;Jw3Cu|-La8hCfF$C9>qEXNEo(qtj+XC6@)wpL~gzsQ$l`7a7!B4 zoO8b-fx?*56R(NK)L-igE;i%~Uy38mT)`LrQ!A%G40*8cQ9 z1+6^cnDy=(wyeGJuESz8nUAID(dCeVsUS!eSmnL;Lt7NoGyk7<=TJ9cC=R3e6`g|g zpQ`{tW@b7;eRE+3L9&@W1}I1sqNbv5kAPa!rm|_@;RX^y!V~cT2;er$w&a}r Date: Mon, 1 Jun 2015 16:52:50 +0200 Subject: [PATCH 06/39] Add offenders to imageOptimization --- front/src/css/rule.css | 28 ++++++++++++--- .../src/js/directives/offendersDirectives.js | 4 +++ front/src/less/rule.less | 34 +++++++++++++++--- front/src/views/rule.html | 19 +++++++++- lib/metadata/policies.js | 2 +- lib/tools/weightChecker/imageOptimizer.js | 4 ++- lib/tools/weightChecker/weightChecker.js | 35 ++----------------- test/core/imageOptimizerTest.js | 3 +- test/core/weightCheckerTest.js | 3 +- 9 files changed, 85 insertions(+), 47 deletions(-) diff --git a/front/src/css/rule.css b/front/src/css/rule.css index 40f0546..ed5794a 100644 --- a/front/src/css/rule.css +++ b/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; @@ -216,3 +218,21 @@ 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; +} diff --git a/front/src/js/directives/offendersDirectives.js b/front/src/js/directives/offendersDirectives.js index 974e70f..52d9db0 100644 --- a/front/src/js/directives/offendersDirectives.js +++ b/front/src/js/directives/offendersDirectives.js @@ -864,6 +864,10 @@ var kilo = bytes / 1024; + if (kilo < 1) { + return bytes + ' Bytes'; + } + if (kilo < 100) { return kilo.toFixed(1) + ' KB'; } diff --git a/front/src/less/rule.less b/front/src/less/rule.less index db20709..ce3bd5c 100644 --- a/front/src/less/rule.less +++ b/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; @@ -235,4 +237,26 @@ .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; + } } \ No newline at end of file diff --git a/front/src/views/rule.html b/front/src/views/rule.html index c2a8c03..d9f1a7f 100644 --- a/front/src/views/rule.html +++ b/front/src/views/rule.html @@ -190,7 +190,7 @@

This is the colors palette, sized by total occurrences:

-
+
{{offender.color}} ({{offender.occurrences}} times)
@@ -213,6 +213,23 @@

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 lie to you because they're generally not very good for optimization. Use specialized tools such as Kraken.io or the excellent ImageOption on Mac.

+
+

{{rule.value | bytes}} could be saved on

+
+
+
+ Current file: +
+
+
+

Current weight: {{image.original | bytes}}

+

With a lossless optimization:
{{image.lossless | bytes}} (-{{image.original - image.lossless | bytes}})

+

With a lossy optimization:
{{image.lossy | bytes}} (-{{image.original - image.lossy | bytes}})

+
+
+
+
+

{{rule.value}} offenders

Please open the JS timeline diff --git a/lib/metadata/policies.js b/lib/metadata/policies.js index 0701acb..32979ce 100644 --- a/lib/metadata/policies.js +++ b/lib/metadata/policies.js @@ -865,7 +865,7 @@ var policies = { "imageOptimization": { "tool": "weightChecker", "label": "Image optimization", - "message": "

This metric mesures the number of bytes that could be saved by optimizing images.

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 lie to you because they're generally not very good for optimization. Use specialized tools such as Kraken.io or the excellent ImageOption on Mac.

The tools in use in YellowLabTools are not set to their maximum optimization power, so you might be able to compress even more (the max JPEG quality is set to 85, which should be sufficient for any website).

", + "message": "

This metric mesures the number of bytes that could be saved by optimizing images.

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 Kraken.io or the excellent ImageOption on Mac. For SVG images, you can use SVGOMG

The tools in use in YellowLabTools are not set to their maximum optimization power, so you might be able to compress even more.

", "isOkThreshold": 30720, "isBadThreshold": 122880, "isAbnormalThreshold": 204800, diff --git a/lib/tools/weightChecker/imageOptimizer.js b/lib/tools/weightChecker/imageOptimizer.js index be0944c..93838f8 100644 --- a/lib/tools/weightChecker/imageOptimizer.js +++ b/lib/tools/weightChecker/imageOptimizer.js @@ -149,10 +149,12 @@ var ImageOptimizer = function() { 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); + return (gain > 2048 || (ratio > 0.2 && gain > 100)); } function isJpeg(entry) { diff --git a/lib/tools/weightChecker/weightChecker.js b/lib/tools/weightChecker/weightChecker.js index 8c2fe5a..f718355 100644 --- a/lib/tools/weightChecker/weightChecker.js +++ b/lib/tools/weightChecker/weightChecker.js @@ -140,45 +140,16 @@ var WeightChecker = function() { function listImageNotOptimized(requests) { var results = { totalGain: 0, - byType: { - jpeg: { - totalGain: 0, - requests: [] - }, - png: { - totalGain: 0, - requests: [] - }, - svg: { - totalGain: 0, - requests: [] - } - } + images: [] }; requests.forEach(function(req) { - var type = null; - - switch(req.contentType) { - case 'image/jpeg': - type = 'jpeg'; - break; - case 'image/png': - type = 'png'; - break; - case 'image/svg+xml': - type = 'svg'; - break; - } - - if (type && req.weightCheck.bodySize && req.weightCheck.isOptimized === false) { + if (req.weightCheck.bodySize && req.weightCheck.isOptimized === false) { var gain = req.weightCheck.bodySize - req.weightCheck.optimized; results.totalGain += gain; - results.byType[type].totalGain += gain; - - results.byType[type].requests.push({ + results.images.push({ url: req.url, original: req.weightCheck.bodySize, optimized: req.weightCheck.optimized, diff --git a/test/core/imageOptimizerTest.js b/test/core/imageOptimizerTest.js index 07df3c5..59f5bbf 100644 --- a/test/core/imageOptimizerTest.js +++ b/test/core/imageOptimizerTest.js @@ -309,7 +309,8 @@ describe('imageOptimizer', function() { imageOptimizer.gainIsEnough(20000, 21000).should.equal(false); imageOptimizer.gainIsEnough(20000, 40000).should.equal(false); imageOptimizer.gainIsEnough(20000, 19500).should.equal(false); - imageOptimizer.gainIsEnough(200, 100).should.equal(true); + 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); }); diff --git a/test/core/weightCheckerTest.js b/test/core/weightCheckerTest.js index eb04860..babde1f 100644 --- a/test/core/weightCheckerTest.js +++ b/test/core/weightCheckerTest.js @@ -87,8 +87,7 @@ describe('weightChecker', function() { data.toolsResults.weightChecker.offenders.should.have.a.property('imageOptimization'); data.toolsResults.weightChecker.offenders.imageOptimization.totalGain.should.be.above(0); - data.toolsResults.weightChecker.offenders.imageOptimization.byType.jpeg.requests.length.should.equal(1); - data.toolsResults.weightChecker.offenders.imageOptimization.byType.svg.requests.length.should.equal(1); + data.toolsResults.weightChecker.offenders.imageOptimization.images.length.should.equal(2); done(); }) From cea74dd63d152f4402699cba6c9cca883403f510 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Mon, 1 Jun 2015 17:44:18 +0200 Subject: [PATCH 07/39] Cooler Image Optimization score --- lib/metadata/policies.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/metadata/policies.js b/lib/metadata/policies.js index 32979ce..af1fec1 100644 --- a/lib/metadata/policies.js +++ b/lib/metadata/policies.js @@ -865,10 +865,10 @@ var policies = { "imageOptimization": { "tool": "weightChecker", "label": "Image optimization", - "message": "

This metric mesures the number of bytes that could be saved by optimizing images.

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 Kraken.io or the excellent ImageOption on Mac. For SVG images, you can use SVGOMG

The tools in use in YellowLabTools are not set to their maximum optimization power, so you might be able to compress even more.

", + "message": "

This metric mesures the number of bytes that could be saved by optimizing images.

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 Kraken.io or the excellent ImageOption on Mac. For SVG images, you can use SVGOMG

The tools in use in YellowLabTools are not set to their maximum optimization power, so you might be able to compress even more!

", "isOkThreshold": 30720, "isBadThreshold": 122880, - "isAbnormalThreshold": 204800, + "isAbnormalThreshold": 409600, "hasOffenders": true, "unit": 'bytes' }, From 41bc676b1219249561f8ad687c70b0536ec43931 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Mon, 1 Jun 2015 17:44:34 +0200 Subject: [PATCH 08/39] Fix bug with about:blank --- lib/tools/weightChecker/weightChecker.js | 5 +++++ test/core/weightCheckerTest.js | 11 +++++++++++ 2 files changed, 16 insertions(+) diff --git a/lib/tools/weightChecker/weightChecker.js b/lib/tools/weightChecker/weightChecker.js index f718355..ec50a38 100644 --- a/lib/tools/weightChecker/weightChecker.js +++ b/lib/tools/weightChecker/weightChecker.js @@ -185,6 +185,11 @@ var WeightChecker = function() { return deferred.promise; } + if (entry.url === 'about:blank') { + onError('not downloading about:blank'); + return deferred.promise; + } + debug('Downloading %s', entry.url); diff --git a/test/core/weightCheckerTest.js b/test/core/weightCheckerTest.js index babde1f..16d15b2 100644 --- a/test/core/weightCheckerTest.js +++ b/test/core/weightCheckerTest.js @@ -56,6 +56,16 @@ describe('weightChecker', function() { isImage: true, type: 'image', contentType: 'image/svg+xml' + }, + { + method: 'GET', + url: 'about:blank', + requestHeaders: { + 'User-Agent': 'something', + Referer: 'http://www.google.fr/', + Accept: '*/*' + }, + status: 200 } ]; @@ -84,6 +94,7 @@ describe('weightChecker', function() { data.toolsResults.weightChecker.offenders.totalWeight.byType.html.requests.length.should.equal(1); data.toolsResults.weightChecker.offenders.totalWeight.byType.js.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(1); data.toolsResults.weightChecker.offenders.should.have.a.property('imageOptimization'); data.toolsResults.weightChecker.offenders.imageOptimization.totalGain.should.be.above(0); From c1bae20f01168ff28a1c0fd1267d172466f94817 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Tue, 16 Jun 2015 20:33:19 +0200 Subject: [PATCH 09/39] File minification --- .../src/js/directives/offendersDirectives.js | 2 +- front/src/views/rule.html | 24 +- lib/metadata/policies.js | 12 +- lib/metadata/scoreProfileGeneric.json | 4 +- lib/tools/weightChecker/fileMinifier.js | 195 +++++ lib/tools/weightChecker/imageOptimizer.js | 7 +- lib/tools/weightChecker/weightChecker.js | 80 +- package.json | 27 +- test/core/fileMinifierTest.js | 214 ++++++ test/core/weightCheckerTest.js | 36 +- test/www/unminified-script.js | 190 +++++ test/www/unminified-stylesheet.css | 702 ++++++++++++++++++ 12 files changed, 1456 insertions(+), 37 deletions(-) create mode 100644 lib/tools/weightChecker/fileMinifier.js create mode 100644 test/core/fileMinifierTest.js create mode 100644 test/www/unminified-script.js create mode 100644 test/www/unminified-stylesheet.css diff --git a/front/src/js/directives/offendersDirectives.js b/front/src/js/directives/offendersDirectives.js index 52d9db0..d122393 100644 --- a/front/src/js/directives/offendersDirectives.js +++ b/front/src/js/directives/offendersDirectives.js @@ -865,7 +865,7 @@ var kilo = bytes / 1024; if (kilo < 1) { - return bytes + ' Bytes'; + return bytes + ' bytes'; } if (kilo < 100) { diff --git a/front/src/views/rule.html b/front/src/views/rule.html index d9f1a7f..7e3d15a 100644 --- a/front/src/views/rule.html +++ b/front/src/views/rule.html @@ -137,9 +137,9 @@
-
+
- ({{offender.size}} kB) + ({{offender.size}} KB)
@@ -230,6 +230,26 @@
+
+

{{rule.value | bytes}} could be saved on

+
+
+
File
+
Current weight
+
Minified
+
Gain
+
+
+
+ +
+
{{file.original | bytes}}
+
{{file.minified | bytes}}
+
-{{file.gain | bytes}}
+
+
+
+

{{rule.value}} offenders

Please open the JS timeline diff --git a/lib/metadata/policies.js b/lib/metadata/policies.js index af1fec1..269c8b2 100644 --- a/lib/metadata/policies.js +++ b/lib/metadata/policies.js @@ -872,6 +872,16 @@ var policies = { "hasOffenders": true, "unit": 'bytes' }, + "fileMinification": { + "tool": "weightChecker", + "label": "File minification", + "message": "

This is the weight that could be saved if all text resources were correctly minified.

The tools in use here are UglifyJS, clean-css and HTMLMinifier.

The gains of minification are generally small, but the impact can be high when these text files are loaded on the critical path.

", + "isOkThreshold": 20480, + "isBadThreshold": 81920, + "isAbnormalThreshold": 153600, + "hasOffenders": true, + "unit": 'bytes' + }, "requests": { "tool": "phantomas", "label": "Total requests number", @@ -888,7 +898,7 @@ var policies = { .map(function(offender) { return offendersHelpers.fileWithSizePattern(offender); }).sort(function(a, b) { - return b.size - a.size; + return (b.file < a.file) ? 1 : (b.file > a.file) ? -1 : 0; }) }; } diff --git a/lib/metadata/scoreProfileGeneric.json b/lib/metadata/scoreProfileGeneric.json index 81c7781..1407255 100644 --- a/lib/metadata/scoreProfileGeneric.json +++ b/lib/metadata/scoreProfileGeneric.json @@ -73,7 +73,9 @@ "label": "Page weight", "policies": { "totalWeight": 5, - "imageOptimization": 3 + "imageOptimization": 2, + "fileMinification": 1, + "assetsNotGzipped": 2 } }, "requests": { diff --git a/lib/tools/weightChecker/fileMinifier.js b/lib/tools/weightChecker/fileMinifier.js new file mode 100644 index 0000000..dd5ec2b --- /dev/null +++ b/lib/tools/weightChecker/fileMinifier.js @@ -0,0 +1,195 @@ +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); + + if (entry.isJS) { + + debug('File is a JS'); + + // Starting softly with a lossless compression + return minifyJs(entry.weightCheck.body) + + .then(function(newFile) { + if (!newFile) { + debug('Optimization didn\'t work'); + return entry; + } + + var newFileSize = newFile.length; + + debug('JS minification complete for %s', entry.url); + + if (gainIsEnough(fileSize, newFileSize)) { + entry.weightCheck.minified = newFileSize; + entry.weightCheck.isMinified = 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'); + + // Starting softly with a lossless compression + return minifyCss(entry.weightCheck.body) + + .then(function(newFile) { + if (!newFile) { + debug('Optimization didn\'t work'); + return entry; + } + + var newFileSize = newFile.length; + + debug('CSS minification complete for %s', entry.url); + + if (gainIsEnough(fileSize, newFileSize)) { + entry.weightCheck.minified = newFileSize; + entry.weightCheck.isMinified = 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'); + + // Starting softly with a lossless compression + return minifyHtml(entry.weightCheck.body) + + .then(function(newFile) { + console.log('KKKKKKKKKKKK'); + if (!newFile) { + debug('Optimization didn\'t work'); + return entry; + } + + var newFileSize = newFile.length; + + debug('HTML minification complete for %s', entry.url); + + if (gainIsEnough(fileSize, newFileSize)) { + entry.weightCheck.minified = newFileSize; + entry.weightCheck.isMinified = false; + debug('Filesize is %d bytes smaller (-%d%)', fileSize - newFileSize, Math.round((fileSize - newFileSize) * 100 / fileSize)); + } else { + console.log('OOOO old file size: ' + fileSize); + console.log('OOOO new file size: ' + newFileSize); + console.log(entry.weightCheck); + } + + return entry; + }) + + .fail(function(err) { + console.log('LLLLLLLLLLLLLLL'); + console.log(err); + 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 > 2096 || (ratio > 0.2 && gain > 400)); + } + + // Uglify + function minifyJs(body) { + var deferred = Q.defer(); + + try { + var result = UglifyJS.minify(body, {fromString: true}); + deferred.resolve(result.code); + } catch(err) { + deferred.reject(err); + } + + 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; + } + + return { + minifyFile: minifyFile, + minifyJs: minifyJs, + minifyCss: minifyCss, + minifyHtml: minifyHtml, + gainIsEnough: gainIsEnough + }; +}; + +module.exports = new FileMinifier(); \ No newline at end of file diff --git a/lib/tools/weightChecker/imageOptimizer.js b/lib/tools/weightChecker/imageOptimizer.js index 93838f8..453f1b8 100644 --- a/lib/tools/weightChecker/imageOptimizer.js +++ b/lib/tools/weightChecker/imageOptimizer.js @@ -7,18 +7,18 @@ var jpegoptim = require('imagemin-jpegoptim'); var ImageOptimizer = function() { var MAX_JPEG_QUALITY = 85; - var OPTIPNG_COMPRESSION_LEVEL = 2; + var OPTIPNG_COMPRESSION_LEVEL = 1; function optimizeImage(entry) { var deferred = Q.defer(); - if (!entry.weightCheck.body) { + if (!entry.weightCheck || !entry.weightCheck.body) { // No valid file available deferred.resolve(entry); return deferred.promise; } - var fileSize = entry.weightCheck.bodySize; + var fileSize = entry.weightCheck.uncompressedSize; debug('Let\'s try to optimize %s', entry.url); debug('Current file size is %d', fileSize); @@ -227,7 +227,6 @@ var ImageOptimizer = function() { } return { - recompressIfImage: optimizeImage, optimizeImage: optimizeImage, compressJpegLosslessly: compressJpegLosslessly, compressJpegLossly: compressJpegLossly, diff --git a/lib/tools/weightChecker/weightChecker.js b/lib/tools/weightChecker/weightChecker.js index ec50a38..d90e629 100644 --- a/lib/tools/weightChecker/weightChecker.js +++ b/lib/tools/weightChecker/weightChecker.js @@ -13,6 +13,7 @@ var async = require('async'); var request = require('request'); var imageOptimizer = require('./imageOptimizer'); +var fileMinifier = require('./fileMinifier'); var WeightChecker = function() { @@ -23,6 +24,8 @@ var WeightChecker = function() { // 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); @@ -35,7 +38,9 @@ var WeightChecker = function() { redownloadEntry(entry) - .then(imageOptimizer.recompressIfImage) + .then(imageOptimizer.optimizeImage) + + .then(fileMinifier.minifyFile) .then(function(newEntry) { callback(null, newEntry); @@ -49,11 +54,20 @@ var WeightChecker = function() { // 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); + + // Remove unwanted requests (redirections, about:blank) + results = results.filter(function(result) { + return (result !== null && result.weightCheck && result.weightCheck.bodySize > 0); + }); var metrics = {}; var offenders = {}; @@ -67,6 +81,11 @@ var WeightChecker = function() { offenders.imageOptimization = listImageNotOptimized(results); metrics.imageOptimization = offenders.imageOptimization.totalGain; + // File minification + offenders.fileMinification = listFilesNotMinified(results); + metrics.fileMinification = offenders.fileMinification.totalGain; + + data.toolsResults.weightChecker = { metrics: metrics, @@ -144,14 +163,14 @@ var WeightChecker = function() { }; requests.forEach(function(req) { - if (req.weightCheck.bodySize && req.weightCheck.isOptimized === false) { - var gain = req.weightCheck.bodySize - req.weightCheck.optimized; + if (req.weightCheck.uncompressedSize && req.weightCheck.isOptimized === false) { + var gain = req.weightCheck.uncompressedSize - req.weightCheck.optimized; results.totalGain += gain; results.images.push({ url: req.url, - original: req.weightCheck.bodySize, + original: req.weightCheck.uncompressedSize, optimized: req.weightCheck.optimized, lossless: req.weightCheck.lossless, lossy: req.weightCheck.lossy, @@ -164,10 +183,35 @@ var WeightChecker = function() { } + function listFilesNotMinified(requests) { + var results = { + totalGain: 0, + files: [] + }; + + requests.forEach(function(req) { + if (req.weightCheck.uncompressedSize && req.weightCheck.isMinified === false) { + var gain = req.weightCheck.uncompressedSize - req.weightCheck.minified; + + results.totalGain += gain; + + results.files.push({ + url: req.url, + original: req.weightCheck.uncompressedSize, + minified: req.weightCheck.minified, + gain: gain + }); + } + }); + + return results; + } + + function redownloadEntry(entry) { var deferred = Q.defer(); - function onError(message) { + function downloadError(message) { debug('Could not download %s Error: %s', entry.url, message); entry.weightCheck = { message: message @@ -175,18 +219,32 @@ var WeightChecker = function() { 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') { - onError('only downloading GET'); + notDownloadableFile('only downloading GET'); return deferred.promise; } if (entry.status !== 200) { - onError('only downloading requests with status code 200'); + unwantedFile('only downloading requests with status code 200'); return deferred.promise; } if (entry.url === 'about:blank') { - onError('not downloading about:blank'); + unwantedFile('not downloading about:blank'); return deferred.promise; } @@ -202,15 +260,14 @@ var WeightChecker = function() { url: entry.url, headers: reqHeaders, timeout: REQUEST_TIMEOUT - //encoding: (entry.contentType === 'image/jpeg' || entry.contentType === 'image/png') ? 'binary' : null }; download(requestOptions, entry.contentType, function(error, result) { if (error) { if (error.code === 'ETIMEDOUT') { - onError('timeout after ' + REQUEST_TIMEOUT + 'ms'); + downloadError('timeout after ' + REQUEST_TIMEOUT + 'ms'); } else { - onError('error while downloading: ' + error.code); + downloadError('error while downloading: ' + error.code); } return; } @@ -332,7 +389,6 @@ var WeightChecker = function() { }); } - return { recheckAllFiles: recheckAllFiles, listRequestWeight: listRequestWeight, diff --git a/package.json b/package.json index 03c1a33..bf30697 100644 --- a/package.json +++ b/package.json @@ -11,21 +11,24 @@ }, "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", - "imagemin": "~3.2.0", - "imagemin-jpegoptim": "~4.0.0", + "async": "1.0.0", + "body-parser": "1.12.4", + "clean-css": "3.3.0", + "compression": "1.4.4", + "cors": "2.6.0", + "debug": "2.2.0", + "express": "4.12.4", + "imagemin": "3.2.0", + "imagemin-jpegoptim": "4.0.0", "lwip": "0.0.6", - "meow": "^3.1.0", + "meow": "3.1.0", + "minimize": "1.4.1", "phantomas": "1.10.2", "ps-node": "0.0.4", - "q": "~1.4.1", - "rimraf": "~2.3.4", - "temporary": "0.0.8" + "q": "1.4.1", + "rimraf": "2.3.4", + "temporary": "0.0.8", + "uglify-js": "2.4.23" }, "devDependencies": { "chai": "^2.3.0", diff --git a/test/core/fileMinifierTest.js b/test/core/fileMinifierTest.js new file mode 100644 index 0000000..baa92f5 --- /dev/null +++ b/test/core/fileMinifierTest.js @@ -0,0 +1,214 @@ +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('isMinified').that.equals(false); + newEntry.weightCheck.should.have.a.property('minified').that.is.below(fileSize); + + done(); + }) + + .fail(function(err) { + done(err); + }); + }); + + it('should fail minifying an already minified JS', function(done) { + 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('isMinified'); + newEntry.weightCheck.should.not.have.a.property('minified'); + + 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('isMinified'); + newEntry.weightCheck.should.not.have.a.property('minified'); + + 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('isMinified').that.equals(false); + newEntry.weightCheck.should.have.a.property('minified').that.is.below(fileSize); + + 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); + }); + }); + +}); diff --git a/test/core/weightCheckerTest.js b/test/core/weightCheckerTest.js index 16d15b2..61ddf9d 100644 --- a/test/core/weightCheckerTest.js +++ b/test/core/weightCheckerTest.js @@ -57,6 +57,30 @@ describe('weightChecker', function() { 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', @@ -92,14 +116,19 @@ describe('weightChecker', function() { 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(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(1); + 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('fileMinification'); + data.toolsResults.weightChecker.offenders.fileMinification.totalGain.should.be.above(0); + data.toolsResults.weightChecker.offenders.fileMinification.files.length.should.equal(2); + done(); }) @@ -222,8 +251,7 @@ describe('weightChecker', function() { weightChecker.redownloadEntry(entry) .then(function(newEntry) { - newEntry.weightCheck.should.have.a.property('message').that.equals('only downloading requests with status code 200'); - + newEntry.should.not.have.a.property('weightCheck'); done(); }) diff --git a/test/www/unminified-script.js b/test/www/unminified-script.js new file mode 100644 index 0000000..ff44234 --- /dev/null +++ b/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 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); + }); + } + }; +}]); \ No newline at end of file diff --git a/test/www/unminified-stylesheet.css b/test/www/unminified-stylesheet.css new file mode 100644 index 0000000..620bf3b --- /dev/null +++ b/test/www/unminified-stylesheet.css @@ -0,0 +1,702 @@ +/* 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; +} \ No newline at end of file From baa145e15c87ae533a52ab6b30057e63543ed16d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Thu, 18 Jun 2015 11:07:19 +0200 Subject: [PATCH 10/39] Add a gzip compression task --- lib/tools/weightChecker/gzipCompressor.js | 101 ++++ lib/tools/weightChecker/weightChecker.js | 3 + test/core/gzipCompressorTest.js | 586 ++++++++++++++++++++++ test/www/minified-script.js | 14 + 4 files changed, 704 insertions(+) create mode 100644 lib/tools/weightChecker/gzipCompressor.js create mode 100644 test/core/gzipCompressorTest.js create mode 100644 test/www/minified-script.js diff --git a/lib/tools/weightChecker/gzipCompressor.js b/lib/tools/weightChecker/gzipCompressor.js new file mode 100644 index 0000000..3ce8537 --- /dev/null +++ b/lib/tools/weightChecker/gzipCompressor.js @@ -0,0 +1,101 @@ +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 (isCompressible(entry) && !entry.weightCheck.isCompressed) { + 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; + deferred.resolve(entry); + } else { + debug('Gain is not enough, was %d and is now %d bytes', uncompressedSize, compressedSize); + } + } + }); + } 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 (isCompressible(entry) && !entry.weightCheck.isOptimized && !entry.weightCheck.isMinified) { + debug('Trying to gzip file after minification: %s', entry.url); + + var uncompressedSize = entry.weightCheck.optimized || entry.weightCheck.minified; + + zlib.gzip(new Buffer(entry.weightCheck.bodyAfterMinification, 'utf8'), function(err, buffer) { + if (err) { + debug('Could not compress minified 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.afterOptimizationAndCompression = compressedSize; + deferred.resolve(entry); + } else { + debug('Gain is not enough, was %d and is now %d bytes', uncompressedSize, compressedSize); + } + } + }); + } else { + deferred.resolve(entry); + } + + return deferred.promise; + } + + function isCompressible(entry) { + return entry.isJS || entry.isCSS || entry.isHTML || entry.isJSON || entry.isSVG || entry.isTTF || entry.isXML || entry.isFavicon; + } + + // 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)); + } + + return { + compressFile: compressFile + }; +}; + +module.exports = new GzipCompressor(); \ No newline at end of file diff --git a/lib/tools/weightChecker/weightChecker.js b/lib/tools/weightChecker/weightChecker.js index d90e629..f326ccf 100644 --- a/lib/tools/weightChecker/weightChecker.js +++ b/lib/tools/weightChecker/weightChecker.js @@ -14,6 +14,7 @@ var request = require('request'); var imageOptimizer = require('./imageOptimizer'); var fileMinifier = require('./fileMinifier'); +var gzipCompressor = require('./gzipCompressor'); var WeightChecker = function() { @@ -42,6 +43,8 @@ var WeightChecker = function() { .then(fileMinifier.minifyFile) + .then(gzipCompressor.compress) + .then(function(newEntry) { callback(null, newEntry); }) diff --git a/test/core/gzipCompressorTest.js b/test/core/gzipCompressorTest.js new file mode 100644 index 0000000..d69fcc7 --- /dev/null +++ b/test/core/gzipCompressorTest.js @@ -0,0 +1,586 @@ +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, + isMinified: 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'), + bodyAfterMinification: minifiedContent.toString('utf8'), + totalWeight: fileSize + 200, + headersSize: 200, + bodySize: fileSize, + isCompressed: false, + uncompressedSize: fileSize, + isMinified: false, + minified: minifiedSize + } + }; + + gzipCompressor.compressFile(entry) + + .then(function(newEntry) { + delete newEntry.weightCheck.body; + delete newEntry.weightCheck.bodyAfterMinification; + console.log(newEntry.weightCheck); + 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'), + bodyAfterMinification: minifiedContent.toString('utf8'), + totalWeight: gzipedSize + 200, + headersSize: 200, + bodySize: gzipedSize, + isCompressed: true, + uncompressedSize: fileSize, + isMinified: false, + minified: 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, + isMinified: true + } + }; + + gzipCompressor.compressFile(entry) + + .then(function(newEntry) { + newEntry.weightCheck.should.not.have.a.property('minified'); + newEntry.weightCheck.should.not.have.a.property('bodyAfterMinification'); + 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, + isMinified: 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, + isMinified: 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, + isMinified: 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, + isMinified: 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, + isMinified: 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, + isMinified: 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, + isMinified: 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); + }); + }); + +}); diff --git a/test/www/minified-script.js b/test/www/minified-script.js new file mode 100644 index 0000000..7f3d520 --- /dev/null +++ b/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;r0)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)})}}}]) From db57f67e98f3a1127526c5b7075e18faa4d3a7f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Thu, 18 Jun 2015 11:08:16 +0200 Subject: [PATCH 11/39] Change policy text --- lib/metadata/policies.js | 2 +- lib/tools/weightChecker/fileMinifier.js | 9 +-------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/lib/metadata/policies.js b/lib/metadata/policies.js index 269c8b2..3354348 100644 --- a/lib/metadata/policies.js +++ b/lib/metadata/policies.js @@ -875,7 +875,7 @@ var policies = { "fileMinification": { "tool": "weightChecker", "label": "File minification", - "message": "

This is the weight that could be saved if all text resources were correctly minified.

The tools in use here are UglifyJS, clean-css and HTMLMinifier.

The gains of minification are generally small, but the impact can be high when these text files are loaded on the critical path.

", + "message": "

This is the weight that could be saved if all text resources were correctly minified.

The tools in use here are UglifyJS, clean-css and HTMLMinifier. These tools are so good that some of your minified files can be marked as unminified. Change your tool it this happens :)

The gains of minification are generally small, but the impact can be high when these text files are loaded on the critical path.

", "isOkThreshold": 20480, "isBadThreshold": 81920, "isAbnormalThreshold": 153600, diff --git a/lib/tools/weightChecker/fileMinifier.js b/lib/tools/weightChecker/fileMinifier.js index dd5ec2b..c879cef 100644 --- a/lib/tools/weightChecker/fileMinifier.js +++ b/lib/tools/weightChecker/fileMinifier.js @@ -89,7 +89,6 @@ var FileMinifier = function() { return minifyHtml(entry.weightCheck.body) .then(function(newFile) { - console.log('KKKKKKKKKKKK'); if (!newFile) { debug('Optimization didn\'t work'); return entry; @@ -103,18 +102,12 @@ var FileMinifier = function() { entry.weightCheck.minified = newFileSize; entry.weightCheck.isMinified = false; debug('Filesize is %d bytes smaller (-%d%)', fileSize - newFileSize, Math.round((fileSize - newFileSize) * 100 / fileSize)); - } else { - console.log('OOOO old file size: ' + fileSize); - console.log('OOOO new file size: ' + newFileSize); - console.log(entry.weightCheck); } return entry; }) .fail(function(err) { - console.log('LLLLLLLLLLLLLLL'); - console.log(err); return entry; }); @@ -127,7 +120,7 @@ var FileMinifier = function() { } // The gain is estimated of enough value if it's over 2KB or over 20%, - // but it's ignored if is below 100 bytes + // but it's ignored if is below 400 bytes function gainIsEnough(oldWeight, newWeight) { var gain = oldWeight - newWeight; var ratio = gain / oldWeight; From 0d3d6c88e437e766739fdaf1fa35d8dbebcbc3e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Thu, 18 Jun 2015 11:10:47 +0200 Subject: [PATCH 12/39] Trying to fix build on Travis --- .travis.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index b184fdb..06daa68 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,5 +2,7 @@ language: node_js node_js: - "0.10.33" before_install: + - "npm install -g npm@next" - "npm install -g grunt-cli" -install: npm install \ No newline at end of file +install: + - "npm install" \ No newline at end of file From 04700beab3b40031fed65f0ee6a0957a941b9ab4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Thu, 18 Jun 2015 11:55:36 +0200 Subject: [PATCH 13/39] Fixing tests on Travis --- test/core/fileMinifierTest.js | 2 ++ test/core/gzipCompressorTest.js | 3 --- test/core/weightCheckerTest.js | 2 ++ 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/test/core/fileMinifierTest.js b/test/core/fileMinifierTest.js index baa92f5..9c0d244 100644 --- a/test/core/fileMinifierTest.js +++ b/test/core/fileMinifierTest.js @@ -61,6 +61,8 @@ describe('fileMinifier', function() { }); 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; diff --git a/test/core/gzipCompressorTest.js b/test/core/gzipCompressorTest.js index d69fcc7..a820bc3 100644 --- a/test/core/gzipCompressorTest.js +++ b/test/core/gzipCompressorTest.js @@ -79,9 +79,6 @@ describe('gzipCompressor', function() { gzipCompressor.compressFile(entry) .then(function(newEntry) { - delete newEntry.weightCheck.body; - delete newEntry.weightCheck.bodyAfterMinification; - console.log(newEntry.weightCheck); 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); diff --git a/test/core/weightCheckerTest.js b/test/core/weightCheckerTest.js index 61ddf9d..a524eae 100644 --- a/test/core/weightCheckerTest.js +++ b/test/core/weightCheckerTest.js @@ -6,6 +6,8 @@ var path = require('path'); describe('weightChecker', function() { it('should download a list of files', function(done) { + this.timeout(5000); + var requestsList = [ { method: 'GET', From 612627d66f67c9e0bdc9a90b8d113cf80027fbad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Thu, 18 Jun 2015 12:11:37 +0200 Subject: [PATCH 14/39] Restore console.log correctly --- test/core/indexTest.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/core/indexTest.js b/test/core/indexTest.js index 66d9a07..3a4d719 100644 --- a/test/core/indexTest.js +++ b/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); }); }); From 8623085c2a94e80cb54c33aaea2afc3cf93a5767 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Thu, 18 Jun 2015 13:50:34 +0200 Subject: [PATCH 15/39] Debugging test failing on travis --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index bf30697..feb5028 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,6 @@ "sinon-chai": "^2.7.0" }, "scripts": { - "test": "grunt test" + "test": "DEBUG=ylt:imageOptimizer grunt test" } } From 2b599125495ef67b8f5c9fead338d1bdf087c240 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Thu, 18 Jun 2015 14:52:26 +0200 Subject: [PATCH 16/39] Debugging test failing on travis --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 06daa68..46752ec 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,7 +2,7 @@ language: node_js node_js: - "0.10.33" before_install: - - "npm install -g npm@next" + - "npm install -g npm@2.10.0" - "npm install -g grunt-cli" install: - "npm install" \ No newline at end of file From bdb15a7780fd76bdc47ca0ba7b6c2167abc151d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Thu, 18 Jun 2015 15:09:10 +0200 Subject: [PATCH 17/39] Debugging test failing on travis --- .travis.yml | 3 ++- package.json | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 46752ec..5e8c9b0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,7 +2,8 @@ language: node_js node_js: - "0.10.33" before_install: - - "npm install -g npm@2.10.0" + - sudo apt-get install build-essential + - "npm install -g npm@2.9.0" - "npm install -g grunt-cli" install: - "npm install" \ No newline at end of file diff --git a/package.json b/package.json index feb5028..bf30697 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,6 @@ "sinon-chai": "^2.7.0" }, "scripts": { - "test": "DEBUG=ylt:imageOptimizer grunt test" + "test": "grunt test" } } From eef8f00d8b7492c605ec4a00f3b56d9dafdcad87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Thu, 18 Jun 2015 15:51:00 +0200 Subject: [PATCH 18/39] Debugging test failing on travis --- .travis.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 5e8c9b0..3e5dd80 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,8 @@ language: node_js node_js: - - "0.10.33" + - "0.12.4" before_install: - - sudo apt-get install build-essential - - "npm install -g npm@2.9.0" + - "npm install -g npm" - "npm install -g grunt-cli" install: - "npm install" \ No newline at end of file From 37e35b0ed6cc4d36cab27e04000075fa9c241c86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Thu, 18 Jun 2015 16:42:24 +0200 Subject: [PATCH 19/39] Debugging test failing on travis --- test/core/weightCheckerTest.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/core/weightCheckerTest.js b/test/core/weightCheckerTest.js index a524eae..cb817bc 100644 --- a/test/core/weightCheckerTest.js +++ b/test/core/weightCheckerTest.js @@ -6,8 +6,8 @@ var path = require('path'); describe('weightChecker', function() { it('should download a list of files', function(done) { - this.timeout(5000); - + this.timeout(10000); + var requestsList = [ { method: 'GET', From 113665139a0c9fff2f8a38803d62dd62bda78924 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Thu, 18 Jun 2015 22:51:02 +0200 Subject: [PATCH 20/39] Rename isMinified into isOptimized --- lib/tools/weightChecker/fileMinifier.js | 22 ++++++++----- lib/tools/weightChecker/gzipCompressor.js | 31 +++++++++--------- lib/tools/weightChecker/imageOptimizer.js | 12 +++++-- lib/tools/weightChecker/weightChecker.js | 38 +++++++++++++++++++---- test/core/fileMinifierTest.js | 22 +++++++------ test/core/gzipCompressorTest.js | 32 +++++++++---------- test/core/imageOptimizerTest.js | 2 ++ test/core/weightCheckerTest.js | 8 ++++- 8 files changed, 111 insertions(+), 56 deletions(-) diff --git a/lib/tools/weightChecker/fileMinifier.js b/lib/tools/weightChecker/fileMinifier.js index c879cef..e80d2e8 100644 --- a/lib/tools/weightChecker/fileMinifier.js +++ b/lib/tools/weightChecker/fileMinifier.js @@ -39,8 +39,9 @@ var FileMinifier = function() { debug('JS minification complete for %s', entry.url); if (gainIsEnough(fileSize, newFileSize)) { - entry.weightCheck.minified = newFileSize; - entry.weightCheck.isMinified = false; + 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)); } @@ -69,8 +70,9 @@ var FileMinifier = function() { debug('CSS minification complete for %s', entry.url); if (gainIsEnough(fileSize, newFileSize)) { - entry.weightCheck.minified = newFileSize; - entry.weightCheck.isMinified = false; + 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)); } @@ -99,8 +101,9 @@ var FileMinifier = function() { debug('HTML minification complete for %s', entry.url); if (gainIsEnough(fileSize, newFileSize)) { - entry.weightCheck.minified = newFileSize; - entry.weightCheck.isMinified = false; + 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)); } @@ -176,12 +179,17 @@ var FileMinifier = function() { return deferred.promise; } + function entryTypeCanBeMinified(entry) { + return entry.isJS || entry.isCSS || entry.isHTML; + } + return { minifyFile: minifyFile, minifyJs: minifyJs, minifyCss: minifyCss, minifyHtml: minifyHtml, - gainIsEnough: gainIsEnough + gainIsEnough: gainIsEnough, + entryTypeCanBeMinified: entryTypeCanBeMinified }; }; diff --git a/lib/tools/weightChecker/gzipCompressor.js b/lib/tools/weightChecker/gzipCompressor.js index 3ce8537..b3f8e33 100644 --- a/lib/tools/weightChecker/gzipCompressor.js +++ b/lib/tools/weightChecker/gzipCompressor.js @@ -15,7 +15,7 @@ var GzipCompressor = function() { function gzipUncompressedFile(entry) { var deferred = Q.defer(); - if (isCompressible(entry) && !entry.weightCheck.isCompressed) { + if (entryTypeCanBeGzipped(entry) && entry.weightCheck && !entry.weightCheck.isCompressed) { debug('Compression missing, trying to gzip file %s', entry.url); var uncompressedSize = entry.weightCheck.uncompressedSize; @@ -33,10 +33,11 @@ var GzipCompressor = function() { debug('File correctly gziped, was %d and is now %d bytes', uncompressedSize, compressedSize); entry.weightCheck.afterCompression = compressedSize; - deferred.resolve(entry); } else { - debug('Gain is not enough, was %d and is now %d bytes', uncompressedSize, compressedSize); + debug('Gzip gain is not enough, was %d and is now %d bytes', uncompressedSize, compressedSize); } + + deferred.resolve(entry); } }); } else { @@ -50,12 +51,12 @@ var GzipCompressor = function() { function gzipOptimizedFile(entry) { var deferred = Q.defer(); - if (isCompressible(entry) && !entry.weightCheck.isOptimized && !entry.weightCheck.isMinified) { + if (entryTypeCanBeGzipped(entry) && entry.weightCheck && entry.weightCheck.isOptimized === false) { debug('Trying to gzip file after minification: %s', entry.url); - var uncompressedSize = entry.weightCheck.optimized || entry.weightCheck.minified; + var uncompressedSize = entry.weightCheck.optimized; - zlib.gzip(new Buffer(entry.weightCheck.bodyAfterMinification, 'utf8'), function(err, buffer) { + zlib.gzip(new Buffer(entry.weightCheck.bodyAfterOptimization, 'utf8'), function(err, buffer) { if (err) { debug('Could not compress minified file with gzip'); debug(err); @@ -65,13 +66,14 @@ var GzipCompressor = function() { var compressedSize = buffer.length; if (gainIsEnough(uncompressedSize, compressedSize)) { - debug('File correctly gziped, was %d and is now %d bytes', uncompressedSize, compressedSize); + debug('Minified file correctly gziped, was %d and is now %d bytes', uncompressedSize, compressedSize); entry.weightCheck.afterOptimizationAndCompression = compressedSize; - deferred.resolve(entry); } else { - debug('Gain is not enough, was %d and is now %d bytes', uncompressedSize, compressedSize); + debug('Gzip gain is not enough on minified file, was %d and is now %d bytes', uncompressedSize, compressedSize); } + + deferred.resolve(entry); } }); } else { @@ -81,10 +83,6 @@ var GzipCompressor = function() { return deferred.promise; } - function isCompressible(entry) { - return entry.isJS || entry.isCSS || entry.isHTML || entry.isJSON || entry.isSVG || entry.isTTF || entry.isXML || entry.isFavicon; - } - // 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) { @@ -93,8 +91,13 @@ var GzipCompressor = function() { 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 + compressFile: compressFile, + entryTypeCanBeGzipped: entryTypeCanBeGzipped }; }; diff --git a/lib/tools/weightChecker/imageOptimizer.js b/lib/tools/weightChecker/imageOptimizer.js index 453f1b8..06fc9fa 100644 --- a/lib/tools/weightChecker/imageOptimizer.js +++ b/lib/tools/weightChecker/imageOptimizer.js @@ -22,7 +22,7 @@ var ImageOptimizer = function() { debug('Let\'s try to optimize %s', entry.url); debug('Current file size is %d', fileSize); - if (isJpeg(entry)) { + if (isJPEG(entry)) { debug('File is a JPEG'); // Starting softly with a lossless compression @@ -129,6 +129,7 @@ var ImageOptimizer = function() { debug('SVG lossless compression complete for %s', entry.url); if (gainIsEnough(fileSize, newFileSize)) { + entry.weightCheck.bodyAfterOptimization = newFile; 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)); @@ -157,7 +158,7 @@ var ImageOptimizer = function() { return (gain > 2048 || (ratio > 0.2 && gain > 100)); } - function isJpeg(entry) { + function isJPEG(entry) { return entry.isImage && entry.contentType === 'image/jpeg'; } @@ -226,13 +227,18 @@ var ImageOptimizer = function() { 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 + gainIsEnough: gainIsEnough, + entryTypeCanBeOptimized: entryTypeCanBeOptimized }; }; diff --git a/lib/tools/weightChecker/weightChecker.js b/lib/tools/weightChecker/weightChecker.js index f326ccf..ca3cba5 100644 --- a/lib/tools/weightChecker/weightChecker.js +++ b/lib/tools/weightChecker/weightChecker.js @@ -43,7 +43,7 @@ var WeightChecker = function() { .then(fileMinifier.minifyFile) - .then(gzipCompressor.compress) + .then(gzipCompressor.compressFile) .then(function(newEntry) { callback(null, newEntry); @@ -88,7 +88,9 @@ var WeightChecker = function() { offenders.fileMinification = listFilesNotMinified(results); metrics.fileMinification = offenders.fileMinification.totalGain; - + // Gzip compression + offenders.gzipCompression = listFilesNotGzipped(results); + metrics.gzipCompression = offenders.gzipCompression.totalGain; data.toolsResults.weightChecker = { metrics: metrics, @@ -166,7 +168,7 @@ var WeightChecker = function() { }; requests.forEach(function(req) { - if (req.weightCheck.uncompressedSize && req.weightCheck.isOptimized === false) { + if (req.weightCheck.uncompressedSize && imageOptimizer.entryTypeCanBeOptimized(req) && req.weightCheck.isOptimized === false) { var gain = req.weightCheck.uncompressedSize - req.weightCheck.optimized; results.totalGain += gain; @@ -193,15 +195,39 @@ var WeightChecker = function() { }; requests.forEach(function(req) { - if (req.weightCheck.uncompressedSize && req.weightCheck.isMinified === false) { - var gain = req.weightCheck.uncompressedSize - req.weightCheck.minified; + if (req.weightCheck.uncompressedSize && fileMinifier.entryTypeCanBeMinified(req) && req.weightCheck.isOptimized === false) { + var gain = req.weightCheck.uncompressedSize - req.weightCheck.optimized; results.totalGain += gain; results.files.push({ url: req.url, original: req.weightCheck.uncompressedSize, - minified: req.weightCheck.minified, + 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 }); } diff --git a/test/core/fileMinifierTest.js b/test/core/fileMinifierTest.js index 9c0d244..b06d012 100644 --- a/test/core/fileMinifierTest.js +++ b/test/core/fileMinifierTest.js @@ -49,8 +49,9 @@ describe('fileMinifier', function() { fileMinifier.minifyFile(entry) .then(function(newEntry) { - newEntry.weightCheck.should.have.a.property('isMinified').that.equals(false); - newEntry.weightCheck.should.have.a.property('minified').that.is.below(fileSize); + 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'); done(); }) @@ -62,7 +63,7 @@ describe('fileMinifier', function() { 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; @@ -92,8 +93,9 @@ describe('fileMinifier', function() { fileMinifier.minifyFile(entry) .then(function(newEntry) { - newEntry.weightCheck.should.not.have.a.property('isMinified'); - newEntry.weightCheck.should.not.have.a.property('minified'); + 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(); }) @@ -133,8 +135,9 @@ describe('fileMinifier', function() { fileMinifier.minifyFile(entry) .then(function(newEntry) { - newEntry.weightCheck.should.not.have.a.property('isMinified'); - newEntry.weightCheck.should.not.have.a.property('minified'); + 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(); }) @@ -188,8 +191,9 @@ describe('fileMinifier', function() { fileMinifier.minifyFile(entry) .then(function(newEntry) { - newEntry.weightCheck.should.have.a.property('isMinified').that.equals(false); - newEntry.weightCheck.should.have.a.property('minified').that.is.below(fileSize); + 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'); done(); }) diff --git a/test/core/gzipCompressorTest.js b/test/core/gzipCompressorTest.js index a820bc3..66a66d6 100644 --- a/test/core/gzipCompressorTest.js +++ b/test/core/gzipCompressorTest.js @@ -29,7 +29,7 @@ describe('gzipCompressor', function() { bodySize: fileSize, isCompressed: false, uncompressedSize: fileSize, - isMinified: true + isOptimized: true } }; @@ -65,14 +65,14 @@ describe('gzipCompressor', function() { contentLength: 999, weightCheck: { body: fileContent.toString('utf8'), - bodyAfterMinification: minifiedContent.toString('utf8'), + bodyAfterOptimization: minifiedContent.toString('utf8'), totalWeight: fileSize + 200, headersSize: 200, bodySize: fileSize, isCompressed: false, uncompressedSize: fileSize, - isMinified: false, - minified: minifiedSize + isOptimized: false, + optimized: minifiedSize } }; @@ -109,14 +109,14 @@ describe('gzipCompressor', function() { contentLength: 999, weightCheck: { body: fileContent.toString('utf8'), - bodyAfterMinification: minifiedContent.toString('utf8'), + bodyAfterOptimization: minifiedContent.toString('utf8'), totalWeight: gzipedSize + 200, headersSize: 200, bodySize: gzipedSize, isCompressed: true, uncompressedSize: fileSize, - isMinified: false, - minified: minifiedSize + isOptimized: false, + optimized: minifiedSize } }; @@ -156,7 +156,7 @@ describe('gzipCompressor', function() { bodySize: gzipedSize, isCompressed: true, uncompressedSize: fileSize, - isMinified: true + isOptimized: true } }; @@ -164,7 +164,7 @@ describe('gzipCompressor', function() { .then(function(newEntry) { newEntry.weightCheck.should.not.have.a.property('minified'); - newEntry.weightCheck.should.not.have.a.property('bodyAfterMinification'); + newEntry.weightCheck.should.not.have.a.property('bodyAfterOptimization'); newEntry.weightCheck.should.not.have.a.property('afterCompression'); newEntry.weightCheck.should.not.have.a.property('afterOptimizationAndCompression'); @@ -194,7 +194,7 @@ describe('gzipCompressor', function() { bodySize: fileSize, isCompressed: false, uncompressedSize: fileSize, - isMinified: true + isOptimized: true } }; @@ -230,7 +230,7 @@ describe('gzipCompressor', function() { bodySize: fileSize, isCompressed: false, uncompressedSize: fileSize, - isMinified: true + isOptimized: true } }; @@ -267,7 +267,7 @@ describe('gzipCompressor', function() { bodySize: fileSize, isCompressed: false, uncompressedSize: fileSize, - isMinified: true + isOptimized: true } }; @@ -303,7 +303,7 @@ describe('gzipCompressor', function() { bodySize: fileSize, isCompressed: false, uncompressedSize: fileSize, - isMinified: true + isOptimized: true } }; @@ -339,7 +339,7 @@ describe('gzipCompressor', function() { bodySize: fileSize, isCompressed: false, uncompressedSize: fileSize, - isMinified: true + isOptimized: true } }; @@ -376,7 +376,7 @@ describe('gzipCompressor', function() { bodySize: fileSize, isCompressed: false, uncompressedSize: fileSize, - isMinified: true + isOptimized: true } }; @@ -413,7 +413,7 @@ describe('gzipCompressor', function() { bodySize: fileSize, isCompressed: false, uncompressedSize: fileSize, - isMinified: true + isOptimized: true } }; diff --git a/test/core/imageOptimizerTest.js b/test/core/imageOptimizerTest.js index 59f5bbf..3f5f923 100644 --- a/test/core/imageOptimizerTest.js +++ b/test/core/imageOptimizerTest.js @@ -207,6 +207,8 @@ describe('imageOptimizer', function() { .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'); done(); }) diff --git a/test/core/weightCheckerTest.js b/test/core/weightCheckerTest.js index cb817bc..9b1bb08 100644 --- a/test/core/weightCheckerTest.js +++ b/test/core/weightCheckerTest.js @@ -56,6 +56,7 @@ describe('weightChecker', function() { }, status: 200, isImage: true, + isSVG: true, type: 'image', contentType: 'image/svg+xml' }, @@ -127,6 +128,10 @@ describe('weightChecker', function() { 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); @@ -149,7 +154,8 @@ describe('weightChecker', function() { Accept: '*/*' }, status: 200, - isJS: true + isJS: true, + type: 'js' }; weightChecker.redownloadEntry(entry) From d4815e855e4291fd20856aa87b12a8d607d48472 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Thu, 18 Jun 2015 23:06:57 +0200 Subject: [PATCH 21/39] Fix bug with SVG minification+compression --- lib/tools/weightChecker/gzipCompressor.js | 2 +- lib/tools/weightChecker/imageOptimizer.js | 2 +- test/core/fileMinifierTest.js | 4 ++-- test/core/imageOptimizerTest.js | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/tools/weightChecker/gzipCompressor.js b/lib/tools/weightChecker/gzipCompressor.js index b3f8e33..15e0e4d 100644 --- a/lib/tools/weightChecker/gzipCompressor.js +++ b/lib/tools/weightChecker/gzipCompressor.js @@ -66,7 +66,7 @@ var GzipCompressor = function() { var compressedSize = buffer.length; if (gainIsEnough(uncompressedSize, compressedSize)) { - debug('Minified file correctly gziped, was %d and is now %d bytes', uncompressedSize, compressedSize); + debug('Correctly gziped the minified file, was %d and is now %d bytes', uncompressedSize, compressedSize); entry.weightCheck.afterOptimizationAndCompression = compressedSize; } else { diff --git a/lib/tools/weightChecker/imageOptimizer.js b/lib/tools/weightChecker/imageOptimizer.js index 06fc9fa..a0b90d5 100644 --- a/lib/tools/weightChecker/imageOptimizer.js +++ b/lib/tools/weightChecker/imageOptimizer.js @@ -129,7 +129,7 @@ var ImageOptimizer = function() { debug('SVG lossless compression complete for %s', entry.url); if (gainIsEnough(fileSize, newFileSize)) { - entry.weightCheck.bodyAfterOptimization = newFile; + 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)); diff --git a/test/core/fileMinifierTest.js b/test/core/fileMinifierTest.js index b06d012..33e3db3 100644 --- a/test/core/fileMinifierTest.js +++ b/test/core/fileMinifierTest.js @@ -51,7 +51,7 @@ describe('fileMinifier', function() { .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'); + newEntry.weightCheck.should.have.a.property('bodyAfterOptimization').that.is.a('string'); done(); }) @@ -193,7 +193,7 @@ describe('fileMinifier', function() { .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'); + newEntry.weightCheck.should.have.a.property('bodyAfterOptimization').that.is.a('string'); done(); }) diff --git a/test/core/imageOptimizerTest.js b/test/core/imageOptimizerTest.js index 3f5f923..ba273db 100644 --- a/test/core/imageOptimizerTest.js +++ b/test/core/imageOptimizerTest.js @@ -208,7 +208,7 @@ describe('imageOptimizer', function() { 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'); + newEntry.weightCheck.should.have.a.property('bodyAfterOptimization').that.is.a('string'); done(); }) From 583b6b9a5512cbe149306a9654f262fe8a2a6224 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Fri, 19 Jun 2015 14:05:21 +0200 Subject: [PATCH 22/39] Optimizing JS minification step that takes too long --- lib/tools/weightChecker/fileMinifier.js | 159 +++++++++++++++++++++- lib/tools/weightChecker/gzipCompressor.js | 11 +- lib/tools/weightChecker/weightChecker.js | 27 ++-- test/core/fileMinifierTest.js | 19 +++ 4 files changed, 191 insertions(+), 25 deletions(-) diff --git a/lib/tools/weightChecker/fileMinifier.js b/lib/tools/weightChecker/fileMinifier.js index e80d2e8..8e0279e 100644 --- a/lib/tools/weightChecker/fileMinifier.js +++ b/lib/tools/weightChecker/fileMinifier.js @@ -20,12 +20,12 @@ var FileMinifier = function() { 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) { + if (entry.isJS && !isKnownAsMinified(entry.url) && !looksAlreadyMinified(entry.weightCheck.body)) { debug('File is a JS'); - // Starting softly with a lossless compression return minifyJs(entry.weightCheck.body) .then(function(newFile) { @@ -34,6 +34,8 @@ var FileMinifier = function() { return entry; } + var endTime = Date.now(); + var newFileSize = newFile.length; debug('JS minification complete for %s', entry.url); @@ -56,7 +58,6 @@ var FileMinifier = function() { debug('File is a CSS'); - // Starting softly with a lossless compression return minifyCss(entry.weightCheck.body) .then(function(newFile) { @@ -65,6 +66,9 @@ var FileMinifier = function() { 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); @@ -87,7 +91,6 @@ var FileMinifier = function() { debug('File is an HTML'); - // Starting softly with a lossless compression return minifyHtml(entry.weightCheck.body) .then(function(newFile) { @@ -96,6 +99,9 @@ var FileMinifier = function() { 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); @@ -132,18 +138,118 @@ var FileMinifier = function() { // 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 result = UglifyJS.minify(body, {fromString: true}); - deferred.resolve(result.code); + 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(); @@ -179,6 +285,44 @@ var FileMinifier = function() { 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); + + + 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 / 1024; + var looksMinified = linesRatio > 1; + + debug('Lines ratio is %d KB per line', linesRatio.toFixed(1)); + debug(looksMinified ? 'It looks already minified' : 'It doesn\'t look minified'); + + return looksMinified; + } + function entryTypeCanBeMinified(entry) { return entry.isJS || entry.isCSS || entry.isHTML; } @@ -189,7 +333,8 @@ var FileMinifier = function() { minifyCss: minifyCss, minifyHtml: minifyHtml, gainIsEnough: gainIsEnough, - entryTypeCanBeMinified: entryTypeCanBeMinified + entryTypeCanBeMinified: entryTypeCanBeMinified, + isKnownAsMinified: isKnownAsMinified }; }; diff --git a/lib/tools/weightChecker/gzipCompressor.js b/lib/tools/weightChecker/gzipCompressor.js index 15e0e4d..9981391 100644 --- a/lib/tools/weightChecker/gzipCompressor.js +++ b/lib/tools/weightChecker/gzipCompressor.js @@ -15,7 +15,7 @@ var GzipCompressor = function() { function gzipUncompressedFile(entry) { var deferred = Q.defer(); - if (entryTypeCanBeGzipped(entry) && entry.weightCheck && !entry.weightCheck.isCompressed) { + 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; @@ -65,13 +65,8 @@ var GzipCompressor = function() { } else { var compressedSize = buffer.length; - if (gainIsEnough(uncompressedSize, compressedSize)) { - debug('Correctly gziped the minified file, was %d and is now %d bytes', uncompressedSize, compressedSize); - - entry.weightCheck.afterOptimizationAndCompression = compressedSize; - } else { - debug('Gzip gain is not enough on minified file, was %d and is now %d bytes', uncompressedSize, compressedSize); - } + debug('Correctly gziped the minified file, was %d and is now %d bytes', uncompressedSize, compressedSize); + entry.weightCheck.afterOptimizationAndCompression = compressedSize; deferred.resolve(entry); } diff --git a/lib/tools/weightChecker/weightChecker.js b/lib/tools/weightChecker/weightChecker.js index ca3cba5..3611b15 100644 --- a/lib/tools/weightChecker/weightChecker.js +++ b/lib/tools/weightChecker/weightChecker.js @@ -20,7 +20,7 @@ var gzipCompressor = require('./gzipCompressor'); var WeightChecker = function() { var MAX_PARALLEL_DOWNLOADS = 10; - var REQUEST_TIMEOUT = 10000; // 10 seconds + var REQUEST_TIMEOUT = 15000; // 15 seconds // This function will re-download every asset and check if it could be optimized @@ -195,17 +195,24 @@ var WeightChecker = function() { }; requests.forEach(function(req) { - if (req.weightCheck.uncompressedSize && fileMinifier.entryTypeCanBeMinified(req) && req.weightCheck.isOptimized === false) { - var gain = req.weightCheck.uncompressedSize - req.weightCheck.optimized; + 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.afterCompression; + var gain = before - after; - results.totalGain += gain; + if (gain > 200) { + results.totalGain += gain; - results.files.push({ - url: req.url, - original: req.weightCheck.uncompressedSize, - optimized: req.weightCheck.optimized, - gain: 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 + }); + } } }); diff --git a/test/core/fileMinifierTest.js b/test/core/fileMinifierTest.js index 33e3db3..39d0f9c 100644 --- a/test/core/fileMinifierTest.js +++ b/test/core/fileMinifierTest.js @@ -217,4 +217,23 @@ describe('fileMinifier', function() { }); }); + 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('http://anydomain.com/anyurl').should.equal(false); + }); + }); From 02245aa5f17c9dfa57b627c8e30fbfe4d86b895a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Fri, 19 Jun 2015 14:55:13 +0200 Subject: [PATCH 23/39] Fix test --- lib/tools/weightChecker/fileMinifier.js | 6 +-- test/www/unminified-stylesheet.css | 72 ++++++++++++++++++++++++- 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/lib/tools/weightChecker/fileMinifier.js b/lib/tools/weightChecker/fileMinifier.js index 8e0279e..8e5124a 100644 --- a/lib/tools/weightChecker/fileMinifier.js +++ b/lib/tools/weightChecker/fileMinifier.js @@ -314,10 +314,10 @@ var FileMinifier = function() { // Less than 1000kb per line is suspicious function looksAlreadyMinified(code) { var linesCount = code.split(/\r\n|\r|\n/).length; - var linesRatio = code.length / linesCount / 1024; - var looksMinified = linesRatio > 1; + var linesRatio = code.length / linesCount; + var looksMinified = linesRatio > 1024; - debug('Lines ratio is %d KB per line', linesRatio.toFixed(1)); + debug('Lines ratio is %d bytes per line', Math.round(linesRatio)); debug(looksMinified ? 'It looks already minified' : 'It doesn\'t look minified'); return looksMinified; diff --git a/test/www/unminified-stylesheet.css b/test/www/unminified-stylesheet.css index 620bf3b..a4d5fa2 100644 --- a/test/www/unminified-stylesheet.css +++ b/test/www/unminified-stylesheet.css @@ -699,4 +699,74 @@ input[type=submit].clicked { left: 0.1em; top: 0.2em; box-shadow: none; -} \ No newline at end of file +} +/* + * 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); +} From 31d80791a98ee316d6f8c5daae4c6a0b741926ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Fri, 19 Jun 2015 15:24:19 +0200 Subject: [PATCH 24/39] Compare minified files on gzipped size --- front/src/views/rule.html | 13 +++----- lib/metadata/policies.js | 45 ++++++++------------------- lib/metadata/scoreProfileGeneric.json | 4 +-- 3 files changed, 20 insertions(+), 42 deletions(-) diff --git a/front/src/views/rule.html b/front/src/views/rule.html index 7e3d15a..ded0da4 100644 --- a/front/src/views/rule.html +++ b/front/src/views/rule.html @@ -146,11 +146,6 @@
-
- - ({{offender.type}}) -
-
cached for {{offender.ttlWithUnit}} {{offender.unit}} @@ -241,10 +236,12 @@
- +
-
{{file.original | bytes}}
-
{{file.minified | bytes}}
+
{{file.original | bytes}} (gzipped)
+
{{file.original | bytes}} ({{file.afterCompression | bytes}} gzipped)
+
{{file.afterOptimizationAndCompression | bytes}} (gzipped)
+
{{file.optimized | bytes}} ({{file.afterOptimizationAndCompression | bytes}} gzipped)
-{{file.gain | bytes}}
diff --git a/lib/metadata/policies.js b/lib/metadata/policies.js index 3354348..eda9cc2 100644 --- a/lib/metadata/policies.js +++ b/lib/metadata/policies.js @@ -872,13 +872,23 @@ var policies = { "hasOffenders": true, "unit": 'bytes' }, + "gzipCompression": { + "tool": "weightChecker", + "label": "Gzip compression", + "message": "

Mesures the number of bytes that could be saved by compressing file transfers.

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. Here is a list of Content-Types that should be gzipped.

", + "isOkThreshold": 20480, + "isBadThreshold": 81920, + "isAbnormalThreshold": 153600, + "hasOffenders": true, + "unit": 'bytes' + }, "fileMinification": { "tool": "weightChecker", "label": "File minification", "message": "

This is the weight that could be saved if all text resources were correctly minified.

The tools in use here are UglifyJS, clean-css and HTMLMinifier. These tools are so good that some of your minified files can be marked as unminified. Change your tool it this happens :)

The gains of minification are generally small, but the impact can be high when these text files are loaded on the critical path.

", - "isOkThreshold": 20480, - "isBadThreshold": 81920, - "isAbnormalThreshold": 153600, + "isOkThreshold": 10240, + "isBadThreshold": 76800, + "isAbnormalThreshold": 122880, "hasOffenders": true, "unit": 'bytes' }, @@ -1099,35 +1109,6 @@ var policies = { "isAbnormalThreshold": 1, "hasOffenders": true }, - "assetsNotGzipped": { - "tool": "phantomas", - "label": "Not gzipped", - "message": "

This is the number of requests that should be compressed with gzip but aren't.

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. Here is a list of Content-Types that should be gzipped.

", - "isOkThreshold": 0, - "isBadThreshold": 12, - "isAbnormalThreshold": 20, - "hasOffenders": true, - "offendersTransformFn": function(offenders) { - return { - count: offenders.length, - list: offenders.map(function(offender) { - var parts = /^([^ ]*) \((.+)\)$/.exec(offender); - - if (!parts) { - debug('assetsNotGzipped offenders transform function error with "%s"', offender); - return { - parseError: offender - }; - } - - return { - file: parts[1], - type: parts[2] - }; - }) - }; - } - }, "closedConnections": { "tool": "phantomas", "label": "Connections closed", diff --git a/lib/metadata/scoreProfileGeneric.json b/lib/metadata/scoreProfileGeneric.json index 1407255..277b06d 100644 --- a/lib/metadata/scoreProfileGeneric.json +++ b/lib/metadata/scoreProfileGeneric.json @@ -74,8 +74,8 @@ "policies": { "totalWeight": 5, "imageOptimization": 2, - "fileMinification": 1, - "assetsNotGzipped": 2 + "gzipCompression": 2, + "fileMinification": 1 } }, "requests": { From d166003dfe0223a0fe5b113b70ab9b2114beb358 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Fri, 19 Jun 2015 16:15:19 +0200 Subject: [PATCH 25/39] Update Phantomas, needed for svg optimization --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index bf30697..7d9f162 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "lwip": "0.0.6", "meow": "3.1.0", "minimize": "1.4.1", - "phantomas": "1.10.2", + "phantomas": "1.11.0", "ps-node": "0.0.4", "q": "1.4.1", "rimraf": "2.3.4", From 53a9aa377a20b6310e45bfb8b47aec2e91e9e5be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Fri, 19 Jun 2015 16:16:00 +0200 Subject: [PATCH 26/39] Add Gzip offenders --- front/src/views/rule.html | 20 ++++++++++++++++++++ lib/metadata/policies.js | 10 +++++----- lib/tools/weightChecker/fileMinifier.js | 2 +- lib/tools/weightChecker/gzipCompressor.js | 1 + lib/tools/weightChecker/imageOptimizer.js | 4 ++-- 5 files changed, 29 insertions(+), 8 deletions(-) diff --git a/front/src/views/rule.html b/front/src/views/rule.html index ded0da4..e8df0cd 100644 --- a/front/src/views/rule.html +++ b/front/src/views/rule.html @@ -225,6 +225,26 @@ +
+

{{rule.value | bytes}} could be saved on

+
+
+
File
+
Current weight
+
Gzipped
+
Gain
+
+
+
+ +
+
{{file.original | bytes}}
+
{{file.gzipped | bytes}}
+
-{{file.gain | bytes}}
+
+
+
+

{{rule.value | bytes}} could be saved on

diff --git a/lib/metadata/policies.js b/lib/metadata/policies.js index eda9cc2..b785c47 100644 --- a/lib/metadata/policies.js +++ b/lib/metadata/policies.js @@ -866,9 +866,9 @@ var policies = { "tool": "weightChecker", "label": "Image optimization", "message": "

This metric mesures the number of bytes that could be saved by optimizing images.

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 Kraken.io or the excellent ImageOption on Mac. For SVG images, you can use SVGOMG

The tools in use in YellowLabTools are not set to their maximum optimization power, so you might be able to compress even more!

", - "isOkThreshold": 30720, + "isOkThreshold": 10240, "isBadThreshold": 122880, - "isAbnormalThreshold": 409600, + "isAbnormalThreshold": 307200, "hasOffenders": true, "unit": 'bytes' }, @@ -876,7 +876,7 @@ var policies = { "tool": "weightChecker", "label": "Gzip compression", "message": "

Mesures the number of bytes that could be saved by compressing file transfers.

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. Here is a list of Content-Types that should be gzipped.

", - "isOkThreshold": 20480, + "isOkThreshold": 5125, "isBadThreshold": 81920, "isAbnormalThreshold": 153600, "hasOffenders": true, @@ -886,8 +886,8 @@ var policies = { "tool": "weightChecker", "label": "File minification", "message": "

This is the weight that could be saved if all text resources were correctly minified.

The tools in use here are UglifyJS, clean-css and HTMLMinifier. These tools are so good that some of your minified files can be marked as unminified. Change your tool it this happens :)

The gains of minification are generally small, but the impact can be high when these text files are loaded on the critical path.

", - "isOkThreshold": 10240, - "isBadThreshold": 76800, + "isOkThreshold": 5125, + "isBadThreshold": 61440, "isAbnormalThreshold": 122880, "hasOffenders": true, "unit": 'bytes' diff --git a/lib/tools/weightChecker/fileMinifier.js b/lib/tools/weightChecker/fileMinifier.js index 8e5124a..4f87476 100644 --- a/lib/tools/weightChecker/fileMinifier.js +++ b/lib/tools/weightChecker/fileMinifier.js @@ -121,7 +121,7 @@ var FileMinifier = function() { }); } else { - debug('File type %s is not an (optimizable) image', entry.contentType); + debug('Not minfiable type or already minified'); deferred.resolve(entry); } diff --git a/lib/tools/weightChecker/gzipCompressor.js b/lib/tools/weightChecker/gzipCompressor.js index 9981391..795cb47 100644 --- a/lib/tools/weightChecker/gzipCompressor.js +++ b/lib/tools/weightChecker/gzipCompressor.js @@ -41,6 +41,7 @@ var GzipCompressor = function() { } }); } else { + deferred.resolve(entry); } diff --git a/lib/tools/weightChecker/imageOptimizer.js b/lib/tools/weightChecker/imageOptimizer.js index a0b90d5..fcff06a 100644 --- a/lib/tools/weightChecker/imageOptimizer.js +++ b/lib/tools/weightChecker/imageOptimizer.js @@ -143,7 +143,7 @@ var ImageOptimizer = function() { }); } else { - debug('File type %s is not an (optimizable) image', entry.contentType); + debug('File type %s is not an optimizable image', entry.contentType); deferred.resolve(entry); } @@ -167,7 +167,7 @@ var ImageOptimizer = function() { } function isSVG(entry) { - return entry.isImage && entry.contentType === 'image/svg+xml'; + return entry.isImage && entry.isSVG; } function compressJpegLosslessly(imageBody) { From 4ca525f70177031822a672cb17e9fafff6ade290 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Fri, 19 Jun 2015 17:16:30 +0200 Subject: [PATCH 27/39] Compare gzip size for svg optimizations --- front/src/views/rule.html | 9 ++++--- lib/tools/weightChecker/weightChecker.js | 30 ++++++++++++++---------- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/front/src/views/rule.html b/front/src/views/rule.html index e8df0cd..1d74569 100644 --- a/front/src/views/rule.html +++ b/front/src/views/rule.html @@ -217,9 +217,12 @@
-

Current weight: {{image.original | bytes}}

-

With a lossless optimization:
{{image.lossless | bytes}} (-{{image.original - image.lossless | bytes}})

-

With a lossy optimization:
{{image.lossy | bytes}} (-{{image.original - image.lossy | bytes}})

+

Current weight: {{image.original | bytes}}

+

Current weight: {{image.original | bytes}} ({{image.afterCompression | bytes}} gzipped)

+

With a lossless optimization:
{{image.afterOptimizationAndCompression | bytes}} gzipped (-{{image.gain | bytes}} gzipped)

+

With a lossless optimization:
{{image.lossless | bytes}} (-{{image.gain | bytes}})

+

With a lossy optimization:
{{image.afterOptimizationAndCompression | bytes}} gzipped (-{{image.gain | bytes}} gzipped)

+

With a lossy optimization:
{{image.lossy | bytes}} (-{{image.gain | bytes}})

diff --git a/lib/tools/weightChecker/weightChecker.js b/lib/tools/weightChecker/weightChecker.js index 3611b15..7fb85db 100644 --- a/lib/tools/weightChecker/weightChecker.js +++ b/lib/tools/weightChecker/weightChecker.js @@ -168,19 +168,25 @@ var WeightChecker = function() { }; requests.forEach(function(req) { - if (req.weightCheck.uncompressedSize && imageOptimizer.entryTypeCanBeOptimized(req) && req.weightCheck.isOptimized === false) { - var gain = req.weightCheck.uncompressedSize - req.weightCheck.optimized; + 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; - results.totalGain += gain; + if (gain > 200) { + results.totalGain += gain; - results.images.push({ - url: req.url, - original: req.weightCheck.uncompressedSize, - optimized: req.weightCheck.optimized, - lossless: req.weightCheck.lossless, - lossy: req.weightCheck.lossy, - gain: 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 + }); + } } }); @@ -197,7 +203,7 @@ var WeightChecker = function() { 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.afterCompression; + var after = req.weightCheck.afterOptimizationAndCompression || req.weightCheck.optimized; var gain = before - after; if (gain > 200) { From 2620f75955cdaa0bfd8e4df8339caa0fcd9b21d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Fri, 19 Jun 2015 18:01:02 +0200 Subject: [PATCH 28/39] Fix SVG test --- test/core/imageOptimizerTest.js | 1 + 1 file changed, 1 insertion(+) diff --git a/test/core/imageOptimizerTest.js b/test/core/imageOptimizerTest.js index ba273db..f1bc613 100644 --- a/test/core/imageOptimizerTest.js +++ b/test/core/imageOptimizerTest.js @@ -189,6 +189,7 @@ describe('imageOptimizer', function() { }, status: 200, isImage: true, + isSVG: true, type: 'image', contentType: 'image/svg+xml', contentLength: 999, From 8d4a0c34e7645d471acc93d67ac94179f125943e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Fri, 19 Jun 2015 18:45:34 +0200 Subject: [PATCH 29/39] Fix requests offenders --- front/src/views/rule.html | 3 +-- lib/metadata/policies.js | 2 +- lib/offendersHelpers.js | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/front/src/views/rule.html b/front/src/views/rule.html index 1d74569..fda2731 100644 --- a/front/src/views/rule.html +++ b/front/src/views/rule.html @@ -137,9 +137,8 @@ -
+
- ({{offender.size}} KB)
diff --git a/lib/metadata/policies.js b/lib/metadata/policies.js index b785c47..b80d276 100644 --- a/lib/metadata/policies.js +++ b/lib/metadata/policies.js @@ -900,7 +900,7 @@ var policies = { "isBadThreshold": 100, "isAbnormalThreshold": 200, "hasOffenders": true, - "takeOffendersFrom": ["htmlCount", "jsCount", "cssCount", "imageCount", "webfontCount", "videoCount", "jsonCount", "jsonCount"], + "takeOffendersFrom": ["htmlCount", "jsCount", "cssCount", "imageCount", "webfontCount", "videoCount", "jsonCount", "otherCount"], "offendersTransformFn": function(offenders) { return { count: offenders.length, diff --git a/lib/offendersHelpers.js b/lib/offendersHelpers.js index a3630e0..862e660 100644 --- a/lib/offendersHelpers.js +++ b/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 { From ccbb324270b03386237396c71054bfd80a82ed2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Fri, 19 Jun 2015 23:46:39 +0200 Subject: [PATCH 30/39] Replace TotalRequests and SmallRequests rules --- front/src/css/rule.css | 6 + front/src/less/rule.less | 7 + front/src/views/rule.html | 38 ++++++ lib/metadata/policies.js | 163 ++--------------------- lib/metadata/scoreProfileGeneric.json | 2 +- lib/tools/weightChecker/weightChecker.js | 65 ++++++++- test/core/weightCheckerTest.js | 17 +++ 7 files changed, 143 insertions(+), 155 deletions(-) diff --git a/front/src/css/rule.css b/front/src/css/rule.css index ed5794a..ba01a54 100644 --- a/front/src/css/rule.css +++ b/front/src/css/rule.css @@ -236,3 +236,9 @@ border: 1px solid #000; margin-top: 0.5em; } +.smallPreview { + max-height: 1.5em; + max-width: 4em; + border: 1px solid #000; + margin-top: 0.2em; +} diff --git a/front/src/less/rule.less b/front/src/less/rule.less index ce3bd5c..18c6310 100644 --- a/front/src/less/rule.less +++ b/front/src/less/rule.less @@ -259,4 +259,11 @@ border: 1px solid #000; margin-top: 0.5em; } +} + +.smallPreview { + max-height: 1.5em; + max-width: 4em; + border: 1px solid #000; + margin-top: 0.2em; } \ No newline at end of file diff --git a/front/src/views/rule.html b/front/src/views/rule.html index fda2731..553bd73 100644 --- a/front/src/views/rule.html +++ b/front/src/views/rule.html @@ -269,6 +269,44 @@
+
+

Requests by MIME type

+
+

+

Reduce the number of stylesheets by concatenating them.

+

Reduce the number of scripts by concatenating them.

+

Reduce the number of images by lazyloading them or by spriting them.

+

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

+

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

+
+
+
+
+
+
+
+ +
+
+

+

Try to inline these styles in the head of the HTML or to merge them with other files.

+

Try to inline these scripts in the HTML or merge them with other files.

+

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.

+
+
+
Preview
+
File
+
Weight (bytes)
+
+
+
+
+
{{request.size}}
+
+
+
+
+

{{rule.value}} offenders

Please open the JS timeline diff --git a/lib/metadata/policies.js b/lib/metadata/policies.js index b80d276..b30d589 100644 --- a/lib/metadata/policies.js +++ b/lib/metadata/policies.js @@ -892,162 +892,23 @@ var policies = { "hasOffenders": true, "unit": 'bytes' }, - "requests": { - "tool": "phantomas", + "totalRequests": { + "tool": "weightChecker", "label": "Total requests number", "message": "

This is one of the most important performance rule. Every request is slowing down the page loading.

There are several technics to reduce their number:

  • Concatenate JS files
  • Concatenate CSS files
  • Embed or inline small JS or CSS files in the HTML
  • Create sprites or icon fonts
  • Base64 encode small images in HTML or stylesheets
  • Use lazyloading for images

", "isOkThreshold": 15, "isBadThreshold": 100, - "isAbnormalThreshold": 200, - "hasOffenders": true, - "takeOffendersFrom": ["htmlCount", "jsCount", "cssCount", "imageCount", "webfontCount", "videoCount", "jsonCount", "otherCount"], - "offendersTransformFn": function(offenders) { - return { - count: offenders.length, - list: offenders - .map(function(offender) { - return offendersHelpers.fileWithSizePattern(offender); - }).sort(function(a, b) { - return (b.file < a.file) ? 1 : (b.file > a.file) ? -1 : 0; - }) - }; - } + "isAbnormalThreshold": 180, + "hasOffenders": true }, - "htmlCount": { - "tool": "phantomas", - "label": "Document count", - "message": "

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

", - "isOkThreshold": 10, - "isBadThreshold": 20, - "isAbnormalThreshold": 30, - "hasOffenders": true, - "offendersTransformFn": function(offenders) { - return { - count: offenders.length, - list: offenders.map(function(offender) { - return offendersHelpers.fileWithSizePattern(offender); - }) - }; - } - }, - "jsCount": { - "tool": "phantomas", - "label": "Script count", - "message": "

Reduce the number of scripts by concatenating them.

", - "isOkThreshold": 5, - "isBadThreshold": 15, - "isAbnormalThreshold": 30, - "hasOffenders": true, - "offendersTransformFn": function(offenders) { - return { - count: offenders.length, - list: offenders.map(function(offender) { - return offendersHelpers.fileWithSizePattern(offender); - }) - }; - } - }, - "cssCount": { - "tool": "phantomas", - "label": "CSS count", - "message": "

Reduce the number of stylesheets by concatenating them.

", - "isOkThreshold": 3, - "isBadThreshold": 10, - "isAbnormalThreshold": 22, - "hasOffenders": true, - "offendersTransformFn": function(offenders) { - return { - count: offenders.length, - list: offenders.map(function(offender) { - return offendersHelpers.fileWithSizePattern(offender); - }) - }; - } - }, - "imageCount": { - "tool": "phantomas", - "label": "Image count", - "message": "

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

", - "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": "

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

", - "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": "

The number of videos loaded.

", - "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": "

The number of AJAX requests to JSON files or webservices.

", - "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": "

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

", - "isOkThreshold": 5, - "isBadThreshold": 20, - "isAbnormalThreshold": 40, - "hasOffenders": true, - "offendersTransformFn": function(offenders) { - return { - count: offenders.length, - list: offenders.map(function(offender) { - return offendersHelpers.fileWithSizePattern(offender); - }) - }; - } + "smallRequests": { + "tool": "weightChecker", + "label": "Small requests", + "message": "

List of all requests that are less than 2 KB. Try to merge them with other files.

", + "isOkThreshold": 4, + "isBadThreshold": 30, + "isAbnormalThreshold": 50, + "hasOffenders": true }, "smallJsFiles": { "tool": "phantomas", diff --git a/lib/metadata/scoreProfileGeneric.json b/lib/metadata/scoreProfileGeneric.json index 277b06d..24f29e8 100644 --- a/lib/metadata/scoreProfileGeneric.json +++ b/lib/metadata/scoreProfileGeneric.json @@ -81,7 +81,7 @@ "requests": { "label": "Requests", "policies": { - "requests": 5, + "totalRequests": 5, "domains": 3, "notFound": 3, "multipleRequests": 2, diff --git a/lib/tools/weightChecker/weightChecker.js b/lib/tools/weightChecker/weightChecker.js index 7fb85db..dfc00b3 100644 --- a/lib/tools/weightChecker/weightChecker.js +++ b/lib/tools/weightChecker/weightChecker.js @@ -67,13 +67,17 @@ var WeightChecker = function() { 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); }); - - var metrics = {}; - var offenders = {}; // Total weight @@ -92,6 +96,10 @@ var WeightChecker = function() { 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 @@ -249,6 +257,57 @@ var WeightChecker = function() { 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(); diff --git a/test/core/weightCheckerTest.js b/test/core/weightCheckerTest.js index 9b1bb08..6e68d67 100644 --- a/test/core/weightCheckerTest.js +++ b/test/core/weightCheckerTest.js @@ -136,6 +136,23 @@ describe('weightChecker', function() { 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(); }) From 41cdc8783e2aff58d9a749b695a2be96dfa0836a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Fri, 19 Jun 2015 23:47:25 +0200 Subject: [PATCH 31/39] Remove uneeded policies --- lib/metadata/policies.js | 51 ---------------------------------------- 1 file changed, 51 deletions(-) diff --git a/lib/metadata/policies.js b/lib/metadata/policies.js index b30d589..5cdc5f6 100644 --- a/lib/metadata/policies.js +++ b/lib/metadata/policies.js @@ -910,57 +910,6 @@ var policies = { "isAbnormalThreshold": 50, "hasOffenders": true }, - "smallJsFiles": { - "tool": "phantomas", - "label": "Small JS files", - "message": "

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

", - "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": "

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

", - "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": "

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

", - "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", From 2f447da34b992162866efe62fe6b08732ac57c84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Sat, 20 Jun 2015 00:12:47 +0200 Subject: [PATCH 32/39] Add Images not lazyloaded rule --- front/src/css/rule.css | 2 +- front/src/less/rule.less | 2 +- front/src/views/rule.html | 5 +++-- lib/metadata/policies.js | 9 +++++++++ lib/metadata/scoreProfileGeneric.json | 3 ++- 5 files changed, 16 insertions(+), 5 deletions(-) diff --git a/front/src/css/rule.css b/front/src/css/rule.css index ba01a54..508d3b0 100644 --- a/front/src/css/rule.css +++ b/front/src/css/rule.css @@ -237,7 +237,7 @@ margin-top: 0.5em; } .smallPreview { - max-height: 1.5em; + max-height: 1.6em; max-width: 4em; border: 1px solid #000; margin-top: 0.2em; diff --git a/front/src/less/rule.less b/front/src/less/rule.less index 18c6310..9cf165e 100644 --- a/front/src/less/rule.less +++ b/front/src/less/rule.less @@ -262,7 +262,7 @@ } .smallPreview { - max-height: 1.5em; + max-height: 1.6em; max-width: 4em; border: 1px solid #000; margin-top: 0.2em; diff --git a/front/src/views/rule.html b/front/src/views/rule.html index 553bd73..663be94 100644 --- a/front/src/views/rule.html +++ b/front/src/views/rule.html @@ -137,8 +137,9 @@
-
- +
+ +
diff --git a/lib/metadata/policies.js b/lib/metadata/policies.js index 5cdc5f6..35ea13a 100644 --- a/lib/metadata/policies.js +++ b/lib/metadata/policies.js @@ -910,6 +910,15 @@ var policies = { "isAbnormalThreshold": 50, "hasOffenders": true }, + "lazyLoadableImagesBelowTheFold": { + "tool": "phantomas", + "label": "Not lazyloaded images", + "message": "

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.

I recommend using this lazyloader.

", + "isOkThreshold": 1, + "isBadThreshold": 12, + "isAbnormalThreshold": 30, + "hasOffenders": true + }, "notFound": { "tool": "phantomas", "label": "404 not found", diff --git a/lib/metadata/scoreProfileGeneric.json b/lib/metadata/scoreProfileGeneric.json index 24f29e8..2a18c37 100644 --- a/lib/metadata/scoreProfileGeneric.json +++ b/lib/metadata/scoreProfileGeneric.json @@ -85,7 +85,8 @@ "domains": 3, "notFound": 3, "multipleRequests": 2, - "smallRequests": 1 + "smallRequests": 1, + "lazyLoadableImagesBelowTheFold": 2 } }, "serverConfig": { From 8133164f44b39fcfd5d05ec4ae0a9543685f961a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Sat, 20 Jun 2015 13:51:34 +0200 Subject: [PATCH 33/39] Rename total requests number --- lib/metadata/policies.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/metadata/policies.js b/lib/metadata/policies.js index 35ea13a..bc9cc1f 100644 --- a/lib/metadata/policies.js +++ b/lib/metadata/policies.js @@ -894,7 +894,7 @@ var policies = { }, "totalRequests": { "tool": "weightChecker", - "label": "Total requests number", + "label": "Requests number", "message": "

This is one of the most important performance rule. Every request is slowing down the page loading.

There are several technics to reduce their number:

  • Concatenate JS files
  • Concatenate CSS files
  • Embed or inline small JS or CSS files in the HTML
  • Create sprites or icon fonts
  • Base64 encode small images in HTML or stylesheets
  • Use lazyloading for images

", "isOkThreshold": 15, "isBadThreshold": 100, From f86bd8604392bd516fba5ad46f505f294ef28fab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Sat, 20 Jun 2015 14:20:31 +0200 Subject: [PATCH 34/39] Add google analytics to already minified fies --- lib/tools/weightChecker/fileMinifier.js | 2 ++ test/core/fileMinifierTest.js | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/lib/tools/weightChecker/fileMinifier.js b/lib/tools/weightChecker/fileMinifier.js index 4f87476..d7b54a5 100644 --- a/lib/tools/weightChecker/fileMinifier.js +++ b/lib/tools/weightChecker/fileMinifier.js @@ -301,6 +301,8 @@ var FileMinifier = function() { // 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); diff --git a/test/core/fileMinifierTest.js b/test/core/fileMinifierTest.js index 39d0f9c..90ce452 100644 --- a/test/core/fileMinifierTest.js +++ b/test/core/fileMinifierTest.js @@ -233,6 +233,11 @@ describe('fileMinifier', function() { 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); }); From 1225b27510de8c3ef6124712206fcc74bee0dc0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Sat, 20 Jun 2015 14:34:13 +0200 Subject: [PATCH 35/39] Fix timeline script filter --- front/src/js/controllers/timelineCtrl.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/front/src/js/controllers/timelineCtrl.js b/front/src/js/controllers/timelineCtrl.js index c641dc6..e23fee1 100644 --- a/front/src/js/controllers/timelineCtrl.js +++ b/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); From 8a8b871908d56f32a3dd40c74250f41ef4f9dc35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Sat, 20 Jun 2015 15:19:50 +0200 Subject: [PATCH 36/39] Changed some thresholds, mainly cooler --- lib/metadata/policies.js | 133 ++++++++++++++++++--------------------- 1 file changed, 62 insertions(+), 71 deletions(-) diff --git a/lib/metadata/policies.js b/lib/metadata/policies.js index bc9cc1f..23083f5 100644 --- a/lib/metadata/policies.js +++ b/lib/metadata/policies.js @@ -43,7 +43,7 @@ var policies = { "message": "

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

", "isOkThreshold": 0, "isBadThreshold": 5, - "isAbnormalThreshold": 10, + "isAbnormalThreshold": 50, "hasOffenders": true, "offendersTransformFn": function(offenders) { return { @@ -122,7 +122,7 @@ var policies = { "message": "

Number of 'scroll' event listeners binded to 'window' or 'document'.

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.

", "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": "

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

", - "isOkThreshold": 0, - "isBadThreshold": 10, - "isAbnormalThreshold": 20, - "hasOffenders": false - },*/ "documentWriteCalls": { "tool": "phantomas", "label": "document.write calls", @@ -256,7 +247,7 @@ var policies = { "message": "

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

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

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

", "isOkThreshold": 0, "isBadThreshold": 10, - "isAbnormalThreshold": 25, + "isAbnormalThreshold": 50, "hasOffenders": false }, "globalVariables": { @@ -264,8 +255,8 @@ var policies = { "label": "Global variables", "message": "

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

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

", "isOkThreshold": 30, - "isBadThreshold": 100, - "isAbnormalThreshold": 400, + "isBadThreshold": 150, + "isAbnormalThreshold": 700, "hasOffenders": true, "offendersTransformFn": function(offenders) { return { @@ -375,7 +366,7 @@ var policies = { "message": "

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

Maybe a CSS validator can help you.

", "isOkThreshold": 0, "isBadThreshold": 1, - "isAbnormalThreshold": 8, + "isAbnormalThreshold": 20, "hasOffenders": true, "offendersTransformFn": function(offenders) { return { @@ -901,6 +892,62 @@ var policies = { "isAbnormalThreshold": 180, "hasOffenders": true }, + "domains": { + "tool": "phantomas", + "label": "Different domains", + "message": "

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

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

", + "isOkThreshold": 10, + "isBadThreshold": 25, + "isAbnormalThreshold": 50, + "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]) + }; + }) + }; + } + }, + "notFound": { + "tool": "phantomas", + "label": "404 not found", + "message": "

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

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

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

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

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

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

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

", + "isOkThreshold": 0, + "isBadThreshold": 3, + "isAbnormalThreshold": 10, + "hasOffenders": true + }, "smallRequests": { "tool": "weightChecker", "label": "Small requests", @@ -919,33 +966,6 @@ var policies = { "isAbnormalThreshold": 30, "hasOffenders": true }, - "notFound": { - "tool": "phantomas", - "label": "404 not found", - "message": "

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

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

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

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

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

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

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

", - "isOkThreshold": 0, - "isBadThreshold": 3, - "isAbnormalThreshold": 10, - "hasOffenders": true - }, "cachingDisabled": { "tool": "phantomas", "label": "Caching disabled", @@ -1022,35 +1042,6 @@ var policies = { }) }; } - }, - "domains": { - "tool": "phantomas", - "label": "Different domains", - "message": "

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

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

", - "isOkThreshold": 10, - "isBadThreshold": 25, - "isAbnormalThreshold": 50, - "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]) - }; - }) - }; - } } }; From b7bc4dbadc8b47ab3a2868f47c33cccb4195d516 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Sat, 20 Jun 2015 15:22:42 +0200 Subject: [PATCH 37/39] Made Weight and Requests the first categories because they are the most important --- lib/metadata/scoreProfileGeneric.json | 44 +++++++++++++-------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/lib/metadata/scoreProfileGeneric.json b/lib/metadata/scoreProfileGeneric.json index 2a18c37..eed3045 100644 --- a/lib/metadata/scoreProfileGeneric.json +++ b/lib/metadata/scoreProfileGeneric.json @@ -1,5 +1,25 @@ { "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": { @@ -69,26 +89,6 @@ "cssRedundantChildNodesSelectors": 1 } }, - "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 - } - }, "serverConfig": { "label": "Server config", "policies": { @@ -100,6 +100,8 @@ } }, "globalScore": { + "pageWeight": 3, + "requests": 3, "domComplexity": 2, "domManipulations": 2, "scroll": 1, @@ -108,8 +110,6 @@ "cssSyntaxError": 1, "cssComplexity": 1, "badCSS": 1, - "pageWeight": 3, - "requests": 3, "serverConfig": 1 } } \ No newline at end of file From 414aea2637712588b9cf9370493f786bc500fa40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Sat, 20 Jun 2015 15:51:46 +0200 Subject: [PATCH 38/39] Update dependencies and set version v1.7.0 --- bower.json | 2 +- package.json | 24 ++++++++++++------------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/bower.json b/bower.json index e5db6d7..12cd42e 100644 --- a/bower.json +++ b/bower.json @@ -6,7 +6,7 @@ "angular-resource": "1.3.15", "angular-sanitize": "1.3.15", "angular-animate": "1.3.15", - "angular-local-storage": "0.2.0", + "angular-local-storage": "0.2.2", "angular-chart.js": "0.7.1", "Chart.js": "~1.0.2" } diff --git a/package.json b/package.json index 7d9f162..152758c 100644 --- a/package.json +++ b/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,27 +11,27 @@ }, "main": "./lib/index.js", "dependencies": { - "async": "1.0.0", - "body-parser": "1.12.4", + "async": "1.2.1", + "body-parser": "1.13.1", "clean-css": "3.3.0", - "compression": "1.4.4", - "cors": "2.6.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.6", - "meow": "3.1.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", + "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", @@ -53,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" From c9b12c48920fa293be5a289bbffd1fabf1ccc619 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Sat, 20 Jun 2015 16:11:10 +0200 Subject: [PATCH 39/39] Remove bugged lossless image optim gain when there's a lossy one --- front/src/views/rule.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front/src/views/rule.html b/front/src/views/rule.html index 663be94..cf61e4f 100644 --- a/front/src/views/rule.html +++ b/front/src/views/rule.html @@ -220,7 +220,7 @@

Current weight: {{image.original | bytes}}

Current weight: {{image.original | bytes}} ({{image.afterCompression | bytes}} gzipped)

With a lossless optimization:
{{image.afterOptimizationAndCompression | bytes}} gzipped (-{{image.gain | bytes}} gzipped)

-

With a lossless optimization:
{{image.lossless | bytes}} (-{{image.gain | bytes}})

+

With a lossless optimization:
{{image.lossless | bytes}} (-{{image.gain | bytes}})

With a lossy optimization:
{{image.afterOptimizationAndCompression | bytes}} gzipped (-{{image.gain | bytes}} gzipped)

With a lossy optimization:
{{image.lossy | bytes}} (-{{image.gain | bytes}})