From 34468198ec672c3cca084949c411cb32743c2212 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Mon, 12 Jan 2015 16:39:54 +0100 Subject: [PATCH 01/54] Hide ProTip on dashboard if user comes from social sharing --- front/src/views/dashboard.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front/src/views/dashboard.html b/front/src/views/dashboard.html index e95421d..7d5a7b6 100644 --- a/front/src/views/dashboard.html +++ b/front/src/views/dashboard.html @@ -35,7 +35,7 @@ -
+
Did you know? Yellow Lab Tools now has an API! Here is the JSON output for this run. Checkout the API doc.
From 6521d2492580165f8dd087c80da823bd35b32c1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Thu, 15 Jan 2015 12:19:25 +0100 Subject: [PATCH 02/54] Add quotes to user-agent in debug command line --- lib/tools/phantomas/phantomasWrapper.js | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/lib/tools/phantomas/phantomasWrapper.js b/lib/tools/phantomas/phantomasWrapper.js index bdef3d7..6c7937f 100644 --- a/lib/tools/phantomas/phantomasWrapper.js +++ b/lib/tools/phantomas/phantomasWrapper.js @@ -1,6 +1,7 @@ var async = require('async'); var Q = require('q'); var ps = require('ps-node'); +var path = require('path'); var debug = require('debug')('ylt:phantomaswrapper'); var phantomas = require('phantomas'); @@ -46,8 +47,8 @@ var PhantomasWrapper = function() { 'windowPerformance' // overriden ].join(','), 'include-dirs': [ - 'lib/tools/phantomas/custom_modules/core', - 'lib/tools/phantomas/custom_modules/modules' + path.join(__dirname, 'custom_modules/core'), + path.join(__dirname, 'custom_modules/modules') ].join(',') }; @@ -55,7 +56,14 @@ var PhantomasWrapper = function() { debug('If you want to reproduce the phantomas task only, copy the following command line:'); var optionsString = ''; for (var opt in options) { - optionsString += ' ' + '--' + opt + '=' + options[opt]; + var value = options[opt]; + + if ((typeof value === 'string' || value instanceof String) && value.indexOf(' ') >= 0) { + value = '"' + value + '"'; + } + + optionsString += ' ' + '--' + opt + '=' + value; + } debug('node node_modules/phantomas/bin/phantomas.js --url=' + task.url + optionsString + ' --verbose'); From 0f00111373d65be8a17ee2569aa91a5b934aa603 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Thu, 15 Jan 2015 12:34:47 +0100 Subject: [PATCH 03/54] Fix #39 Error 1001 when using the Node module --- Gruntfile.js | 5 +- .../custom_modules/modules/domQYLT/domQYLT.js | 2 +- .../custom_modules/modules/jQYLT/jQYLT.js | 2 +- .../modules/jsFileLoadYLT/jsFileLoadYLT.js | 29 ---------- .../custom_modules/util/collection.js | 54 +++++++++++++++++++ 5 files changed, 59 insertions(+), 33 deletions(-) delete mode 100644 lib/tools/phantomas/custom_modules/modules/jsFileLoadYLT/jsFileLoadYLT.js create mode 100644 lib/tools/phantomas/custom_modules/util/collection.js diff --git a/Gruntfile.js b/Gruntfile.js index 197fe33..475359a 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -93,7 +93,9 @@ module.exports = function(grunt) { coverage: { files: [ {src: ['test/**'], dest: 'coverage/'}, - {src: ['lib/metadata/**'], dest: 'coverage/'} + {src: ['lib/metadata/**'], dest: 'coverage/'}, + {src: ['node_modules/phantomas/**'], dest: 'coverage/'}, + {src: ['lib/tools/phantomas/custom_modules/**'], dest: 'coverage/'} ] }, build: { @@ -336,7 +338,6 @@ module.exports = function(grunt) { ]); grunt.registerTask('test-current-work', [ - 'build', 'jshint', 'express:testSuite', 'clean:coverage', diff --git a/lib/tools/phantomas/custom_modules/modules/domQYLT/domQYLT.js b/lib/tools/phantomas/custom_modules/modules/domQYLT/domQYLT.js index 81985f4..fe19cdc 100644 --- a/lib/tools/phantomas/custom_modules/modules/domQYLT/domQYLT.js +++ b/lib/tools/phantomas/custom_modules/modules/domQYLT/domQYLT.js @@ -289,7 +289,7 @@ exports.module = function(phantomas) { // count DOM queries by either ID, tag name, class name and selector query // @see https://dvcs.w3.org/hg/domcore/raw-file/tip/Overview.html#dom-document-doctype - var Collection = require('../../../../../../node_modules/phantomas/lib/collection'), + var Collection = require('../../util/collection'), DOMqueries = new Collection(); phantomas.on('domQuery', function(type, query, fnName, context) { diff --git a/lib/tools/phantomas/custom_modules/modules/jQYLT/jQYLT.js b/lib/tools/phantomas/custom_modules/modules/jQYLT/jQYLT.js index a850312..d76db71 100644 --- a/lib/tools/phantomas/custom_modules/modules/jQYLT/jQYLT.js +++ b/lib/tools/phantomas/custom_modules/modules/jQYLT/jQYLT.js @@ -276,7 +276,7 @@ exports.module = function(phantomas) { // count Sizzle calls to detect duplicated queries - var Collection = require('../../../../../../node_modules/phantomas/lib/collection'), + var Collection = require('../../util/collection'), sizzleCalls = new Collection(), jQueryLoading = new Collection(); diff --git a/lib/tools/phantomas/custom_modules/modules/jsFileLoadYLT/jsFileLoadYLT.js b/lib/tools/phantomas/custom_modules/modules/jsFileLoadYLT/jsFileLoadYLT.js deleted file mode 100644 index 4aa6c7d..0000000 --- a/lib/tools/phantomas/custom_modules/modules/jsFileLoadYLT/jsFileLoadYLT.js +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Meters the number of page errors, and provides traces as offenders for "jsErrors" metric - */ - -exports.version = '0.0'; - -exports.module = function(phantomas) { - 'use strict'; - - /*phantomas.on('recv', function(entry, res) { - if (!entry.isJS) { - return; - } - - // Yeah, this is weird, i'm sending the information back to the browser... - phantomas.evaluate(function(url) { - (function(phantomas) { - - phantomas.pushContext({ - type: 'script loaded', - callDetails: { - arguments: [url] - } - }); - - })(window.__phantomas); - }, entry.url); - });*/ -}; diff --git a/lib/tools/phantomas/custom_modules/util/collection.js b/lib/tools/phantomas/custom_modules/util/collection.js new file mode 100644 index 0000000..db1ca7f --- /dev/null +++ b/lib/tools/phantomas/custom_modules/util/collection.js @@ -0,0 +1,54 @@ +/** + * Push items and count them + */ + +function collection() { + /* jshint validthis: true */ + this.items = {}; +} + +collection.prototype = { + push: function(item) { + if (typeof this.items[item] === 'undefined') { + this.items[item] = { + cnt: 1 + }; + } else { + this.items[item].cnt++; + } + }, + + sort: function() { + var newItems = {}, + sortedKeys; + + // sort in descending order (by cnt) + sortedKeys = Object.keys(this.items).sort((function(a, b) { + return this.items[b].cnt - this.items[a].cnt; + }).bind(this)); + + // build new items dictionary + sortedKeys.forEach(function(key) { + newItems[key] = this.items[key]; + }, this); + + this.items = newItems; + return this; + }, + + forEach: function(callback) { + Object.keys(this.items).forEach(function(key) { + callback(key, this.items[key].cnt); + }, this); + }, + + has: function(item) { + return (typeof this.items[item] !== 'undefined'); + }, + + getLength: function() { + return Object.keys(this.items).length; + } +}; + +module.exports = collection; From 41c02cb7324d3cf76bf32ee924dd725df99148d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Fri, 16 Jan 2015 15:50:56 +0100 Subject: [PATCH 04/54] Remove coverage from 'grunt test-current-work' command --- Gruntfile.js | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/Gruntfile.js b/Gruntfile.js index 475359a..819db4f 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -141,7 +141,7 @@ module.exports = function(grunt) { options: { reporter: 'spec', }, - src: ['coverage/test/api/apiTest.js'] + src: ['test/core/offendersHelpersTest.js'] }, coverage: { options: { @@ -184,6 +184,13 @@ module.exports = function(grunt) { showStack: true } }, + 'test-current-work': { + options: { + port: 8387, + server: './bin/server.js', + showStack: true + } + }, testSuite: { options: { port: 8388, @@ -342,11 +349,7 @@ module.exports = function(grunt) { 'express:testSuite', 'clean:coverage', 'copy-test-server-settings', - 'lineremover:beforeCoverage', - 'copy:beforeCoverage', - 'blanket', - 'copy:coverage', - 'express:test', + 'express:test-current-work', 'mochaTest:test-current-work', 'clean:tmp' ]); From 49a6e4633a2d0f3c50f72ef194b743f70ff4e462 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Fri, 16 Jan 2015 15:51:45 +0100 Subject: [PATCH 05/54] Include angular-sanitize module --- bower.json | 3 ++- front/src/js/app.js | 1 + front/src/main.html | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/bower.json b/bower.json index 8609ca8..eb2c5d9 100644 --- a/bower.json +++ b/bower.json @@ -3,6 +3,7 @@ "dependencies": { "angular": "~1.3.8", "angular-route": "~1.3.8", - "angular-resource": "~1.3.7" + "angular-resource": "~1.3.7", + "angular-sanitize": "~1.4.0-beta.0" } } diff --git a/front/src/js/app.js b/front/src/js/app.js index a7f73d3..0adc65f 100644 --- a/front/src/js/app.js +++ b/front/src/js/app.js @@ -1,5 +1,6 @@ var yltApp = angular.module('YellowLabTools', [ 'ngRoute', + 'ngSanitize', 'indexCtrl', 'aboutCtrl', 'dashboardCtrl', diff --git a/front/src/main.html b/front/src/main.html index d966016..88f1dd6 100644 --- a/front/src/main.html +++ b/front/src/main.html @@ -20,6 +20,7 @@ + From bce7a1ad9a0481ec68892f7a87eaf412bae93819 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Fri, 16 Jan 2015 15:52:56 +0100 Subject: [PATCH 06/54] Add the possibility to change offenders' layout --- front/src/css/rule.css | 14 ++++ front/src/js/controllers/ruleCtrl.js | 5 +- front/src/less/rule.less | 17 ++++ front/src/views/rule.html | 13 ++- lib/metadata/policies.js | 14 +++- lib/offendersHelpers.js | 65 +++++++++++++++ lib/rulesChecker.js | 29 +++++-- test/core/indexTest.js | 3 +- test/core/offendersHelpersTest.js | 83 +++++++++++++++++++ test/core/rulesCheckerTest.js | 2 +- test/fixtures/rulesCheckerOutput.json | 6 +- ...rPolicies.json => rulesCheckerPolicies.js} | 17 +++- 12 files changed, 248 insertions(+), 20 deletions(-) create mode 100644 lib/offendersHelpers.js create mode 100644 test/core/offendersHelpersTest.js rename test/fixtures/{rulesCheckerPolicies.json => rulesCheckerPolicies.js} (86%) diff --git a/front/src/css/rule.css b/front/src/css/rule.css index 9fea690..57d9b17 100644 --- a/front/src/css/rule.css +++ b/front/src/css/rule.css @@ -82,3 +82,17 @@ font-size: 3em; margin-bottom: 1em; } +.offendersHtml { + display: inline-block; +} +.offendersHtml .domTree div { + text-align: left; + margin-left: 1em; +} +.offendersHtml .domTree div span:only-child { + font-weight: bold; +} +.offendersHtml .domTree div span:only-child span { + font-style: italic; + font-weight: normal; +} diff --git a/front/src/js/controllers/ruleCtrl.js b/front/src/js/controllers/ruleCtrl.js index 63fa2ef..e63adc1 100644 --- a/front/src/js/controllers/ruleCtrl.js +++ b/front/src/js/controllers/ruleCtrl.js @@ -22,7 +22,10 @@ ruleCtrl.controller('RuleCtrl', ['$scope', '$rootScope', '$routeParams', '$locat function init() { $scope.rule = $scope.result.rules[$scope.policyName]; - $scope.message = $sce.trustAsHtml($scope.rule.policy.message); + + if (angular.isString($scope.rule.offenders)) { + $scope.htmlOffenders = $scope.rule.offenders; + } } $scope.backToDashboard = function() { diff --git a/front/src/less/rule.less b/front/src/less/rule.less index a12463a..feddc09 100644 --- a/front/src/less/rule.less +++ b/front/src/less/rule.less @@ -88,4 +88,21 @@ font-size: 3em; margin-bottom: 1em; } +} + +.offendersHtml { + display: inline-block; + + .domTree div { + text-align: left; + margin-left: 1em; + + span:only-child { + font-weight: bold; + span { + font-style: italic; + font-weight: normal; + } + } + } } \ No newline at end of file diff --git a/front/src/views/rule.html b/front/src/views/rule.html index ee540ce..639ed91 100644 --- a/front/src/views/rule.html +++ b/front/src/views/rule.html @@ -10,24 +10,31 @@

Value: {{rule.value}}

-
+

Warning

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

-
+

-
{{offender}}
+
+
+

+ + +

+
+

404

Rule "{{policyName}}"" not found diff --git a/lib/metadata/policies.js b/lib/metadata/policies.js index b2b70fd..9c12353 100644 --- a/lib/metadata/policies.js +++ b/lib/metadata/policies.js @@ -1,4 +1,5 @@ var debug = require('debug')('ylt:policies'); +var offendersHelpers = require('../offendersHelpers'); var policies = { "DOMelementsCount": { @@ -15,7 +16,10 @@ var policies = { "message": "

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

It also slows down JavaScript modifications to the DOM because changing the dimensions of an element makes the browser re-calculate the dimensions of it's parents. Same thing for JavaScript events, that bubble up to the document root.

", "isOkThreshold": 10, "isBadThreshold": 20, - "isAbnormalThreshold": 28 + "isAbnormalThreshold": 28, + "offendersTransformFn": function(offenders) { + return offendersHelpers.listOfDomPathsToHTML(offenders); + } }, "iframesCount": { "tool": "phantomas", @@ -31,7 +35,13 @@ var policies = { "message": "

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

", "isOkThreshold": 0, "isBadThreshold": 5, - "isAbnormalThreshold": 10 + "isAbnormalThreshold": 10, + "offendersTransformFn": function(offenders) { + return offenders.map(function(offender) { + var results = /^(.*): (\d) occurrences$/.exec(offender); + return '#' + results[1] + ': ' + results[2] + ' occurrences'; + }); + } }, "DOMinserts": { "tool": "phantomas", diff --git a/lib/offendersHelpers.js b/lib/offendersHelpers.js new file mode 100644 index 0000000..bbab2bf --- /dev/null +++ b/lib/offendersHelpers.js @@ -0,0 +1,65 @@ + + +var OffendersHelpers = function() { + + + this.domPathToArray = function(str) { + return str.split(/\s?>\s?/); + }; + + this.listOfDomArraysToTree = function(listOfDomArrays) { + var result = {}; + + function recursiveTreeBuilder(tree, domArray) { + if (domArray.length > 0) { + var currentDomElement = domArray.shift(domArray); + if (tree === null) { + tree = {}; + } + tree[currentDomElement] = recursiveTreeBuilder(tree[currentDomElement] || null, domArray); + return tree; + } else if (tree === null) { + return 1; + } else { + return tree + 1; + } + } + + listOfDomArrays.forEach(function(domArray) { + result = recursiveTreeBuilder(result, domArray); + }); + + return result; + }; + + this.domTreeToHTML = function(domTree) { + + function recursiveHtmlBuilder(tree) { + var html = ''; + var keys = Object.keys(tree); + + keys.forEach(function(key) { + if (isNaN(tree[key])) { + html += '
' + key + '' + recursiveHtmlBuilder(tree[key]) + '
'; + } else if (tree[key] > 1) { + html += '
' + key + ' (x' + tree[key] + ')
'; + } else { + html += '
' + key + '
'; + } + }); + + return html; + } + + return '
' + recursiveHtmlBuilder(domTree) + '
'; + }; + + this.listOfDomPathsToHTML = function(domPaths) { + var domArrays = domPaths.map(this.domPathToArray); + var domTree = this.listOfDomArraysToTree(domArrays); + return this.domTreeToHTML(domTree); + }; + +}; + +module.exports = new OffendersHelpers(); \ No newline at end of file diff --git a/lib/rulesChecker.js b/lib/rulesChecker.js index f8eabd1..7fb238d 100644 --- a/lib/rulesChecker.js +++ b/lib/rulesChecker.js @@ -24,10 +24,12 @@ var RulesChecker = function() { policy: policy }; + var offenders = []; + // Take DOMqueriesAvoidable's offenders from DOMqueriesDuplicated, for example. if (policy.takeOffendersFrom) { + var fromList = policy.takeOffendersFrom; - var offenders = []; // takeOffendersFrom option can be a string or an array of strings. if (typeof fromList === 'string') { @@ -35,16 +37,31 @@ var RulesChecker = function() { } fromList.forEach(function(from) { - offenders = offenders.concat(data.toolsResults[policy.tool].offenders[from]); + if (data.toolsResults[policy.tool] && + data.toolsResults[policy.tool].offenders && + data.toolsResults[policy.tool].offenders[from]) { + offenders = offenders.concat(data.toolsResults[policy.tool].offenders[from]); + } }); data.toolsResults[policy.tool].offenders[metricName] = offenders; + + } else if (data.toolsResults[policy.tool] && + data.toolsResults[policy.tool].offenders && + data.toolsResults[policy.tool].offenders[metricName]) { + offenders = data.toolsResults[policy.tool].offenders[metricName]; + } + + // It is possible to declare a transformation function for the offenders. + // The function should take an array of strings as single parameter and return a string. + if (policy.offendersTransformFn) { + rule.offendersCount = offenders.length; + offenders = policy.offendersTransformFn(offenders); + delete policy.offendersTransformFn; } - if (data.toolsResults[policy.tool].offenders && - data.toolsResults[policy.tool].offenders[metricName] && - data.toolsResults[policy.tool].offenders[metricName].length > 0) { - rule.offenders = data.toolsResults[policy.tool].offenders[metricName]; + if (offenders && offenders.length > 0) { + rule.offenders = offenders; } rule.bad = rule.value > policy.isOkThreshold; diff --git a/test/core/indexTest.js b/test/core/indexTest.js index 6207241..715f2f9 100644 --- a/test/core/indexTest.js +++ b/test/core/indexTest.js @@ -72,7 +72,8 @@ describe('index.js', function() { "abnormal": false, "score": 100, "abnormalityScore": 0, - "offenders": ["body > h1[1]"] + "offenders": "
body
h1[1]
", + "offendersCount": 1 }); // Test javascriptExecutionTree diff --git a/test/core/offendersHelpersTest.js b/test/core/offendersHelpersTest.js new file mode 100644 index 0000000..5c1a72c --- /dev/null +++ b/test/core/offendersHelpersTest.js @@ -0,0 +1,83 @@ +var should = require('chai').should(); +var offendersHelpers = require('../../lib/offendersHelpers'); + +describe('offendersHelpers', function() { + + describe('domPathToArray', function() { + + it('should transform a path to an array', function() { + var result = offendersHelpers.domPathToArray('body > section#page > div.alternate-color > ul.retroGuide > li[0] > div.retro-chaine.france2'); + result.should.deep.equal(['body', 'section#page', 'div.alternate-color', 'ul.retroGuide', 'li[0]', 'div.retro-chaine.france2']); + }); + + it('should work even if a space is missing', function() { + var result = offendersHelpers.domPathToArray('body > section#page> div.alternate-color > ul.retroGuide >li[0] > div.retro-chaine.france2'); + result.should.deep.equal(['body', 'section#page', 'div.alternate-color', 'ul.retroGuide', 'li[0]', 'div.retro-chaine.france2']); + }); + + }); + + describe('listOfDomArraysToTree', function() { + + it('should transform a list of arrays into a tree', function() { + var result = offendersHelpers.listOfDomArraysToTree([ + ['body', 'section#page', 'div.alternate-color', 'ul.retroGuide', 'li[0]', 'div.retro-chaine.france2'], + ['body', 'section#page', 'div.alternate-color', 'ul.retroGuide', 'li[0]', 'div.retro-chaine.france2'], + ['body', 'section#page', 'div.alternate-color', 'ul.retroGuide', 'li[1]', 'div.retro-chaine.france2'] + ]); + result.should.deep.equal({ + 'body': { + 'section#page': { + 'div.alternate-color': { + 'ul.retroGuide': { + 'li[0]': { + 'div.retro-chaine.france2': 2 + }, + 'li[1]': { + 'div.retro-chaine.france2': 1 + } + } + } + } + } + }); + }); + + }); + + describe('domTreeToHTML', function() { + + it('should transform a dom tree into HTML with the awaited format', function() { + var result = offendersHelpers.domTreeToHTML({ + 'body': { + 'ul.retroGuide': { + 'li[0]': { + 'div.retro-chaine.france2': 2 + }, + 'li[1]': { + 'div.retro-chaine.france2': 1 + } + } + } + }); + + result.should.equal('
body
ul.retroGuide
li[0]
div.retro-chaine.france2 (x2)
li[1]
div.retro-chaine.france2
'); + }); + + }); + + describe('listOfDomPathsToHTML', function() { + + it('should transform a list of path strings into HTML', function() { + var result = offendersHelpers.listOfDomPathsToHTML([ + 'body > ul.retroGuide > li[0] > div.retro-chaine.france2', + 'body > ul.retroGuide > li[1] > div.retro-chaine.france2', + 'body > ul.retroGuide > li[0] > div.retro-chaine.france2', + ]); + + result.should.equal('
body
ul.retroGuide
li[0]
div.retro-chaine.france2 (x2)
li[1]
div.retro-chaine.france2
'); + }); + + }); + +}); diff --git a/test/core/rulesCheckerTest.js b/test/core/rulesCheckerTest.js index 841c1ab..f979109 100644 --- a/test/core/rulesCheckerTest.js +++ b/test/core/rulesCheckerTest.js @@ -9,7 +9,7 @@ describe('rulesChecker', function() { it('should produce a nice rules object', function() { var data = require('../fixtures/rulesCheckerInput.json'); - var policies = require('../fixtures/rulesCheckerPolicies.json'); + var policies = require('../fixtures/rulesCheckerPolicies'); var expected = require('../fixtures/rulesCheckerOutput.json'); var results = rulesChecker.check(data, policies); diff --git a/test/fixtures/rulesCheckerOutput.json b/test/fixtures/rulesCheckerOutput.json index 5327aa5..9f6ddf4 100644 --- a/test/fixtures/rulesCheckerOutput.json +++ b/test/fixtures/rulesCheckerOutput.json @@ -25,7 +25,8 @@ "takeOffendersFrom": "metric3" }, "value": 222, - "offenders": ["offender1", "offender2"], + "offenders": "offender1 - offender2", + "offendersCount": 2, "bad": false, "abnormal": false, "score": 100, @@ -41,7 +42,8 @@ "isAbnormalThreshold": 5000 }, "value": 6666, - "offenders": ["offender1", "offender2"], + "offenders": "offender1/offender2", + "offendersCount": 2, "bad": true, "abnormal": true, "score": 0, diff --git a/test/fixtures/rulesCheckerPolicies.json b/test/fixtures/rulesCheckerPolicies.js similarity index 86% rename from test/fixtures/rulesCheckerPolicies.json rename to test/fixtures/rulesCheckerPolicies.js index 9147e01..a4fecab 100644 --- a/test/fixtures/rulesCheckerPolicies.json +++ b/test/fixtures/rulesCheckerPolicies.js @@ -1,4 +1,5 @@ -{ +var policies = { + "metric1": { "tool": "tool1", "label": "The metric 1", @@ -14,7 +15,10 @@ "isOkThreshold": 1000, "isBadThreshold": 3000, "isAbnormalThreshold": 5000, - "takeOffendersFrom": "metric3" + "takeOffendersFrom": "metric3", + "offendersTransformFn": function(offenders) { + return offenders.join(' - '); + } }, "metric3": { "tool": "tool1", @@ -22,7 +26,10 @@ "message": "A great message", "isOkThreshold": 1000, "isBadThreshold": 3000, - "isAbnormalThreshold": 5000 + "isAbnormalThreshold": 5000, + "offendersTransformFn": function(offenders) { + return offenders.join('/'); + } }, "metric4": { "tool": "tool1", @@ -81,4 +88,6 @@ "isBadThreshold": 3000, "isAbnormalThreshold": 5000 } -} \ No newline at end of file +}; + +module.exports = policies; \ No newline at end of file From a4f22a85426d52460d9ecac6ecda23638cef3927 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Sat, 17 Jan 2015 09:21:50 +0100 Subject: [PATCH 07/54] Offenders layout for DOM inserts --- front/src/css/rule.css | 53 +++++++++++++++++++-- front/src/less/rule.less | 78 +++++++++++++++++++++++++++---- lib/metadata/policies.js | 13 ++++-- lib/offendersHelpers.js | 34 +++++++++++++- test/core/offendersHelpersTest.js | 44 +++++++++++++++++ 5 files changed, 205 insertions(+), 17 deletions(-) diff --git a/front/src/css/rule.css b/front/src/css/rule.css index 57d9b17..cbdcfee 100644 --- a/front/src/css/rule.css +++ b/front/src/css/rule.css @@ -82,17 +82,64 @@ font-size: 3em; margin-bottom: 1em; } +.offenders .eltButton { + display: inline-block; + position: relative; + background: #efe; + padding: 0 0.5em; + margin: 0.2em 0; + border-radius: 0.4em; + z-index: 1; +} +.offenders .eltButton.opens { + padding-right: 0.75em; +} +.offenders .eltButton.opens:after { + position: relative; + left: 0.5em; + content: '\25BC'; + font-size: 0.8em; +} +.offenders .eltButton > div { + display: none; + position: absolute; + right: 0; + min-width: 100%; + background: inherit; + border-bottom-left-radius: 0.4em; + border-bottom-right-radius: 0.4em; + border-top: 1px solid #999; +} +.offenders .eltButton .domTree { + text-align: left; + white-space: nowrap; +} +.offenders .eltButton .domTree > div { + margin: 0.5em; +} +.offenders .eltButton .domTree > div div { + margin-left: 1em; +} +.offenders .eltButton:hover { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + background: #ffe0cc; + z-index: 2; +} +.offenders .eltButton:hover > div { + display: block; +} .offendersHtml { display: inline-block; } -.offendersHtml .domTree div { +.domTree div { text-align: left; margin-left: 1em; } -.offendersHtml .domTree div span:only-child { +.domTree div span:only-child { font-weight: bold; } -.offendersHtml .domTree div span:only-child span { +.domTree div span:only-child span { font-style: italic; font-weight: normal; } diff --git a/front/src/less/rule.less b/front/src/less/rule.less index feddc09..a54a753 100644 --- a/front/src/less/rule.less +++ b/front/src/less/rule.less @@ -90,19 +90,77 @@ } } -.offendersHtml { - display: inline-block; +.offenders { + .eltButton { + display: inline-block; + position: relative; + background: #efe; + padding: 0 0.5em; + margin: 0.2em 0; + border-radius: 0.4em; + z-index: 1; - .domTree div { - text-align: left; - margin-left: 1em; + &.opens { + padding-right: 0.75em; - span:only-child { - font-weight: bold; - span { - font-style: italic; - font-weight: normal; + &:after { + position: relative; + left: 0.5em; + content: '\25BC'; + font-size: 0.8em; + } + } + + > div { + display: none; + position: absolute; + right: 0; + min-width: 100%; + background: inherit; + border-bottom-left-radius: 0.4em; + border-bottom-right-radius: 0.4em; + border-top: 1px solid #999; + } + + .domTree { + text-align: left; + white-space: nowrap; + + > div { + margin: 0.5em; + + div { + margin-left: 1em; + } + } + } + + &:hover { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + background: #ffe0cc; + z-index: 2; + + & > div { + display: block; } } } +} + +.offendersHtml { + display: inline-block; +} + +.domTree div { + text-align: left; + margin-left: 1em; + + span:only-child { + font-weight: bold; + span { + font-style: italic; + font-weight: normal; + } + } } \ No newline at end of file diff --git a/lib/metadata/policies.js b/lib/metadata/policies.js index 9c12353..f0cdb18 100644 --- a/lib/metadata/policies.js +++ b/lib/metadata/policies.js @@ -38,8 +38,8 @@ var policies = { "isAbnormalThreshold": 10, "offendersTransformFn": function(offenders) { return offenders.map(function(offender) { - var results = /^(.*): (\d) occurrences$/.exec(offender); - return '#' + results[1] + ': ' + results[2] + ' occurrences'; + var parts = /^(.*): (\d) occurrences$/.exec(offender); + return '#' + parts[1] + ': ' + parts[2] + ' occurrences'; }); } }, @@ -49,7 +49,14 @@ var policies = { "message": "

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

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

", "isOkThreshold": 10, "isBadThreshold": 400, - "isAbnormalThreshold": 1000 + "isAbnormalThreshold": 1000, + "offendersTransformFn": function(offenders) { + return offenders.map(function(offender) { + var parts = /^"(.*)" appended to "(.*)"$/.exec(offender); + + return offendersHelpers.domPathToButton(parts[1]) + ' appended to ' + offendersHelpers.domPathToButton(parts[2]); + }); + } }, "DOMqueries": { "tool": "phantomas", diff --git a/lib/offendersHelpers.js b/lib/offendersHelpers.js index bbab2bf..24a523e 100644 --- a/lib/offendersHelpers.js +++ b/lib/offendersHelpers.js @@ -1,7 +1,6 @@ var OffendersHelpers = function() { - this.domPathToArray = function(str) { return str.split(/\s?>\s?/); @@ -60,6 +59,39 @@ var OffendersHelpers = function() { return this.domTreeToHTML(domTree); }; + this.domPathToButton = function(domPath) { + var domArray = this.domPathToArray(domPath); + var domTree = this.listOfDomPathsToHTML([domPath]); + + if (domArray[0] === 'html') { + return '
html
'; + } + if (domArray[0] === 'body') { + if (domArray.length === 1) { + return '
body
'; + } else { + return '
DOM element ' + domArray[domArray.length - 1] + '' + domTree + '
'; + } + } + if (domArray[0] === 'head') { + return '
head
'; + } + if (domArray[0] === 'DocumentFragment') { + if (domArray.length === 1) { + return '
Fragment
'; + } else { + return '
Fragment element ' + domArray[domArray.length - 1] + '' + domTree + '
'; + } + } + + // Not attached element, such as just created with document.createElement() + if (domArray.length === 1) { + return '
Created element ' + domPath + '
'; + } else { + return '
Created element ' + domArray[domArray.length - 1] + '' + domTree + '
'; + } + }; + }; module.exports = new OffendersHelpers(); \ No newline at end of file diff --git a/test/core/offendersHelpersTest.js b/test/core/offendersHelpersTest.js index 5c1a72c..30c2d15 100644 --- a/test/core/offendersHelpersTest.js +++ b/test/core/offendersHelpersTest.js @@ -80,4 +80,48 @@ describe('offendersHelpers', function() { }); + describe('domPathToButton', function() { + + it('should transform html', function() { + var result = offendersHelpers.domPathToButton('html'); + result.should.equal('
html
'); + }); + + it('should transform body', function() { + var result = offendersHelpers.domPathToButton('body'); + result.should.equal('
body
'); + }); + + it('should transform head', function() { + var result = offendersHelpers.domPathToButton('head'); + result.should.equal('
head
'); + }); + + it('should transform a standard in-body element', function() { + var result = offendersHelpers.domPathToButton('body > div#colorbox > div#cboxContent'); + result.should.equal('
DOM element div#cboxContent
body
div#colorbox
div#cboxContent
'); + }); + + it('should transform a domFragment element', function() { + var result = offendersHelpers.domPathToButton('DocumentFragment'); + result.should.equal('
Fragment
'); + }); + + it('should transform a domFragment element', function() { + var result = offendersHelpers.domPathToButton('DocumentFragment > div#colorbox > div#cboxContent'); + result.should.equal('
Fragment element div#cboxContent
DocumentFragment
div#colorbox
div#cboxContent
'); + }); + + it('should transform an not-attached element', function() { + var result = offendersHelpers.domPathToButton('div#sizcache'); + result.should.equal('
Created element div#sizcache
'); + }); + + it('should transform an not-attached element path', function() { + var result = offendersHelpers.domPathToButton('div > div#sizcache'); + result.should.equal('
Created element div#sizcache
div
div#sizcache
'); + }); + + }); + }); From 6fc502b0b2f3932bad5d59e8748a0f216a037959 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Sat, 17 Jan 2015 09:44:50 +0100 Subject: [PATCH 08/54] Offenders layout for DOM queries without results --- lib/metadata/policies.js | 25 ++++++++++++++++++++++++- lib/offendersHelpers.js | 3 +++ test/core/offendersHelpersTest.js | 5 +++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/lib/metadata/policies.js b/lib/metadata/policies.js index f0cdb18..046d9a2 100644 --- a/lib/metadata/policies.js +++ b/lib/metadata/policies.js @@ -39,6 +39,12 @@ var policies = { "offendersTransformFn": function(offenders) { return offenders.map(function(offender) { var parts = /^(.*): (\d) occurrences$/.exec(offender); + + if (!parts) { + debug('DOMidDuplicated offenders transform function error with "%s"', offender); + return offender; + } + return '#' + parts[1] + ': ' + parts[2] + ' occurrences'; }); } @@ -54,6 +60,11 @@ var policies = { return offenders.map(function(offender) { var parts = /^"(.*)" appended to "(.*)"$/.exec(offender); + if (!parts) { + debug('DOMinserts offenders transform function error with "%s"', offender); + return offender; + } + return offendersHelpers.domPathToButton(parts[1]) + ' appended to ' + offendersHelpers.domPathToButton(parts[2]); }); } @@ -81,7 +92,19 @@ var policies = { "message": "

Number of queries that return no result.

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

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

", "isOkThreshold": 0, "isBadThreshold": 100, - "isAbnormalThreshold": 200 + "isAbnormalThreshold": 200, + "offendersTransformFn": function(offenders) { + return offenders.map(function(offender) { + var parts = /^(.*) \(in (.*)\) using (.*)$/.exec(offender); + + if (!parts) { + debug('DOMqueriesWithoutResults offenders transform function error with "%s"', offender); + return offender; + } + + return '' + parts[1] + ' (in ' + offendersHelpers.domPathToButton(parts[2]) + ') using ' + parts[3] + ''; + }); + } }, "eventsBound": { "tool": "phantomas", diff --git a/lib/offendersHelpers.js b/lib/offendersHelpers.js index 24a523e..1ce47ef 100644 --- a/lib/offendersHelpers.js +++ b/lib/offendersHelpers.js @@ -76,6 +76,9 @@ var OffendersHelpers = function() { if (domArray[0] === 'head') { return '
head
'; } + if (domArray[0] === '#document') { + return '
document
'; + } if (domArray[0] === 'DocumentFragment') { if (domArray.length === 1) { return '
Fragment
'; diff --git a/test/core/offendersHelpersTest.js b/test/core/offendersHelpersTest.js index 30c2d15..4cea34c 100644 --- a/test/core/offendersHelpersTest.js +++ b/test/core/offendersHelpersTest.js @@ -97,6 +97,11 @@ describe('offendersHelpers', function() { result.should.equal('
head
'); }); + it('should transform #document', function() { + var result = offendersHelpers.domPathToButton('#document'); + result.should.equal('
document
'); + }); + it('should transform a standard in-body element', function() { var result = offendersHelpers.domPathToButton('body > div#colorbox > div#cboxContent'); result.should.equal('
DOM element div#cboxContent
body
div#colorbox
div#cboxContent
'); From 23dc46d697528d6d3b7baed52d288e6b360e9d12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Mon, 19 Jan 2015 11:24:16 +0100 Subject: [PATCH 09/54] Offenders layout for DOMqueriesAvoidable, jsErrors and globalVariables --- front/src/css/rule.css | 21 +++--- front/src/less/rule.less | 6 +- lib/metadata/policies.js | 74 +++++++++++++++---- lib/offendersHelpers.js | 68 +++++++++++++++--- test/core/offendersHelpersTest.js | 115 +++++++++++++++++++++++++++--- 5 files changed, 240 insertions(+), 44 deletions(-) diff --git a/front/src/css/rule.css b/front/src/css/rule.css index cbdcfee..4f5f41d 100644 --- a/front/src/css/rule.css +++ b/front/src/css/rule.css @@ -82,7 +82,7 @@ font-size: 3em; margin-bottom: 1em; } -.offenders .eltButton { +.offenders .offenderButton { display: inline-block; position: relative; background: #efe; @@ -91,16 +91,16 @@ border-radius: 0.4em; z-index: 1; } -.offenders .eltButton.opens { +.offenders .offenderButton.opens { padding-right: 0.75em; } -.offenders .eltButton.opens:after { +.offenders .offenderButton.opens:after { position: relative; left: 0.5em; content: '\25BC'; font-size: 0.8em; } -.offenders .eltButton > div { +.offenders .offenderButton > div { display: none; position: absolute; right: 0; @@ -110,23 +110,26 @@ border-bottom-right-radius: 0.4em; border-top: 1px solid #999; } -.offenders .eltButton .domTree { +.offenders .offenderButton .domTree { text-align: left; white-space: nowrap; } -.offenders .eltButton .domTree > div { +.offenders .offenderButton .domTree > div { margin: 0.5em; } -.offenders .eltButton .domTree > div div { +.offenders .offenderButton .domTree > div div { margin-left: 1em; } -.offenders .eltButton:hover { +.offenders .offenderButton .backtrace { + white-space: nowrap; +} +.offenders .offenderButton:hover { border-bottom-left-radius: 0; border-bottom-right-radius: 0; background: #ffe0cc; z-index: 2; } -.offenders .eltButton:hover > div { +.offenders .offenderButton:hover > div { display: block; } .offendersHtml { diff --git a/front/src/less/rule.less b/front/src/less/rule.less index a54a753..7a19d52 100644 --- a/front/src/less/rule.less +++ b/front/src/less/rule.less @@ -91,7 +91,7 @@ } .offenders { - .eltButton { + .offenderButton { display: inline-block; position: relative; background: #efe; @@ -135,6 +135,10 @@ } } + .backtrace { + white-space: nowrap; + } + &:hover { border-bottom-left-radius: 0; border-bottom-right-radius: 0; diff --git a/lib/metadata/policies.js b/lib/metadata/policies.js index 046d9a2..8016d11 100644 --- a/lib/metadata/policies.js +++ b/lib/metadata/policies.js @@ -44,7 +44,7 @@ var policies = { debug('DOMidDuplicated offenders transform function error with "%s"', offender); return offender; } - + return '#' + parts[1] + ': ' + parts[2] + ' occurrences'; }); } @@ -77,15 +77,6 @@ var policies = { "isBadThreshold": 1000, "isAbnormalThreshold": 2000 }, - "DOMqueriesAvoidable": { - "tool": "phantomas", - "label": "Duplicated DOM queries", - "message": "

This is the number of queries that could be avoided by removing all duplicated queries.

Simply save the result of a query in a variable. Ok it is not always simple, especially with third-party scripts, but at least do it with your own code.

", - "isOkThreshold": 0, - "isBadThreshold": 200, - "isAbnormalThreshold": 500, - "takeOffendersFrom": "DOMqueriesDuplicated" - }, "DOMqueriesWithoutResults": { "tool": "phantomas", "label": "DOM queries without result", @@ -102,7 +93,28 @@ var policies = { return offender; } - return '' + parts[1] + ' (in ' + offendersHelpers.domPathToButton(parts[2]) + ') using ' + parts[3] + ''; + return '' + parts[1] + ' (in ' + offendersHelpers.domPathToButton(parts[2]) + ') using ' + parts[3]; + }); + } + }, + "DOMqueriesAvoidable": { + "tool": "phantomas", + "label": "Duplicated DOM queries", + "message": "

This is the number of queries that could be avoided by removing all duplicated queries.

Simply save the result of a query in a variable. Ok it is not always simple, especially with third-party scripts, but at least do it with your own code.

", + "isOkThreshold": 0, + "isBadThreshold": 200, + "isAbnormalThreshold": 500, + "takeOffendersFrom": "DOMqueriesDuplicated", + "offendersTransformFn": function(offenders) { + return offenders.map(function(offender) { + var parts = /^.* "(.*)" with (.*) \(in context (.*)\): (.*)\s?queries$/.exec(offender); + + if (!parts) { + debug('DOMqueriesAvoidable offenders transform function error with "%s"', offender); + return offender; + } + + return '' + parts[1] + ' (in ' + offendersHelpers.domPathToButton(parts[3]) + ') using ' + parts[2] + ': ' + parts[4] + ' queries'; }); } }, @@ -112,7 +124,19 @@ var policies = { "message": "

Binding too many events has a cost.

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

", "isOkThreshold": 100, "isBadThreshold": 800, - "isAbnormalThreshold": 1500 + "isAbnormalThreshold": 1500, + "offendersTransformFn": function(offenders) { + return offenders.map(function(offender) { + var parts = /^"(.*)" bound to "(.*)"$/.exec(offender); + + if (!parts) { + debug('eventsBound offenders transform function error with "%s"', offender); + return offender; + } + + return '' + parts[1] + ' bound to ' + offendersHelpers.domPathToButton(parts[2]); + }); + } }, "jsErrors": { "tool": "phantomas", @@ -120,7 +144,24 @@ var policies = { "message": "

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

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

", "isOkThreshold": 0, "isBadThreshold": 1, - "isAbnormalThreshold": 4 + "isAbnormalThreshold": 4, + "offendersTransformFn": function(offenders) { + return offenders.map(function(offender) { + var parts = /^(.*) - (.*)$/.exec(offender); + + if (!parts) { + debug('jsErrors offenders transform function error with "%s"', offender); + return offender; + } + + var backtraceArray = offendersHelpers.backtraceToArray(parts[2]); + if (backtraceArray === null) { + return offender; + } else { + return '' + parts[1] + ' ' + offendersHelpers.backtraceArrayToHtml(backtraceArray); + } + }); + } }, "evalCalls": { "tool": "phantomas", @@ -152,7 +193,10 @@ var policies = { "message": "

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

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

", "isOkThreshold": 10, "isBadThreshold": 50, - "isAbnormalThreshold": 200 + "isAbnormalThreshold": 200, + "offendersTransformFn": function(offenders) { + return offendersHelpers.sortVarsLikeChromeDevTools(offenders); + } }, "jQueryVersion": { "label": "jQuery version", @@ -210,7 +254,7 @@ var policies = { "jQueryDifferentVersions": { "tool": "phantomas", "label": "Several versions loaded", - "message": "

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

", + "message": "

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

", "isOkThreshold": 1, "isBadThreshold": 2, "isAbnormalThreshold": 2 diff --git a/lib/offendersHelpers.js b/lib/offendersHelpers.js index 1ce47ef..6b427ab 100644 --- a/lib/offendersHelpers.js +++ b/lib/offendersHelpers.js @@ -11,7 +11,7 @@ var OffendersHelpers = function() { function recursiveTreeBuilder(tree, domArray) { if (domArray.length > 0) { - var currentDomElement = domArray.shift(domArray); + var currentDomElement = domArray.shift(); if (tree === null) { tree = {}; } @@ -64,37 +64,85 @@ var OffendersHelpers = function() { var domTree = this.listOfDomPathsToHTML([domPath]); if (domArray[0] === 'html') { - return '
html
'; + return '
html
'; } if (domArray[0] === 'body') { if (domArray.length === 1) { - return '
body
'; + return '
body
'; } else { - return '
DOM element ' + domArray[domArray.length - 1] + '' + domTree + '
'; + return '
DOM element ' + domArray[domArray.length - 1] + '' + domTree + '
'; } } if (domArray[0] === 'head') { - return '
head
'; + return '
head
'; } if (domArray[0] === '#document') { - return '
document
'; + return '
document
'; + } + if (domArray[0] === 'window') { + return '
window
'; } if (domArray[0] === 'DocumentFragment') { if (domArray.length === 1) { - return '
Fragment
'; + return '
Fragment
'; } else { - return '
Fragment element ' + domArray[domArray.length - 1] + '' + domTree + '
'; + return '
Fragment element ' + domArray[domArray.length - 1] + '' + domTree + '
'; } } // Not attached element, such as just created with document.createElement() if (domArray.length === 1) { - return '
Created element ' + domPath + '
'; + return '
Created element ' + domPath + '
'; } else { - return '
Created element ' + domArray[domArray.length - 1] + '' + domTree + '
'; + return '
Created element ' + domArray[domArray.length - 1] + '' + domTree + '
'; } }; + + this.backtraceToArray = function(str) { + var traceArray = str.split(/ \/ /); + + if (traceArray) { + var results = []; + var parts = null; + + for (var i=0 ; ino backtrace
'; + } + + var html = '
backtrace
'; + backtraceArray.forEach(function(backtraceObj) { + html += '
' + backtraceObj.file + ' line ' + backtraceObj.line + '
'; + }); + return html + '
'; + }; + + + this.sortVarsLikeChromeDevTools = function(vars) { + return vars.sort(function(a, b) { + return (a < b) ? -1 : 1; + }); + }; + }; module.exports = new OffendersHelpers(); \ No newline at end of file diff --git a/test/core/offendersHelpersTest.js b/test/core/offendersHelpersTest.js index 4cea34c..bf1a887 100644 --- a/test/core/offendersHelpersTest.js +++ b/test/core/offendersHelpersTest.js @@ -84,47 +84,144 @@ describe('offendersHelpers', function() { it('should transform html', function() { var result = offendersHelpers.domPathToButton('html'); - result.should.equal('
html
'); + result.should.equal('
html
'); }); it('should transform body', function() { var result = offendersHelpers.domPathToButton('body'); - result.should.equal('
body
'); + result.should.equal('
body
'); }); it('should transform head', function() { var result = offendersHelpers.domPathToButton('head'); - result.should.equal('
head
'); + result.should.equal('
head
'); }); it('should transform #document', function() { var result = offendersHelpers.domPathToButton('#document'); - result.should.equal('
document
'); + result.should.equal('
document
'); + }); + + it('should transform window', function() { + var result = offendersHelpers.domPathToButton('window'); + result.should.equal('
window
'); }); it('should transform a standard in-body element', function() { var result = offendersHelpers.domPathToButton('body > div#colorbox > div#cboxContent'); - result.should.equal('
DOM element div#cboxContent
body
div#colorbox
div#cboxContent
'); + result.should.equal('
DOM element div#cboxContent
body
div#colorbox
div#cboxContent
'); }); it('should transform a domFragment element', function() { var result = offendersHelpers.domPathToButton('DocumentFragment'); - result.should.equal('
Fragment
'); + result.should.equal('
Fragment
'); }); it('should transform a domFragment element', function() { var result = offendersHelpers.domPathToButton('DocumentFragment > div#colorbox > div#cboxContent'); - result.should.equal('
Fragment element div#cboxContent
DocumentFragment
div#colorbox
div#cboxContent
'); + result.should.equal('
Fragment element div#cboxContent
DocumentFragment
div#colorbox
div#cboxContent
'); }); it('should transform an not-attached element', function() { var result = offendersHelpers.domPathToButton('div#sizcache'); - result.should.equal('
Created element div#sizcache
'); + result.should.equal('
Created element div#sizcache
'); }); it('should transform an not-attached element path', function() { var result = offendersHelpers.domPathToButton('div > div#sizcache'); - result.should.equal('
Created element div#sizcache
div
div#sizcache
'); + result.should.equal('
Created element div#sizcache
div
div#sizcache
'); + }); + + }); + + describe('backtraceToArray', function() { + + it('should transform a backtrace into an array', function() { + var result = offendersHelpers.backtraceToArray('http://pouet.com/js/jquery.footer-transverse-min-v1.0.20.js:1 / http://pouet.com/js/main.js:1'); + + result.should.deep.equal([ + { + file: 'http://pouet.com/js/jquery.footer-transverse-min-v1.0.20.js', + line: 1 + }, + { + file: 'http://pouet.com/js/main.js', + line: 1 + } + ]); + }); + + it('should return null if it fails', function() { + var result = offendersHelpers.backtraceToArray('http://pouet.com/js/jquery.footer-transverse-min-v1.0.20.js:1 /http://pouet.com/js/main.js:1'); + + should.equal(result, null); + }); + + }); + + describe('backtraceArrayToHtml', function() { + + it('should create a button from a backtrace array', function() { + var result = offendersHelpers.backtraceArrayToHtml([ + { + file: 'http://pouet.com/js/jquery.footer-transverse-min-v1.0.20.js', + line: 1 + }, + { + file: 'http://pouet.com/js/main.js', + line: 1 + } + ]); + + result.should.equal(''); + }); + + it('should display "no backtrace"', function() { + var result = offendersHelpers.backtraceArrayToHtml([]); + + result.should.equal('
no backtrace
'); + }); + + }); + + describe('sortVarsLikeChromeDevTools', function() { + + it('should sort in the same strange order', function() { + var result = offendersHelpers.sortVarsLikeChromeDevTools([ + 'a', + 'aaa', + 'a2', + 'b', + 'A', + 'AAA', + 'B', + '_a', + '_aaa', + '__a', + 'a_a', + 'aA', + 'a__', + '$', + '$a' + ]); + + result.should.deep.equal([ + '$', + '$a', + 'A', + 'AAA', + 'B', + '__a', + '_a', + '_aaa', + 'a', + 'a2', + 'aA', + 'a__', + 'a_a', + 'aaa', + 'b' + ]); }); }); From 4ef83a844fc81f4b9bb3d39fe39105a90d133b13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Mon, 19 Jan 2015 12:15:05 +0100 Subject: [PATCH 10/54] Fix offendersTransformFn deleted after the first run --- lib/rulesChecker.js | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/lib/rulesChecker.js b/lib/rulesChecker.js index 7fb238d..8f82b68 100644 --- a/lib/rulesChecker.js +++ b/lib/rulesChecker.js @@ -1,3 +1,5 @@ +var extend = require('util')._extend; + var debug = require('debug')('ylt:ruleschecker'); var RulesChecker = function() { @@ -21,7 +23,7 @@ var RulesChecker = function() { rule = { value: data.toolsResults[policy.tool].metrics[metricName], - policy: policy + policy: extend({}, policy) // Clone object policy instead of reference }; var offenders = []; @@ -56,8 +58,15 @@ var RulesChecker = function() { // The function should take an array of strings as single parameter and return a string. if (policy.offendersTransformFn) { rule.offendersCount = offenders.length; - offenders = policy.offendersTransformFn(offenders); - delete policy.offendersTransformFn; + + try { + offenders = policy.offendersTransformFn(offenders); + } catch(err) { + debug('Error while transforming offenders for %s', metricName); + debug(err); + } + + delete rule.policy.offendersTransformFn; } if (offenders && offenders.length > 0) { From e6bd0d0006bc0cf5deda7cf77254724be897dc37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Mon, 19 Jan 2015 12:16:19 +0100 Subject: [PATCH 11/54] Strange spaces disapearance in offenders --- lib/metadata/policies.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/metadata/policies.js b/lib/metadata/policies.js index 8016d11..f1c9d7d 100644 --- a/lib/metadata/policies.js +++ b/lib/metadata/policies.js @@ -38,7 +38,7 @@ var policies = { "isAbnormalThreshold": 10, "offendersTransformFn": function(offenders) { return offenders.map(function(offender) { - var parts = /^(.*): (\d) occurrences$/.exec(offender); + var parts = /^(.*): ?(\d) ?occurrences$/.exec(offender); if (!parts) { debug('DOMidDuplicated offenders transform function error with "%s"', offender); @@ -58,7 +58,7 @@ var policies = { "isAbnormalThreshold": 1000, "offendersTransformFn": function(offenders) { return offenders.map(function(offender) { - var parts = /^"(.*)" appended to "(.*)"$/.exec(offender); + var parts = /^"(.*)" ?appended ?to ?"(.*)"$/.exec(offender); if (!parts) { debug('DOMinserts offenders transform function error with "%s"', offender); @@ -86,7 +86,7 @@ var policies = { "isAbnormalThreshold": 200, "offendersTransformFn": function(offenders) { return offenders.map(function(offender) { - var parts = /^(.*) \(in (.*)\) using (.*)$/.exec(offender); + var parts = /^(.*) ?\(in ?(.*)\) ?using ?(.*)$/.exec(offender); if (!parts) { debug('DOMqueriesWithoutResults offenders transform function error with "%s"', offender); @@ -107,7 +107,7 @@ var policies = { "takeOffendersFrom": "DOMqueriesDuplicated", "offendersTransformFn": function(offenders) { return offenders.map(function(offender) { - var parts = /^.* "(.*)" with (.*) \(in context (.*)\): (.*)\s?queries$/.exec(offender); + var parts = /^.* "(.*)" ?with ?(.*) ?\(in ?context ?(.*)\): ?(.*)\s?queries$/.exec(offender); if (!parts) { debug('DOMqueriesAvoidable offenders transform function error with "%s"', offender); @@ -127,7 +127,7 @@ var policies = { "isAbnormalThreshold": 1500, "offendersTransformFn": function(offenders) { return offenders.map(function(offender) { - var parts = /^"(.*)" bound to "(.*)"$/.exec(offender); + var parts = /^"(.*)" ?bound ?to ?"(.*)"$/.exec(offender); if (!parts) { debug('eventsBound offenders transform function error with "%s"', offender); From cac36727e8ba9367767e93849f9702afc6a07ddc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Mon, 19 Jan 2015 12:16:56 +0100 Subject: [PATCH 12/54] Handle function names in backtraces --- front/src/css/rule.css | 1 + front/src/less/rule.less | 1 + lib/offendersHelpers.js | 19 +++++++++++++------ test/core/offendersHelpersTest.js | 6 ++++-- 4 files changed, 19 insertions(+), 8 deletions(-) diff --git a/front/src/css/rule.css b/front/src/css/rule.css index 4f5f41d..80474b4 100644 --- a/front/src/css/rule.css +++ b/front/src/css/rule.css @@ -122,6 +122,7 @@ } .offenders .offenderButton .backtrace { white-space: nowrap; + padding: 0.5em; } .offenders .offenderButton:hover { border-bottom-left-radius: 0; diff --git a/front/src/less/rule.less b/front/src/less/rule.less index 7a19d52..71bb103 100644 --- a/front/src/less/rule.less +++ b/front/src/less/rule.less @@ -137,6 +137,7 @@ .backtrace { white-space: nowrap; + padding: 0.5em; } &:hover { diff --git a/lib/offendersHelpers.js b/lib/offendersHelpers.js index 6b427ab..39a048f 100644 --- a/lib/offendersHelpers.js +++ b/lib/offendersHelpers.js @@ -107,13 +107,19 @@ var OffendersHelpers = function() { var parts = null; for (var i=0 ; i' + backtraceObj.file + ' line ' + backtraceObj.line + '
'; + var functionName = (backtraceObj.functionName) ? backtraceObj.functionName + '() ' : ''; + html += '
' + functionName + '' + backtraceObj.file + ' line ' + backtraceObj.line + '
'; }); return html + ''; }; diff --git a/test/core/offendersHelpersTest.js b/test/core/offendersHelpersTest.js index bf1a887..ce23d07 100644 --- a/test/core/offendersHelpersTest.js +++ b/test/core/offendersHelpersTest.js @@ -137,7 +137,7 @@ describe('offendersHelpers', function() { describe('backtraceToArray', function() { it('should transform a backtrace into an array', function() { - var result = offendersHelpers.backtraceToArray('http://pouet.com/js/jquery.footer-transverse-min-v1.0.20.js:1 / http://pouet.com/js/main.js:1'); + var result = offendersHelpers.backtraceToArray('http://pouet.com/js/jquery.footer-transverse-min-v1.0.20.js:1 / callback http://pouet.com/js/main.js:1'); result.should.deep.equal([ { @@ -145,6 +145,7 @@ describe('offendersHelpers', function() { line: 1 }, { + functionName: 'callback', file: 'http://pouet.com/js/main.js', line: 1 } @@ -168,12 +169,13 @@ describe('offendersHelpers', function() { line: 1 }, { + functionName: 'callback', file: 'http://pouet.com/js/main.js', line: 1 } ]); - result.should.equal(''); + result.should.equal(''); }); it('should display "no backtrace"', function() { From fd2271f6d6c7a24369a65ce042e7b0893a01384f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Mon, 19 Jan 2015 20:31:06 +0100 Subject: [PATCH 13/54] Offenders layout for various CSS metrics --- lib/metadata/policies.js | 192 ++++++++++++++++++++++++++++-- lib/offendersHelpers.js | 24 +++- test/core/offendersHelpersTest.js | 41 ++++++- 3 files changed, 242 insertions(+), 15 deletions(-) diff --git a/lib/metadata/policies.js b/lib/metadata/policies.js index f1c9d7d..82ce5b7 100644 --- a/lib/metadata/policies.js +++ b/lib/metadata/policies.js @@ -38,7 +38,7 @@ var policies = { "isAbnormalThreshold": 10, "offendersTransformFn": function(offenders) { return offenders.map(function(offender) { - var parts = /^(.*): ?(\d) ?occurrences$/.exec(offender); + var parts = /^(.*): ?(\d+) ?occurrences$/.exec(offender); if (!parts) { debug('DOMidDuplicated offenders transform function error with "%s"', offender); @@ -107,7 +107,7 @@ var policies = { "takeOffendersFrom": "DOMqueriesDuplicated", "offendersTransformFn": function(offenders) { return offenders.map(function(offender) { - var parts = /^.* "(.*)" ?with ?(.*) ?\(in ?context ?(.*)\): ?(.*)\s?queries$/.exec(offender); + var parts = /^[^"]* ?"(.*)" ?with ?(.*) ?\(in ?context ?(.*)\): ?(.*)\s?queries$/.exec(offender); if (!parts) { debug('DOMqueriesAvoidable offenders transform function error with "%s"', offender); @@ -265,7 +265,34 @@ var policies = { "message": "

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

Maybe a CSS validator can help you.

", "isOkThreshold": 0, "isBadThreshold": 1, - "isAbnormalThreshold": 1 + "isAbnormalThreshold": 1, + "offendersTransformFn": function(offenders) { + return offenders.map(function(offender) { + var parts = /^(([^ \(]*|) ?)?(?:\((((?! @ ).)*)( @ (\d+):(\d+))?\))?$/.exec(offender); + + if (!parts) { + debug('cssParsingErrors offenders transform function error with "%s"', offender); + return offender; + } + + var html = (parts[3]) ? parts[3] : ''; + + if (parts[2]) { + html += (parts[2] === '') ? ' <inline CSS>' : ' ' + offendersHelpers.urlToLink(parts[2]); + } + + if (parts[5]) { + html += ' @ ' + parts[6] + ':' + parts[7]; + } + + if (parts[2] && parts[2] !== '') { + var w3cDirectUrl = 'http://jigsaw.w3.org/css-validator/validator?uri=' + encodeURIComponent(parts[2]) + '&profile=css3&usermedium=all&warning=no'; + html += ' (Check on the W3C validator)'; + } + + return html; + }); + } }, "cssRules": { "tool": "phantomas", @@ -281,7 +308,14 @@ var policies = { "message": "

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

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

", "isOkThreshold": 0, "isBadThreshold": 500, - "isAbnormalThreshold": 2000 + "isAbnormalThreshold": 2000, + "offendersTransformFn": function(offenders) { + return offenders.map(function(offender) { + var splittedOffender = offendersHelpers.cssOffenderPattern(offender); + + return '' + splittedOffender.offender + ' @ ' + splittedOffender.line + ':' + splittedOffender.character; + }); + } }, "cssComplexSelectorsByAttribute": { "tool": "phantomas", @@ -289,7 +323,16 @@ var policies = { "message": "

Complex attributes selectors are one of these:

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

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

", "isOkThreshold": 0, "isBadThreshold": 50, - "isAbnormalThreshold": 100 + "isAbnormalThreshold": 100, + "offendersTransformFn": function(offenders) { + return offenders.map(function(offender) { + var splittedOffender = offendersHelpers.cssOffenderPattern(offender); + + var boldedOffender = splittedOffender.offender.replace(/(\[[^ ]+[~\|\^\$\*]="[^"]+"\])/g, '$1'); + + return boldedOffender + ' @ ' + splittedOffender.line + ':' + splittedOffender.character; + }); + } }, "cssImports": { "tool": "phantomas", @@ -297,7 +340,14 @@ var policies = { "message": "

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

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

", "isOkThreshold": 0, "isBadThreshold": 1, - "isAbnormalThreshold": 1 + "isAbnormalThreshold": 1, + "offendersTransformFn": function(offenders) { + return offenders.map(function(offender) { + var splittedOffender = offendersHelpers.cssOffenderPattern(offender); + + return '' + splittedOffender.offender + ' @ ' + splittedOffender.line + ':' + splittedOffender.character; + }); + } }, "cssDuplicatedSelectors": { "tool": "phantomas", @@ -305,7 +355,19 @@ var policies = { "message": "

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

", "isOkThreshold": 0, "isBadThreshold": 40, - "isAbnormalThreshold": 80 + "isAbnormalThreshold": 80, + "offendersTransformFn": function(offenders) { + return offenders.map(function(offender) { + var parts = /^(.*) \((\d+) times\)$/.exec(offender); + + if (!parts) { + debug('cssDuplicatedSelectors offenders transform function error with "%s"', offender); + return offender; + } + + return parts[1] + ' (x' + parts[2] + ')'; + }); + } }, "cssDuplicatedProperties": { "tool": "phantomas", @@ -313,7 +375,21 @@ var policies = { "message": "

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

", "isOkThreshold": 0, "isBadThreshold": 50, - "isAbnormalThreshold": 100 + "isAbnormalThreshold": 100, + "offendersTransformFn": function(offenders) { + return offenders.map(function(offender) { + var splittedOffender = offendersHelpers.cssOffenderPattern(offender); + + var parts = /^([^{]+) {([^ ]+): (.+)}$/.exec(splittedOffender.offender); + + if (!parts) { + debug('cssDuplicatedProperties offenders transform function error with "%s"', offender); + return offender; + } + + return 'Property ' + parts[2] + ' duplicated in ' + parts[1] + ' { } @ ' + splittedOffender.line + ':' + splittedOffender.character; + }); + } }, "cssEmptyRules": { "tool": "phantomas", @@ -321,7 +397,13 @@ var policies = { "message": "

Very easy to fix: remove all empty rules.

", "isOkThreshold": 0, "isBadThreshold": 40, - "isAbnormalThreshold": 100 + "isAbnormalThreshold": 100, + "offendersTransformFn": function(offenders) { + return offenders.map(function(offender) { + var splittedOffender = offendersHelpers.cssOffenderPattern(offender); + return '' + splittedOffender.offender + ' { } @ ' + splittedOffender.line + ':' + splittedOffender.character; + }); + } }, "cssExpressions": { "tool": "phantomas", @@ -329,7 +411,21 @@ var policies = { "message": "

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

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

", "isOkThreshold": 0, "isBadThreshold": 1, - "isAbnormalThreshold": 20 + "isAbnormalThreshold": 20, + "offendersTransformFn": function(offenders) { + return offenders.map(function(offender) { + var splittedOffender = offendersHelpers.cssOffenderPattern(offender); + + var parts = /^(.*) {([^ ]+): expression\((.*)\)}$/.exec(splittedOffender.offender); + + if (!parts) { + debug('cssExpressions offenders transform function error with "%s"', offender); + return offender; + } + + return parts[1] + ' {' + parts[2] + ': expression(' + parts[3] + ')} @ ' + splittedOffender.line + ':' + splittedOffender.character; + }); + } }, "cssImportants": { "tool": "phantomas", @@ -337,7 +433,21 @@ var policies = { "message": "

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

", "isOkThreshold": 0, "isBadThreshold": 50, - "isAbnormalThreshold": 150 + "isAbnormalThreshold": 150, + "offendersTransformFn": function(offenders) { + return offenders.map(function(offender) { + var splittedOffender = offendersHelpers.cssOffenderPattern(offender); + + var parts = /^(.*) {([^ ]+): (.*) ?\!important}$/.exec(splittedOffender.offender); + + if (!parts) { + debug('cssImportants offenders transform function error with "%s"', offender); + return offender; + } + + return parts[1] + ' {' + parts[2] + ': ' + parts[3] + ' !important} @ ' + splittedOffender.line + ':' + splittedOffender.character; + }); + } }, "cssOldIEFixes": { "tool": "phantomas", @@ -345,7 +455,49 @@ var policies = { "message": "

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

IE6:

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

IE7:

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

IE9:

  • -ms-filter
  • progid:DXImageTransform.Microsoft

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

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

", "isOkThreshold": 0, "isBadThreshold": 50, - "isAbnormalThreshold": 300 + "isAbnormalThreshold": 300, + "offendersTransformFn": function(offenders) { + return offenders.map(function(offender) { + var splittedOffender = offendersHelpers.cssOffenderPattern(offender); + + var parts = /^([^{]*)(?: { ([^ ]+): (.*) }) \/\/ (.*)$/.exec(splittedOffender.offender); + + if (!parts) { + debug('cssOldPropertyPrefixes offenders transform function error with "%s"', offender); + return offender; + } + + return '
' + parts[1] + ' {' + parts[2] + ': ' + parts[3] + '} @ ' + splittedOffender.line + ':' + splittedOffender.character + '
' + parts[4] + '
'; + }); + } }, "cssUniversalSelectors": { "tool": "phantomas", diff --git a/lib/offendersHelpers.js b/lib/offendersHelpers.js index 39a048f..66b11f3 100644 --- a/lib/offendersHelpers.js +++ b/lib/offendersHelpers.js @@ -136,9 +136,10 @@ var OffendersHelpers = function() { } var html = '
backtrace
'; + var that = this; backtraceArray.forEach(function(backtraceObj) { var functionName = (backtraceObj.functionName) ? backtraceObj.functionName + '() ' : ''; - html += '
' + functionName + '' + backtraceObj.file + ' line ' + backtraceObj.line + '
'; + html += '
' + functionName + that.urlToLink(backtraceObj.file) + ' line ' + backtraceObj.line + '
'; }); return html + '
'; }; @@ -150,6 +151,27 @@ var OffendersHelpers = function() { }); }; + this.urlToLink = function(url) { + var shortUrl = (url.length > 110) ? url.substr(0, 47) + ' ... ' + url.substr(-48) : url; + return '' + shortUrl + ''; + }; + + this.cssOffenderPattern = function(offender) { + var parts = /^(.*) @ (\d+):(\d+)$/.exec(offender); + + if (!parts) { + return { + offender: offender + }; + } else { + return { + offender: parts[1], + line: parseInt(parts[2], 10), + character: parseInt(parts[3], 10) + }; + } + }; + }; module.exports = new OffendersHelpers(); \ No newline at end of file diff --git a/test/core/offendersHelpersTest.js b/test/core/offendersHelpersTest.js index ce23d07..072d2d1 100644 --- a/test/core/offendersHelpersTest.js +++ b/test/core/offendersHelpersTest.js @@ -175,7 +175,7 @@ describe('offendersHelpers', function() { } ]); - result.should.equal(''); + result.should.equal(''); }); it('should display "no backtrace"', function() { @@ -228,4 +228,43 @@ describe('offendersHelpers', function() { }); + describe('urlToLink', function() { + + it('should transform an url into an html link', function() { + var result = offendersHelpers.urlToLink('http://www.google.com/js/main.js'); + + result.should.equal('http://www.google.com/js/main.js'); + }); + + it('should ellypsis the url if too long', function() { + var result = offendersHelpers.urlToLink('http://www.google.com/js/longlonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglong/main.js'); + + result.should.equal('http://www.google.com/js/longlonglonglonglonglo ... longlonglonglonglonglonglonglonglonglong/main.js'); + }); + + }); + + + describe('cssOffenderPattern', function() { + + it('should transform a css offender into an object', function() { + var result = offendersHelpers.cssOffenderPattern('.pagination .plus ul li @ 30:31862'); + + result.should.deep.equal({ + offender: '.pagination .plus ul li', + line: 30, + character: 31862 + }); + }); + + it('should handle the case where line and char are not here', function() { + var result = offendersHelpers.cssOffenderPattern('.pagination .plus ul li'); + + result.should.deep.equal({ + offender: '.pagination .plus ul li' + }); + }); + + }); + }); From 4a8a5829d74834db3ce3ae9339fdde5460a90000 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Thu, 22 Jan 2015 14:54:51 +0100 Subject: [PATCH 14/54] End of the offender transform functions --- lib/metadata/policies.js | 231 ++++++++++++++++++++++++++---- lib/offendersHelpers.js | 15 ++ test/core/offendersHelpersTest.js | 13 ++ 3 files changed, 235 insertions(+), 24 deletions(-) diff --git a/lib/metadata/policies.js b/lib/metadata/policies.js index 82ce5b7..b6f2c35 100644 --- a/lib/metadata/policies.js +++ b/lib/metadata/policies.js @@ -510,7 +510,7 @@ var policies = { return offenders.map(function(offender) { var splittedOffender = offendersHelpers.cssOffenderPattern(offender); - var parts = /^([^{]*)(?: { ([^ ]+): (.*) }) \/\/ (.*)$/.exec(splittedOffender.offender); + var parts = /^([^{]*)(?: ?{ ?([^ ]+): (.*) ?}) \/\/ (.*)$/.exec(splittedOffender.offender); if (!parts) { debug('cssOldPropertyPrefixes offenders transform function error with "%s"', offender); @@ -535,7 +535,17 @@ var policies = { "message": "

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

", "isOkThreshold": 0, "isBadThreshold": 50, - "isAbnormalThreshold": 200 + "isAbnormalThreshold": 200, + "offendersTransformFn": function(offenders) { + return offenders.map(function(offender) { + var splittedOffender = offendersHelpers.cssOffenderPattern(offender); + + var rule = splittedOffender.offender; + rule = rule.replace(/body/, 'body'); + + return rule + ' @ ' + splittedOffender.line + ':' + splittedOffender.character; + }); + } }, "cssRedundantChildNodesSelectors": { "tool": "phantomas", @@ -543,7 +553,27 @@ var policies = { "message": "

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

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

", "isOkThreshold": 0, "isBadThreshold": 50, - "isAbnormalThreshold": 200 + "isAbnormalThreshold": 200, + "offendersTransformFn": function(offenders) { + return offenders.map(function(offender) { + var splittedOffender = offendersHelpers.cssOffenderPattern(offender); + + var rule = splittedOffender.offender; + var redundanters = [ + ['ul', 'li'], + ['ol', 'li'], + ['select', 'option'], + ['table', 'tr'], + ['table', 'th'], + ]; + + redundanters.forEach(function(couple) { + rule = rule.replace(new RegExp('(^| |>)' + couple[0] + '([^ >]*)?([ >]| > )' + couple[1] + '([^\\w-]|$)', 'g'), '$1' + couple[0] + '$2$3' + couple[1] + '$4'); + }); + + return rule + ' @ ' + splittedOffender.line + ':' + splittedOffender.character; + }); + } }, "requests": { "tool": "phantomas", @@ -552,7 +582,17 @@ var policies = { "isOkThreshold": 15, "isBadThreshold": 100, "isAbnormalThreshold": 200, - "takeOffendersFrom": ["htmlCount", "jsCount", "cssCount", "imageCount", "webfontCount", "videoCount", "jsonCount", "jsonCount"] + "takeOffendersFrom": ["htmlCount", "jsCount", "cssCount", "imageCount", "webfontCount", "videoCount", "jsonCount", "jsonCount"], + "offendersTransformFn": function(offenders) { + return offenders + .map(function(offender) { + return offendersHelpers.fileWithSizePattern(offender); + }).sort(function(a, b) { + return b.size - a.size; + }).map(function(fileObj) { + return offendersHelpers.urlToLink(fileObj.file) + ((fileObj.size !== undefined) ? ' (' + fileObj.size + ' kB)' : ''); + }); + } }, "htmlCount": { "tool": "phantomas", @@ -560,7 +600,13 @@ var policies = { "message": "

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

", "isOkThreshold": 10, "isBadThreshold": 20, - "isAbnormalThreshold": 30 + "isAbnormalThreshold": 30, + "offendersTransformFn": function(offenders) { + return offenders.map(function(offender) { + var fileObj = offendersHelpers.fileWithSizePattern(offender); + return offendersHelpers.urlToLink(fileObj.file) + ((fileObj.size !== undefined) ? ' (' + fileObj.size + ' kB)' : ''); + }); + } }, "jsCount": { "tool": "phantomas", @@ -568,7 +614,13 @@ var policies = { "message": "

Reduce the number of scripts by concatenating them.

", "isOkThreshold": 5, "isBadThreshold": 15, - "isAbnormalThreshold": 30 + "isAbnormalThreshold": 30, + "offendersTransformFn": function(offenders) { + return offenders.map(function(offender) { + var fileObj = offendersHelpers.fileWithSizePattern(offender); + return offendersHelpers.urlToLink(fileObj.file) + ((fileObj.size !== undefined) ? ' (' + fileObj.size + ' kB)' : ''); + }); + } }, "cssCount": { "tool": "phantomas", @@ -576,7 +628,13 @@ var policies = { "message": "

Reduce the number of stylesheets by concatenating them.

", "isOkThreshold": 3, "isBadThreshold": 10, - "isAbnormalThreshold": 22 + "isAbnormalThreshold": 22, + "offendersTransformFn": function(offenders) { + return offenders.map(function(offender) { + var fileObj = offendersHelpers.fileWithSizePattern(offender); + return offendersHelpers.urlToLink(fileObj.file) + ((fileObj.size !== undefined) ? ' (' + fileObj.size + ' kB)' : ''); + }); + } }, "imageCount": { "tool": "phantomas", @@ -584,7 +642,13 @@ var policies = { "message": "

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

", "isOkThreshold": 15, "isBadThreshold": 40, - "isAbnormalThreshold": 70 + "isAbnormalThreshold": 70, + "offendersTransformFn": function(offenders) { + return offenders.map(function(offender) { + var fileObj = offendersHelpers.fileWithSizePattern(offender); + return offendersHelpers.urlToLink(fileObj.file) + ((fileObj.size !== undefined) ? ' (' + fileObj.size + ' kB)' : ''); + }); + } }, "webfontCount": { "tool": "phantomas", @@ -592,15 +656,27 @@ var policies = { "message": "

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

", "isOkThreshold": 0, "isBadThreshold": 3, - "isAbnormalThreshold": 5 + "isAbnormalThreshold": 5, + "offendersTransformFn": function(offenders) { + return offenders.map(function(offender) { + var fileObj = offendersHelpers.fileWithSizePattern(offender); + return offendersHelpers.urlToLink(fileObj.file) + ((fileObj.size !== undefined) ? ' (' + fileObj.size + ' kB)' : ''); + }); + } }, "videoCount": { "tool": "phantomas", - "label": "Videos count", + "label": "Video count", "message": "

The number of videos loaded.

", "isOkThreshold": 1, "isBadThreshold": 5, - "isAbnormalThreshold": 15 + "isAbnormalThreshold": 15, + "offendersTransformFn": function(offenders) { + return offenders.map(function(offender) { + var fileObj = offendersHelpers.fileWithSizePattern(offender); + return offendersHelpers.urlToLink(fileObj.file) + ((fileObj.size !== undefined) ? ' (' + fileObj.size + ' kB)' : ''); + }); + } }, "jsonCount": { "tool": "phantomas", @@ -608,7 +684,13 @@ var policies = { "message": "

The number of AJAX requests to JSON files or webservices.

", "isOkThreshold": 2, "isBadThreshold": 10, - "isAbnormalThreshold": 25 + "isAbnormalThreshold": 25, + "offendersTransformFn": function(offenders) { + return offenders.map(function(offender) { + var fileObj = offendersHelpers.fileWithSizePattern(offender); + return offendersHelpers.urlToLink(fileObj.file) + ((fileObj.size !== undefined) ? ' (' + fileObj.size + ' kB)' : ''); + }); + } }, "otherCount": { "tool": "phantomas", @@ -616,7 +698,13 @@ var policies = { "message": "

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

", "isOkThreshold": 5, "isBadThreshold": 20, - "isAbnormalThreshold": 40 + "isAbnormalThreshold": 40, + "offendersTransformFn": function(offenders) { + return offenders.map(function(offender) { + var fileObj = offendersHelpers.fileWithSizePattern(offender); + return offendersHelpers.urlToLink(fileObj.file) + ((fileObj.size !== undefined) ? ' (' + fileObj.size + ' kB)' : ''); + }); + } }, "smallJsFiles": { "tool": "phantomas", @@ -624,7 +712,13 @@ var policies = { "message": "

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

", "isOkThreshold": 2, "isBadThreshold": 10, - "isAbnormalThreshold": 16 + "isAbnormalThreshold": 16, + "offendersTransformFn": function(offenders) { + return offenders.map(function(offender) { + var fileObj = offendersHelpers.fileWithSizePattern(offender); + return offendersHelpers.urlToLink(fileObj.file) + ((fileObj.size !== undefined) ? ' (' + fileObj.size + ' kB)' : ''); + }); + } }, "smallCssFiles": { "tool": "phantomas", @@ -632,7 +726,13 @@ var policies = { "message": "

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

", "isOkThreshold": 0, "isBadThreshold": 8, - "isAbnormalThreshold": 12 + "isAbnormalThreshold": 12, + "offendersTransformFn": function(offenders) { + return offenders.map(function(offender) { + var fileObj = offendersHelpers.fileWithSizePattern(offender); + return offendersHelpers.urlToLink(fileObj.file) + ((fileObj.size !== undefined) ? ' (' + fileObj.size + ' kB)' : ''); + }); + } }, "smallImages": { "tool": "phantomas", @@ -640,7 +740,13 @@ var policies = { "message": "

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

", "isOkThreshold": 2, "isBadThreshold": 17, - "isAbnormalThreshold": 30 + "isAbnormalThreshold": 30, + "offendersTransformFn": function(offenders) { + return offenders.map(function(offender) { + var fileObj = offendersHelpers.fileWithSizePattern(offender); + return offendersHelpers.urlToLink(fileObj.file) + ((fileObj.size !== undefined) ? ' (' + fileObj.size + ' kB)' : ''); + }); + } }, "notFound": { "tool": "phantomas", @@ -648,7 +754,12 @@ var policies = { "message": "

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

", "isOkThreshold": 0, "isBadThreshold": 1, - "isAbnormalThreshold": 1 + "isAbnormalThreshold": 1, + "offendersTransformFn": function(offenders) { + return offenders.map(function(offender) { + return offendersHelpers.urlToLink(offender); + }); + } }, "closedConnections": { "tool": "phantomas", @@ -656,15 +767,25 @@ var policies = { "message": "

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

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

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

", "isOkThreshold": 0, "isBadThreshold": 8, - "isAbnormalThreshold": 20 + "isAbnormalThreshold": 20, + "offendersTransformFn": function(offenders) { + return offenders.map(function(offender) { + return offendersHelpers.urlToLink(offender); + }); + } }, "multipleRequests": { "tool": "phantomas", "label": "Duplicated requests", "message": "

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

", "isOkThreshold": 0, - "isBadThreshold": 5, - "isAbnormalThreshold": 10 + "isBadThreshold": 3, + "isAbnormalThreshold": 10, + "offendersTransformFn": function(offenders) { + return offenders.map(function(offender) { + return offendersHelpers.urlToLink(offender); + }); + } }, "cachingDisabled": { "tool": "phantomas", @@ -672,7 +793,12 @@ var policies = { "message": "

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

Fix immediatly if on static assets.

", "isOkThreshold": 0, "isBadThreshold": 12, - "isAbnormalThreshold": 25 + "isAbnormalThreshold": 25, + "offendersTransformFn": function(offenders) { + return offenders.map(function(offender) { + return offendersHelpers.urlToLink(offender); + }); + } }, "cachingNotSpecified": { "tool": "phantomas", @@ -680,7 +806,12 @@ var policies = { "message": "

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

", "isOkThreshold": 5, "isBadThreshold": 20, - "isAbnormalThreshold": 40 + "isAbnormalThreshold": 40, + "offendersTransformFn": function(offenders) { + return offenders.map(function(offender) { + return offendersHelpers.urlToLink(offender); + }); + } }, "cachingTooShort": { "tool": "phantomas", @@ -688,7 +819,47 @@ var policies = { "message": "

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

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

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

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

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

", "isOkThreshold": 10, "isBadThreshold": 25, - "isAbnormalThreshold": 50 + "isAbnormalThreshold": 50, + "offendersTransformFn": function(offenders) { + return offenders.map(function(offender) { + var parts = /^([^ ]*): (\d+) request\(s\)$/.exec(offender); + + if (!parts) { + debug('domains offenders transform function error with "%s"', offender); + return offender; + } + + return '' + parts[1] + ' (' + parts[2] + ' ' + ((parseInt(parts[2], 10) > 1) ? 'requests' : 'request') + ')'; + }); + } } }; diff --git a/lib/offendersHelpers.js b/lib/offendersHelpers.js index 66b11f3..36151c6 100644 --- a/lib/offendersHelpers.js +++ b/lib/offendersHelpers.js @@ -172,6 +172,21 @@ var OffendersHelpers = function() { } }; + this.fileWithSizePattern = function(fileWithSize) { + var parts = /^([^ ]*) \((\d+\.\d{2}) kB\)$/.exec(fileWithSize); + + if (!parts) { + return { + file: fileWithSize + }; + } else { + return { + file: parts[1], + size: parseFloat(parts[2]) + }; + } + }; + }; module.exports = new OffendersHelpers(); \ No newline at end of file diff --git a/test/core/offendersHelpersTest.js b/test/core/offendersHelpersTest.js index 072d2d1..d6632f0 100644 --- a/test/core/offendersHelpersTest.js +++ b/test/core/offendersHelpersTest.js @@ -267,4 +267,17 @@ describe('offendersHelpers', function() { }); + describe('fileWithSizePattern', function() { + + it('should return an object', function() { + var result = offendersHelpers.fileWithSizePattern('http://img3.pouet.com/2008/portail/js/jq-timer.js (1.72 kB)'); + + result.should.deep.equal({ + file: 'http://img3.pouet.com/2008/portail/js/jq-timer.js', + size: 1.72 + }); + }); + + }); + }); From ee666a3a6f707f5bc276ca1265beab1aaca506ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Thu, 22 Jan 2015 15:15:38 +0100 Subject: [PATCH 15/54] Temporary fix for negative ttl in cachingTooShort --- .../custom_modules/modules/cachYLT/cachYLT.js | 79 +++++++++++++++++++ lib/tools/phantomas/phantomasWrapper.js | 1 + 2 files changed, 80 insertions(+) create mode 100644 lib/tools/phantomas/custom_modules/modules/cachYLT/cachYLT.js diff --git a/lib/tools/phantomas/custom_modules/modules/cachYLT/cachYLT.js b/lib/tools/phantomas/custom_modules/modules/cachYLT/cachYLT.js new file mode 100644 index 0000000..213edfe --- /dev/null +++ b/lib/tools/phantomas/custom_modules/modules/cachYLT/cachYLT.js @@ -0,0 +1,79 @@ +/** + * Analyzes HTTP caching headers + * + * @see https://developers.google.com/speed/docs/best-practices/caching + */ + +exports.version = '0.2.a'; + +exports.module = function(phantomas) { + 'use strict'; + + var cacheControlRegExp = /max-age=(\d+)/; + + function getCachingTime(url, headers) { + // false means "no caching" + var ttl = false, + headerName, + now = new Date(), + headerDate; + + for (headerName in headers) { + var value = headers[headerName]; + + switch (headerName.toLowerCase()) { + // parse max-age=... + // + // max-age=2592000 + // public, max-age=300, must-revalidate + case 'cache-control': + var matches = value.match(cacheControlRegExp); + + if (matches) { + ttl = parseInt(matches[1], 10); + } + break; + + // catch Expires and Pragma headers + case 'expires': + case 'pragma': + // and Varnish specific headers + case 'x-pass-expires': + case 'x-pass-cache-control': + phantomas.incrMetric('oldCachingHeaders'); // @desc number of responses with old, HTTP 1.0 caching headers (Expires and Pragma) + phantomas.addOffender('oldCachingHeaders', url + ' - ' + headerName + ': ' + value); + headerDate = Date.parse(value); + if (headerDate) ttl = Math.round((headerDate - now) / 1000); + break; + } + } + + //console.log(JSON.stringify(headers)); console.log("TTL: " + ttl + ' s'); + + return ttl; + } + + phantomas.setMetric('cachingNotSpecified'); // @desc number of responses with no caching header sent (no Cache-Control header) + phantomas.setMetric('cachingTooShort'); // @desc number of responses with too short (less than a week) caching time + phantomas.setMetric('cachingDisabled'); // @desc number of responses with caching disabled (max-age=0) + + phantomas.setMetric('oldCachingHeaders'); + + phantomas.on('recv', function(entry, res) { + var ttl = getCachingTime(entry.url, entry.headers); + + // static assets + if (entry.isImage || entry.isJS || entry.isCSS) { + if (ttl === false) { + phantomas.incrMetric('cachingNotSpecified'); + phantomas.addOffender('cachingNotSpecified', entry.url); + } else if (ttl <= 0) { + phantomas.incrMetric('cachingDisabled'); + phantomas.addOffender('cachingDisabled', entry.url); + } else if (ttl < 7 * 86400) { + phantomas.incrMetric('cachingTooShort'); + phantomas.addOffender('cachingTooShort', entry.url + ' cached for ' + ttl + ' s'); + } + } + }); +}; diff --git a/lib/tools/phantomas/phantomasWrapper.js b/lib/tools/phantomas/phantomasWrapper.js index 6c7937f..4706201 100644 --- a/lib/tools/phantomas/phantomasWrapper.js +++ b/lib/tools/phantomas/phantomasWrapper.js @@ -34,6 +34,7 @@ var PhantomasWrapper = function() { 'analyze-css': true, 'skip-modules': [ 'blockDomains', // not needed + 'caching', // overriden 'domMutations', // not compatible with webkit 'domQueries', // overriden 'eventListeners', // overridden From d829525ab8aa0a14ecb89728f8f6efd599048bec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Mon, 26 Jan 2015 11:32:37 +0100 Subject: [PATCH 16/54] Update phantomas to v1.9 --- package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/package.json b/package.json index fa04850..e2d31ec 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "cors": "^2.5.2", "debug": "~2.1.0", "express": "~4.10.6", - "phantomas": "1.8.0", + "phantomas": "1.9.0", "ps-node": "0.0.3", "q": "~1.1.2", "rimraf": "~2.2.8" @@ -44,7 +44,6 @@ "grunt-usemin": "^3.0.0", "matchdep": "^0.3.0", "mocha": "^2.1.0", - "phantomjs": "^1.9.13", "request": "^2.51.0", "sinon": "^1.12.1", "sinon-chai": "^2.6.0" From 901116fde662e64b46deed737daefe9529f764e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Mon, 26 Jan 2015 11:34:38 +0100 Subject: [PATCH 17/54] Separate offender analyze, parsing in the core, display in the front --- front/src/css/rule.css | 7 +- front/src/js/app.js | 1 + front/src/js/controllers/ruleCtrl.js | 4 - .../src/js/directives/offendersDirectives.js | 120 +++ front/src/less/rule.less | 6 +- front/src/main.html | 1 + front/src/views/domElementButton.html | 7 + front/src/views/rule.html | 141 ++- lib/metadata/policies.js | 848 +++++++++++------- lib/offendersHelpers.js | 102 +-- lib/rulesChecker.js | 88 +- lib/tools/phantomas/phantomasWrapper.js | 3 +- test/core/customPoliciesTest.js | 396 +++++++- test/core/indexTest.js | 13 +- test/core/offendersHelpersTest.js | 198 ++-- test/fixtures/rulesCheckerOutput.json | 36 +- test/fixtures/rulesCheckerPolicies.js | 28 +- 17 files changed, 1436 insertions(+), 563 deletions(-) create mode 100644 front/src/js/directives/offendersDirectives.js create mode 100644 front/src/views/domElementButton.html diff --git a/front/src/css/rule.css b/front/src/css/rule.css index 80474b4..b66c470 100644 --- a/front/src/css/rule.css +++ b/front/src/css/rule.css @@ -120,17 +120,18 @@ .offenders .offenderButton .domTree > div div { margin-left: 1em; } -.offenders .offenderButton .backtrace { +.offenders .offenderButton .backtrace, +.offenders .offenderButton .cssFileAndLine { white-space: nowrap; padding: 0.5em; } -.offenders .offenderButton:hover { +.offenders .offenderButton.opens.mouseOver { border-bottom-left-radius: 0; border-bottom-right-radius: 0; background: #ffe0cc; z-index: 2; } -.offenders .offenderButton:hover > div { +.offenders .offenderButton.opens.mouseOver > div { display: block; } .offendersHtml { diff --git a/front/src/js/app.js b/front/src/js/app.js index 0adc65f..7ddabb4 100644 --- a/front/src/js/app.js +++ b/front/src/js/app.js @@ -11,6 +11,7 @@ var yltApp = angular.module('YellowLabTools', [ 'resultsFactory', 'menuService', 'gradeDirective', + 'offendersDirectives' ]); yltApp.run(['$rootScope', '$location', function($rootScope, $location) { diff --git a/front/src/js/controllers/ruleCtrl.js b/front/src/js/controllers/ruleCtrl.js index e63adc1..7ff0622 100644 --- a/front/src/js/controllers/ruleCtrl.js +++ b/front/src/js/controllers/ruleCtrl.js @@ -22,10 +22,6 @@ ruleCtrl.controller('RuleCtrl', ['$scope', '$rootScope', '$routeParams', '$locat function init() { $scope.rule = $scope.result.rules[$scope.policyName]; - - if (angular.isString($scope.rule.offenders)) { - $scope.htmlOffenders = $scope.rule.offenders; - } } $scope.backToDashboard = function() { diff --git a/front/src/js/directives/offendersDirectives.js b/front/src/js/directives/offendersDirectives.js new file mode 100644 index 0000000..cb691d0 --- /dev/null +++ b/front/src/js/directives/offendersDirectives.js @@ -0,0 +1,120 @@ +var offendersDirectives = angular.module('offendersDirectives', []); + +offendersDirectives.directive('domTree', function() { + return { + restrict: 'E', + scope: { + tree: '=' + }, + template: '
', + replace: true, + link: function(scope, element, attrs) { + + function recursiveHtmlBuilder(tree) { + var html = ''; + var keys = Object.keys(tree); + + keys.forEach(function(key) { + if (isNaN(tree[key])) { + html += '
' + key + '' + recursiveHtmlBuilder(tree[key]) + '
'; + } else if (tree[key] > 1) { + html += '
' + key + ' (x' + tree[key] + ')
'; + } else { + html += '
' + key + '
'; + } + }); + + return html; + } + + element.append(recursiveHtmlBuilder(scope.tree)); + } + }; +}); + +offendersDirectives.directive('domElementButton', function() { + return { + restrict: 'E', + scope: { + obj: '=' + }, + templateUrl: 'views/domElementButton.html', + replace: true + }; +}); + +offendersDirectives.filter('shortenUrl', function() { + return function(url, maxLength) { + if (!maxLength) { + maxLength = 110; + } + + // Why dividing by 2.1? Because it adds a 5% margin. + var leftLength = Math.floor((maxLength - 5) / 2.1); + var rightLength = Math.ceil((maxLength - 5) / 2.1); + + return (url.length > maxLength) ? url.substr(0, leftLength) + ' ... ' + url.substr(-rightLength) : url; + }; +}); + +offendersDirectives.directive('urlLink', function() { + return { + restrict: 'E', + scope: { + url: '=', + maxLength: '=' + }, + template: '{{url | shortenUrl:maxLength}}', + replace: true + }; +}); + +offendersDirectives.filter('encodeURIComponent', function() { + return window.encodeURIComponent; +}); + +offendersDirectives.directive('fileAndLine', function() { + return { + restrict: 'E', + scope: { + file: '=', + line: '=', + column: '=' + }, + template: '<inline CSS> @ {{line}}:{{column}}', + replace: true + }; +}); + +offendersDirectives.directive('fileAndLineButton', function() { + return { + restrict: 'E', + scope: { + file: '=', + line: '=', + column: '=' + }, + template: '
css file
', + replace: true + }; +}); + +offendersDirectives.directive('offenderButton', function() { + return { + restrict: 'C', + link: function(scope, element, attrs) { + + console.log('initializing touchstart'); + + element.bind('touchstart mouseenter', function(e) { + element.addClass('mouseOver'); + e.preventDefault(); + }); + + element.bind('touchend mouseleave click', function(e) { + element.removeClass('mouseOver'); + e.preventDefault(); + }); + } + }; +}); \ No newline at end of file diff --git a/front/src/less/rule.less b/front/src/less/rule.less index 71bb103..ccf862a 100644 --- a/front/src/less/rule.less +++ b/front/src/less/rule.less @@ -135,18 +135,18 @@ } } - .backtrace { + .backtrace, .cssFileAndLine { white-space: nowrap; padding: 0.5em; } - &:hover { + &.opens.mouseOver { border-bottom-left-radius: 0; border-bottom-right-radius: 0; background: #ffe0cc; z-index: 2; - & > div { + > div { display: block; } } diff --git a/front/src/main.html b/front/src/main.html index 88f1dd6..fe5ec86 100644 --- a/front/src/main.html +++ b/front/src/main.html @@ -32,6 +32,7 @@ + diff --git a/front/src/views/domElementButton.html b/front/src/views/domElementButton.html new file mode 100644 index 0000000..78dda24 --- /dev/null +++ b/front/src/views/domElementButton.html @@ -0,0 +1,7 @@ +
+ {{obj.type}} + DOM element {{obj.element}} + Fragment element {{obj.element}} + Created element {{obj.element}} + +
\ No newline at end of file diff --git a/front/src/views/rule.html b/front/src/views/rule.html index 639ed91..28e3c31 100644 --- a/front/src/views/rule.html +++ b/front/src/views/rule.html @@ -17,23 +17,134 @@

Warning

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

-
-

- - -

-
-
-
+
+

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

- - -

-
+ + +
+ +
+ +
+ +
+

404

diff --git a/lib/metadata/policies.js b/lib/metadata/policies.js index b6f2c35..00e667b 100644 --- a/lib/metadata/policies.js +++ b/lib/metadata/policies.js @@ -8,7 +8,8 @@ var policies = { "message": "

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

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

", "isOkThreshold": 1000, "isBadThreshold": 2500, - "isAbnormalThreshold": 4000 + "isAbnormalThreshold": 4000, + "hasOffenders": false }, "DOMelementMaxDepth": { "tool": "phantomas", @@ -17,8 +18,14 @@ var policies = { "isOkThreshold": 10, "isBadThreshold": 20, "isAbnormalThreshold": 28, + "hasOffenders": true, "offendersTransformFn": function(offenders) { - return offendersHelpers.listOfDomPathsToHTML(offenders); + var domArrays = offenders.map(offendersHelpers.domPathToArray); + + return { + count: offenders.length, + tree: offendersHelpers.listOfDomArraysToTree(domArrays) + }; } }, "iframesCount": { @@ -27,7 +34,8 @@ var policies = { "message": "

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

", "isOkThreshold": 2, "isBadThreshold": 15, - "isAbnormalThreshold": 30 + "isAbnormalThreshold": 30, + "hasOffenders": false }, "DOMidDuplicated": { "tool": "phantomas", @@ -36,17 +44,26 @@ var policies = { "isOkThreshold": 0, "isBadThreshold": 5, "isAbnormalThreshold": 10, + "hasOffenders": true, "offendersTransformFn": function(offenders) { - return offenders.map(function(offender) { - var parts = /^(.*): ?(\d+) ?occurrences$/.exec(offender); + return { + count: offenders.length, + list: offenders.map(function(offender) { + var parts = /^(.*): ?(\d+) ?occurrences$/.exec(offender); - if (!parts) { - debug('DOMidDuplicated offenders transform function error with "%s"', offender); - return offender; - } + if (!parts) { + debug('DOMidDuplicated offenders transform function error with "%s"', offender); + return { + parseError: offender + }; + } - return '#' + parts[1] + ': ' + parts[2] + ' occurrences'; - }); + return { + id: parts[1], + occurrences: parseInt(parts[2], 10) + }; + }) + }; } }, "DOMinserts": { @@ -56,17 +73,26 @@ var policies = { "isOkThreshold": 10, "isBadThreshold": 400, "isAbnormalThreshold": 1000, + "hasOffenders": true, "offendersTransformFn": function(offenders) { - return offenders.map(function(offender) { - var parts = /^"(.*)" ?appended ?to ?"(.*)"$/.exec(offender); + return { + count: offenders.length, + list: offenders.map(function(offender) { + var parts = /^"(.*)" ?appended ?to ?"(.*)"$/.exec(offender); - if (!parts) { - debug('DOMinserts offenders transform function error with "%s"', offender); - return offender; - } + if (!parts) { + debug('DOMinserts offenders transform function error with "%s"', offender); + return { + parseError: offender + }; + } - return offendersHelpers.domPathToButton(parts[1]) + ' appended to ' + offendersHelpers.domPathToButton(parts[2]); - }); + return { + insertedElement: offendersHelpers.domPathToDomElementObj(parts[1]), + receiverElement: offendersHelpers.domPathToDomElementObj(parts[2]) + }; + }) + }; } }, "DOMqueries": { @@ -75,7 +101,8 @@ var policies = { "message": "

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

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

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

", "isOkThreshold": 50, "isBadThreshold": 1000, - "isAbnormalThreshold": 2000 + "isAbnormalThreshold": 2000, + "hasOffenders": false }, "DOMqueriesWithoutResults": { "tool": "phantomas", @@ -84,17 +111,27 @@ var policies = { "isOkThreshold": 0, "isBadThreshold": 100, "isAbnormalThreshold": 200, + "hasOffenders": true, "offendersTransformFn": function(offenders) { - return offenders.map(function(offender) { - var parts = /^(.*) ?\(in ?(.*)\) ?using ?(.*)$/.exec(offender); + return { + count: offenders.length, + list: offenders.map(function(offender) { + var parts = /^(.*) ?\(in ?(.*)\) ?using ?(.*)$/.exec(offender); - if (!parts) { - debug('DOMqueriesWithoutResults offenders transform function error with "%s"', offender); - return offender; - } + if (!parts) { + debug('DOMqueriesWithoutResults offenders transform function error with "%s"', offender); + return { + parseError: offender + }; + } - return '' + parts[1] + ' (in ' + offendersHelpers.domPathToButton(parts[2]) + ') using ' + parts[3]; - }); + return { + query: parts[1], + context: offendersHelpers.domPathToDomElementObj(parts[2]), + fn: parts[3] + }; + }) + }; } }, "DOMqueriesAvoidable": { @@ -104,18 +141,29 @@ var policies = { "isOkThreshold": 0, "isBadThreshold": 200, "isAbnormalThreshold": 500, + "hasOffenders": true, "takeOffendersFrom": "DOMqueriesDuplicated", "offendersTransformFn": function(offenders) { - return offenders.map(function(offender) { - var parts = /^[^"]* ?"(.*)" ?with ?(.*) ?\(in ?context ?(.*)\): ?(.*)\s?queries$/.exec(offender); + return { + count: offenders.length, + list: offenders.map(function(offender) { + var parts = /^[^"]* ?"(.*)" ?with ?(.*) ?\(in ?context ?(.*)\): ?(.*)\s?queries$/.exec(offender); - if (!parts) { - debug('DOMqueriesAvoidable offenders transform function error with "%s"', offender); - return offender; - } + if (!parts) { + debug('DOMqueriesAvoidable offenders transform function error with "%s"', offender); + return { + parseError: offender + }; + } - return '' + parts[1] + ' (in ' + offendersHelpers.domPathToButton(parts[3]) + ') using ' + parts[2] + ': ' + parts[4] + ' queries'; - }); + return { + query: parts[1], + context: offendersHelpers.domPathToDomElementObj(parts[3]), + fn: parts[2], + count: parseInt(parts[4], 10) + }; + }) + }; } }, "eventsBound": { @@ -125,17 +173,26 @@ var policies = { "isOkThreshold": 100, "isBadThreshold": 800, "isAbnormalThreshold": 1500, + "hasOffenders": true, "offendersTransformFn": function(offenders) { - return offenders.map(function(offender) { - var parts = /^"(.*)" ?bound ?to ?"(.*)"$/.exec(offender); + return { + count: offenders.length, + list: offenders.map(function(offender) { + var parts = /^"(.*)" ?bound ?to ?"(.*)"$/.exec(offender); - if (!parts) { - debug('eventsBound offenders transform function error with "%s"', offender); - return offender; - } + if (!parts) { + debug('eventsBound offenders transform function error with "%s"', offender); + return { + parseError: offender + }; + } - return '' + parts[1] + ' bound to ' + offendersHelpers.domPathToButton(parts[2]); - }); + return { + eventName: parts[1], + element: offendersHelpers.domPathToDomElementObj(parts[2]) + }; + }) + }; } }, "jsErrors": { @@ -145,22 +202,28 @@ var policies = { "isOkThreshold": 0, "isBadThreshold": 1, "isAbnormalThreshold": 4, + "hasOffenders": true, "offendersTransformFn": function(offenders) { - return offenders.map(function(offender) { - var parts = /^(.*) - (.*)$/.exec(offender); + return { + count: offenders.length, + list: offenders.map(function(offender) { + var parts = /^(.*) - (.*)$/.exec(offender); - if (!parts) { - debug('jsErrors offenders transform function error with "%s"', offender); - return offender; - } + if (!parts) { + debug('jsErrors offenders transform function error with "%s"', offender); + return { + parseError: offender + }; + } - var backtraceArray = offendersHelpers.backtraceToArray(parts[2]); - if (backtraceArray === null) { - return offender; - } else { - return '' + parts[1] + ' ' + offendersHelpers.backtraceArrayToHtml(backtraceArray); - } - }); + var backtraceArray = offendersHelpers.backtraceToArray(parts[2]); + + return { + error: parts[1], + backtrace: backtraceArray || [] + }; + }) + }; } }, "evalCalls": { @@ -169,7 +232,8 @@ var policies = { "message": "

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

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

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

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

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

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

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

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

", "isOkThreshold": 0, "isBadThreshold": 10, - "isAbnormalThreshold": 25 + "isAbnormalThreshold": 25, + "hasOffenders": false }, "globalVariables": { "tool": "phantomas", @@ -194,17 +260,22 @@ var policies = { "isOkThreshold": 10, "isBadThreshold": 50, "isAbnormalThreshold": 200, + "hasOffenders": true, "offendersTransformFn": function(offenders) { - return offendersHelpers.sortVarsLikeChromeDevTools(offenders); + return { + count: offenders.length, + list: offendersHelpers.sortVarsLikeChromeDevTools(offenders) + }; } }, "jQueryVersion": { "label": "jQuery version", "message": "

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

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

", + "hasOffenders": false, "scoreFn": function(data) { var differentVersions = data.toolsResults.phantomas.metrics.jQueryDifferentVersions; - if (differentVersions === 0 || differentVersions > 1) { + if (differentVersions === 0 || differentVersions > 1 || !data.toolsResults.phantomas.metrics.jQueryVersion) { // Not applicable return null; } else { @@ -257,7 +328,8 @@ var policies = { "message": "

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

", "isOkThreshold": 1, "isBadThreshold": 2, - "isAbnormalThreshold": 2 + "isAbnormalThreshold": 2, + "hasOffenders": true }, "cssParsingErrors": { "tool": "phantomas", @@ -266,32 +338,28 @@ var policies = { "isOkThreshold": 0, "isBadThreshold": 1, "isAbnormalThreshold": 1, + "hasOffenders": true, "offendersTransformFn": function(offenders) { - return offenders.map(function(offender) { - var parts = /^(([^ \(]*|) ?)?(?:\((((?! @ ).)*)( @ (\d+):(\d+))?\))?$/.exec(offender); + return { + count: offenders.length, + list: offenders.map(function(offender) { + var parts = /^(?:(?:<([^ \(]*)>|\[inline CSS\]) ?)?(?:\((((?! @ ).)*)(?: @ (\d+):(\d+))?\))?$/.exec(offender); - if (!parts) { - debug('cssParsingErrors offenders transform function error with "%s"', offender); - return offender; - } + if (!parts) { + debug('cssParsingErrors offenders transform function error with "%s"', offender); + return { + parseError: offender + }; + } - var html = (parts[3]) ? parts[3] : ''; - - if (parts[2]) { - html += (parts[2] === '') ? ' <inline CSS>' : ' ' + offendersHelpers.urlToLink(parts[2]); - } - - if (parts[5]) { - html += ' @ ' + parts[6] + ':' + parts[7]; - } - - if (parts[2] && parts[2] !== '') { - var w3cDirectUrl = 'http://jigsaw.w3.org/css-validator/validator?uri=' + encodeURIComponent(parts[2]) + '&profile=css3&usermedium=all&warning=no'; - html += ' (Check on the W3C validator)'; - } - - return html; - }); + return { + error: parts[2], + file: parts[1] || null, + line: (parts[4] && parts[5]) ? parseInt(parts[4], 10) : null, + column: (parts[4] && parts[5]) ? parseInt(parts[5], 10) : null + }; + }) + }; } }, "cssRules": { @@ -300,7 +368,8 @@ var policies = { "message": "

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

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

", "isOkThreshold": 500, "isBadThreshold": 2500, - "isAbnormalThreshold": 4000 + "isAbnormalThreshold": 4000, + "hasOffenders": false }, "cssComplexSelectors": { "tool": "phantomas", @@ -309,12 +378,15 @@ var policies = { "isOkThreshold": 0, "isBadThreshold": 500, "isAbnormalThreshold": 2000, + "hasOffenders": true, "offendersTransformFn": function(offenders) { - return offenders.map(function(offender) { - var splittedOffender = offendersHelpers.cssOffenderPattern(offender); - - return '' + splittedOffender.offender + ' @ ' + splittedOffender.line + ':' + splittedOffender.character; - }); + return { + count: offenders.length, + list: offenders.map(function(offender) { + var splittedOffender = offendersHelpers.cssOffenderPattern(offender); + return splittedOffender; + }) + }; } }, "cssComplexSelectorsByAttribute": { @@ -324,14 +396,18 @@ var policies = { "isOkThreshold": 0, "isBadThreshold": 50, "isAbnormalThreshold": 100, + "hasOffenders": true, "offendersTransformFn": function(offenders) { - return offenders.map(function(offender) { - var splittedOffender = offendersHelpers.cssOffenderPattern(offender); + return { + count: offenders.length, + list: offenders.map(function(offender) { + var splittedOffender = offendersHelpers.cssOffenderPattern(offender); - var boldedOffender = splittedOffender.offender.replace(/(\[[^ ]+[~\|\^\$\*]="[^"]+"\])/g, '$1'); + splittedOffender.bolded = splittedOffender.css.replace(/(\[[^ ]+[~\|\^\$\*]="[^"]+"\])/g, '$1'); - return boldedOffender + ' @ ' + splittedOffender.line + ':' + splittedOffender.character; - }); + return splittedOffender; + }) + }; } }, "cssImports": { @@ -341,12 +417,15 @@ var policies = { "isOkThreshold": 0, "isBadThreshold": 1, "isAbnormalThreshold": 1, + "hasOffenders": true, "offendersTransformFn": function(offenders) { - return offenders.map(function(offender) { - var splittedOffender = offendersHelpers.cssOffenderPattern(offender); - - return '' + splittedOffender.offender + ' @ ' + splittedOffender.line + ':' + splittedOffender.character; - }); + return { + count: offenders.length, + list: offenders.map(function(offender) { + var splittedOffender = offendersHelpers.cssOffenderPattern(offender); + return splittedOffender; + }) + }; } }, "cssDuplicatedSelectors": { @@ -356,17 +435,26 @@ var policies = { "isOkThreshold": 0, "isBadThreshold": 40, "isAbnormalThreshold": 80, + "hasOffenders": true, "offendersTransformFn": function(offenders) { - return offenders.map(function(offender) { - var parts = /^(.*) \((\d+) times\)$/.exec(offender); + return { + count: offenders.length, + list: offenders.map(function(offender) { + var parts = /^(.*) \((\d+) times\)$/.exec(offender); - if (!parts) { - debug('cssDuplicatedSelectors offenders transform function error with "%s"', offender); - return offender; - } + if (!parts) { + debug('cssDuplicatedSelectors offenders transform function error with "%s"', offender); + return { + parseError: offender + }; + } - return parts[1] + ' (x' + parts[2] + ')'; - }); + return { + rule: parts[1], + occurrences: parseInt(parts[2], 10) + }; + }) + }; } }, "cssDuplicatedProperties": { @@ -376,19 +464,31 @@ var policies = { "isOkThreshold": 0, "isBadThreshold": 50, "isAbnormalThreshold": 100, + "hasOffenders": true, "offendersTransformFn": function(offenders) { - return offenders.map(function(offender) { - var splittedOffender = offendersHelpers.cssOffenderPattern(offender); + return { + count: offenders.length, + list: offenders.map(function(offender) { + var splittedOffender = offendersHelpers.cssOffenderPattern(offender); - var parts = /^([^{]+) {([^ ]+): (.+)}$/.exec(splittedOffender.offender); + var parts = /^([^{]+) {([^ ]+): (.+)}$/.exec(splittedOffender.css); - if (!parts) { - debug('cssDuplicatedProperties offenders transform function error with "%s"', offender); - return offender; - } + if (!parts) { + debug('cssDuplicatedProperties offenders transform function error with "%s"', offender); + return { + parseError: offender + }; + } - return 'Property ' + parts[2] + ' duplicated in ' + parts[1] + ' { } @ ' + splittedOffender.line + ':' + splittedOffender.character; - }); + return { + property: parts[2], + rule: parts[1], + file: splittedOffender.file, + line: splittedOffender.line, + column: splittedOffender.column + }; + }) + }; } }, "cssEmptyRules": { @@ -398,11 +498,16 @@ var policies = { "isOkThreshold": 0, "isBadThreshold": 40, "isAbnormalThreshold": 100, + "hasOffenders": true, "offendersTransformFn": function(offenders) { - return offenders.map(function(offender) { - var splittedOffender = offendersHelpers.cssOffenderPattern(offender); - return '' + splittedOffender.offender + ' { } @ ' + splittedOffender.line + ':' + splittedOffender.character; - }); + return { + count: offenders.length, + list: offenders.map(function(offender) { + var splittedOffender = offendersHelpers.cssOffenderPattern(offender); + + return splittedOffender; + }) + }; } }, "cssExpressions": { @@ -412,19 +517,32 @@ var policies = { "isOkThreshold": 0, "isBadThreshold": 1, "isAbnormalThreshold": 20, + "hasOffenders": true, "offendersTransformFn": function(offenders) { - return offenders.map(function(offender) { - var splittedOffender = offendersHelpers.cssOffenderPattern(offender); + return { + count: offenders.length, + list: offenders.map(function(offender) { + var splittedOffender = offendersHelpers.cssOffenderPattern(offender); - var parts = /^(.*) {([^ ]+): expression\((.*)\)}$/.exec(splittedOffender.offender); + var parts = /^(.*) {([^ ]+): expression\((.*)\)}$/.exec(splittedOffender.css); - if (!parts) { - debug('cssExpressions offenders transform function error with "%s"', offender); - return offender; - } + if (!parts) { + debug('cssExpressions offenders transform function error with "%s"', offender); + return { + parseError: offender + }; + } - return parts[1] + ' {' + parts[2] + ': expression(' + parts[3] + ')} @ ' + splittedOffender.line + ':' + splittedOffender.character; - }); + return { + rule: parts[1], + property: parts[2], + expression: parts[3], + file: splittedOffender.file, + line: splittedOffender.line, + column: splittedOffender.column + }; + }) + }; } }, "cssImportants": { @@ -434,19 +552,32 @@ var policies = { "isOkThreshold": 0, "isBadThreshold": 50, "isAbnormalThreshold": 150, + "hasOffenders": true, "offendersTransformFn": function(offenders) { - return offenders.map(function(offender) { - var splittedOffender = offendersHelpers.cssOffenderPattern(offender); + return { + count: offenders.length, + list: offenders.map(function(offender) { + var splittedOffender = offendersHelpers.cssOffenderPattern(offender); - var parts = /^(.*) {([^ ]+): (.*) ?\!important}$/.exec(splittedOffender.offender); + var parts = /^(.*) {([^ ]+): (.*) ?\!important}$/.exec(splittedOffender.css); - if (!parts) { - debug('cssImportants offenders transform function error with "%s"', offender); - return offender; - } + if (!parts) { + debug('cssImportants offenders transform function error with "%s"', offender); + return { + parseError: offender + }; + } - return parts[1] + ' {' + parts[2] + ': ' + parts[3] + ' !important} @ ' + splittedOffender.line + ':' + splittedOffender.character; - }); + return { + rule: parts[1], + property: parts[2], + value: parts[3], + file: splittedOffender.file, + line: splittedOffender.line, + column: splittedOffender.column + }; + }) + }; } }, "cssOldIEFixes": { @@ -456,47 +587,54 @@ var policies = { "isOkThreshold": 0, "isBadThreshold": 50, "isAbnormalThreshold": 300, + "hasOffenders": true, "offendersTransformFn": function(offenders) { - return offenders.map(function(offender) { - var splittedOffender = offendersHelpers.cssOffenderPattern(offender); + return { + count: offenders.length, + list: offenders.map(function(offender) { + var splittedOffender = offendersHelpers.cssOffenderPattern(offender); - var parts = /^([^{]*)( {([^ ]+): (.*)})?$/.exec(splittedOffender.offender); + var parts = /^([^{]*)( {([^ ]+): (.*)})?$/.exec(splittedOffender.css); - if (!parts) { - debug('cssOldIEFixes offenders transform function error with "%s"', offender); - return offender; - } + if (!parts) { + debug('cssOldIEFixes offenders transform function error with "%s"', offender); + return { + parseError: offender + }; + } - var rule = parts[1]; - var property = parts[3]; - var value = parts[4]; - var browser = null; + var rule = parts[1]; + var property = parts[3]; + var value = parts[4]; + var browser = null; - if (rule.indexOf('* html') === 0) { - rule = rule.replace(/^\* html/, '* html'); - browser = 'IE6'; - } else if (rule.indexOf('html>body') === 0) { - rule = rule.replace(/^html>body/, 'html>body'); - browser = 'IE6'; - } else if (property.indexOf('*') === 0) { - property = '' + property + ''; - browser = 'IE7'; - } else if (value.match(/\!ie$/)) { - value = value.replace(/\!ie$/, '!ie'); - browser = 'IE7'; - } else if (property === '-ms-filter') { - property = '-ms-filter'; - browser = 'IE9'; - } else if (value.indexOf('progid:DXImageTransform.Microsoft') === 0) { - value = value.replace(/^progid:DXImageTransform\.Microsoft/, 'progid:DXImageTransform.Microsoft'); - browser = 'IE9'; - } + if (rule.indexOf('* html') === 0) { + rule = rule.replace(/^\* html/, '* html'); + browser = 'IE6'; + } else if (rule.indexOf('html>body') === 0) { + rule = rule.replace(/^html>body/, 'html>body'); + browser = 'IE6'; + } else if (property.indexOf('*') === 0) { + property = '' + property + ''; + browser = 'IE7'; + } else if (value.match(/\!ie$/)) { + value = value.replace(/\!ie$/, '!ie'); + browser = 'IE7'; + } else if (property === '-ms-filter') { + property = '-ms-filter'; + browser = 'IE9'; + } else if (value.indexOf('progid:DXImageTransform.Microsoft') >= 0) { + value = value.replace(/progid:DXImageTransform\.Microsoft/, 'progid:DXImageTransform.Microsoft'); + browser = 'IE9'; + } - browser = browser ? '' + browser + ' fix: ' : ''; - var propertyAndValue = (property && value) ? ' {' + property + ': ' + value + '}' : ''; + var propertyAndValue = (property && value) ? ' {' + property + ': ' + value + '}' : ''; + splittedOffender.bolded = rule + propertyAndValue; + splittedOffender.browser = browser; - return browser + rule + propertyAndValue + ' @ ' + splittedOffender.line + ':' + splittedOffender.character; - }); + return splittedOffender; + }) + }; } }, "cssOldPropertyPrefixes": { @@ -506,19 +644,32 @@ var policies = { "isOkThreshold": 0, "isBadThreshold": 50, "isAbnormalThreshold": 300, + "hasOffenders": true, "offendersTransformFn": function(offenders) { - return offenders.map(function(offender) { - var splittedOffender = offendersHelpers.cssOffenderPattern(offender); + return { + count: offenders.length, + list: offenders.map(function(offender) { + var splittedOffender = offendersHelpers.cssOffenderPattern(offender); - var parts = /^([^{]*)(?: ?{ ?([^ ]+): (.*) ?}) \/\/ (.*)$/.exec(splittedOffender.offender); + var parts = /^([^{]*)(?: ?{ ?([^ ]+): (.*) ?}) \/\/ (.*)$/.exec(splittedOffender.css); - if (!parts) { - debug('cssOldPropertyPrefixes offenders transform function error with "%s"', offender); - return offender; - } + if (!parts) { + debug('cssOldPropertyPrefixes offenders transform function error with "%s"', offender); + return { + parseError: offender + }; + } - return '
' + parts[1] + ' {' + parts[2] + ': ' + parts[3] + '} @ ' + splittedOffender.line + ':' + splittedOffender.character + '
' + parts[4] + '
'; - }); + return { + rule: parts[1], + property: parts[2], + value: parts[3], + file: splittedOffender.file, + line: splittedOffender.line, + column: splittedOffender.column + }; + }) + }; } }, "cssUniversalSelectors": { @@ -527,7 +678,17 @@ var policies = { "message": "

Universal selectors are the most expensive CSS selectors.

More informations here.

", "isOkThreshold": 0, "isBadThreshold": 40, - "isAbnormalThreshold": 150 + "isAbnormalThreshold": 150, + "hasOffenders": true, + "offendersTransformFn": function(offenders) { + return { + count: offenders.length, + list: offenders.map(function(offender) { + var splittedOffender = offendersHelpers.cssOffenderPattern(offender); + return splittedOffender; + }) + }; + } }, "cssRedundantBodySelectors": { "tool": "phantomas", @@ -536,15 +697,18 @@ var policies = { "isOkThreshold": 0, "isBadThreshold": 50, "isAbnormalThreshold": 200, + "hasOffenders": true, "offendersTransformFn": function(offenders) { - return offenders.map(function(offender) { - var splittedOffender = offendersHelpers.cssOffenderPattern(offender); + return { + count: offenders.length, + list: offenders.map(function(offender) { + var splittedOffender = offendersHelpers.cssOffenderPattern(offender); - var rule = splittedOffender.offender; - rule = rule.replace(/body/, 'body'); + splittedOffender.bolded = splittedOffender.css.replace(/body/, 'body'); - return rule + ' @ ' + splittedOffender.line + ':' + splittedOffender.character; - }); + return splittedOffender; + }) + }; } }, "cssRedundantChildNodesSelectors": { @@ -554,25 +718,31 @@ var policies = { "isOkThreshold": 0, "isBadThreshold": 50, "isAbnormalThreshold": 200, + "hasOffenders": true, "offendersTransformFn": function(offenders) { - return offenders.map(function(offender) { - var splittedOffender = offendersHelpers.cssOffenderPattern(offender); + return { + count: offenders.length, + list: offenders.map(function(offender) { + var splittedOffender = offendersHelpers.cssOffenderPattern(offender); - var rule = splittedOffender.offender; - var redundanters = [ - ['ul', 'li'], - ['ol', 'li'], - ['select', 'option'], - ['table', 'tr'], - ['table', 'th'], - ]; + var rule = splittedOffender.css; + var redundanters = [ + ['ul', 'li'], + ['ol', 'li'], + ['select', 'option'], + ['table', 'tr'], + ['table', 'th'], + ]; - redundanters.forEach(function(couple) { - rule = rule.replace(new RegExp('(^| |>)' + couple[0] + '([^ >]*)?([ >]| > )' + couple[1] + '([^\\w-]|$)', 'g'), '$1' + couple[0] + '$2$3' + couple[1] + '$4'); - }); + redundanters.forEach(function(couple) { + rule = rule.replace(new RegExp('(^| |>)' + couple[0] + '([^ >]*)?([ >]| > )' + couple[1] + '([^\\w-]|$)', 'g'), '$1' + couple[0] + '$2$3' + couple[1] + '$4'); + }); - return rule + ' @ ' + splittedOffender.line + ':' + splittedOffender.character; - }); + splittedOffender.bolded = rule; + + return splittedOffender; + }) + }; } }, "requests": { @@ -582,16 +752,18 @@ var policies = { "isOkThreshold": 15, "isBadThreshold": 100, "isAbnormalThreshold": 200, + "hasOffenders": true, "takeOffendersFrom": ["htmlCount", "jsCount", "cssCount", "imageCount", "webfontCount", "videoCount", "jsonCount", "jsonCount"], "offendersTransformFn": function(offenders) { - return offenders - .map(function(offender) { - return offendersHelpers.fileWithSizePattern(offender); - }).sort(function(a, b) { - return b.size - a.size; - }).map(function(fileObj) { - return offendersHelpers.urlToLink(fileObj.file) + ((fileObj.size !== undefined) ? ' (' + fileObj.size + ' kB)' : ''); - }); + return { + count: offenders.length, + list: offenders + .map(function(offender) { + return offendersHelpers.fileWithSizePattern(offender); + }).sort(function(a, b) { + return b.size - a.size; + }) + }; } }, "htmlCount": { @@ -601,11 +773,14 @@ var policies = { "isOkThreshold": 10, "isBadThreshold": 20, "isAbnormalThreshold": 30, + "hasOffenders": true, "offendersTransformFn": function(offenders) { - return offenders.map(function(offender) { - var fileObj = offendersHelpers.fileWithSizePattern(offender); - return offendersHelpers.urlToLink(fileObj.file) + ((fileObj.size !== undefined) ? ' (' + fileObj.size + ' kB)' : ''); - }); + return { + count: offenders.length, + list: offenders.map(function(offender) { + return offendersHelpers.fileWithSizePattern(offender); + }) + }; } }, "jsCount": { @@ -615,11 +790,14 @@ var policies = { "isOkThreshold": 5, "isBadThreshold": 15, "isAbnormalThreshold": 30, + "hasOffenders": true, "offendersTransformFn": function(offenders) { - return offenders.map(function(offender) { - var fileObj = offendersHelpers.fileWithSizePattern(offender); - return offendersHelpers.urlToLink(fileObj.file) + ((fileObj.size !== undefined) ? ' (' + fileObj.size + ' kB)' : ''); - }); + return { + count: offenders.length, + list: offenders.map(function(offender) { + return offendersHelpers.fileWithSizePattern(offender); + }) + }; } }, "cssCount": { @@ -629,11 +807,14 @@ var policies = { "isOkThreshold": 3, "isBadThreshold": 10, "isAbnormalThreshold": 22, + "hasOffenders": true, "offendersTransformFn": function(offenders) { - return offenders.map(function(offender) { - var fileObj = offendersHelpers.fileWithSizePattern(offender); - return offendersHelpers.urlToLink(fileObj.file) + ((fileObj.size !== undefined) ? ' (' + fileObj.size + ' kB)' : ''); - }); + return { + count: offenders.length, + list: offenders.map(function(offender) { + return offendersHelpers.fileWithSizePattern(offender); + }) + }; } }, "imageCount": { @@ -643,11 +824,14 @@ var policies = { "isOkThreshold": 15, "isBadThreshold": 40, "isAbnormalThreshold": 70, + "hasOffenders": true, "offendersTransformFn": function(offenders) { - return offenders.map(function(offender) { - var fileObj = offendersHelpers.fileWithSizePattern(offender); - return offendersHelpers.urlToLink(fileObj.file) + ((fileObj.size !== undefined) ? ' (' + fileObj.size + ' kB)' : ''); - }); + return { + count: offenders.length, + list: offenders.map(function(offender) { + return offendersHelpers.fileWithSizePattern(offender); + }) + }; } }, "webfontCount": { @@ -657,11 +841,14 @@ var policies = { "isOkThreshold": 0, "isBadThreshold": 3, "isAbnormalThreshold": 5, + "hasOffenders": true, "offendersTransformFn": function(offenders) { - return offenders.map(function(offender) { - var fileObj = offendersHelpers.fileWithSizePattern(offender); - return offendersHelpers.urlToLink(fileObj.file) + ((fileObj.size !== undefined) ? ' (' + fileObj.size + ' kB)' : ''); - }); + return { + count: offenders.length, + list: offenders.map(function(offender) { + return offendersHelpers.fileWithSizePattern(offender); + }) + }; } }, "videoCount": { @@ -671,11 +858,14 @@ var policies = { "isOkThreshold": 1, "isBadThreshold": 5, "isAbnormalThreshold": 15, + "hasOffenders": true, "offendersTransformFn": function(offenders) { - return offenders.map(function(offender) { - var fileObj = offendersHelpers.fileWithSizePattern(offender); - return offendersHelpers.urlToLink(fileObj.file) + ((fileObj.size !== undefined) ? ' (' + fileObj.size + ' kB)' : ''); - }); + return { + count: offenders.length, + list: offenders.map(function(offender) { + return offendersHelpers.fileWithSizePattern(offender); + }) + }; } }, "jsonCount": { @@ -685,11 +875,14 @@ var policies = { "isOkThreshold": 2, "isBadThreshold": 10, "isAbnormalThreshold": 25, + "hasOffenders": true, "offendersTransformFn": function(offenders) { - return offenders.map(function(offender) { - var fileObj = offendersHelpers.fileWithSizePattern(offender); - return offendersHelpers.urlToLink(fileObj.file) + ((fileObj.size !== undefined) ? ' (' + fileObj.size + ' kB)' : ''); - }); + return { + count: offenders.length, + list: offenders.map(function(offender) { + return offendersHelpers.fileWithSizePattern(offender); + }) + }; } }, "otherCount": { @@ -699,11 +892,14 @@ var policies = { "isOkThreshold": 5, "isBadThreshold": 20, "isAbnormalThreshold": 40, + "hasOffenders": true, "offendersTransformFn": function(offenders) { - return offenders.map(function(offender) { - var fileObj = offendersHelpers.fileWithSizePattern(offender); - return offendersHelpers.urlToLink(fileObj.file) + ((fileObj.size !== undefined) ? ' (' + fileObj.size + ' kB)' : ''); - }); + return { + count: offenders.length, + list: offenders.map(function(offender) { + return offendersHelpers.fileWithSizePattern(offender); + }) + }; } }, "smallJsFiles": { @@ -713,11 +909,14 @@ var policies = { "isOkThreshold": 2, "isBadThreshold": 10, "isAbnormalThreshold": 16, + "hasOffenders": true, "offendersTransformFn": function(offenders) { - return offenders.map(function(offender) { - var fileObj = offendersHelpers.fileWithSizePattern(offender); - return offendersHelpers.urlToLink(fileObj.file) + ((fileObj.size !== undefined) ? ' (' + fileObj.size + ' kB)' : ''); - }); + return { + count: offenders.length, + list: offenders.map(function(offender) { + return offendersHelpers.fileWithSizePattern(offender); + }) + }; } }, "smallCssFiles": { @@ -727,11 +926,14 @@ var policies = { "isOkThreshold": 0, "isBadThreshold": 8, "isAbnormalThreshold": 12, + "hasOffenders": true, "offendersTransformFn": function(offenders) { - return offenders.map(function(offender) { - var fileObj = offendersHelpers.fileWithSizePattern(offender); - return offendersHelpers.urlToLink(fileObj.file) + ((fileObj.size !== undefined) ? ' (' + fileObj.size + ' kB)' : ''); - }); + return { + count: offenders.length, + list: offenders.map(function(offender) { + return offendersHelpers.fileWithSizePattern(offender); + }) + }; } }, "smallImages": { @@ -741,11 +943,14 @@ var policies = { "isOkThreshold": 2, "isBadThreshold": 17, "isAbnormalThreshold": 30, + "hasOffenders": true, "offendersTransformFn": function(offenders) { - return offenders.map(function(offender) { - var fileObj = offendersHelpers.fileWithSizePattern(offender); - return offendersHelpers.urlToLink(fileObj.file) + ((fileObj.size !== undefined) ? ' (' + fileObj.size + ' kB)' : ''); - }); + return { + count: offenders.length, + list: offenders.map(function(offender) { + return offendersHelpers.fileWithSizePattern(offender); + }) + }; } }, "notFound": { @@ -755,11 +960,7 @@ var policies = { "isOkThreshold": 0, "isBadThreshold": 1, "isAbnormalThreshold": 1, - "offendersTransformFn": function(offenders) { - return offenders.map(function(offender) { - return offendersHelpers.urlToLink(offender); - }); - } + "hasOffenders": true }, "closedConnections": { "tool": "phantomas", @@ -768,11 +969,7 @@ var policies = { "isOkThreshold": 0, "isBadThreshold": 8, "isAbnormalThreshold": 20, - "offendersTransformFn": function(offenders) { - return offenders.map(function(offender) { - return offendersHelpers.urlToLink(offender); - }); - } + "hasOffenders": true }, "multipleRequests": { "tool": "phantomas", @@ -781,11 +978,7 @@ var policies = { "isOkThreshold": 0, "isBadThreshold": 3, "isAbnormalThreshold": 10, - "offendersTransformFn": function(offenders) { - return offenders.map(function(offender) { - return offendersHelpers.urlToLink(offender); - }); - } + "hasOffenders": true }, "cachingDisabled": { "tool": "phantomas", @@ -794,11 +987,7 @@ var policies = { "isOkThreshold": 0, "isBadThreshold": 12, "isAbnormalThreshold": 25, - "offendersTransformFn": function(offenders) { - return offenders.map(function(offender) { - return offendersHelpers.urlToLink(offender); - }); - } + "hasOffenders": true }, "cachingNotSpecified": { "tool": "phantomas", @@ -807,11 +996,7 @@ var policies = { "isOkThreshold": 5, "isBadThreshold": 20, "isAbnormalThreshold": 40, - "offendersTransformFn": function(offenders) { - return offenders.map(function(offender) { - return offendersHelpers.urlToLink(offender); - }); - } + "hasOffenders": true }, "cachingTooShort": { "tool": "phantomas", @@ -820,45 +1005,56 @@ var policies = { "isOkThreshold": 5, "isBadThreshold": 20, "isAbnormalThreshold": 40, + "hasOffenders": true, "offendersTransformFn": function(offenders) { - return offenders - .map(function(offender) { - var parts = /^([^ ]*) cached for (-?\d+(\.\d+)?) s$/.exec(offender); + return { + count: offenders.length, + list: offenders + .map(function(offender) { + var parts = /^([^ ]*) cached for (-?\d+(\.\d+)?) s$/.exec(offender); + + if (!parts) { + debug('cachingTooShort offenders transform function error with "%s"', offender); + return { + file: offender + }; + } - if (!parts) { - debug('cachingTooShort offenders transform function error with "%s"', offender); return { - file: offender + file: parts[1], + ttl: Math.round(parseFloat(parts[2])) }; - } - return { - file: parts[1], - ttl: Math.round(parseFloat(parts[2])) - }; - }).sort(function(a, b) { - return a.ttl - b.ttl; - }).map(function(obj) { - var duration = obj.ttl; - var unit = 'seconds'; + }).sort(function(a, b) { - if (duration >= 120) { - duration = Math.round(duration / 60); - unit = 'minutes'; - } + return a.ttl - b.ttl; - if (duration >= 120) { - duration = Math.round(duration / 60); - unit = 'hours'; - } + }).map(function(obj) { - if (duration >= 48) { - duration = Math.round(duration / 24); - unit = 'days'; - } + var duration = obj.ttl; + var unit = 'seconds'; - return offendersHelpers.urlToLink(obj.file) + ' cached for ' + duration + ' ' + unit + ''; - }); + if (duration >= 120) { + duration = Math.round(duration / 60); + unit = 'minutes'; + } + + if (duration >= 120) { + duration = Math.round(duration / 60); + unit = 'hours'; + } + + if (duration >= 48) { + duration = Math.round(duration / 24); + unit = 'days'; + } + + obj.ttlWithUnit = duration; + obj.unit = unit; + + return obj; + }) + }; } }, "domains": { @@ -868,17 +1064,27 @@ var policies = { "isOkThreshold": 10, "isBadThreshold": 25, "isAbnormalThreshold": 50, + "hasOffenders": true, "offendersTransformFn": function(offenders) { - return offenders.map(function(offender) { - var parts = /^([^ ]*): (\d+) request\(s\)$/.exec(offender); + 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 offender; - } + if (!parts) { + debug('domains offenders transform function error with "%s"', offender); + return { + file: offender + }; + } - return '' + parts[1] + ' (' + parts[2] + ' ' + ((parseInt(parts[2], 10) > 1) ? 'requests' : 'request') + ')'; - }); + return { + domain: parts[1], + requests: parseInt(parts[2]) + }; + + }) + }; } } }; diff --git a/lib/offendersHelpers.js b/lib/offendersHelpers.js index 36151c6..f2dde54 100644 --- a/lib/offendersHelpers.js +++ b/lib/offendersHelpers.js @@ -31,70 +31,69 @@ var OffendersHelpers = function() { return result; }; - this.domTreeToHTML = function(domTree) { - - function recursiveHtmlBuilder(tree) { - var html = ''; - var keys = Object.keys(tree); - - keys.forEach(function(key) { - if (isNaN(tree[key])) { - html += '
' + key + '' + recursiveHtmlBuilder(tree[key]) + '
'; - } else if (tree[key] > 1) { - html += '
' + key + ' (x' + tree[key] + ')
'; - } else { - html += '
' + key + '
'; - } - }); - - return html; - } - - return '
' + recursiveHtmlBuilder(domTree) + '
'; - }; - - this.listOfDomPathsToHTML = function(domPaths) { - var domArrays = domPaths.map(this.domPathToArray); - var domTree = this.listOfDomArraysToTree(domArrays); - return this.domTreeToHTML(domTree); - }; - - this.domPathToButton = function(domPath) { + this.domPathToDomElementObj = function(domPath) { var domArray = this.domPathToArray(domPath); - var domTree = this.listOfDomPathsToHTML([domPath]); + var domTree = this.listOfDomArraysToTree([this.domPathToArray(domPath)]); if (domArray[0] === 'html') { - return '
html
'; + return { + type: 'html' + }; } if (domArray[0] === 'body') { if (domArray.length === 1) { - return '
body
'; + return { + type: 'body' + }; } else { - return '
DOM element ' + domArray[domArray.length - 1] + '' + domTree + '
'; + return { + type: 'domElement', + element: domArray[domArray.length - 1], + tree: domTree + }; } } if (domArray[0] === 'head') { - return '
head
'; + return { + type: 'head' + }; } if (domArray[0] === '#document') { - return '
document
'; + return { + type: 'document' + }; } if (domArray[0] === 'window') { - return '
window
'; + return { + type: 'window' + }; } if (domArray[0] === 'DocumentFragment') { if (domArray.length === 1) { - return '
Fragment
'; + return { + type: 'fragment' + }; } else { - return '
Fragment element ' + domArray[domArray.length - 1] + '' + domTree + '
'; + return { + type: 'fragmentElement', + element: domArray[domArray.length - 1], + tree: domTree + }; } } // Not attached element, such as just created with document.createElement() if (domArray.length === 1) { - return '
Created element ' + domPath + '
'; + return { + type: 'createdElement', + element: domPath + }; } else { - return '
Created element ' + domArray[domArray.length - 1] + '' + domTree + '
'; + return { + type: 'createdElement', + element: domArray[domArray.length - 1], + tree: domTree + }; } }; @@ -130,20 +129,6 @@ var OffendersHelpers = function() { } }; - this.backtraceArrayToHtml = function(backtraceArray) { - if (backtraceArray.length === 0) { - return '
no backtrace
'; - } - - var html = '
backtrace
'; - var that = this; - backtraceArray.forEach(function(backtraceObj) { - var functionName = (backtraceObj.functionName) ? backtraceObj.functionName + '() ' : ''; - html += '
' + functionName + that.urlToLink(backtraceObj.file) + ' line ' + backtraceObj.line + '
'; - }); - return html + '
'; - }; - this.sortVarsLikeChromeDevTools = function(vars) { return vars.sort(function(a, b) { @@ -157,7 +142,7 @@ var OffendersHelpers = function() { }; this.cssOffenderPattern = function(offender) { - var parts = /^(.*) @ (\d+):(\d+)$/.exec(offender); + var parts = /^(.*) (?:<([^ \(]*)>|\[inline CSS\]) @ (\d+):(\d+)$/.exec(offender); if (!parts) { return { @@ -165,9 +150,10 @@ var OffendersHelpers = function() { }; } else { return { - offender: parts[1], - line: parseInt(parts[2], 10), - character: parseInt(parts[3], 10) + css: parts[1], + file: parts[2] || null, + line: parseInt(parts[3], 10), + column: parseInt(parts[4], 10) }; } }; diff --git a/lib/rulesChecker.js b/lib/rulesChecker.js index 8f82b68..b82b7d7 100644 --- a/lib/rulesChecker.js +++ b/lib/rulesChecker.js @@ -26,52 +26,64 @@ var RulesChecker = function() { policy: extend({}, policy) // Clone object policy instead of reference }; - var offenders = []; - // Take DOMqueriesAvoidable's offenders from DOMqueriesDuplicated, for example. - if (policy.takeOffendersFrom) { - - var fromList = policy.takeOffendersFrom; - - // takeOffendersFrom option can be a string or an array of strings. - if (typeof fromList === 'string') { - fromList = [fromList]; - } - - fromList.forEach(function(from) { - if (data.toolsResults[policy.tool] && - data.toolsResults[policy.tool].offenders && - data.toolsResults[policy.tool].offenders[from]) { - offenders = offenders.concat(data.toolsResults[policy.tool].offenders[from]); + // Deal with offenders + if (policy.hasOffenders) { + + var offenders = []; + + // Take DOMqueriesAvoidable's offenders from DOMqueriesDuplicated, for example. + if (policy.takeOffendersFrom) { + + var fromList = policy.takeOffendersFrom; + + // takeOffendersFrom option can be a string or an array of strings. + if (typeof fromList === 'string') { + fromList = [fromList]; } - }); + + fromList.forEach(function(from) { + if (data.toolsResults[policy.tool] && + data.toolsResults[policy.tool].offenders && + data.toolsResults[policy.tool].offenders[from]) { + offenders = offenders.concat(data.toolsResults[policy.tool].offenders[from]); + } + }); - data.toolsResults[policy.tool].offenders[metricName] = offenders; + data.toolsResults[policy.tool].offenders[metricName] = offenders; - } else if (data.toolsResults[policy.tool] && - data.toolsResults[policy.tool].offenders && - data.toolsResults[policy.tool].offenders[metricName]) { - offenders = data.toolsResults[policy.tool].offenders[metricName]; - } - - // It is possible to declare a transformation function for the offenders. - // The function should take an array of strings as single parameter and return a string. - if (policy.offendersTransformFn) { - rule.offendersCount = offenders.length; - - try { - offenders = policy.offendersTransformFn(offenders); - } catch(err) { - debug('Error while transforming offenders for %s', metricName); - debug(err); + } else if (data.toolsResults[policy.tool] && + data.toolsResults[policy.tool].offenders && + data.toolsResults[policy.tool].offenders[metricName]) { + offenders = data.toolsResults[policy.tool].offenders[metricName]; } + + var offendersObj = {}; - delete rule.policy.offendersTransformFn; + // It is possible to declare a transformation function for the offenders. + // The function should take an array of strings as single parameter and return a string. + if (policy.offendersTransformFn) { + + try { + offendersObj = policy.offendersTransformFn(offenders); + } catch(err) { + debug('Error while transforming offenders for %s', metricName); + debug(err); + } + + delete rule.policy.offendersTransformFn; + + } else { + + offendersObj = { + count: offenders.length, + list: offenders + }; + } + + rule.offendersObj = offendersObj; } - if (offenders && offenders.length > 0) { - rule.offenders = offenders; - } rule.bad = rule.value > policy.isOkThreshold; rule.abnormal = policy.isAbnormalThreshold && rule.value >= policy.isAbnormalThreshold; diff --git a/lib/tools/phantomas/phantomasWrapper.js b/lib/tools/phantomas/phantomasWrapper.js index 4706201..594cde9 100644 --- a/lib/tools/phantomas/phantomasWrapper.js +++ b/lib/tools/phantomas/phantomasWrapper.js @@ -40,7 +40,8 @@ var PhantomasWrapper = function() { 'eventListeners', // overridden 'filmStrip', // not needed 'har', // not needed for the moment - 'javaScriptBottlenecks', // needs to be launched after custom module scopeYLT, + 'javaScriptBottlenecks', // needs to be launched after custom module scopeYLT + 'jQuery', // overridden 'jserrors', // overridden 'pageSource', // not needed 'screenshot', // not needed for the moment diff --git a/test/core/customPoliciesTest.js b/test/core/customPoliciesTest.js index 5593f2f..302b1c4 100644 --- a/test/core/customPoliciesTest.js +++ b/test/core/customPoliciesTest.js @@ -4,10 +4,362 @@ var rulesChecker = require('../../lib/rulesChecker'); describe('rulesChecker', function() { var policies = require('../../lib/metadata/policies.js'); + var results; + it('should transform DOMelementMaxDepth offenders', function() { + results = rulesChecker.check({ + "toolsResults": { + "phantomas": { + "metrics": { + "DOMelementMaxDepth": 3 + }, + "offenders": { + "DOMelementMaxDepth": [ + "body > div#foo > span.bar" + ] + } + } + } + }, policies); + + results.should.have.a.property('DOMelementMaxDepth'); + results.DOMelementMaxDepth.should.have.a.property('offendersObj').that.deep.equals({ + "count": 1, + "tree": { + "body": { + "div#foo": { + "span.bar": 1 + } + } + } + }); + }); + + + it('should transform DOMidDuplicated offenders', function() { + results = rulesChecker.check({ + "toolsResults": { + "phantomas": { + "metrics": { + "DOMidDuplicated": 2 + }, + "offenders": { + "DOMidDuplicated": [ + "colorswitch-30883-30865: 4 occurrences", + "foo: 1 occurrences" + ] + } + } + } + }, policies); + + results.should.have.a.property('DOMidDuplicated'); + results.DOMidDuplicated.should.have.a.property('offendersObj').that.deep.equals({ + "count": 2, + "list": [ + { + "id": "colorswitch-30883-30865", + "occurrences": 4 + }, + { + "id": "foo", + "occurrences": 1 + } + ] + }); + }); + + + it('should transform DOMinserts offenders', function() { + results = rulesChecker.check({ + "toolsResults": { + "phantomas": { + "metrics": { + "DOMinserts": 4 + }, + "offenders": { + "DOMinserts": [ + "\"div\" appended to \"html\"", + "\"DocumentFragment > link[0]\" appended to \"head\"", + "\"div#Netaff-yh1XbS0vK3NaRGu\" appended to \"body > div#Global\"", + "\"img\" appended to \"body\"" + ] + } + } + } + }, policies); + + results.should.have.a.property('DOMinserts'); + results.DOMinserts.should.have.a.property('offendersObj').that.deep.equals({ + "count": 4, + "list": [ + { + "insertedElement": { + "type": "createdElement", + "element": "div" + }, + "receiverElement": { + "type": "html" + } + }, + { + "insertedElement": { + "type": "fragmentElement", + "element": "link[0]", + "tree": { + "DocumentFragment": { + "link[0]": 1 + } + } + }, + "receiverElement": { + "type": "head" + } + }, + { + "insertedElement": { + "type": "createdElement", + "element": "div#Netaff-yh1XbS0vK3NaRGu" + }, + "receiverElement": { + "type": "domElement", + "element": "div#Global", + "tree": { + "body": { + "div#Global": 1 + } + } + } + }, + { + "insertedElement": { + "type": "createdElement", + "element": "img" + }, + "receiverElement": { + "type": "body" + } + } + ] + }); + }); + + + it('should transform DOMqueriesWithoutResults offenders', function() { + results = rulesChecker.check({ + "toolsResults": { + "phantomas": { + "metrics": { + "DOMqueriesWithoutResults": 2 + }, + "offenders": { + "DOMqueriesWithoutResults": [ + "#SearchMenu (in #document) using getElementById", + ".partnership-link (in body > div#Global > div#Header > ul#MainMenu) using getElementsByClassName" + ] + } + } + } + }, policies); + + results.should.have.a.property('DOMqueriesWithoutResults'); + results.DOMqueriesWithoutResults.should.have.a.property('offendersObj').that.deep.equals({ + "count": 2, + "list": [ + { + "context": { + "type": "document" + }, + "fn": "getElementById", + "query": "#SearchMenu " + }, + { + "context": { + "element": "ul#MainMenu", + "tree": { + "body": { + "div#Global": { + "div#Header": { + "ul#MainMenu": 1 + } + } + } + }, + "type": "domElement" + }, + "fn": "getElementsByClassName", + "query": ".partnership-link " + } + ] + }); + }); + + + it('should transform DOMqueriesAvoidable offenders', function() { + results = rulesChecker.check({ + "toolsResults": { + "phantomas": { + "metrics": { + "DOMqueriesAvoidable": 2 + }, + "offenders": { + "DOMqueriesDuplicated": [ + "id \"#j2t-top-cart\" with getElementById (in context #document): 4 queries", + "class \".listingResult\" with getElementsByClassName (in context body > div#Global > div#Listing): 4 queries" + ] + } + } + } + }, policies); + + results.should.have.a.property('DOMqueriesAvoidable'); + results.DOMqueriesAvoidable.should.have.a.property('offendersObj').that.deep.equals({ + "count": 2, + "list": [ + { + "query": "#j2t-top-cart", + "context": { + "type": "document" + }, + "fn": "getElementById ", + "count": 4 + }, + { + "query": ".listingResult", + "context": { + "type": "domElement", + "element": "div#Listing", + "tree": { + "body": { + "div#Global": { + "div#Listing": 1 + } + } + } + }, + "fn": "getElementsByClassName ", + "count": 4 + } + ] + }); + }); + + + it('should transform eventsBound offenders', function() { + results = rulesChecker.check({ + "toolsResults": { + "phantomas": { + "metrics": { + "eventsBound": 2 + }, + "offenders": { + "eventsBound": [ + "\"DOMContentLoaded\" bound to \"#document\"", + "\"unload\" bound to \"window\"", + "\"submit\" bound to \"body > div#Global > div#Header > form#search_mini_form\"" + ] + } + } + } + }, policies); + + results.should.have.a.property('eventsBound'); + results.eventsBound.should.have.a.property('offendersObj').that.deep.equals({ + "count": 3, + "list": [ + { + "element": { + "type": "document" + }, + "eventName": "DOMContentLoaded" + }, + { + "element": { + "type": "window" + }, + "eventName": "unload" + }, + { + "element": { + "element": "form#search_mini_form", + "tree": { + "body": { + "div#Global": { + "div#Header": { + "form#search_mini_form": 1 + } + } + } + }, + "type": "domElement" + }, + "eventName": "submit" + } + ] + }); + }); + + + it('should transform jsErrors offenders', function() { + results = rulesChecker.check({ + "toolsResults": { + "phantomas": { + "metrics": { + "jsErrors": 2 + }, + "offenders": { + "jsErrors": [ + "TypeError: 'undefined' is not a function (evaluating 'this.successfullyCollected.bind(this)') - http://asset.easydmp.net/js/collect.js:1160 / callCollecte http://asset.easydmp.net/js/collect.js:1203 / callbackUpdateParams http://asset.easydmp.net/js/collect.js:1135 / http://asset.easydmp.net/js/collect.js:1191", + "TypeError: 'undefined' is not an object (evaluating 'd.readyState') - http://me.hunkal.com/p/:3" + ] + } + } + } + }, policies); + + results.should.have.a.property('jsErrors'); + results.jsErrors.should.have.a.property('offendersObj').that.deep.equals({ + "count": 2, + "list": [ + { + "error": "TypeError: 'undefined' is not a function (evaluating 'this.successfullyCollected.bind(this)')", + "backtrace": [ + { + "file": "http://asset.easydmp.net/js/collect.js", + "line": 1160 + }, + { + "file": "http://asset.easydmp.net/js/collect.js", + "line": 1203, + "functionName": "callCollecte" + }, + { + "file": "http://asset.easydmp.net/js/collect.js", + "line": 1135, + "functionName": "callbackUpdateParams" + }, + { + "file": "http://asset.easydmp.net/js/collect.js", + "line": 1191 + } + ] + }, + { + "error": "TypeError: 'undefined' is not an object (evaluating 'd.readyState')", + "backtrace": [ + { + "file": "http://me.hunkal.com/p/", + "line": 3 + } + ] + } + ] + }); + }); + + it('should grade correctly jQuery versions', function() { - var versions = { '1.2.9': 0, @@ -84,4 +436,46 @@ describe('rulesChecker', function() { results.jQueryDifferentVersions.should.have.a.property('score').that.equals(0); results.jQueryDifferentVersions.should.have.a.property('abnormal').that.equals(true); }); + + + it('should transform cssParsingErrors offenders', function() { + results = rulesChecker.check({ + "toolsResults": { + "phantomas": { + "metrics": { + "cssParsingErrors": 2 + }, + "offenders": { + "cssParsingErrors": [ + " (Error: CSS parsing failed: missing '}' @ 4:1)", + " (Empty CSS was provided)" + ] + } + } + } + }, policies); + + results.should.have.a.property('cssParsingErrors'); + results.cssParsingErrors.should.have.a.property('offendersObj').that.deep.equals({ + "count": 2, + "list": [ + { + "error": "Error: CSS parsing failed: missing '}'", + "file": "http://www.sudexpress.com/skin/frontend/sudexpress/default/css/styles.css", + "line": 4, + "column": 1 + }, + { + "error": "Empty CSS was provided", + "file": "http://www.sudexpress.com/skin/frontend/sudexpress/default/css/reset.css", + "line": null, + "column": null + } + ] + }); + }); + + + // Enough for the moment, to be complete... + }); diff --git a/test/core/indexTest.js b/test/core/indexTest.js index 715f2f9..2801b85 100644 --- a/test/core/indexTest.js +++ b/test/core/indexTest.js @@ -65,15 +65,22 @@ describe('index.js', function() { "message": "

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

It also slows down JavaScript modifications to the DOM because changing the dimensions of an element makes the browser re-calculate the dimensions of it's parents. Same thing for JavaScript events, that bubble up to the document root.

", "isOkThreshold": 10, "isBadThreshold": 20, - "isAbnormalThreshold": 28 + "isAbnormalThreshold": 28, + "hasOffenders": true }, "value": 1, "bad": false, "abnormal": false, "score": 100, "abnormalityScore": 0, - "offenders": "
body
h1[1]
", - "offendersCount": 1 + "offendersObj": { + "count": 1, + "tree": { + "body": { + "h1[1]": 1 + } + } + } }); // Test javascriptExecutionTree diff --git a/test/core/offendersHelpersTest.js b/test/core/offendersHelpersTest.js index d6632f0..6bf59c9 100644 --- a/test/core/offendersHelpersTest.js +++ b/test/core/offendersHelpersTest.js @@ -20,11 +20,15 @@ describe('offendersHelpers', function() { describe('listOfDomArraysToTree', function() { it('should transform a list of arrays into a tree', function() { - var result = offendersHelpers.listOfDomArraysToTree([ + var input = [ ['body', 'section#page', 'div.alternate-color', 'ul.retroGuide', 'li[0]', 'div.retro-chaine.france2'], ['body', 'section#page', 'div.alternate-color', 'ul.retroGuide', 'li[0]', 'div.retro-chaine.france2'], ['body', 'section#page', 'div.alternate-color', 'ul.retroGuide', 'li[1]', 'div.retro-chaine.france2'] - ]); + ]; + + var inputClone = input.slice(); + + var result = offendersHelpers.listOfDomArraysToTree(input); result.should.deep.equal({ 'body': { 'section#page': { @@ -41,95 +45,105 @@ describe('offendersHelpers', function() { } } }); + + input.should.deep.equal(inputClone); }); }); - describe('domTreeToHTML', function() { + describe('domPathToDomElementObj', function() { - it('should transform a dom tree into HTML with the awaited format', function() { - var result = offendersHelpers.domTreeToHTML({ - 'body': { - 'ul.retroGuide': { - 'li[0]': { - 'div.retro-chaine.france2': 2 - }, - 'li[1]': { - 'div.retro-chaine.france2': 1 + it('should transform html', function() { + var result = offendersHelpers.domPathToDomElementObj('html'); + result.should.deep.equal({ + type: 'html' + }); + }); + + it('should transform body', function() { + var result = offendersHelpers.domPathToDomElementObj('body'); + result.should.deep.equal({ + type: 'body' + }); + }); + + it('should transform head', function() { + var result = offendersHelpers.domPathToDomElementObj('head'); + result.should.deep.equal({ + type: 'head' + }); + }); + + it('should transform #document', function() { + var result = offendersHelpers.domPathToDomElementObj('#document'); + result.should.deep.equal({ + type: 'document' + }); + }); + + it('should transform window', function() { + var result = offendersHelpers.domPathToDomElementObj('window'); + result.should.deep.equal({ + type: 'window' + }); + }); + + it('should transform a standard in-body element', function() { + var result = offendersHelpers.domPathToDomElementObj('body > div#colorbox > div#cboxContent'); + result.should.deep.equal({ + type: 'domElement', + element: 'div#cboxContent', + tree: { + 'body': { + 'div#colorbox': { + 'div#cboxContent': 1 } } } }); - - result.should.equal('
body
ul.retroGuide
li[0]
div.retro-chaine.france2 (x2)
li[1]
div.retro-chaine.france2
'); - }); - - }); - - describe('listOfDomPathsToHTML', function() { - - it('should transform a list of path strings into HTML', function() { - var result = offendersHelpers.listOfDomPathsToHTML([ - 'body > ul.retroGuide > li[0] > div.retro-chaine.france2', - 'body > ul.retroGuide > li[1] > div.retro-chaine.france2', - 'body > ul.retroGuide > li[0] > div.retro-chaine.france2', - ]); - - result.should.equal('
body
ul.retroGuide
li[0]
div.retro-chaine.france2 (x2)
li[1]
div.retro-chaine.france2
'); - }); - - }); - - describe('domPathToButton', function() { - - it('should transform html', function() { - var result = offendersHelpers.domPathToButton('html'); - result.should.equal('
html
'); - }); - - it('should transform body', function() { - var result = offendersHelpers.domPathToButton('body'); - result.should.equal('
body
'); - }); - - it('should transform head', function() { - var result = offendersHelpers.domPathToButton('head'); - result.should.equal('
head
'); - }); - - it('should transform #document', function() { - var result = offendersHelpers.domPathToButton('#document'); - result.should.equal('
document
'); - }); - - it('should transform window', function() { - var result = offendersHelpers.domPathToButton('window'); - result.should.equal('
window
'); - }); - - it('should transform a standard in-body element', function() { - var result = offendersHelpers.domPathToButton('body > div#colorbox > div#cboxContent'); - result.should.equal('
DOM element div#cboxContent
body
div#colorbox
div#cboxContent
'); }); it('should transform a domFragment element', function() { - var result = offendersHelpers.domPathToButton('DocumentFragment'); - result.should.equal('
Fragment
'); + var result = offendersHelpers.domPathToDomElementObj('DocumentFragment'); + result.should.deep.equal({ + type: 'fragment' + }); }); it('should transform a domFragment element', function() { - var result = offendersHelpers.domPathToButton('DocumentFragment > div#colorbox > div#cboxContent'); - result.should.equal('
Fragment element div#cboxContent
DocumentFragment
div#colorbox
div#cboxContent
'); + var result = offendersHelpers.domPathToDomElementObj('DocumentFragment > div#colorbox > div#cboxContent'); + result.should.deep.equal({ + type: 'fragmentElement', + element: 'div#cboxContent', + tree: { + 'DocumentFragment': { + 'div#colorbox': { + 'div#cboxContent': 1 + } + } + } + }); }); it('should transform an not-attached element', function() { - var result = offendersHelpers.domPathToButton('div#sizcache'); - result.should.equal('
Created element div#sizcache
'); + var result = offendersHelpers.domPathToDomElementObj('div#sizcache'); + result.should.deep.equal({ + type: 'createdElement', + element: 'div#sizcache' + }); }); it('should transform an not-attached element path', function() { - var result = offendersHelpers.domPathToButton('div > div#sizcache'); - result.should.equal('
Created element div#sizcache
div
div#sizcache
'); + var result = offendersHelpers.domPathToDomElementObj('div > div#sizcache'); + result.should.deep.equal({ + type: 'createdElement', + element: 'div#sizcache', + tree: { + 'div': { + 'div#sizcache': 1 + } + } + }); }); }); @@ -160,32 +174,6 @@ describe('offendersHelpers', function() { }); - describe('backtraceArrayToHtml', function() { - - it('should create a button from a backtrace array', function() { - var result = offendersHelpers.backtraceArrayToHtml([ - { - file: 'http://pouet.com/js/jquery.footer-transverse-min-v1.0.20.js', - line: 1 - }, - { - functionName: 'callback', - file: 'http://pouet.com/js/main.js', - line: 1 - } - ]); - - result.should.equal(''); - }); - - it('should display "no backtrace"', function() { - var result = offendersHelpers.backtraceArrayToHtml([]); - - result.should.equal('
no backtrace
'); - }); - - }); - describe('sortVarsLikeChromeDevTools', function() { it('should sort in the same strange order', function() { @@ -248,12 +236,24 @@ describe('offendersHelpers', function() { describe('cssOffenderPattern', function() { it('should transform a css offender into an object', function() { - var result = offendersHelpers.cssOffenderPattern('.pagination .plus ul li @ 30:31862'); + var result = offendersHelpers.cssOffenderPattern('.pagination .plus ul li @ 30:31862'); result.should.deep.equal({ - offender: '.pagination .plus ul li', + css: '.pagination .plus ul li', + file: 'http://www.pouet.com/css/main.css', line: 30, - character: 31862 + column: 31862 + }); + }); + + it('should work with an inline css', function() { + var result = offendersHelpers.cssOffenderPattern('.pagination .plus ul li [inline CSS] @ 1:32'); + + result.should.deep.equal({ + css: '.pagination .plus ul li', + file: null, + line: 1, + column: 32 }); }); diff --git a/test/fixtures/rulesCheckerOutput.json b/test/fixtures/rulesCheckerOutput.json index 9f6ddf4..e12ad22 100644 --- a/test/fixtures/rulesCheckerOutput.json +++ b/test/fixtures/rulesCheckerOutput.json @@ -6,7 +6,8 @@ "message": "A great message", "isOkThreshold": 1000, "isBadThreshold": 3000, - "isAbnormalThreshold": 5000 + "isAbnormalThreshold": 5000, + "hasOffenders": false }, "value": 1236, "bad": true, @@ -22,11 +23,14 @@ "isOkThreshold": 1000, "isBadThreshold": 3000, "isAbnormalThreshold": 5000, + "hasOffenders": true, "takeOffendersFrom": "metric3" }, "value": 222, - "offenders": "offender1 - offender2", - "offendersCount": 2, + "offendersObj": { + "count": 2, + "str": "offender1 - offender2" + }, "bad": false, "abnormal": false, "score": 100, @@ -39,11 +43,14 @@ "message": "A great message", "isOkThreshold": 1000, "isBadThreshold": 3000, - "isAbnormalThreshold": 5000 + "isAbnormalThreshold": 5000, + "hasOffenders": true }, "value": 6666, - "offenders": "offender1/offender2", - "offendersCount": 2, + "offendersObj": { + "count": 2, + "test": "offender1/offender2" + }, "bad": true, "abnormal": true, "score": 0, @@ -56,10 +63,14 @@ "message": "A great message", "isOkThreshold": 1000, "isBadThreshold": 3000, - "isAbnormalThreshold": 5000 + "isAbnormalThreshold": 5000, + "hasOffenders": true }, "value": 1000, - "offenders": ["offender3"], + "offendersObj": { + "count": 1, + "list": ["offender3"] + }, "bad": false, "abnormal": false, "score": 100, @@ -73,10 +84,14 @@ "isOkThreshold": 1000, "isBadThreshold": 3000, "isAbnormalThreshold": 5000, + "hasOffenders": true, "takeOffendersFrom": ["metric3", "metric4"] }, "value": 3000, - "offenders": ["offender1", "offender2", "offender3"], + "offendersObj": { + "count": 3, + "list": ["offender1", "offender2", "offender3"] + }, "bad": true, "abnormal": false, "score": 0, @@ -120,7 +135,8 @@ "message": "

This is from another tool!

", "isOkThreshold": 0, "isBadThreshold": 3, - "isAbnormalThreshold": 11 + "isAbnormalThreshold": 11, + "hasOffenders": false }, "value": 22, "bad": true, diff --git a/test/fixtures/rulesCheckerPolicies.js b/test/fixtures/rulesCheckerPolicies.js index a4fecab..2391781 100644 --- a/test/fixtures/rulesCheckerPolicies.js +++ b/test/fixtures/rulesCheckerPolicies.js @@ -6,7 +6,8 @@ var policies = { "message": "A great message", "isOkThreshold": 1000, "isBadThreshold": 3000, - "isAbnormalThreshold": 5000 + "isAbnormalThreshold": 5000, + "hasOffenders": false }, "metric2": { "tool": "tool1", @@ -16,8 +17,12 @@ var policies = { "isBadThreshold": 3000, "isAbnormalThreshold": 5000, "takeOffendersFrom": "metric3", + "hasOffenders": true, "offendersTransformFn": function(offenders) { - return offenders.join(' - '); + return { + count: 2, + str: offenders.join(' - ') + }; } }, "metric3": { @@ -27,8 +32,12 @@ var policies = { "isOkThreshold": 1000, "isBadThreshold": 3000, "isAbnormalThreshold": 5000, + "hasOffenders": true, "offendersTransformFn": function(offenders) { - return offenders.join('/'); + return { + count: 2, + test: offenders.join('/') + }; } }, "metric4": { @@ -37,7 +46,8 @@ var policies = { "message": "A great message", "isOkThreshold": 1000, "isBadThreshold": 3000, - "isAbnormalThreshold": 5000 + "isAbnormalThreshold": 5000, + "hasOffenders": true, }, "metric5": { "tool": "tool1", @@ -46,6 +56,7 @@ var policies = { "isOkThreshold": 1000, "isBadThreshold": 3000, "isAbnormalThreshold": 5000, + "hasOffenders": true, "takeOffendersFrom": ["metric3", "metric4"] }, "metric6": { @@ -71,7 +82,8 @@ var policies = { "message": "

This is from another tool!

", "isOkThreshold": 0, "isBadThreshold": 3, - "isAbnormalThreshold": 11 + "isAbnormalThreshold": 11, + "hasOffenders": false, }, "unexistantMetric": { @@ -80,13 +92,15 @@ var policies = { "message": "", "isOkThreshold": 1000, "isBadThreshold": 3000, - "isAbnormalThreshold": 5000 + "isAbnormalThreshold": 5000, + "hasOffenders": true }, "unexistantTool": { "tool": "unexistant", "isOkThreshold": 1000, "isBadThreshold": 3000, - "isAbnormalThreshold": 5000 + "isAbnormalThreshold": 5000, + "hasOffenders": false } }; From 61c09a5b49da9627b973596f88ef830aca4d39d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Mon, 26 Jan 2015 15:10:26 +0100 Subject: [PATCH 18/54] Revert custom dev and implement jQueryVersionsLoaded --- front/src/views/rule.html | 2 +- lib/metadata/policies.js | 4 +- lib/metadata/scoreProfileGeneric.json | 2 +- .../custom_modules/modules/jQYLT/jQYLT.js | 108 ++++++++++-------- 4 files changed, 65 insertions(+), 51 deletions(-) diff --git a/front/src/views/rule.html b/front/src/views/rule.html index 28e3c31..ab7d250 100644 --- a/front/src/views/rule.html +++ b/front/src/views/rule.html @@ -128,7 +128,7 @@ ()
-
+
{{offender}}
diff --git a/lib/metadata/policies.js b/lib/metadata/policies.js index 00e667b..ac11d83 100644 --- a/lib/metadata/policies.js +++ b/lib/metadata/policies.js @@ -273,7 +273,7 @@ var policies = { "message": "

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

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

", "hasOffenders": false, "scoreFn": function(data) { - var differentVersions = data.toolsResults.phantomas.metrics.jQueryDifferentVersions; + var differentVersions = data.toolsResults.phantomas.metrics.jQueryVersionsLoaded; if (differentVersions === 0 || differentVersions > 1 || !data.toolsResults.phantomas.metrics.jQueryVersion) { // Not applicable @@ -322,7 +322,7 @@ var policies = { } } }, - "jQueryDifferentVersions": { + "jQueryVersionsLoaded": { "tool": "phantomas", "label": "Several versions loaded", "message": "

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

", diff --git a/lib/metadata/scoreProfileGeneric.json b/lib/metadata/scoreProfileGeneric.json index 48a81bb..8a3b2d3 100644 --- a/lib/metadata/scoreProfileGeneric.json +++ b/lib/metadata/scoreProfileGeneric.json @@ -32,7 +32,7 @@ "label": "jQuery version", "policies": { "jQueryVersion": 5, - "jQueryDifferentVersions": 0.1 + "jQueryVersionsLoaded": 0.1 } }, "cssSyntaxError": { diff --git a/lib/tools/phantomas/custom_modules/modules/jQYLT/jQYLT.js b/lib/tools/phantomas/custom_modules/modules/jQYLT/jQYLT.js index d76db71..c4a962d 100644 --- a/lib/tools/phantomas/custom_modules/modules/jQYLT/jQYLT.js +++ b/lib/tools/phantomas/custom_modules/modules/jQYLT/jQYLT.js @@ -7,16 +7,17 @@ /* global document: true, window: true */ /* jshint -W030 */ -exports.version = '0.2.a'; +exports.version = '1.0.a'; exports.module = function(phantomas) { 'use strict'; phantomas.setMetric('jQueryVersion', ''); // @desc version of jQuery framework (if loaded) [string] + phantomas.setMetric('jQueryVersionsLoaded'); // @desc number of loaded jQuery "instances" (even in the same version) phantomas.setMetric('jQueryOnDOMReadyFunctions'); // @desc number of functions bound to onDOMReady event + phantomas.setMetric('jQueryWindowOnLoadFunctions'); // @desc number of functions bound to windowOnLoad event phantomas.setMetric('jQuerySizzleCalls'); // @desc number of calls to Sizzle (including those that will be resolved using querySelectorAll) - phantomas.setMetric('jQuerySizzleCallsDuplicated'); // @desc number of calls on the same Sizzle request - phantomas.setMetric('jQueryDifferentVersions'); //@desc number of different jQuery versions loaded on the page (not counting iframes) + //phantomas.setMetric('jQueryEventTriggers'); // @desc number of jQuery event triggers var jQueryFunctions = [ // DOM manipulations @@ -110,30 +111,28 @@ exports.module = function(phantomas) { phantomas.evaluate(function(jQueryFunctions) { (function(phantomas) { var jQuery; + var oldJQuery; - // TODO: create a helper - phantomas.spyGlobalVar() ? - window.__defineSetter__('jQuery', function(val) { + phantomas.spyGlobalVar('jQuery', function(jQuery) { var version; - var jQueryFn; - var oldJQuery = jQuery; - if (!val || !val.fn) { + if (!jQuery || !jQuery.fn) { phantomas.log('jQuery: unable to detect version!'); return; } - version = val.fn.jquery; - jQuery = val; - jQueryFn = val.fn; - // Older jQuery (v?.?) compatibility - if (!jQueryFn) { - jQueryFn = jQuery; + // Tag the current version of jQuery to avoid multiple reports of jQuery being loaded + // when it's actually only restored via $.noConflict(true) - see comments in #435 + if (jQuery.__phantomas === true) { + phantomas.log('jQuery: this instance has already been seen by phantomas'); + return; } + jQuery.__phantomas = true; - phantomas.log('jQuery: loaded v' + version); - phantomas.setMetric('jQueryVersion', version); + // report the version of jQuery + version = jQuery.fn.jquery; phantomas.emit('jQueryLoaded', version); - + phantomas.pushContext({ type: (oldJQuery) ? 'jQuery version change' : 'jQuery loaded', callDetails: { @@ -141,11 +140,13 @@ exports.module = function(phantomas) { }, backtrace: phantomas.getBacktrace() }); + oldJQuery = version; // jQuery.ready.promise // works for jQuery 1.8.0+ (released Aug 09 2012) - phantomas.spy(val.ready, 'promise', function(func) { + phantomas.spy(jQuery.ready, 'promise', function(func) { phantomas.incrMetric('jQueryOnDOMReadyFunctions'); + phantomas.addOffender('jQueryOnDOMReadyFunctions', phantomas.getCaller(3)); phantomas.pushContext({ type: 'jQuery - onDOMReady', @@ -160,9 +161,9 @@ exports.module = function(phantomas) { // Sizzle calls - jQuery.find // works for jQuery 1.3+ (released Jan 13 2009) - phantomas.spy(val, 'find', function(selector, context) { + phantomas.spy(jQuery, 'find', function(selector, context) { phantomas.incrMetric('jQuerySizzleCalls'); - phantomas.emit('onSizzleCall', selector + ' (context: ' + (phantomas.getDOMPath(context) || 'unknown') + ')'); + phantomas.addOffender('jQuerySizzleCalls', '%s (in %s)', selector, (phantomas.getDOMPath(context) || 'unknown')); phantomas.enterContext({ type: 'jQuery - find', @@ -183,12 +184,39 @@ exports.module = function(phantomas) { phantomas.leaveContext(moreData); }) || phantomas.log('jQuery: can not measure jQuerySizzleCalls (jQuery used on the page is too old)!'); + /*if (!jQuery.event) { + phantomas.spy(jQuery.event, 'trigger', function(ev, data, elem) { + var path = phantomas.getDOMPath(elem), + type = ev.type || ev; + + phantomas.log('Event: triggered "%s" on "%s"', type, path); + + phantomas.incrMetric('jQueryEventTriggers'); + phantomas.addOffender('jQueryEventTriggers', '"%s" on "%s"', type, path); + }); + }*/ + + // jQuery events bound to window' onLoad event (#451) + phantomas.spy(jQuery.fn, 'on', function(eventName, func) { + if ((eventName === 'load') && (this[0] === window)) { + phantomas.incrMetric('jQueryWindowOnLoadFunctions'); + phantomas.addOffender('jQueryWindowOnLoadFunctions', phantomas.getCaller(2)); + + phantomas.pushContext({ + type: 'jQuery - windowOnLoad', + callDetails: { + arguments: [func] + }, + backtrace: phantomas.getBacktrace() + }); + } + }); // Add spys on many jQuery functions jQueryFunctions.forEach(function(functionName) { var capitalizedName = functionName.substring(0,1).toUpperCase() + functionName.substring(1); - phantomas.spy(jQueryFn, functionName, function(args) { + phantomas.spy(jQuery.fn, functionName, function(args) { // Clean args args = [].slice.call(arguments); @@ -263,42 +291,28 @@ exports.module = function(phantomas) { phantomas.leaveContext(); }) || phantomas.log('jQuery: can not track jQuery - ' + capitalizedName + ' (this version of jQuery doesn\'t support it)'); }); - - - }); - - window.__defineGetter__('jQuery', function() { - return jQuery; }); })(window.__phantomas); }, jQueryFunctions); }); - // count Sizzle calls to detect duplicated queries - var Collection = require('../../util/collection'), - sizzleCalls = new Collection(), - jQueryLoading = new Collection(); - - phantomas.on('onSizzleCall', function(request) { - sizzleCalls.push(request); + // store the last resource that was received + // try to report where given jQuery version was loaded from + phantomas.on('recv', function(entry) { + if (entry.isJS) { + lastUrl = entry.url; + } }); phantomas.on('jQueryLoaded', function(version) { - jQueryLoading.push(version); - }); + phantomas.log('jQuery: loaded v' + version); + phantomas.setMetric('jQueryVersion', version); - phantomas.on('report', function() { - sizzleCalls.sort().forEach(function(id, cnt) { - if (cnt > 1) { - phantomas.incrMetric('jQuerySizzleCallsDuplicated'); - phantomas.addOffender('jQuerySizzleCallsDuplicated', '%s: %d', id, cnt); - } - }); + // report multiple jQuery "instances" (issue #435) + phantomas.incrMetric('jQueryVersionsLoaded'); + phantomas.addOffender('jQueryVersionsLoaded', 'v%s', version); - jQueryLoading.forEach(function(version) { - phantomas.incrMetric('jQueryDifferentVersions'); - phantomas.addOffender('jQueryDifferentVersions', '%s', version); - }); + phantomas.log('jQuery: v%s (probably loaded from <%s>)', version, lastUrl); }); }; From ed4ddc0a432893d0ffd8a3e070f766712045791e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Mon, 26 Jan 2015 19:29:08 +0100 Subject: [PATCH 19/54] Add CSS colors count and a palette as offender --- front/src/css/rule.css | 42 +++++++++++++++++++++++ front/src/less/rule.less | 48 +++++++++++++++++++++++++++ front/src/views/rule.html | 7 ++++ lib/metadata/policies.js | 46 +++++++++++++++++++++++++ lib/metadata/scoreProfileGeneric.json | 3 +- lib/rulesChecker.js | 2 +- test/core/customPoliciesTest.js | 16 ++++----- 7 files changed, 154 insertions(+), 10 deletions(-) diff --git a/front/src/css/rule.css b/front/src/css/rule.css index b66c470..2a2dce0 100644 --- a/front/src/css/rule.css +++ b/front/src/css/rule.css @@ -148,3 +148,45 @@ font-style: italic; font-weight: normal; } +.colorPalette { + width: 30em; + border: 2px solid #000; + text-align: left; + /* Checkerboard background */ + background-color: #ddd; + background-image: linear-gradient(45deg, #aaaaaa 25%, transparent 25%, transparent 75%, #aaaaaa 75%, #aaaaaa), linear-gradient(45deg, #aaaaaa 25%, transparent 25%, transparent 75%, #aaaaaa 75%, #aaaaaa); + background-size: 1em 1em; + background-position: 0 0, 0.5em 0.5em; +} +.colorPalette > div { + display: inline-block; + height: 2em; + position: relative; +} +.colorPalette > div div { + display: none; + position: absolute; + left: 100%; + top: 100%; + background: #FFF; + padding: 0.5em; + border: 2px solid #f1c40f; + border-radius: 0.5em; + white-space: nowrap; + z-index: 3; + font-weight: bold; +} +.colorPalette > div:hover div { + display: block; +} +.colorPalette > div:hover:after { + content: " "; + position: absolute; + background-color: inherit; + left: -0.2em; + top: -0.2em; + width: 100%; + height: 100%; + z-index: 2; + border: 0.2em solid #f1c40f; +} diff --git a/front/src/less/rule.less b/front/src/less/rule.less index ccf862a..ce68297 100644 --- a/front/src/less/rule.less +++ b/front/src/less/rule.less @@ -168,4 +168,52 @@ font-weight: normal; } } +} + +.colorPalette { + width: 30em; + border: 2px solid #000; + text-align: left; + + /* Checkerboard background */ + background-color: #ddd; + background-image: linear-gradient(45deg, #AAA 25%, transparent 25%, transparent 75%, #AAA 75%, #AAA), linear-gradient(45deg, #AAA 25%, transparent 25%, transparent 75%, #AAA 75%, #AAA); + background-size:1em 1em; + background-position:0 0, 0.5em 0.5em; + + > div { + display: inline-block; + height: 2em; + position: relative; + + div { + display: none; + position: absolute; + left: 100%; + top: 100%; + background: #FFF; + padding: 0.5em; + border: 2px solid #f1c40f; + border-radius: 0.5em; + white-space: nowrap; + z-index: 3; + font-weight: bold; + } + + &:hover div { + display: block; + } + + &:hover:after { + content: " "; + position: absolute; + background-color: inherit; + left: -0.2em; + top: -0.2em; + width: 100%; + height: 100%; + z-index: 2; + border: 0.2em solid #f1c40f; + } + } } \ No newline at end of file diff --git a/front/src/views/rule.html b/front/src/views/rule.html index ab7d250..e954f73 100644 --- a/front/src/views/rule.html +++ b/front/src/views/rule.html @@ -143,6 +143,13 @@
+
+

This is the color palette, sized by total occurrences:

+
+
{{offender.color}} ({{offender.occurrences}} times)
+
+
+
diff --git a/lib/metadata/policies.js b/lib/metadata/policies.js index ac11d83..92b99e0 100644 --- a/lib/metadata/policies.js +++ b/lib/metadata/policies.js @@ -410,6 +410,52 @@ var policies = { }; } }, + "cssColors": { + "tool": "phantomas", + "label": "Different colors", + "message": "

This is the number of different colors defined in CSS.

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

", + "isOkThreshold": 30, + "isBadThreshold": 150, + "isAbnormalThreshold": 300, + "hasOffenders": true, + "offendersTransformFn": function(offenders, ruleObject) { + var deduplicatedObj = {}; + + offenders.map(function(offender) { + var parts = /^([^ ]*) \((\d+) times\)$/.exec(offender); + + if (!parts) { + debug('cssColors offenders transform function error with "%s"', offender); + return; + } + + var color = parts[1]; + var count = parseInt(parts[2], 10); + + deduplicatedObj[color] = (deduplicatedObj[color] || 0) + count; + }); + + var deduplicatedTable = []; + for (var color in deduplicatedObj) { + deduplicatedTable.push({ + color: color, + occurrences: deduplicatedObj[color] + }); + } + + deduplicatedTable.sort(function(a, b) { + return b.occurrences - a.occurrences; + }); + + // Override rules.value + ruleObject.value = deduplicatedTable.length; + + return { + count: deduplicatedTable.length, + palette: deduplicatedTable + }; + } + }, "cssImports": { "tool": "phantomas", "label": "Uses of @import", diff --git a/lib/metadata/scoreProfileGeneric.json b/lib/metadata/scoreProfileGeneric.json index 8a3b2d3..044db52 100644 --- a/lib/metadata/scoreProfileGeneric.json +++ b/lib/metadata/scoreProfileGeneric.json @@ -46,7 +46,8 @@ "policies": { "cssRules": 2, "cssComplexSelectors": 2, - "cssComplexSelectorsByAttribute": 1.5 + "cssComplexSelectorsByAttribute": 1.5, + "cssColors": 0.5 } }, "badCSS": { diff --git a/lib/rulesChecker.js b/lib/rulesChecker.js index b82b7d7..bc570a7 100644 --- a/lib/rulesChecker.js +++ b/lib/rulesChecker.js @@ -65,7 +65,7 @@ var RulesChecker = function() { if (policy.offendersTransformFn) { try { - offendersObj = policy.offendersTransformFn(offenders); + offendersObj = policy.offendersTransformFn(offenders, rule); } catch(err) { debug('Error while transforming offenders for %s', metricName); debug(err); diff --git a/test/core/customPoliciesTest.js b/test/core/customPoliciesTest.js index 302b1c4..abfb601 100644 --- a/test/core/customPoliciesTest.js +++ b/test/core/customPoliciesTest.js @@ -404,20 +404,20 @@ describe('rulesChecker', function() { results.should.deep.equals({}); - // If jQueryDifferentVersions is 0 + // If jQueryVersionsLoaded is 0 results = rulesChecker.check({ "toolsResults": { "phantomas": { "metrics": { "jQueryVersion": "1.6.0", - "jQueryDifferentVersions": 0 + "jQueryVersionsLoaded": 0 } } } }, policies); results.should.not.have.a.property('jQueryVersion'); - results.should.have.a.property('jQueryDifferentVersions'); - results.jQueryDifferentVersions.should.have.a.property('score').that.equals(100); + results.should.have.a.property('jQueryVersionsLoaded'); + results.jQueryVersionsLoaded.should.have.a.property('score').that.equals(100); // If there are more than 1 jQuery version @@ -426,15 +426,15 @@ describe('rulesChecker', function() { "phantomas": { "metrics": { "jQueryVersion": "1.6.0", - "jQueryDifferentVersions": 2 + "jQueryVersionsLoaded": 2 } } } }, policies); results.should.not.have.a.property('jQueryVersion'); - results.should.have.a.property('jQueryDifferentVersions'); - results.jQueryDifferentVersions.should.have.a.property('score').that.equals(0); - results.jQueryDifferentVersions.should.have.a.property('abnormal').that.equals(true); + results.should.have.a.property('jQueryVersionsLoaded'); + results.jQueryVersionsLoaded.should.have.a.property('score').that.equals(0); + results.jQueryVersionsLoaded.should.have.a.property('abnormal').that.equals(true); }); From d5368e5f90efa026b6ab4c77130aa6ce636c2560 Mon Sep 17 00:00:00 2001 From: Loic Kartono Date: Tue, 3 Feb 2015 10:51:48 +1100 Subject: [PATCH 20/54] Chore : Fix typo on policies --- lib/metadata/policies.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/metadata/policies.js b/lib/metadata/policies.js index b2b70fd..6051996 100644 --- a/lib/metadata/policies.js +++ b/lib/metadata/policies.js @@ -186,7 +186,7 @@ var policies = { "cssRules": { "tool": "phantomas", "label": "Rules count", - "message": "

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

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

", + "message": "

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

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

", "isOkThreshold": 500, "isBadThreshold": 2500, "isAbnormalThreshold": 4000 @@ -395,7 +395,7 @@ var policies = { "notFound": { "tool": "phantomas", "label": "404 not found", - "message": "

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

", + "message": "

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

", "isOkThreshold": 0, "isBadThreshold": 1, "isAbnormalThreshold": 1 @@ -403,7 +403,7 @@ var policies = { "closedConnections": { "tool": "phantomas", "label": "Connections closed", - "message": "

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

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

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

", + "message": "

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

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

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

", "isOkThreshold": 0, "isBadThreshold": 8, "isAbnormalThreshold": 20 From 798f295912b38394e69114d50a4c9a3868ef80ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Tue, 3 Feb 2015 20:23:09 +0100 Subject: [PATCH 21/54] Remove double jsHint check --- Gruntfile.js | 1 - screenshot.png | Bin 136058 -> 0 bytes 2 files changed, 1 deletion(-) delete mode 100644 screenshot.png diff --git a/Gruntfile.js b/Gruntfile.js index 819db4f..9525960 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -330,7 +330,6 @@ module.exports = function(grunt) { grunt.registerTask('test', [ 'build', - 'jshint', 'express:testSuite', 'clean:coverage', 'copy-test-server-settings', diff --git a/screenshot.png b/screenshot.png deleted file mode 100644 index b95be34e5942b55f1b1c1fc1bf5aca0d13422922..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 136058 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 z zN>A>mAaX(4ql(6mImdg>5yJ;nkfw1^G9lD?9+y*Zg z>O962l3~t#>j|^6@B{hQJl&`0Z|gTBogZyHceyPf9@64v;(H4BYYFw6ezW6+ zXhah_r6k}5metB5ZDDhnBzTlBDH>2hSx7`G8;YcM89_G&_p?2AYF&FJ*(@r`KS~MU zU7%5WoA)C!4zU=TJI2k%$%pBTAs5As z=1SpCsNmvRSwSMeMWdj9hr*KljPT1}3cQ;S-&}k0!Y(8jtfom=@P3BJPvh`Jj2hz; zw*dc<5Hcde7AXxm7Q5o+L2Mp(66FkczHn_5JX2q*o1o^90?f9e3BujG6#W=0#0sN+ z!Gpt!&kKWZu5-w`8s%hoKy`U6@rg>gPVO>u2pI{aeEw_1K=)oPCU1EJycAE1zt%us z+(0_zki4H)_FYQo5ax=AcxmNTq803b;<*6@J%uk_^{_xs9h6VCBJuL>zj?|nm|jSt z>>~JVbHevmz8I1FYqkY!AtTTa_)3)vz z;n8ZhR-UIti3_<6G>2%byLSB51!yd;-4I!v_!wPE3KCpQl7g*PsK7EUR!52OtU884 zr?$pP4r``bh?KaI{Br|2Mx_kBjVtdEp5F51rZ@_G8FrNV_uP)v`H!y>LkUKBRppFacq@QNsd1) zxzrtNivh=&7)#*uB)+lbL8GWwTC=G21F`vCiT z`}pjTlGx3-(Qay}=hBM+ADxMtldp$Iumpog*0Sh>dtzbugTkE&EICRlaudOnf-DJ% zZf63f1Os-#q85WTk0hRLCQ0IQ!+O55#^P;_axg&pr zp|_@F*{+=rZZiZgGLF_@=TR=fjG;EKB2;V9Sn$Fv>uvtdl0fD&i!dbn$Ywl*Z6;qC z>s}7_UA>F3t1EXiBO`}SL_iY0HO!k$YECo=I?!+?^4lh#7UiTsv9YY%UmZ6Tx?3OQ zN_*@Zg5Brn%eB@aygD}RF(goTxIt7zguDl97Y;V)YM~mnM|=vQJd!-owUIP44Uo8E z8A-l7lco-}ffxdRHF_-`$5eeas&Uk?@qtx~hOHMJ|i*j{2Aj}On*KQjB9p+!>gc>Q$x(_!I;Aa&#oh z{IwITV^3Yx?6yCYge0TE)N&F-LTKRG==ZM(_~-AusM)M~R!2+85batwVXQ=Zv+H$9 z$dw>d@y^-*^zibBhu5zbfWI2s@M!sY+1{}~o(MHpvG=KExHt4}fu0+#l0fJ)E#OIg zLWu9o=p)D#$e1$VD>G&jczt#8vCNUD;yaFpfgE8LZX?P-_EhQ1Pd^;)QViJ<15CQ^ zUjR}&<^$xS0+v@z2Z7#G^^{`2q{m=Im^WmOds{)HEaYUNMrSK40q<47@Ag?UhHy3mzDUvPOlj+SZIT8 zv9x^~LH(=iDJvm?weKr%9h!_Jq`8XrJulb;oga46`_J*qOAw{W-3ywJ_asGS+CWJ$ zW)hJYF84gb;(--w1s^Z3ZA`@oQ8VMVw8{?(r#D)Oa-gVK8{DM-haLN6zbySLHil#1RP)LsiCVvsTvWu`c7Y3$h zc-Pe}&OzvLzp*EbI(XnWehYzt@Yr&XYhvQ>?#AVb#;jlafkG&?pvO_o(7*M6zy8dx zTL5_KbAm>^rllZBM7QqMqe(aQvrZN{Tu7S+M54U_jW(U z7h$EKwe*Ji63elYbcVTNz;Fe%#I@bkhLi5mEtV+0e)Hpf>nvy^zb`;`qUYkqgL?C` zRBvb^b@sidr)+lAbOgf^5)$Hhp03KueZV}DeS@8)gG6nrA@RtNi=T8S5opcG6V03)`J2S?U5ex-;O8#+z-3!OOZj=T#-H(?#7k~R0Tf;`OPJGxn+A7ORTGJ*Ul&TuHJ zpg}1Hx@h)YXR5S_(gIJ1^;DSqt~ZHuCqr$V6+6JD(t;}sUtP6{pRkNRC#qp(kuE8r zA`t)>Sn3Vl>gF&Z@;Mw_3Q>kimZ8$Jg3WA#2Z8{>UiI$X!8|{l;y> z!ouO5URZaOR=5rXPtW21KBJXTra(_KG~5H0y9^h&aIGC3_VJ=>PF`xwf8}l?`sc)> zeu_q7UYd^6n3s#QK_FbIBu}P*IX`*>l|lX#ot&i_eI@G4fyFqnSr<#jY5^V*<9+V6 zaljz_G%hRn2BQFcFAx_cu4@}s#8hzwd4B)zcEp*#KZbNulF_g$W+Bc>@qN-nkdMUt zgjam$&vJ?@zk;nZ|B#BFzi!1QD2`n^3Viy`W9by1YUOpg)#5GoqN}G-%nPU2CN42 zsIF!t(GNTRy#3RU^oB+sSH;rXuJ8;Yr}@M7M;U~!H&D1H`pC80|)dCjs zb;gV-e!ZHm?(Ay* zHqQ45bIlcI5}WAN(8v`fCz1!uH3iInaW{bC-K9kLb+oj#p+!d5K`+9+TA&GloAjV0 zA9^{1wc-1^IkNI=N&R!gcRy#Jm4mr|V!p2%IO4PO?$vAXcQbSi4(&&HOL6T825(ea zsy-)mr>be+AI|sM+Ir_o3+wV(bTj|mA>P}fjzTPJ#lSJpBP-A)^ajzo2fRZh_dk;B zV^*izv0EGKjbJ-r4~y83BwBM}y?mvU1;?I=s#&>XV8LB{CsMlYtb&8^;3ISSRxBki zP+ELoVKJ6XUJ|t#0vNqU4d868+`6>xbqWO*zU#9*@Oio?# z+#S`{f92z#W28ys1VTl6%KP%g?d5XP@GcuIzJ!?Dsj>~XU)s*AA=o~njD zJ>|kRqcXFjK8$eOW5D??j6f?qw90vdEPtAqm4@BHoaYBX&}8tU02- zZPJQ|XW+<|RIa7_EhLlrkyHKAz29Z!XaXu{UI3p%%ZTX9JWmtTir39MA|yqqf!gT< z<2stFGAsv=^FdWzh9HmW(7oUn&eMv7#Ih$}evIz#J^$77`iB1Rx!+0iqdaGGh^^-C zOxKg03-WLMRCgfZg%8Z5q@T69cgoxNh5z5ANky433$Tel@)e2}9in6U>|jpo;+FTb zayum*#f*k!jIzwfTE$h+Oyv;uk>X~?9q8OZ|78V=!kNwjru9nc>9KjrYeG-wMP2Sn zD2L%Tqn(iA7bm)7z78?3w1GL6y~BEYfP27V$xL`{&u>cu_QHu zjnDM%or=&ckunhiSq2Oh+(W%)9-;!@?;bb~EL8=3k0HZEy%buZF>8lg4(Lnhi13$@ z0;4Fb2b|`SER&e}LaeUQ_X~9)G5t#WKDZ`ogZF_2tAd)^uEujhVL@6(e&Kz&S1WF* z(Fs#i25R&Rq3o-0_JQar{f(7_s#&T?r(?yH?-MdmO@%J8s}#Mx=Be46#{FXBVr_ky zMqQeW$?GFV?7P2*Fh(QWdVSkQNw4*Wnie_~r;+=LDYq-pPCzl?%u(wr2+)0*G#S>y zoKfsZ;jBP2z2ot-uszwz?+cpY!^dM>D0RK&r!2qm*5PEt@fy4qQt+fF1s>7T^BlRy zc}h)*%;p>GO)CV;OiT2U2_$ioUw1S(gn=lBA65x@k;G=A>ss5<-Br`|B$jU?!4(bD z#trut{fdFs6Ve(qs@maCnxQ=KSE3A$gA1f6U{sZ`&yrHD!FDb>b_zP{_dk41sw+IG z(9?FUINcr~lKDpe^ouFD7V*PIua{3&6Sv#&@gsCTXA4Jy{2)8{#XW%@t5y(ykSHN`;*6vum`m`fSJOd) z>mgaw@|o+wM8OZUpDn2r8NWXjTrC^rr~l4bhR>UR8!!(0)tqzZ1^Sij>~Djlq9RuR z*B^p^pa1buDSC?Y)f=8Egcj9>(lGFZ9jt7ofM|$hm{QO6wf#ODB78m%prKLCPT+gM5D`=to~=p4oW0?KV3D=QFEPyizQnm zcgb5BpApRORXHoTpER(GunKz0^Jc=slb$ifLaXcs$xlE>67FGB4^&gUW*!=^WMdwB zsEGaK&eusO&Q)}fa!L|_fV;7!b0ai)a}o%Fk{_v8@m8uc{`QQ5|2H0d{k@6o<|=>s zHU+uQ6ZmAqbE;V3{kooQXKfiv+V%o#aStZD(sL1VS6h~CUWI>UN`EYq-)s)Vq~KCx z$l+q@Z#)^twtV*Z?)^2P`Nq#i{rNO;aI_eB#QHK^$&NmbF|gxMPk!ND;p@T-m3~Ew z5~ZW>ZLG1=4HaGYh$AVhb+_tigm}I-o)csX@O+HhO}iGFkxPfEi(4u zztp7*Wu5ic)yjDzM+|iVl1xITCHotX40F{_tA@|(|2!EVR|t=xLlar$Xs@lUEh&jm zmw)4E5mG?jT~9Lu1esc1(zKrdm9wfUqdM0dJkiKDB9JUy~kTTEQ}{S-C_J2+uL`xB{HJfkA8H*^@irU zSA_h7Z{=}X&KT*PVsHv&d=AHJ)mgO0*J=6}sNOyzWrfmwRR%VxLBfEs*h8dP70RhO zZ;PQtVryF}KZP)076ck{S9Jq4V*W{!BuSyT&Sva%-_3$%?0z8$g&>?dnA~Lj?&k+M zCO4jnnVf9p==UiES#8XCGQ-x&A5lS3<7q+!9Fri~Gs;AIc$kIm$_wPh_E z6T00~SSdU(Oq>hkUAfOFC-t4#EA3A%S7ja-7+A2l|K(iL4>RhhCtumuN^GOvs_(r4&^fJW3@oe2M>1jr)bC z{^uh@clXHrkBr||$R3H|Q9V(+<7hCa3DqX~TbKX5aiK4p@L8+p$%{wtfc&q-&k=Fh zkE>J3o11FQ4kX#q3)Uc&x0)IzBJI=-BX(0=>u&pYuEszg+bC+cV`L?b!#D`14oddd z=3$7oJFXadOMwhS*Im#Z7{1CWCLwtx0JK5o8=nA~?V8F;o2IvK_ZDmh50 zYlkz_K!$iO)D$L79+{b`w@pt>6nyRr6GX;8Ze{Z&He{{J?ZWJy6YoVRPyu^eQN%Ds zYXOfZi?Ok>SF{3ExqIpsI{$-uAj+2C*aQ@pZGqC$ZoOx0k|OFU}i>~tIk2N{nGSWo{q^ZuXu4NH(^s=Ma8>Nziub|8$a;d*w?&nJS!s&#^YWW(F za5lur@84Tn_)}>eTI0F~X`e2~+N6NkGv6hA1RPa(L-1~#qAsrXqQ_4oJh`K6>@pE1 zr-)RpI%FJ9QG4r=Nq@rNRKS5-E~Oo8B+EnCDhy4x%CsTs-U86bn#Ra-wt^K=HnRXu zNXQ?p?jaMcGK*cj{1<mpgW94PM>xZAC z*{X%5Ig-Z&)ME+x&HQtbgXXQ?G1-{-p$#GScB>&~HPodD>ZG&B-?JQEg-K^4 zOzdH|$Cq9NteXluAMI`d4F>o--BiV&4CS{&t-rLEkAgGC^ugOtcThD(>4-!VJCFpi zNd#h8K!S#C4YaY#PHbTAFKyD%KUzJ(Q)R)%K>xVmMfH^NzIR1gS(Ja9jfxQe0Lp!L zP8jlKVL=g3pL|Y)FD|RYqut8m56=b)mfXee6P!nrHO!(LWM}QP*7{x?onYP zCGU5K5n5>GWdXN{kDUn^f(yRzmo$zK$MZYk>&fAW^?tW>>nubjLOdbnZlP(E47ypw z-bY1BE7;jpe%;+r(|!UsUzM1s`DRKh{XfTk-)T9@Zhj2!yY;{g((GVbeQ6>Or=O+U zLWzk*k~p#YP53Z%(ao2CEsRGK3Oj${b7A-{r~K6pp3oQm)D$nXiJUrogFW`I>C!~O z@P+^VgS$Y>{H3QBNlM4vtp5DbnD3vUwm?<2st@f|t#>55cnDsR^LhjHOAM5V2t z#Vm7AH@Qxzc8F{U(E;_Ujb3I({Du)asuN4zQbEK|beS!ty;n^j=jYaRxhW=wIc}X; zud9W`6@DW}Ei{tAI};+9|?>JQtCAJ(mH7 zGWDG~W6IK}vw@1>>*B+0H)QTW#Q43na;~#`s)rAPn@{_r0Sq7&QClOFRj;F0?=*AA zeiEAxQ>gH&4AAU*A9U)QKwOFB!lyD;nVGKC8g3PtUps4f zqt*8UR+!^!p02MXA$&Y*5C?E2ZAy45m>SV`T{rJ~ui0hQtY9BS73I{ND^1cgVq*7f zfGTf}fCxsd;)NadCS6{VZz6=0 zBwry;vZ6DliZ`^<+k3a1me;h_@40?@oqH-`qU;yu6TLZx(V>9Ld7RnkL)e$h3Wg+t z_9yXV2TE43@72KHq0>mbq)j7{=+COmLDO148822kslf{CQD!Yt*>~>CzbV6&fY+Id zHX>y=*spQL#@HbB=<5ax9(CDk)2Iz8PJ5Vzt<8U9!=ujSF#UD5`kH6^axCRgBxNnV zhIgu5rsb&yW4zhhwen)6zeCQ_Y+JhCPNgQ07R|Gc+I+ITqxf6>Uh&r>byZLC?oLlfPhrqORhou;c|ACq-X?3*Mu@0XN z%#COg+xcNHJUT`7K3eM+e9|M70r^`ow%L=A!aJv2w%i8@Byt61H3SW}z7OO9h*qE~ z1nq@KK7JWZ#`CP_Z}o4E23FHwH@_Eioi68e%_3`3`m0&g zOD;&*aq1DPOtoc3iGYc7W*R<7&R%jIFX16&(OI|9j&!&-mzozaGgs77uVXHIT{iS)U82nwdi4d-^1)O^5M$+Ey z?q{Cim$6nj^ae=cutidI_N5G#^W={?pFN3#_Vjy6`g=<2T$PdGNpXlyOB!foNj9Pn zpYDKkDm5`B%>s?Y(>u5e;g;bS$>wy?T3Gm` z!+(VbYpZaE_=@$q-rb@gxpirlLTYameH<>~d=1ds1I4%IWzxS8n0}YkRHYMtYxm{7 z8m6{C0ix$?+`^J)7c$l|UQ1p@y(3&N^Y;E+=F4jQ<*gF9xM_fnO~&x$c7~AP_XG;% z;jZ3|R(1C>;YRQ1yzF$z$@)M~pDMyX5F?jN>eDBG;7syUVJ}iZh`}BA5+b2dF(U8_ zQntCii-s;FjGN~m5YaBKK}ESsH^)j*5`H?=*d0s!>|G%Q8w*p$D#B0kyo`Z`EQzSK z4EqDYST_}VEt-#B*4r`yjIar|C<w0_vIxDt(&uwX?3LpYsUgBF>Pse63o z_&wDpp+{Pr@0z^scqxBJf6Rk~)ZiEQ2`NYs{a~cnHg}cDiKf%ZZnXoW0fKgLW59a1 zSIhtq+4f+(E9KtZ;eDG3MBN@BI^E7HeeQX51G}lOarcsQjoX5JE|xzKjJrwReE$&F z=F4Gu|IgyiP(pPr1qV4j4D?&&o4f*+18Wy&-=oBe5?>kS+i36mcTh9yC9xu?d<%y) z9EV+_ok#=lo*Ix%y^g}FP;29G#aJ@bL(#fBW~q2robxG{4T7w!?`T47W1cAOt{=YQ zsJr54oeHK$1yZRIy!e#j8E#`!(s(bnI4i>BqYzmLsqY;Gw0EY8ReHowwe*5Ld_9N% z`_6aX79SE;;g+Z^=}!Jp=gd_vHhi?LT^?cBDz)nro|tv9(F}>z;p3Ap ztbdKm;W$#Qxe?gSrosh=b!1uZ&%+HCU9y()J$c zC3~Nh62LH_pcv5K{&GBf7SX1cKOcmnkzM-m?Dm)7V6_>nvO7C`;$q? zhv~CBu$#dJX6Ge*7gc+k>GJImsKwF3*Z5s%l=BbwBa@CL3k4!*vUctHQ1w+FFGdt% ze`g{-MKLv4=26sMP3#e zqsb&ZArW(G(QFFnq0POA;b!IvxLDCWD5i97g`CxPaw3*q?PkI_tRE0%W}vc&1px)$-|tnV@?FsU*Te*(3G70s?{;y7fTB@SE}6W z15tA+%?(gwQcBQc!Y|UFL-szpEflvhy0EMBtx4~CmHce|YrQ)BYHCdhmx!>P*WT@JLC-qlIqt6Z!ChuQ_AkK#pGNR?$lG@|Du(aNsFX95 zwS4)crx;zPhY8h7F4+X95+=o+`5g7T`9u1*)PJA*b{GLawqJ=1RbkmxtE%#>fcb5S z?)jgmKRj>dH?DVok5ik^rv3=v#0irj5Q`x|)yB%?*Ax|n#n>{FFz@{ixKIHazhJ`& zuONkDEn>?7$!egCpK>A@1`T)yC?9`7578UwrGNY=1z-BR37nlowr|=12mlDELla+l z&?qLe#~?o9e0ty3Ixnm~@&ZOm{CTW8c~a^F!zfe& zy@Z0z*LuX4I_~Pv`mdk;_ye?v%3p{q4wWLl1AC38b3z4lPh=g{5`&B`EeO(={~>2 z?P6q_tNyOJ7HTeVKI95GJ~%}5R{#;)V-_5cFv?zN7qGiGL9K$||!Pg3>-*GIFQO!!Lr zDib?tq^xJQh)3(4yA3LIDDe8Bqt>u@kKVJgNBj+t7UX+J3SGuEEVSLtV4SK7m*9T! z)yhbWr1x};@r_MS*I&ES-LIl%4^5w#^kV3GdU|eR;*IQ{xI+e$JRoY9Ko@y$sx%Ia zgmQ+7ipx6IhR68&DJuSP5G?r$M4a^umcC2`k{pB*;#Y`ue~0o<;`!O_eQDpIsIaLH z86*>`Q_vP@_uvpSil)n(-wMj>qd8(eWhK}I0@fHF+=;J%h*^D%n9H1%d1nktNWtUx z_QU7WLZGA57-N+fiiv|kQ42iKo_ij#tio$m*@3K&Bxtd0lO13j%>lXrDE{VdlXGeT z1#_xVT9o5_BhLxTy?Ysutx5^kUFxa)NI?f_2{(K$1C`CIHIJq zRHBe-0%yw4;ACgk4Z!o)4<=+>=7hLA!4UMnVu+)Q3?=4zv_721tMwX;{0o4uIcXG4 z|E8v;CCV=Ee5E7qIKga`5p4$pa?-hgSuSGy=<>nOF`Bc@NlX{{5fo^G-hb)U3DdHi zyIMsF$}_sGN{+VE@GX3pk&cEspzHkUaGYfI1Yf!0+OEf7B8v-|suw`1gVT*>3WsYe zU0THfhYJu8{DD9pC{6WnWKW}=D{KMh26F*wu>vYFpwRBh2ovq*#6b^)WO_{Fp&b94PSi4RjF`Q&MGWT5xv1p0~GIfU}veE}Lf zoouoAmpG2fDV*Al>>?s5@1-{HyyxEj)+8t_NHa})iAd6S9|CZNH{I=-y_kGzwsJfx zED*_xF_{SdYTurLCdmW!heb#+10C~R*bEaaQ9drv$EUs5dZOg4Mi+QNY4M7MN= zD^R&&GMRpLZR=9M!QYLtGbEqFC}&Qg-yus!)`5M2pNSBb=Vm9^XrT$$;t@p>lH?pV z)gH*1)>@%wA#;0_!)D)np7|su_ff3T0z`%s6Ohc9^moijX3SL#3qcd`AZf9&=%oDo z^>BsNbRqRvK#)<<$~j{d$Zl)jnXXZ_#$lLEDh+1AY_MwDOp zQ{CzYBwp3Y>EH2H%-n>;D|m^EJ*;rBdYJYyUy^h0-i`k}$)jgYTL+FmuC-8bIW(iD zXSa*+e@10UI~wDZUh-rK~PUVT)JEknvXjq*sQ@ru|yYH zl4p0qtQ7e*{YHX|^<=gs~ z`TZ}_a9j3^go)g54L-YWy);NWR&Z@V794-+`xdHpNz7-?r#O82x+>|1<;Sq9wex(* zMrTNV0**hHVrbbH`~U z3lYE{W}m~laTTx$`6^3`pyyEmYUCV%NvuK_X&`+B6V%)|EorA2&-v=Yg}ojX14n;Y|T z2*Pi9)LsjM#fjRv-@JN~^JJ1sTo5pChs1QVvoFcGY?~BD661tXBD*P18e1OU#LvEU zrf{xb^;(&=D@EdN@zY?5b`m~ua>24bAkm`3#mLu-OpTW&bkw3kt}7LOxDe#!?ZFP8 zAjWTdbc(+`2&{pC&xV=VqpxU*i(#qc;-(}o73gXQzqfcDhIFDKO#wlVQbmr^JhqSV z2*0)yZMY0RpEd1Jp&!Qa&wR>N3!bYLlbK*Chw^;0 z)iNKy%QkQHjECajsx}1q33_&ZpgIkZLWfDUIL~#m62G)mCfcq#ukdLeP2G#(Emm(d|n-&-qKq{)* z=;ZKJDBs>VZ$(mZ3preVf4$28`Y53D!ghNM$aPyrjvXvbT}tJHUo5ZFd7Tv|@+ zx3#-D#ce_}iXxw?r)oaHJ{8n|=M`ru1ycK8l%+lwLzHP4}64Bz$i z^}BF+dZv;+zSLL}cUy5QNOaH_;epLk`->QNGTq+7oIe*71WL&Mcg#*vZ%+vezvJ0C0CC&Q zVGPhO82`HPfd-oR97|E3~V?Y0Q=kJA=kS3>bzH^g6I;*4|6ZvC# z5jiZU3dhgPD2X-PBtM8sCFkoIfJr~yo4HHaRudVg7K{G5*^lcmfG0w-e^t-UGEkNO zX;lwy1M;MvmARH+8dJBLYY|Sch=XN?i7U?JjWs__CL}NN;p`)#ITcb6>MlN_D7M_$ z|1<45EHW~(HwxOjHWHQ%Ib^n1{4?{7LX^`c63Q$_%kX!Z`t#h};KQXa^?pPIJl=T7lYy4U0P-dnirG-C^(*-hAg zHjV0vD>*hiwAOe*VvFASipAa|37u3C%F|#XxQ0E{8|t+gvhl&H$BLX~C)wudlzOGL z))QMIrw_6POa2p+di9Y!VkT$uRNU$Y3SiE1+`PP~)d1=EZMKyvsHnlK83Ef#`2O*1 zQdZp7<#Y|Y%cMv~vR!NOhS2+Lal+Vm80*5HDN6Ulll{G4+kwjr3o&evwSyY_9Lp%w z${z>CQA9wMdf7?F)B8(;;q=cac^XQ+*wck0>W3c1 zy&^>=g1jf4WkaJx-L2}hCTfFmEYoD;Bi`ho0uy0XpFJyr^P$886V&h4qOm;Yw-3By zQt?)YhEB(O?x>U;Z3Yv`c)T=APoHvteMqb#w|}q{u}ox~%+7NhBzFOdKw2kRywdN@ z2q*p6!72s0xM#m)vFsw5(-o~HM3aL?%Wfi5P8$nVob(lk%JbuDTE)n~wbDxRpA9>) zhmJ9+5*X+wh=$rF-;R;RhkBc-ktgcO?1;RP76yOiV%rx})1TRHs~nb5sfyy^voH4+ zD4Vurac~mhR!rbXe^4k!HQ*Ax2zw7RQ-CmHzrzbXE+AOMD#2f&8>pYP@WJtOqb zv=SIU{&Tpz^}Mv#0x#*jXZrsCOfq|rc;G2}tw!h>oIA>I;;?ot^e6VC{HK?3z+~~? z|E3Pzv!=L2ryvCXii`n9<|XVwcT}WyE-zlYB;=0PHOx2FKR$Mw{WI|@Tk3J^_|?fV z-vz<{u2T`ktvJ{$J>@c+S>@Ezn9QdDC;nu*slB$JUwtjr`+_LlI_Kf^)4$K@`YN`= zARoU<9?x-COTLmtK5@)WJy6xOQH-#a3$5VCU?GWCxPxqAB?(3&%V5P$I+=0$d{m^y21DnMg<_#MWW^p_5 z-*T?ttC2p<52Kkw=lda5Sg}xN^4TwLzIaq1miA>_@678H)Q9!B)yl_te^@(+owiTa|tD5#?1@u<@!PRpwG zirbDkkn2+WR@mOYNdBRD!PUIOvb^riB$;>UgCCYEK=FhygV>P5?rS#@4ccSw97SovZU{jC>A!o zRq^)iv;sbnqqBues3yaNLNIFvYGd|^qhn{83N8zKNrEXiw1Y?~YSeyOE$-82;L&^g zPBaTR+XXBXi=!RmhA_1If@1CPRqVfKxPni-k^nj&}L=EUyn>0bx; zrDPmlgrj~=Q>W^$)k=!)5;HV=BHs~aglbE&L?kB4*n?j?(Piw-PTu?6&ka%l?~ozi zO?{!sWMR3Ik}SK!y2hqV8ATLLlbdA9yz3g-;E-g5!&a^ruU%Rm(opL(Gh0=*)<>N@ zLq6m`P<#D>eItFwp!lm4QGyFBT|m*f44Y_h9}+>*ifm%|_<`tU&+X^fgsWvbWQbCl zG)WJpz2Wd&jZu4q#j0KKq+YYb(aBRtG+ZePmAFFV)3%m(*Y-4JhSj!FDEQ7)q#rZZ z7Y)lKVORZA&eH9e>0jQPduFqU!H`8cz3}2yY@qy2>(TQ&Mcmn*5Lm~)muPC z9d+Hq(%m_9x5R*SgS3(&DAL`6ba!_Q-KEkfCEYMIBB9dVAR+y~KJW8>>-`okTrOpt z`Q3BRJ$Ijd_Wqt6`PM%OLxvz%^JDHdWk%>Xcp`(`9MR>*+HojUCqoiA)x1mVOGvAG zKhi90VPBcdHX1-!rY=7)h81k}eX}-v zCiMEMN1RI+^2g4?{7drWO9(w#>awM33|**w21moB;mn(Ei-Lp$PXMBZ0N@JP zHUcL;(A!jMPrtdlc8z7k*)^u>Y-|*K_RI}%7XLXC)tVq-f=aZPdx5av^$WMJMQpmC zpY2@8Lwa|BbTRNP7`YsH76`Bv;O!|1L@V0QRf>O3_|yB_JKK90$GNU@BfjkVPAaPu z+Kes!4tRfGI^{N4O#n(eq!AHCybfTqXI10CoeBWG4pkMNArq4Svtrl}@fg>c+Wy{q zMtWz3yrLX?Jd8T#J&U0!RXOqGNJM9#)(i;xoRaQYk(oODntxU|!0M=g5 z$il_+^6~=caNkm%gb#p=Aba#29GKYv0)EQQ-u;APb;LRxc!kXr@*@u3u&r!{Vd?{a zD&VNze#5;^p%JLiT?k$-)bh=5xEg8ki$tQNSJtG&!uPG0sL&g4a4T0$=lWP(J@C;& zktGjbw&%CS5ZbfY+P}0wgvaZ2E$q(Pb%)ywT#nWvDn3Q~6vFBLWyV-I9evrNKf*BC z4Nq+c(G)il91UHH{qyy}vC~E+gsJjf_|H{Q{dA7edogy5V>XQNPS00XbcS*nQbZ$G zFwSXnYlY9v*uE*i`T-6U!%w!wV!d5mT=2{4z^Dd&A86xl3X{_zm;>@zg}C>*wZ1+? zw5YzK!WtN&RfHBw)1h*_$03Id0*%`+@?k~=4OC<_3Lq~*<9A3WZT0>8cVc2<>B@rG z^hD}J#>fB#)r5FHv-kiV#?3I$b?!ROzaQ535tYSIBrg2}&l% zaX7s}8D`;ADdK&6AF!%i(>oK*jkIM7pu%Q9HH)BU9kXVBN54eqVp~JM3N63epPm zl9U)ir6Y4bKs?7)vwt-?8GUDTM|!p?8mX2-y$eO(h8Mt%<1sKcc``Oq?}1@B-`_^s zUG8KgRn^g{!$UnqpH$p5>Av6F{ItWv-g{FmRwODE1&>g1Qb~jzL$GcvFT7qG#Bb5XL+6Ha z0l15buqQB0G^ymgy!dw$_4%vZz^o(U40xcRVUyQdPZa^}9b+OKXQNs|HpX^~$pRTR zJucGos)~xPuO_YlW(POB!^uLV5ne_Oe)OxvfPB!exvJ1Cc0c;XE>{U;Rr$&yvRa%^ z5d|%VhCxyzutDVSF(;B$5^lwY1O%0{vGkz3Ks<%bZmOYlE%7A%$@V;uV>;n_mw)paBreAcP|HT~jU<20kQ$)cmKgEI zv-HcDn9vidYe-ijbA-fW=ObhXDsQb2@P1?shm*@qWIe+&+XM{}3v+Yhnw&&L`Z0VQ zfUN>y_=?I(0Yit%DO6p=@~)g>4U8cK84(*ZLWR}&u*A41B31?}if_DC3Z|wE+`YD6 z*W@GP6++s5u8RDCFhn)&vOpqN2p!gSlj3)v|BkmQ=5bU2Ym;Hh0K`XBNfe7w5=6J) zWvAhZ2`Qf~Y?i66uqg|WYxud>Ex4D4Q&@p`cC1lK!bnC2c_F_^PbhHpEzavS`=5FM zY^h^teRRjn&Q3g2fK*xX~mds#8fBtw`QP|&N816ACEga zIUsssAl(1s>059X#dbnQoUl$9BVk8#HM9>wiFm=584|=}&e_&a(1M8Q7ZI&OU5CfV z!NIZBvZ8_y;{zPDUj=urrE+K=*@ghax9gej*IUkA17Fd3yYeBrjn0EyC+oCVRD(Q0 z?a^GpTv<-BUu}7OwSx<~dCQ>qsF>_bZz3c6fxf^%QryjpwTBbE| zae;8#hEG^H+jc`id@C}uv9s6pfSwzHMjN(_0hk!Q&Oje_g;*_0T4$*kpjy0#$Pze1 z@(n(zKWl$bu_OrXKRHewEz;56g;g#8j)3pv>A6D=zN_a5cUzLhKXHv(J%mOIMzr+J zey)nG1qLQc$b(`zVjl5^OwRpqq?dS_1k|!x)g*3K_)XV6+}s~- zSGqy>2V)42FNn5TB+7oN4YC+~Ov20M3;_kf+po4HM@+}h>FTMW2C0X)XPa=%gk4_k zY&vZCk1ATTU>Rm9Ro&OG8DqQAQR$!~_{b(&gK+X>_>(@=9vz&|LcaUP(fj-R1W$N7 zD_hr~$Uxo;&PEK=Yg~hS?kfSZ1}a!|5_W z`E}cm(ht&dBF;)DlJ994A-E*rveSP3ncNxp!=CXGAQuiDK<%e2-|xo;Zu$B7O*W%H89nN=KwpFQSpdx(d>B2q zT*jV3_@=o-i{EvN_Uy-Vu^E1>{E>a*dSeC4_n&qTug7Q`muxzJ<`z34{K>%Q?@3Pn z6&LsxZCHMOD_I#?*_MKWf-9H9G%%D^hhYMJL$nZs6T2E~-^4V1C-q5AV?N6Y$_vsv zQ!v?%e^LK{tZ`VFo8GAtkG{fiekX`t&9q)pesl5KX` z*KDcL0_6L)>;JK35gp#_=}re5*91Lz$?)aP9+7Y3aL$NLhbQFAz#){kLpl>wv^_=| z7*|1K=#4bIWCIPg9*U(usi`X>BuRCVsGaHQO)Ee&@E{$9%OtWY?4BAWXyd_T$iPRT zBSwJ9oY|0m0jKzKvsbSyivF2>E$FO+0~~MwvZ0^(2J9+NK~VSq`?TDQQmAdRnx@hQ z3iSpG0c%$xUf4`@iQ;{TB&l#-Q6$C~%6Cn++>YwGzTR(I*ZN0$APE^U{g-(Jxntk;2+SJm&%oUH>20li zAOJxNQ3d;^Va|QF#YIIuD7W(A_AGaAc0LTbdx_}t7)S@w;t4q{%71bmriBYTsCT zMYtzM$1P_N?yH9U9#Rj-j}C)B!457TzNqf(?6@)8Bc76gYrkkHUeA7nEqh)z?H&K5 z6sz~h@fiN=3aMs%pm4?)PP-!v(y6p}c94#L_)Cng)P3xOoY-5n4$cm8=)A&$tc~$U znGfm*)D>SS{vr-|yg&O$-}78F_mViW5A@Bw&)KNi<-+LJ-|VW|652}$TNuU1q28fm z=nosOx!}lpQHi<79*PN4klQ&wk5zthu_Je<`Pd7(ja-&=>4V5`@gfPmSIK%;Fw^PL zdp0(qR7!iHRglwc{T=&fg6c%Np6OzDFYGZctmwaC%Sf`ULCyag`D$+5NcfaQ$-zU( zW7ON7f7c@txP5OPjwxRY&Q*#x*!1=v8hyS0@Nh|Dpy@KsPz=`FSnPHZDcjAq0uQxL z8!L2*B#6^g%jCsNG-R%ZkL&Ee+!wr3>4;)uGfL&3A>gaU>5lLc3(yZ_D17yd{;kND zRVkMC(nPx}zeZauOSa~s=vySV41RPVytZ<-(!U!DmsP|FMnd3SWCvSw!;InI()?$A zcnOkJzgv+f7e(M$Oh^ctFYFLImtYUdv=2LT3Bybv%D&h$xw`QZ1P21-9EM0|!=Y&l ztKXW-G{T{Th9di%2TVvo#?l1;whJ0;Rri7(CTSM;BH+KN0Bnx;kTqy%Q7wGM$Ek>} zn>`!aQFK-7V~%Ds{_y>e3UNBk?0ZO#C5jAws60%6(4vdlE|Xg9f8XSP(j~qE!eyC# z9@mE0qXI%oz%+LYD|IHzQdvXGyLow~Lz6MHSZBuoR3i@P7SuMtNQcGhwGlXV$kU1wspUg`Qn)){Nv=)!i+ zStpq3qsy}X!$~!(j1{zE&gI>V?uPj0u)_KL|D?BIUQBHHn(o4cuC%I^H`HpV^Gf~_ zwWbsFQY7RyqZ@9 zba#VGUV{pC@7{ZQA|R_Uh|psuAbX<9GVv19i6Y22_oB*3pK~*P;)<-mEW5JwJ}$d5 zl*jsAyHsZ*XslL;kiA!LXTQ=-d)=n!^ySPn1mAq@&-F0H?-o}uk_3kjFE~wz6pOHA znH6cJ7mBcO%vyB}ZtUNb;ld2Xym;&cc}zAF6dL)nX}qNN=PV%$Ui#tdGk+v+QJ1b9 zIu}T(uYVoQU6ZW(ET_$NNQ<_eND%Nl(nUUCQ|M*}A@px2=jd=HU0y?V6ljr}`yl5~H{4P#umm74ZE( zKO-jFQ#l)?{uzPd^0;)~TtfprI$>caypfZlLcOPQP2| z+xhKo?A_m9IsI-Wp`}%4eEKx*b_=3xRi?<&QrX1}-W?ERdI{?7lrx@c1PYF{y;}>2 z>1%p|j~_qkHG=CrK#DYnkk(mXlf{a|LXJz#KoAV*=awjP^KEs9paHqw!8|Fivrugd z2uIw%B?5u;pQV1DY7xj(pQCU6aIe&R8S#m)DkeKfDg&XD4@vlp_0ZH(+J?WP9%4KH zP)~0|`|Md8A*`F6fhZ6cU~7W@nt=G$)6)Y0xlr{{G=_EaXL6l9XyQ3z zS)V@e;RFr=`3UEv{<(>UUWcEJpkmuR{MnS_57k-}{mSipPIZ^5`9qvN+ZSLEpdY#* z@-vZoYfAB|*oK`W?Ncpa?M9IA0nS5cXsGcIpmnQVBp(I9Ewi1S!5z@VF9!;=0K?*Z z^+^Zll;P5dvkl~|0~Y9KosU|rA9mOJV{UJ6^F`e-k*c}mO8+h4fJH^B1shz0L^E9` zGj@keC@$kk@597$rVJvF_+uBuVl+Lx{dBq7Jvtywi4D!Qy$cGMf>0=!u3P}zETGZ~ zpcy+b_5|=TJ_Awj^NO}|(y1=ivIgL$hAkHGa0lGo1f7<+7!zov#>-iKR$h0vUhEw$ zcjh;`@kj?7e9|mN$2YI`{OCTKUx!189ez?TgP%f4P3_=m4GL&`60rj>45Xn)MdK3* zp8kQpAy;;|hE04_J=oO$UF@fWeF$lKS#9vzx%(7|P7gNn+l{N&)7V^Qc)X(l_FgUr z4x~rL9(t%!zW_na3_R*Sl}IvGQVbagVVm(cQ#1V=Q056!^O^SbM&RvstNH^gR)M2m zEi=W6c--&Wy-5t|By!w$CWA7pu9d22@xsD^jpuJ_Lp=vnI!orIF_KeiuA!P3rVw=3 zRV{DtR%{r1>_33<3uH87qhtf%5d69R`$7u!Z^(yV6+It{Sw#@}>Nv7iG7oeBYh4km zM4BE{SEhkvv3{ce;?PH7xl*lkO?16*w<~~8n7#%v@L{Xd%GA^pEYrX)KyvEdA)#Uy zfNZ)wKR*v_W`!JoWz0lIqK|eM*eh zgZspw#}m>V`yK-;Zs*hH7(T`b+e%Fl#8m#Z!3U|c-}_BV77);-Ur$Ab1mS{_L{&94 zKw>-ta!?2e_@M-hiaIrh)TCX^!*kMW3);d+te!x0>imhkbC$?+WZsx>JOv1YqQb%% z|EL?vt!Vd0bc_;D4*|p2-~SmoDFAOeh96)0jlchhXekJ<4g@aWfs<*ML+&d zd%gGJDi8BT+hi0h2e>5!UAE2H~E9nbnPOHex)lLC@!^Uiow=m;HT@YX^_UD9$ zhfjy|n4#(etWP8dms(T@)2g-^Jgk2Y>5(>_Piq|<8emI(#KeP>Q%lX(-hT~-yy1n@ zjrGmhKS!C26oa>oLkALQ)$z);^n>jt5fIr<98*$KqWTMDK4Nih_Pyy|T_gL$z%Zm` z!nCsTeONmvCn=e(fr>bohD=)7I&snlUw-f$hkYRvXa4 z+crE*f2Oa8Dgn$f?dEIg^P0rqff@F{nv-Z&>FhJ2c6x8GekDOmyU-N~Ve}axZqk(C zLT3YPMIR)V5q6APT$r(@C*<7`>kCta5P1XDAi7(OX}+X>ai3K#HbMM2grbo0tD1q7 z9j;2wjm;>b6bOyf1)T=KIFRl+vQ7~nGGH2L1VNf1`I0*Gzm)|D5|EhE3pu>G)}@-P zD5p9|MibTRWAj~%EY1eKqCR6$!dH||u~$%*9M*ozFU>24q{iLpq5AXkNOxnDjcE}R zp(x|SrE5_Mh>Q_O!s-zZ0*eYaN75LB`b{B(NG|_od?)d6hMl7VaO06ZYAHCopg=4= zg_S$xqRD=tM-;SL%x*x#fq{_`m|f%Mb)~^%X?W42?|@fhIumiU-&w4A48BOuz>d|E z1a$)K9_f<{1PG8+E>3atBz1g2Y0zUVSoT%w6$g*1es~1aX2L!Glw~vWKxu1YhK6!f zN-dUv*O@sNC4ybsOkawHbWZmOC?2M%h%6w0LHwU1=u_m6hnzoj)3tNlcBo`Li541= zb4C70jzYVXE6v-6lUi(f*3YLjL~UiQrq$?6In5PTksY8R`)u9;~8R><$U4gMkV&cAb&}ACN{pd5|8L!5~3-FJH z7}BR}V_d@L=3`v?^`U$l>8g6sYuITj`UFP)WsyYhZJ))`UE{yT3=np^!%ulp!@SF)>d{e2-IN9P4waepbgUk&P4h zR3)8j*eUQ_n>b|DVOp9AA+*9$G^#Dod*v=Rg9J&p}QrYSDst^>n4HMFaGo&J8LR!IO>3l`59B6f#Y?J|vp{#=Fia-ml=>V@}N zoCJbGh*E6_rf(n4*s!t5ZlB>+K#qVAq|?71|AAv%<727?F=qPe;uAq4L6c_W6BPAY zQ7Z(nURfGpn=d&QFPPgF z7vF4g7QSDDs;H{w!V3&hxsl$=IC+sIk?Hb;2)3|8n5jyh(EK(VQ~j${$InK-rahkq z$Cu@1T<7kUxMY7b%J{jUAn*c|i5g#sdDps!_!~Mp@u` zH^nzjsn-YNtYFCs4~#Ee>oHrf|9#QJz}Fe~U>}FRLbZ?^6&95~u{B3A!+^Zhf;quM z52=kSh(q5_Ks!R z77~G~)_h#5G0G9Zqx^~m@0TSNx*T=lETl0{IOu%(Z5^6_B?6*6|H_v4ez+<>pDS>5 zRRIl|#0yG6z4g(jrbd+iEZvVjW*kV`GN7QO-2%mnbkt_&siCa` zM-;{jfGYE!HL~HspI8ycbIPbq4jqQc78^+0VqEYD!wKg2zA^68Q#%VW*M))oMX8w| z+5U^7L`vSA?VEh0@CLNrf+j`7rSd`3rE*(*Cem8W|Bk%qaycHCjFS~c|1TUiprHog zAhBq}OhkK=UszW^^{hYDa_tQp2>;Vr?|8R;;?(VqD8%)ZiPAuUDMmS-gKYcx z?q<|`ZI!14vgUwL^Gl*_MB-&lh{R1e-T(i+kW4>BcwkL76SUC8T>fe!jaeXU4_bG& zYUs9*{_7qd@P4|Xw`G-@vWZW0RvJ*QZBz2F{~V!afAl7yITEW6A%w_A+JAsVC9pTG zrk<}XF8oYQ=onp(7QjKLhZTB5WVz+IUDVo|Z18}|mY)); zNK_z^ftMY!DACLMnZos=+#dXCgA7~C=O|5YuYsLMY3_w4`O%#3gkF#kipS%sD{o9B zvwZ@yDPuF;Q>o_piuN$QbpJfllD?x0@4V`lkOX%t)7{4xsj7b$mC#$TH-RgAEV1hT z^3qgyf8)<_pFwU{+)lB?>}~Y^172e|DtmPs)w6<-m$prcUkrND);L85q?SA zRugd9vAln{cuakVRAYib;qqV0KJeQAUOh6l75iKsu7BKxalAm*CQUaC-Bv!X@@S*} z*2ZXc=-q(WMtb4`v;3n*;=;p5 z(W;Jz{@09d0-jL-MBXxQe{218nxuswzOQGhn|XSMWcM;5VK1jLOHK!!UcvRHu7sr` z!Pqdz|HS?PQ0(1cUuu5;Fo5*lhJ}iq>ryCP*R?baaf}QmgGR^txR0G&U3RuNW44jn zQD-y%7}0=k6orcS!RX=R_H=w+jF>mK-;AG^mB9S>+1X+JZ&22E3iGTUor%)vzoz07 z_|B=-hb$rq{J+bq;DY%3&+;7YK+(H-t?FF!o0=AGof}?j7@tx+++=RwYZX)lY}UdH zx;uWZD%DT4FGmKDC`PPMD7#?UiMaN8JoqntU-FbiJNscVsl@b)q=nS}NTTV}#L&I31Q!gNDGqmVI~OexG~l*-JTU0`K2()_fuca=J zkbaF9y+Nw2mtp7V=ae{K`YfO+UBbvvaZXyo$@V;&jT!+3Q3VURM1=%_g#Lpp4if_5 zo0oKDs{TusY6y&B1=v+J0D6F%5Q#ng79@sLSj}x$*jpVBbS8%W%Qj?ZBovp{uKDFPwrDqY@5v?@;{ZA570l4vrfBx#z-vP< z3qW2my!(!{v;N@wdMMP^R&vq@GE0;+Qhp^+ezJ1y{+yrVz7u@5eABcT&K=U5f>9Mt z_u$G6{UnZb&5x!K5_p9-B&}6AozLjcf?xgsb%H=KjZfYCJ+HAukuAqLxZdaYa3Nw34Na}S$50oVIRk2g)7_lF&~M?Uu#bE?5-@&LtayE}tV{IiZ57d!63;_081x(ifpZD(bIoKQ?zk^bQVno96eXVN;4HN#rfUZsI;=48)t?!AZBJIUm3=d0p0ByPnmf%|Z zsz;n-i0P>0Cx_iBhnQH$x6U7aA{PJLx;mHq6jwF?J#-#mIPREIzflghAbu9~?WlFX zD2Dr{m-fMV!2)$4-ms{L`W7{>Y>A_M#@huLpxQ=luW#n3UDb zCr=;EY(R01{5I2Y?{|OW-cA7{nVa2mR<;~I+_c0e5SG8VVh1OG*AwifLd}W=uImPA ze!9)Qn~=ErrG%Sa0DRe)??pW8u*>RWNaw>l&+TK7JE2EKcu&K!C(TE<9bDX#q_ zuYWoB;?%TXX7#PlMT1EGq^Hmi{bZ<6I&8$VK0FY(P0=L&ecr1^VG2I~0Y30ML3uf> zF4lEiWSjr#`M-7bS-z#I8!KZHWL$vdcx`0@I7Br|7A&+Ah7@yV?`$ZTbbfcK)&H?P zFUz@EnJ}SusDDBnu4)}?%Xk>2kQPg=qFS%@8$1(gK^o7MprJ3yWuaPI-7-Ps; z=hgVK(Vg{9d;zAk!bwcs-CNx(y!@ZQIK(iabXa}>8f_o&T~qPeoU^9wEC+m+F@YIR zqYqQhWk^6Q`j*@izhsbD2}J4}7haFec4%Di-mluG&h>rV<|dInh!;CT|HQTRa<#&SQod&Bl%%zs=HP^qW09*M2%Hig~#dt%OZ9lc+X0bjR(=HI; zzp5I?GqzLuv3RNmzGRkM_aNL@ThI{23YrGvnJWdkN_4l!5p3GWi^ z!;ENl^X<{Mf005od!RY+n{^$R$-y?pWk7Lk<7U4G)@(K5+9sClwgw!qefBHPdp&u1 zJE+`oxI%Ec?Suqgs4kMy{BFb_H9dG_%COS;sdkLh`L0?y%py^$n-tj|%|ij|fbs@O zk;bdUsddiKr>=kU=}_dN*LE(-^y_3UX5l~O$V?|zQn!#*0~^m;8$;tDlz6EJPYjox z*8;?&xKX)i9K2K?BxlQSO7Q!B^{XRFsid}hMmQ0Nkcqe%e!^#nJ22GpLH3c-P8E2` zxC&)2@AFQFjcYQueceBq$(oOQhN&K$oICqchv}TD+PrYq1!T1-Xvr9v>LA$LW@SU1 zymuZN5#5dD$fcUO5cISoM3o!-{pxSr0^Ny9Sv{EoyR8}=Du!Rni*%>huv7cYDn(H+ zOY$0lOMsmV@sBRUe4ib492!gs;u<5f1kN7g(MS1d@`^?kb2lFFn7~A{aQNro` zuHlCh*TCOud!nuCk|o=o0hzBXF)-fG|EOU?l9;n-T$)gLBTR;0`~`aZmRe0>}Rqu_3hq=H)6egG&liTFYHL|C&wE96#T&x zK+rPq+D!YKKp*8XST*j`&G((5Z%u^6rY!bv`LKEmaj(uV{TMbCVamj5{*l6Pl{V}q zd40FvA6!!c+>sa8@Fl$=Dk<3c=)IUzYB+_ZvbsSAN@_8N+*;i9je|B4m^X5$KlENP zN*KAbmN1S~3zu}$GhPzETrkv8ibK4J^-tjxSAP&>IfqJm)+vgm*U77?4qU2N3TVaq zUiUODK}0a>u!}AT6sZW%=}v;2jmE5IANd5ME^)$ghdvY1?N0TR8F7v15z=rHN?B#u zunZ__?z54gFqkBz;!oh-{dA@z416x{IS~pO#7!&HTlHGUSX_ zI(sve=eUdjLq+T8Ft3KF^otNYNp!`Xg$CAxt|#0pSifGpD=$bQ-FY5P0X z3U=4j4z=h9YXx-7W-}BG7v<4rh2e7p%tVtdmbgO4;A|etHE<^Vu4-DkX>*;hn6h+q ztnEh4l1@@dsS6~s(+R?;wTd&%N930cxS7d+!nYTV7jL&n7NINNU;iY!C&9nC^y@)A zoW^yO&s%x+j4AWrZ<5;J&H0Gh@h)wvjW-_a&GxZV2fIdUt7H2G>8dwgKH}GRa;T3p zx=O)Z796vbEL?PYHMY%Xr-4LY@pC?vDwjrEs96^a!dK4VJWi!wPVjOZ{+)Z&Mjc_diw6VWkRT9zB;w; zclG+3>oK8BlG6HiG-oyW zR}`<@fW^(*>)9Ir*%bDUMBFNkm_0 zQ;_H5u)4o#h^(j5cd(UyWFNDictjNtf_7{UpxGmyxIB z$A@?BFGXBl2a^S-$CfY-j#qiL{PA49CX*)^*21N}CH!eEFyw~9&v6bndeDlQ{+uF} zemYg#`?D_A;tA>cgU}}jeW7gMzp@F+F&5-VsR_~IRN4gD#+C%1tLr(sN&Nm~FN=fy zS5NQw3@K_8oyqQVRb#!TyD)7Eru=WYwI9@%pbp}`tL??rZmz&xL__xZX@~LsBKO#L z5bG`PL~*?6o0y`RLg!bh-eg9;@7J1oUdgp`^T!aT!;J3tUJDD+D$n_IfBmk7N=`zk zU=?fqBp|$%ukCs(JLdl3s`D{~_O1&~VOI$dxP&*Ubn-LeV`9%fu?eHhb2nqZlyqDP z@q@i}6!8j@spAe}tk5Cp((cNkm?^s6&VS@Iy2h-w?zZOo5W*7kM=RYf)5AIa2`PSr zfJ%h#p`Fnmmq{5kcEyN5f_v=A;#OWuNsc}&CAJDq2`J_^ru~J@nzrR4s2khT72}=_ zsvcK0uc_=xyj1_fEY8awtm5ef3={*2_2WmHP7Lg-z6vi86uq;nX%}Qcir_Oqw4aI! zajf`P!gHka1q}%ZAW-vXc<@%K$d!axUa#0R z!d@x~5k~MLv8o`s!N^#r8?E1=!pXhaLQU^#V>e6goafn7kkaXy{>rk))qeUDRq)*X zkSnH#Kk{2DS;s|{lji(src&*OiP5hoWsyvR=#fr)AvYb^_za($vRvk0J)T5J>BKbL zI~PrZl1c#>-kgOLq5nCNL?PuudO&BKG_24^#lwH<&3t-o+GjfKIo^hE$H@~d7aLLN2okC;_jeg)Wr|iN0 z<2AZjdA`4Bt=v=0h);S{OiBr92pmql6mIOgk4o$1%(9=&-XW$E%SNCOhOQp{_PB`S zN~6R3ZMrtowllMRboNx-W6`Gk-2EJCnx7Ei_Cr6SQRxEq@76?=Rqq($@ds^@D99QO zmm&j$yg`zc7Ua0^rMv%r(^CLx@rxBo*$e+O7iR}888?7du3B5WvNWdFD&6$sRy}r*yxDqeT38vJen^PVMutkrjQQ@ zDe%Lm?27hNc^bG=%;PU+=u2>=x+|DqcZb9-FkN2T%jI06NBMLXeeK>a_mGXh z3L`VjP*Lq7X5E;N5A+)@g`uzNg}O^@BYMVrt4`9My)wo+PPHxqK8y0@3zF{Clqm$X z5*~Upr`vzcAQQb%$&6pM*tDW%*>u$6SXua7#jfp>2v*0_@J*~NJ7dWd#K`nwS@KZA zSY<8IP4tv%^d?PPx|gux2WeEvRs>WE{*|vom4#PlG~iXkm0B;k$eIttI16L~WD=S*&R=<$6jPZ+qGSArKA zO6M~k&0ALmqv;ReZ-BF2mW^I5cEjia{M_b({1aNmfZoW5Vv1)(C6v!TujtzL2|C2j z>Kal1LW&=DDPb@O@=aCqc|Kd^4JqT_s56G320llnk}H%`xzqVi&CU|sfUW&)M`Wv? zVe5&U{SRN2w;*tIKpmg1c@L^Mm)h8t?LYQKzBv_dTf{y4VT=0mF)YD} zUu3;VFG~%>8-l=Ifgz8x#K*WwkM=3bVHQ3#C6F|1hTY%U@Ms;G6OIa4OFAgzlaW^ip-IbBK{ij_NxbxqG-wUdy%5)1b{H9=C z;`Z1IK~ElTj?3R3%hm<+i6~>5xll8_X$1JBs0%JTpT0`*kfqKVR{CmiQ?SfrCSJXn zVrdNGtCCBB9F~BWh0i!1hIf*g1heWw>w2&;m*lzkWFMdpS7Ahp4r=K)Frbd1<$t0z zF|5v0%>H?K`3PNMJ0*v4EMAU>@rWj^Yd1Dwhq>%kcxVxwA2#Qb=bt#qt8RLkRx;Hl z7E))pAv=kN*hr75*Imf851`))q&yQW>1Y|QR()SeHB z3mVP^94KK?BTyL|6pOU3#~B1QR#HA!9G zB+O84$vq}+84b73hT+w>$mvspeZt5;H0BFonpLX*t_8Ax(tWbj)B->9W2^Srln3}W zkn2oQRf2r;3HIOeKl#|aefw15vGB#HX;$g9CjK^oZ3{SxIdKSnfYRsFwSQtpA~kUWBAl8Zv4o(iY7s@2Fr@u<35> z5upsgEhmYjl?gXCQUX^Kgo^Qwr6YQ(q~DNJ*T$MsCcnj7sPa8uC9lQiIIq$}6=@K@ zC`ek({hKte*YEW8Lif@Z1wL897%vW8+$t3jls=Fd2(qZG?ZMojoi-l5P2r?NN2ozc zFI?)=@_iw~F(Sl~ClgsItX%W*!K1$8aisrHZ=mRL zPZ%4vkCWJkh{e65y-37UKg3{}h-`KalR0Nb@}Z9oVHy<{7v3M; z>()x4P499CA^T@2t*E+cQ;-B? zCYgsvQfOvU(DCr`B^c*~Yq9e*3YZMnhEm)T5?yw56x^AWlSTj^Q};i`cLw@_jOPnw^8t$m0f+Uivj1BR$=1cXx5pmc z3LJTVf%rda=OtogQH-4|{2WtrU-&#_rBNGO{RvjL?0-vug#N_+v0ppn@Efvu)Lp2- ze1}!q7o(BWf6>EyMU?P7*Y_S z%N26^ycWACY@PVHE>oR;!Rtd8RG2{NTE2$Q6at87Q69D!tw}%GRGKCgzaB^Js?_3X zHvN7$m0%GR<%oWq0%>20xbq31Hbq(pBh6_AKEJ~fO^a)q`s1Ao({>!T*ENQeW-5W1 zjqn3WB81q;x7CBk9C`!aoUkdLneR@8SlLDJZ_#2U()%hW4oc-M?BojNC)rIenaQT6 zNqD5v(ZXNH+$5yR`hJV08V*LMJI+gTvEi-|8bL($<>rg)At*tju-I!MyJ#tt8}4;R z_)|JN&^M1;%Pj85V`gT0r{S$te_+yu`mWNPH`ud1jhYlm4f3$)%qX9FPCkij;Kes! z45>|XHndlxIk+_@MQCA)VM$FYdwv?6RDIGDiupz+c7l$*eef_Wj9HGD*Mz2Y88Hu$ zYl9js9f79+HiZBWq#ya5DIokeD{39XZ8IxF4VX@*6I87<6#tTB>>Jrq`XNO9&2Zo? z3Otc75uf+2fGVRdL2|j-Oxmn>ENiu4e9AGX-U>Dd zwcppbl@FroZ)13!={@j{)Ft_VJiS(CKq>M{_(LRuxVCG6X&My@=7$5m!Nr*rwYAeA zE=}8GV>8*nC~w3eIz5FtKILI|%4vM#I|G+t<^>OVq3*9N-O%WKEbOM6107>~wcsd# zI0?vT@?oz^^TA^APonWO*JkV+#z-UOiTPMKNJgSwicQlW?B(8&IuI9GPL(^~(Qp;> zz3PQXE-1^b!E{`QEO9fj_=QL~erzkOExlN5rLUZ=-x?)Nym&u+o=r=$lOh=SS$=Xn+tmP*vtEN^0$D5hZ+m1yTN z^jZF9E<#ATQknR;%75|KPd-XZ^Eqk44hrV5C$^`h>e#6Hb%)8#vsdMO5G1zxrn;cN zyF&t+Ve}?Hp2(mNRMPYj=7oH$6Xj#KmJY>f6Uj`?pvvnGPkHNoWoWxw3PwAkpU0A} zLVQIg#Q$iyYLvyEBGWVO$nVG6t{d@Wl2JM~)0Ze9n0zbX?WZlj8xcAPisH+QCew<> zJMt?0Ovu%HV+Y(?=W*e9uLd9`@J-A-0cFU@_YVTiQOfzX69ftUxq_Of3w`{e!`WRzsIETtTPOi@2D^N|ecaiqrOzge+Uvyv= zULo*&A>-GT`FfJci}u)TT5BpFTmF+jGycBW!%Jv_M~C%-EK^+8%mYhYba$m(t+sX7 z<>piL%u`wO_}7z$od#cm&^4c*(r>cSZ(a4jwulhZFNVor!wK-Lh=%1*Dz6Fjj_XWM2$sGsjZqgHrlc zRtnNEhJ#IKJ+4(j!>4UzGn5XoQd0)i`sl>T6)vote8UscFYAV{o>GOtxHbi&fkPnO z^S0Z|w>*A-@48_P>1kbdfpz+Imm2g>F9;8=Ek^T zkqp^P76grOZP74!{a}l7doVJV`NJ8%$%HGdTg`JSAl_wFfWRE8Olij$_h;@zBLht< zaCY&S_P5&gLU1;E=wk)V#-cHDYK~8JOMA}p{@oW@`qNiIZ>SyXTMfb$UI_X(d4~uGV#^dUFG*Js5V`GnDjm=*s7=h#4nMSpPg_L zd-4SB%SvQ&KeUHYp%xoojV_RZtXn0JEEJzx@GF++Le<+Qs-ibv*IuD*NB3jzIIqVE z#>?d!v|}>`V?NJhuqF1`+WMaEqb7gYc?yc*Mr$o?BSb$oA*qF>w)KcaS zk*PRxjhn?dfbQ6edzUn=^3-BCXFPwkJ*4$NCs{$;b1W{gP>t&5_#oxXgM|uS_vs^rtGiuCzSn!Q z%X{VU!oTk-ca-IchyHc3LR}SbN@~EoGXmYglX_EzTbJaJ7hbW{iBe5@EkXM)Lg z1gg**A)u4A=-As`8~Vy?NwCFf1@cQbuq)ncx(Mpx=pg-^REbQgymMis)BkNOC@b1< z`VdKgO7n75(sj}qC24Z9@Jit9KI#84^%hW3cHjFqN|$sDAe~AM9TGz$Ae|yPba#q$ zmvlFRg3t#zxAi82+uul1 zJ|~WqGlGe@+Y=k|RY(19J#x&lrvNRkpK^lV_rpw0u#3Q$FU?y*I+OwKEP#8EOeNRF;(juZM~VP%1Etn$OZ2c(0*?c5jERG|Gj z*%9?bjV(CPw_NK-9{ceMQ!}gVdx3_?6}rgglhKe2LN@OxHEV3@=mu;6l9=ZU7w|yk!|Z@=Pr+=JHX540*=Mgo=FhM0sO1RQ zO#V#gbsk%`LYBk1W|I3j!(X`ay@w<&umWB~nWBcE%}JNy2swf(JRU(z-M6^~AAF%}QKq>nhJk^;r|+>jq{)UcjOvB%_QsXZR-=a3$41ikUN?%(-g+M%Yuut& zRuITFCCj{Jm{dj})qV>NruoZCkZ)U@U5hVAhX!ewAXnnjHeyRErFxr%Az+$3iy|aL zvKv`L2Hg*rPv)LNN+v$uHKLx!e@;SHuY*E1pIJKg(a=92+ zeJvP{{ip9UKV~PGi=S-iWEG@gD7Sn2s(n2hWw!IS&DnI;%y#|=lMBZGQYp+PNfD#6 zL&G-pQI2so+I53|=Ia2t5L<{EV!Aqv*Ey)~11gtTtUeKol7%eYOi7xQ5Lbu++6Fw) z_a{``G1;q@G&LsOcxc=1duyA-(JW=piMa7JhZ8qjnel7HH7_Sj?#2eP;yk0~=pR~R zrTKiq!B(*uNvmpqyPQe#!^9#{_!?a|?I(oZ<|=hLp}k4gXK@Ez>r@WK-(zxrCx7Kn zCd_{}+y-bKkI3)(RydmwustJGI!n#cutK8y1%Va9sgEEWM zxT^7Awcd>nHF=MF4ZqGvKd*ZF)Ap&omM774tK;k;v`DDtqy92evK0g!+~*_GlZb1$F3o8&$C-U#MB94F1O+gBWNd}8Vxu;ngd^| z5}){QRs9|uX(zR^IF3qGcjG9&%$U2)k_*1eBOYW=-9X)!SD*WEn@L8EU~#~>{qU_m z>-`7DHhUpPhgoLlM*QI!FjK-XoTa0C~3UNdVR1q`Etja*k7q37er zSMdl;+j6))4ae$-y$ZM*!keB0+0Os9i^z{d&)*>l(7gaXr9NKyczAqZ*2Ur9!Gvd- zznn}nW4+Vo(QF{s6v+lbJ;cQ=Jw13_+68JL3euzmBGSf0w-wE#IYR8oEU+Yv z%yiY6Lh(CI8y(qhcXGiC&zXM35jTf%6pKU|pn+=C&QOx6lE(WZYN9sbV0_s7OKPG= zb=qNjJaqoOp?~k+>I4)+pX;y|2mP3+!d?-&sw5z3zjlYzj6TdESGpFBiH_B+eEL?o zsM}iyIdSlLl{F;lEF-iOR>Mba-)uQi*U0oyZ;oyHn*-yf?SJ)_r_K?=I3?57anUCl=FK<}fDO?+hK#td4eSby=7Elrb7` zY$2frayPb+96bfwcW|x9QPffPqRm1#H>nIRlpoj~eBw!7j^feO!en99?~%ZK!=Td+ zJAw*efD1=5K;)c&f|Z{*YYQ=5D=;Irw7lzA&7~&yyPNFKzG)5#OrWBo5)}|zNX@_E zbu|@}XDDw@BcPzt2vfW3FF(R+sS1za#%(8RFSTi?sA3}&a2mvzp)ioMX4RGD0Kujp zt)oFgk)$Cu8{ERmPOV=;D?{qI+ zxr#llYZ-8k4uUC6aT{W<&h|zI2bYdOo6C7lettp`M*GAvNz*$^cXz(*A$ndRA@7}8 zeyUP>qi4dxQP%l#5C}CjH3da&5;0bozj%UBsWdRo3Z|8eU<|x`ju!A}_29teTx+W-*)thaLT3EZ`6KjLOB zBHaX3pcrHgLpsoe*xvzd{5P+^8Kz*{EG>`I2-eM{FvZ3R|d3>QGWA8XI~4{C(6}Ft%+S8@XyM~ ztSV{Q&cBJ2sS-7zkCY>b7}B%&<|4yJ99o3)oFw_Q>-tCXhTk!2)@b3u9bWuduG{X+ zuAktB|K8qR`v7$OEu0ada8+7|BhC<$E}(i-Te$P|X728KHH^1W+tL@2_dI=we%+cZ z&9r87EQe$V2&wkJKm8O-!EXz?lXQ(dwCxdnag@4g&5zy_b$j4VShtX6A*G~*;PaJa`fH_F!R*H zL`;`L&~V3VHm#PiPfv}$c!ZF@^4($zM8~6`B60-^Bd&{6!+`krOHm4ee=6Q>sxT{O zaQhx_f&o@=F|#3KzZdNA#6#(ro-@^6p8REb_Vy3(uBdq^PSsz zv#!g=HtS|71n>3vas-c!N9bmf_BfGM2@_&^;`C0-B;CkUziR{+QP95$TJE$jk@3XT zcAx*>prs|Hu`}q)&2r85p`>Pfw$__F^ARnIZTWR=95CJ*_YHDHmSF2}(|5Mh4Q01* z*xGBmax~oaM$mqWhVYi1ZMhYu?t<3Vl8BKoCf8IeECyK8Fj?0mIkB~k&9Xy>6`dzS z_SSjI$g858$3xDP9!n@JL{3vvlfA*AX`OUeZC>!&gB`I|_+E|t;Wudga8av!0_10} z5iGq#>*Lr^!Xr0i^<{u&w0L&C-{pnVR-6WJJ)O17vp6)ZaT?R@#9hTY`3^c!ir<5l z0H6w6Fn=+QgIi@P@iA$*&~oWpkP>J>R@dekcoRlk{mnw|?k4r`%Pq8`sPTx0%tdqy z*es84qGn%W2S+!ds8)`rAvlW2Elb>Ha+)p6%4IVyc$|J~mifZMsEuQ+Q&1MjjmWl8 zZ7QTpUwC=2HOZ~S*5lS#KqwJd)ETq-)HxZRR10zb}HA%u&G~-EwZnk z&N(F{j1P6;NnCSVsr~>8G4CHLf@~(MzCdcWOaXV}Oj78GQZnE)6ln+ZWf|6KHsW+t z=|7PG7fRw>efyjhFuZ@=Wd1u}k*@2l~I7VZa(pR-OA4xU*V_C7{s~lyAs>e&C_9GWN`e*{}FC3?+ ztsdaslg_I|VRY#;HKJwm-YZco{#4#alG(Oldl#!Tn5+{q1d)A(uxB}9KDAKVen4Az zy!r?zBx+KC=yT4oBkv-XcX(f&ruyyYMc==m#u%^j2FHB~Xm_evBVeEj^;73&!b@@X z(>0x=(+hDCo|Oz3hq!GggA$4O)pnhDuRva!X)b$LdCUkZN`Y?8W%}RZheDCUpH3{2 z&VkhR3EQ{D7ZNuz3k3__SQKIkPN@VzP7(2+CdV;``Z(25sy7HNQa(q z*8qK+;se?xLbnfi_ZmigR*x3aQ900FU{Ntd9wO5UvI2mWLDi|SHHWq3x55GkV1cae6dl-m%`5A z5Wu0|+gsm+8A~NR?gXR@pHJ$(uBRDpLQAA;2xd0GyN{p49{)4U5JqWq-~yJYQp+stu5#-5d8sF<l%Bl~v&U>_& z>#M&LR})bPMVq`nXOMfNgD+4137RK~);yZSZn>i>R1KEk+~*IOAJ>2e$qT;$68X4I zb9>*zPu3_+eMLrS;Z#zK;5DRuZ_NC7lg##vS0Pcx7b~?3B`@$2K_ZLysqNC%McXBD z|75A&6$EoPu>&&Gv6_{2I$fRzQUx@tapII^Zz@O8kYv&y=t(k)6gLJsncvc>_R&R9 z6+Lg&4c;#Q-mL5$n;6s&6^@ot7mCMZgvzsW(`uizH@1VHhG{Anmksb?p&V%Q>|`aE z;@OGjq%}TBV>d5GN09S*J9mu?IdpI>Jf7o0P4jz>G=$CBM7vA)f13qOGFTt~zNv&m z@&kFDzhBnIa+deaw~JRRZb7N=jWP11*zrXo4UFV+)(i7fpTT8>jN~nFNeelB&vsQ# zOdK3=RXPhS&9Iao^hZ{`HeY2q-FT0Mom%8_(u! zf|=GaV*$(&MRfGS)zAL?M9AZrhfI`r*oz7KH#L=7ih8JX#x)~;+%-04Ba zNfq{_*lBG}d0e3XE!{fLW^;cVv8lS*jSq|4|8w zX!R>}VpXvN8RekrhwjyDBrl|hY64OfQcUyhd6m-%T%(E*+{suNHjtKBI_b$OM0KlY z%b{#KL2)(d#&^@zs?JS9&2eV`Tz2Uwpv>NEJsPeP5q$E_99NYwY}D=R`mdTHfw`rJ zrNU9lpZ;t@a}Z=Q%bTgYvQ&wgyjD+$mW5@~xphmE+06nq95O`Q%1iq4E=eB=mEimB zHnSkP{LZuz7uc?O!*BT(FFxuCH|t_@5n)y&y-`$~x-*qaH^EVR<|o*Js4Q~aUX{y$ zER`e{GUf`>FAx~iLy&#(`^rPT`u-?0A)JNJhrL6w+Z427ZJ2`F!{rrQx6y*5tlBp< zdJzhCT+h?gJ$TRTas@oH-gKASwZ0s1NtE~E5KP9X*={Vjqx)hGK-O2h90AI2;U%2^ z8*LCPRE;kd&SInfiq(JGqOvaq7ltQF7vsUq`?bTxWJ*R%Ab%zT<9|_j#PZ6t;#77w^WYEpJn{h@ z959wF2ElJSqj8&bnm+D+xqBAtw9+9C(5T0-C$Pf;u~34EH#n37^2y-7WfQN z;xtu-QD2U3G9-xA&N+T5#QCUzQa$P*r944Yk0Iy69%B>pXdOJdH;%ZJR>vwCp*bjY zBk)VQ_@P+*Sx;HMF91pC`FqyN5ur$%v7m2;|6*_bmoRHY)!@)Pp4;BnGtc&-ofaV7 z4w!#^wR3Vu&`5?ko}%9Q~UQVJU_`5rT_kT^X~(P~3lc88Q>PP0GEgjc9S80!2v74!_-+BbIO$RaPeszF@ z>c1Gop&w3B&aSi1m$^8OTW!ri(QaqIOK~S|v#1W7w%bKq561Djry_sMhQYg!0-qYi zF(rqUdmQrWkI9Xr&ndGcKKe)?I-fO{Q$~;~e z)L%DlPd|qdJnGWPe;V3(`mG)3ho1{)`8(UQgU7e-LAI-|d!bvXaoo7wZ%NW0VmCuk+4jUsrt*pxPhKUQHrdSza{ zY)tPPMOLJd=SnRGHV&#igCTZ~qa4pyHlZZ}37q z>`@vx8Vo@H|Mv?`Q}L;SlpBxLtr4$5i=cquIme5Af}K+C80950w{}UM@3QI*q(5I$K=DeE2}pYM z1{wQk*m(9Q*Q*{m<2xwoYNOpMgL75I&K<+`yKh~)3Xc7$?Y`)*XB{yf-Za?=mClzY z1B_vU;(uq`fANP^sB6)wA>iOG{0Av#=KfI8fiV}w-d-mYQ95U~jUlUGG}}TOd)Hf+ zo{`Fi?T8PJzYfiw?@7KLs!n~jht?ScX8tqR)f5ehr|6}9QY7%A0Fv z-&5PR7DXVQ+L@=`aHUS7m_5-3moLzMQC* z+DWVQ$a(eI|K??*DbdQWKvBQH)*nOBYTz~g^gPw7>M0A` zeR4nrfZub4ul7px}A##gY{B|~U=dEAURISZz#zw@}lJ(mB{&zioM;O#M( zRL9w++@moU6uw~gbbxW-N<>!N{8#Zv=zgBF}X&w2KSKlWK zUcfU-X#JYl|6J&}zg3}*<=dFGc(QiFATcbm9DK20Pdvt}AGQLF27Gb&5X_hF{Sy(J z;SCbPqs6Z`3HtGUEP`Hfa589^Yp$|6lQ!U<%$yymYQZ0~j|jQ1o);d*#=dtG=D)b@ z4gW2fJiwMdmioc`$F%vVRYpcpHWwJAFtp7Jl{L049`eo_3AFCDC-knI7wh2l{xze6 z50{Au$rmV;kZ{yzXLq-4`z#x?RDCzo10FdaROcpdEF>4FK16@M)i$KUK~Z@lXK#d- zdGpxB$~|H{g^PhUG!YSr-^jaHBJue#7v|;`r!>Y8PtyN}8|I#ls5YbDpR1Cf`o-6G zl(c$dADW%DS;o|2T>E%8_qR8TaNd~eqHg=Iuf$lPv%}F92ots2Cgf8`8Dy+?8sYD; zobh4(aGCc$?o|nS5e^&(+pM;)6^{u)Z@!!I8npiO-GBGiLG59({IIUy<1@{i9t^MF zqPz7%UbYfm(LPDkYi057Rw8+rs*6;{LlSEnmE}Wf9`e8y#*|< zLt#cObX;?up!e(qm~_4W_;GkMc9`pZNr?p_jU$yLb%E#5adSJ-W&{+)LF&UmPp|*s zQ&%2XD^hdpt|R3$4Q=h8p#O>&3cWWUI+YPU{zIJ*!7bSY5DUY(qCbB8INP4lR8uqg z2;2}@?+p=vQ#mI?(O)>m7`72wzNd6thbZ3rcDD15gIJo~VVPI0%c?#{Gh}|=amS^? z&Bg6c=hddvMskRUcU03&(PP+q+D*IA`dk2KnBU~v8>x`RxR{t#2X%Dzi0PuwvUOdZ5%C+2Zw)`F%v!@vN+%y z&b;B+QNxg*v9Yxvew&#o?6v!H1qzB1Ho!=&UpbsR-31hi!WYOkBI@V^7!C*81VVgsM=z8W>ApHHv;c1(-_|@KW4ZrsD;O#I zbSVQ)>Elyj=B8K`xF40%;gzE;)$B?S7jw4%Xc=$s-g}`=q8DbHPyxsJCg3@5{$To{ z`O0OBh-DTxR6qo|NkaKd$ax8Ak^!ZIHZ4eU9T-nK0IKxUr!htS$kU0STm*d@ig%5| z>z1;IeDB<+4uCSEZkL0=z>-BJtKM$vBY;Sd8%j+dfhQ+m)*?w~%GtGX#PgezgU!Pz z-9Dw!@61uPkX}?a>}}?84M>?E9~-}!C17Gk<|nip5?cI&(60gs zx7;f5GF%m=I*#V@CTqaYr78-`iPVOOd2{FE0<#ov;WcOxCIvZqTofl4*E=FkPPTg( zZ7$2JklX#dqN|KoGFflqiL-5A~DaPDt#eC3w5$MKUB6UqQV3{d2j z+SsSuV`e|!Yhz+!#=eF00TT4QO~mpcFoRU6j+XK8;HSgs6(0oVH<|TdEiyAP)hv8=qOqlAVuFKukX9e{;KMM(Ey>xGMzDwrrGGJu06R{VuY7OB z_~L%&0unf&R&Fb+sDNiv_NWHCDMItkDsD{}r=@WTD`(>1OAL8Pw_D%g(UGtc0}&C? zkMZ%r(R@TnZpZ1xI%~rDxwq$V1`mUJCqzrg$Y*i|cR?Wh=1 zg7PoeE@AdW=fR1ICZX%3oAlwYm$D==(&JXpIsc;5(wPE_ZgvF|{UC*DR``RcbbP{1 zW)n)-=CQj&Glo(`={o(_(M-=-n4!q|U!P8woJNc?kJ;tB4d=sQKhZr%xZvJJ?uO4R zMWxVgs7PvGCWCUoKDm&^d=$h|s*$JXDOlz;V@sygCPBCl zV~pI&Ed%_)GG)LJ3ddKBKL&G@+q7C6+K9Ji0tsT0Ra6|9t}(ypH=D%~8GIosA@Qq` ztB`0Ei3?9ZY1A3m6SPvI@ZozBKga(_f`D>&?CY)xSU!=KW4A_4AO+!s?yK`MtQxh7 zmRT<)u@21imM0t>OaWWtOy-cu9pH=x5aQngT!a;{NPri;opb}GaNw=jvpJ5DdeU}fs z^XSx<^Ys-eg9xcRfnW;E{qS+q+R60?0qKMY%^a(>Bb(clzV}f*eezH4!C-vKCMQWI z=O1t009Z_Z`s>PstFqIc8x%@hP8q|&Jj_HF;2(&cFI92_5=Y}{ciW>6T7qf!Z=@Cd z6AAjD7zn2?m57yuh$;{WB(!FUCq%=Smgp0{fvL_=DMu@?VThdKI17$y zsKHUdTI9q@rT+ucOhegTYyAW|&-Jo!Xn2At>+_mLev(%MmiO2CZfJ|~Z9Qh3N1~t` z)b7GGqr#byr7s$6PCYsHleV=0`)CeG#c6aNJcuTrKcmx<`@p?SJD*W_cmNz3ik@Yy0!bmmgqLK=v~8E2DV4l33&od3#(- zh&XTG9yBo4SMOl#ZT)O9K#0RPs5n|g<`%9=MBVQ-*%g)G54hi+VCqEQuz4^cB=2wn zC|JYbIPM`8ibGH#G}9p0FRq$=-$%%|z(4`NSZsul-Yxnm4`fsI#g0D>(IZb`#iu#H znDHo3$clm=@Bu^0h`~V7RG#c=@_O+a> zCc6tBEIo8&ySIm&FsA>(`_cqF?ua2J3Zw<0qNFdmCdO;NQ^lnb&q=&;#^)W1G>0_# zbwg3}SR0*wscvF%%B$hwD3VXtjpOqlU`3fj#H*HY5VW`DWI)de9C)i^rwcVgi*cJm zc~%Dr`=IBla`9Ab{|LIzoI?lI0|yXpV!7f0Sqb6RMpHe>@iv)BpX>Qn{Q=a?a@Rw- z`H&~68`^#Egzk}9biF+AXH3P(E8(@{zMUd2^wm?Wwt%fNO{4Trzp7JE{Ayo)Y+3d{ zFn_nd;MBsaA>zdinYT>gaeR63Hk%)gn{}=m~EBAa3Bdn3wtREtf@b~|Wprah4k(nInMoP0Is-KRd>=6#~ zm8s@N8=}+9^&x~_Uklk#lIQN*CQW;sM-IyU<6D7yM&Np1W zJYjFJNH%60W|{tA3k)W?Z$<(pGx=|Gv&x9)_$AVQ|ESg@^>CBdXnO`2baQUegIN*$ zgUJ?z-!htDgP)P+Q&NasfsHLd%?|X*uuHQ`|M5*5J7jdP@~_>ngwpyfC^vnY;*nsx z9k;P>Szqw`tEu>>ft{hV%eiD(@be21)L-Pwx8?bw3kkIo?)1K(%6cc*LBZDv)zaFTrMD9?- z*nkT=FK;~D88aVl5*7qU*R`>=WiAq*D{vLoL(9}tR(tmhQC(e(_biCRURr7sGl6s; zW4xM>omsC>Ge|IQ4IjOGn+>DfR7&|G@-ezz_rs(^*(Alac%ihQD@;?hEl3|}-4AAR zd7%krY9xC>qFvPkC?H!Gzk|TJiyu)64AR3h;q)v$O+&UhDzel#YdE6AhgPvJW*If!Qg?w6b3cVChPW!ckU` za9)Z(fA+#@uGe}=-_d$VF4xmJ{xFN$ zw+Pa#Zfy*ZdqWnRNlQ`U5c16n3PmDnOTbipJi*1zUedqFS1V`rL6=SnZzXl)gnb0Ix* zZFFS1H(r#ct3@az9_KzcgB%=Q?6n14WfTB;5b~UZy)`2`PZiIqRCMkgv1x`4YH;AX zo*ds;OnLV&S+a(>@Anh!09rEqE`p`1VQTN=x4R-%p$S}(0SqV_vR$-Og3tWRNzV~q z(sW;8grs|Lg{;r!ZJwcgPLWscHwBjc02c1c8J=Fu)>Z$5#pPPS_tX)b%=A)xxDBRVXDntn<5z=+D^tig%XbxSg=R&D{qy5mjE6}Sivw`bEPq`ZN>2s^$?Ok;vBiYQIuXN@P}u*RTZgb_a@f=3vhd+gNydqp z_P{_@)kj8S!bUSD3g!aA9jK+?7-(^(BO=Ib*84a^o2W0P2vm4;haA3$Qew)n;-uC| zXhFGNwJH?g!Zv3`8iRG_J+G!e^3eVdH?1)jhk@$mn_Y+nkA zm8r%tCa10Mu4ryvG+erjSrTHf7>^7m)r-d>RqqT$@9%5cOI9FiynM$~b$v-1+PUIh zA1D^NGt^G?Q(>;*+BVJRgU?^?#*}bNt7YR|+3-JSvD*Sw99H8GA#YP8qu}nW*$Sdm ztwTi(%LY^j><*}0<5ha$CDZx$&pP9G<KUawR;7`uc7NFjgS*!&xJaYD~E&0_sq^<;N)5^cwb{sdpYBwRrd z!=J#-eS~ShHL?b=WG(|}%YVjg8cUmdJ|jl?=BkYOoyhW(-mbv=`(%{L;xu4Mt?di? zuxjN;g69r<3VYA`Xa$AvLm%`vX$X9!zkX+3uP9+qvB5ffb+YG|1l}lL->HF(3v1Lp z9!m+9b5O$nlHm5}=OGO_F^0Q$N^hPp`-1qGkH{&N@fwZGPZ0;Rh`3YNnQA}0z_|(6 ze7_{SsQ+P}kGVW|nM;GnalY--eQ4V5ed>+-Vk!69uzpvrp0fml9?LVs2`66R9Aia8 zbVQn!PPIWzmrL;*gb3i9r=1Q0NX@65j2CtAURVicul~F_ygc13GJNTY9_YmWv8C$} zb1Hj%;q>_84(j(kQeZhr|4?ha9ezO?4Hypw_DvSN`gUQda%%onNEZ zPcLf3^5tZVd(R{ch|VaET$Xq}MbKj+iiU)=)CPdk3;A^ia*|bmi7)7F*VPdA`axa>RQf@3ZdZ1i5ho@af?U zYY5GDuA#kF-{W2uN8hL?t~R)gs*`KP$_hM`x#xATnrW;`VJ zgd%=gJ1KRZf7Z~?KXVi;Ui!%?NA1oS& z-PW)E!E2wqx=~4QJ6g|MhhRatsUt>5$t4v(Kl7z1mp(Jvx6?%QaS}7IU>y_aM;bBCT4F9klzy0xGNmG~<`>#Avy8 zb^7d)<)7r8?PUV{8b)W0==FSRHB$XUA1n|Q&D!UAzU#eq2bI#oAt}gu!X%SrSb+zB zgPNFnB3T2!o#A1(DuX7VG~892Nb|8N&Xx@rS?RTQD# zHP70G=I4|F(Nh1CQcE_)YM6T@5Al5Rd`O9;bnwHIFQ%s}a@2^@I1IDKrjzvoen zG<$f}SKGzM*lvxp_1%^D^X&$3n&{K(Ui8s#O)U#=SN{46%%RtPVI z{&8C`Jb%WpEUd?6tq~8#_3Gt!PuVMUrA*Jg$S!v-2JKrxuRiXKj?{ z`I*%O!Wy*k6Us0P)1=yQG`5!FJwrvq?zS3geZuTtBe_bhqt~x6)Na`{tDNUuZ!J;! z)?pf>Dt(6`SPAr!m{M0o9JPA-R)~_OGai{oMP)?4bCDCq)NVoeyoThZ0eyanJNawC z(ju$>kgRww@4ufNOv#9i{WBn>tYS()edXU|5Q^jEH5gKBvlHtjw?HFz2jd9QN=PeA zMP(t;jS383U-p`7d%gZM+{8}$Y&U#q(dqbhbK|BRTZZ$gRj)C!tDL?KB|c-L{!k||rcTvEv#wRbP90xG?%9z&+2Kzf+b=}bm?uR45AY_Q>XI2F=9qPnq}EA_+(@rFCeRYSUFY;rLlDT1b z?K^}eyIV-d62O_5a;)6(9s$_N=4Ls4Xrk>TR9TodRXH41_(As*@S$lvOnfx0y zUXA342l#?4DFXvT+86tVN#)N+x8Gg6uz{SOw1WXza~%kkDJdvGBI+JGI+zLA7^5GZ zC!h-JZ;Ss;aDrc5v*}cC>@KBwUtFn#qo_H2ge=U-rhD5J#h7PkwWI|-V|cczD|4np zFkKIGtZ2Tg{**goa#A{CH2cHLini1%i@gGz;yG`x90Bwb0BJ`opb7`q$^4r%+Vz;I zY?0*8UoiSc$mv&^Z7PX~h=k(r?Cj*`=ElXvMV_6UY>_nTCPe6FbH>;C+6@5K!tCrU z?F;Wl5;B>2A~j|f7P`^x`DQn3Yu03*d`YoYT)PuoDUnAp!);d59q_9*cxoxT?{mse zL&Z5#U|Kyb)%XmY+gT(DKIl}n`CshR|D{K60G&=?PiTm}ND`#imTx;;luLYj z@)Uj);(cDRQt&Hl*>AT`Frno_agLi4agfX<75=shSLAtXUr7P{+H!M*e&4@p{%~1X zyFcN3*$b0*QTAfuhi9?LuUt8hhaxyfXJ>uE8QdDO@2)`l#t|_}TqY9QlAxIs??2YO~I+{WRHfCej61!@lYPUKS0bgNz`yEITwLE!&BJ6Xx2F?RZ za0d^TAe0~sr9$>6Qyf37T!g8LYziMXje+f5OlV&dTjcZ{zasM!agO!VP)@XGD)O-q(u8 zOZ4?B4|O*tPjBk|#i>$w^m6+%G`DFK|tAYGO@KsyP^Y=PE{8_BU{Eo68 z$m1U3p%dd9ibCW*@H1d_HpNBV?7vPnbVX085lh>@L3P@Fc6L21eAc!x)SQ2Ia}y6t z_lDDhI?Z;g^j^5u$t^BA-5d&XV)0&hueP6xamk>oqw4kMiV}3u14>UfUoNbyGt|<5 zwzav>3J_ue9q1TTV@7g>yXMXl>7<4JS`&@3AxVLTi3bGV9zS$l`u3o85Y!sHPPg@C zXf{UjS>C__Tg5sLl;zZvr;(_Z(MBx*n78d0Uf{3*H+P~X-NPJ!9il{G6cNcfWO|-v z4=T*donhyeaCneOJdFgHsjlz(JpNAdDC}bDSpYmu3JMBJwGkluQgtNB>b;B2oTM@? zm$$WjUy7}wpg;~v0mxt7;1q?$j)qM@B<9`wEX*KI5SBk&t`?yY5l8hk@*-hkoy$kjsL`?QfkZ#fJbxett5y$`@TFCYgaw2FTZ z0JU1iaG0$R($|}p8BQ1GFsRpSw9nikm@fQ%Phb{ajmPiuU#0~O>Pm`=rtN_2yUjgN zhMKQhh<^#XSgcj=2}q!10%99?PCWQo`-2W>G-J_Z zvl3oY9VaH(o>*=JCnqr8>*A{6UZBADTbyu6Izc+ml6hzoK(l!Q1YY+5h|2$R*GJe0 zTGZd0ljvH9N8&)7OnG`gt2_WzjD)7jCN>!nZo&Blw^uN?R&!oJGcg0ooP zf!jL3`xkf@KXtn0Q@~V1k~3isAzURWr^}#Uef}H!Q4-_+9hrBBxQ`&~9jV_1p=sC+ zqqo2^I~TIDhN6@_r-%{~k-MoPQ*7Q#04EYyW@~cAGD0JMv#9dPWwjT##Y7Bjj(L1G zghmB>1vvqof~TVu%$4SNC=@Vc0V@BOB<!u z0#-@M&e5-%>8M@-6mftEXy8i7Eq(lC^dT3fR7XeC4wZIhIU=VFmaTVocQsYf)i@RetdC3}t@cE2 z9V1@dQrzq7Rg@`6#pz z=-HQ5G;P75MGbw3$%kD_=m(>#4_?GX+XC1v0tl%$>~SU15z}gc1xW6rWK6%UdCATm zQmW`2>Ntuq8if6Mmwk)5kOn8DW=s@{6>O#uv3!|Q5oLkw#f{q)IY;stH0q6r$K;Fo zK*J*IZ5lyjAjRuk$8UiG=(~J#^LFtM%X}o#*&1z22=^zErOceRY@9Y$yMCrG=j;sXD9ZN&(S(xd+VzD;XmS@goLb)P74ro(7;5aH@wJUi;sE1bkFn1~bQx_CJBryP)E^4)(lr}i47^v#1apZa!^Ad?dDPNYw zz`x03q|v2Vf+f2hA}~!S7oI|8g5iy#D4D3fr0~v!LZl%jALliY)E~&N{BK;C@2f}2 z+WT3RfnU@6MVM*Mb)NVD0ZvHKrvTj-wj)P)8hIpQRm5qg?EyhZmEt_i;8{)3s5cy! z(i`txsHMvGw?`3-$)c|33WXz)RCM^}0ILPMCS@E=QXX`r^W|f(mYcT?W_LU&LvV+h zb_WI2s`A;4qi`Vaf^FvkyP-^K*%oGO8RU6ZQIdxK#o#v?3?oK)P$oWtUO{Z zF+@T90mkd%RebqiaS_k$$nD_kN97#shruGP`Ey$2188AuQx<796ewwb3Q$(RE)=P6 z!xsrn)y&$o8pB}yn^7i(pxIPGn4D#F4eS88lmasEB>BgE49Y}Q4`yFrA~S?AO9rI{ zkug$v=%L9Jc(>skdOai?OxgjpF}B#bui2z}!t0>4yM}NE(|h|fNJ^*e2XEjBF)%Zu z$#p#v=7Paskvu$UQluJ(8Bp^1Eeet--1D%J&Gf8&Y?zaEP7=}d!&Yw6t_l#yUGgC; zV|;6e1yUf;fRUh~ebYAus{QOzXd`aa2te8V%%A7&`SX}xAO8IYl%3M(i0%`yQShc% zQhfcFulJi-`1_OR57HN_ePuhC=_GNl%=5$iqM>j`bO8lQ-<##Cck!&no%+2$tVa6L zO@+mSuz_GEq^oSJ(Gdfa9SOhn57Da*y72JMU2XN@$S+<#9xMW(KD=Q955f|rSBlpT z8T(i&cpL~cV|Nu=)IU2Kb&B*;Vm(-P$ahV0_1pyzl9Ln6wRtRXbutu*(N0cBje12U(TAVR5FJ`iPj&Pp}BG z5$hJk^!PpqlY`>x_V%RtryB#m{UowRo2(tjm;>zuH%fM7!)h%rj|9|7C;K7M}RD} zv)PO%G_Uhm!kPGROS;uRCkLfk&lGV8#^(oJFq>fFsyH~X@xt$ylZh0msd-xTW8x*k zX< z#q}7)CMruhZwc#A6~OgolL_x%35~TasH(0yt~~^3k5#F&-B#vvjllHMIlS_*VKNW* z*3HqK4RrH)7%vo7pZu%=Ai^8Q$HyD|-%brtY;>fIc|=bqml^%QMdJj#8SCf++*(cn zsagzd=)W6GJIJeCy=C)Bwp-gbrs4pQ6`nY0yT9)*6*#3g`g_QS7An~oreTigTFqYtM*5cK)nltef@kH z&P=C_oy|3Afbj&s*?kEA-j(r9C~F>zV*&lOllx$~eMgN>{Qy+bcONV-_X5k5Nu90* z=Wo0{5l9V2q7mO{8%<5xV?@nILrN=KMqRVE<-Kzu*};Ti+pQ|-s`{p>x!MvphU5CT zR+LKYBZrKCD@D?xh>EY3@5z&lXujchd!`(~0nHDDFd=qep#WRblk*4HNNi2}2u;5!@x zOrHIHZmuyTIUebZ%u~Buk!bQk`@Bbkn8sref*&T>k-LRB5$5_2yu8&I=&X?$bI!pZ z^E@wJSi_-JBR_M5rT3APkbglXfAp5_3?gOB{l5on{r7AV^$0UNNS*qV=cn@i1R_i{ z0z}q<*JB#j_V@lM10-EBxoOm;q;Y2b!2)!?PDDd(*zs5NlI0VZg4B9!%h<}{t0*G> z=xg28nx(PnkL+>GKuTn|UQ@A@HaOU3js9=)fvNwut48d!o0TMJh-KKhdv8_5HkQbE z2W%m*PSQ_C0j!DGWKY7F4+2k)VHvLeMmr}{RKxr#Yrc>NucKizc0aC|ZbYOt=1T#QyQ7=Dvt<_5n4b(q?B!U{B#?_-sb_>(1*7hmEt+9&g!OuMqX~N}>Gm zlO1vH_ky2w)|u={(5vP59tSrzA$-8&a$5h(Ie_WP)?~jH2ykSeFv{#-nJ#{zN%u`S z&&dL4jE@h2%#~UWc;HdEtr6~JHV=eo4$uz_A61~Y6;7MwqFyW4+#M5#+A zA6AHw{l0nhxsT+8HnActAL^52mtogJ>p`a%)yVvLu+KPOhI*+!U@!8)@^Z=XgOu)~& z^wUngY`pk&+VhS4+mD;3pH2&OGj=F|@430B>Sc}od4W42Vu9Bk$SQ9?7Bh{nSQj(> z`y4R8%uo+W`=Mx5(lz|)|6Yn$@fK?M=^XI?@~za0G>{V_+>M;hUy{fV;*u6-kzTX# zGgeYMgZ(+7e1&}2RYENQgS>B>`{`i}d=@{YZ%<#9AEgIY=wHtHL*@fC&l{%`3nuy8 z?pl%5X;VmrPmAT!);C<%9l=k3j~n)`E@5Qa{xKR0us+?_Fi*DKQ^3kxSr@+&GBygc zfxIFZ>sIne>6o!f%|epD^)*VMg%g{HlD(#$CLVlJXbFEO%X{N&e;nUH9uRv=(U(;- z_meu!b7Rq#>&-}Dg?*L2|CbA~R}8BdQok!z8X(!ucPHt!FWv+d>t~crSLgDwO1`QT zvU6PO@}KBjbToaIl?F=(ESVqGriEOCGLJ@3(A8VonZOv>I`W~ocw#WZ$cXt%E|M9~ zcG=Zq*CRdUqQmxbpysZ$u+rDSeCb2eL5pY&2$CsFoB8|iD;$-lw`94@M4De}z}qqA zCQ+9gS-z^1*w&>}TwiwOYEF8%gkn5MD$=|+&Nb%NRLc!N>9E^Epkr@w&;^l#Z*$bm z*yXC;hudU*TP!!+0DSX0DtUX&O$Pq0jm#B#-Zdf)^kh=)NUEQ zT7IRDoB6>`lbp=rG0SafrLVm?WACxsUt0L<-=&#z8o!32i@|j0W%h}aLnXxiTF*%^nN?PhO>nLvOQc7^k{ z@}GP6j+Z0kb^sRHK`l z`SO8V*u%ix`{j!nUorBx1M4PMIaLs&6(n=TGX*x2y!snhZDM2w&vWg38OzOVMN2bG3SeVY>M|Lg;N zRO3psqdblQ|8AHnJ%NzP%7wi#;k(;Zo>cAZ4{WH1nL?iHs+}ICZUC3+9l#^)WIoM# zTbgGP4;*5zYr!hLS$ID`o7#&%0sI*O*JrgWKpF zyAxdl`p()=(xW4TV*g8*fAji_BZENwJM`*^jDTX(J`Q`|z`#C`IRlhEl|(uKR2&8h z8+M)dqhGGmQP4K;n>4OacXz*w!$RJTlwYRHKt2SZzP)Mw00~n`vZSmmEp`I{Xp~h{ zHh=#f5eu^@B*#MSupJq=i`7`~+ZC08nJBt6_}if8gTYt7I`!=EvP+9 z!i!y)IgQ&3_cax-iOsVjz}WdeZ)aY`4L(4a^bhi%<1gX?8L$0|9m3wJzJo7!8=li1 zFQ45oPTw>nSii0$&t#@`&TI!x1X#j+jjiIFYc`YPH@h<)faUORxe$>HlV8})KZ5@2 z#at!eac8@|G#-rsxFqssr8hb{A|iR~XCiSO1VCQ+U6ruvO2F0kYis&Ovh zr=ghG%|HWn#-EF$wOUc9Nv0{lSKMMY-_g-g%0DvCe7ztH8}Db8Cf+Xqk!n)37*32jeS^R*$4jKsA7ytU(X2r=Xa zID}b?0iGm)BmGtas{@G5=~Q9h z7hMP>S;FhLZuim>W3;>Tdg@~hoYg~)P`j8&V6k}Z0em`LU0r~Wi0^Ex83l&0@WLNW zalhdLjH^ZGV`q9NwTMOaJ}&e_Wlrj4VK3kf!P9qAFEH3~Jiex8I{;?$n)IX8|9{(T z!nO{wIMMFYJgC6`=IVkpqX(LrNquDPwX^xU4#6&(I4Wqo^oy6bIMrTrlr#!hQnlSa zbe7r3L780GeuMM3A!eH}EOz`D1i~R-)1Qtp6ql6nhN_(C9A{rU8yfk=a}`^g*tKbR z#X}VpL&mzEPKY3qwEdR&q({hxqOR#hm!-Zw!axxqNk-!1cO`pq9)7Yh!w^$#7kd{% zP~GhUr?cQQ`M6g7wKFJS(Jsy0P%FuSUZ9O65sI5QbhorCl_3*!CBno62vQ;FFvTXL zWdXA{0P2kSy*z86J%fTznJq9XEMLA}^(xi|8*xLJ>o0J3D)xUW4 z{`TW>V!bS`)y+)t++S-e;NLnwkc-tKCf7?3dZC>ckuXE^po0UWRV2!a3P(;8ufn{u zIxU$rGs&UO7HGx19un=|NKmG!Fya5x z*06;DkvHik8V(zJmS;^Zq%#7*>8`-%oe8$@gUqM0`h{nFX_gJ8E+@J+)gH@}`U24S zU6)Psrau|QcN02;Hd>_o4sGm=E}oGaP>0p%UbmTeY-VWd0aQ)NnG!XT8FxYklmWOO zxJZ04niVD=#(JteUT*?``MVgIsC4CBHDHfyh-q;$@iR{;i~?<;MA3tDBPVpt_v$))>o@=2vhd5onT+)- z?ZuP#=jR)dzA-t}B<$F^1k z-=aR*Q-!0GRn)wPq<{4|9XkC{h^u*y>;jkLi5_lImZ%wvKTu}d7?VA?*P~-<1FVP> z7H_2737sfjV!U%pLQ_K>@LLfS9AYOa3y_fM$DZ(Fx-hg&##THxzD^b(NA;^;91|OK z+>JLa=i|5%PKTXT;wez{cY21JtyAtOo_USZ*9c6Bf(aGLzaiUzk|GSBKQC63XTWMx z>IZb(PH?~G#X`YRiR(~pA54DiXrMF2{rRak1Q=!nT9CiD8LTL_3Z)r=6Q{u|1W3qZ z1!Roi@XFGUSpWTnDutFigy;U2+(Wa|*gD0UaS%klQDR-ks~561m-KV(`b5D4BHkmt zr@ylM3rdK!jqbTM929;;3R zfzgn>23y*EB1;JV%*jGAoKZeR{@Soiunz?|cEufN@~ZElF?X_zRJ znVF2_lvfer<%tau++er}4QVr2qVO>4Q!q2VRmiQ6nrxN;!EbE){8* z5<8Eq8s& zapeAKsh|U-~4Y# z?doV+sH}9SbGSNRw%jhZ)fZ2yX#@G3>J7w@y@xj_|7>*%U z6T>RhuZ1J&VVyAsn?p914(4>I9Cl`y3A0lceI%xB*dzFv$Mdi?&88znoc8RFy&H2S z<}QZC01#!0EI>E-r}Fj@=w?8@Xc?-yhLf(Bf=!@61;#!cAv04NpdlsBeQPmLoFp^H zj+9RtL6YFcv87;H$v*VKrFuBRyhHdH1t6^3SascNP%}DYt<*YH*U?E0Qr1(KT0q4f z&p~FE7z~51gne~qmo_7uA^s$%SkM((sB3%tP~Z^?cZA5eS&M(ogpgO@NY{aH;-d!y zq`2=6S>UuuZV@iq{l&n7mjP*jmMt_k_nOmDz&#+Q!!S+DESG#;q@KwA?5BJ>DbMtS z47?9%QT!w(;6R5jxlS!7mdyw<+`r#WQcLB3%j|w`%Ea*^ROBzVPGS;R#x5p%F9YQk z5&l?g0+JoAdR-^2Czh?WR{QLdP$HY&DYk$DxBz0=Emx+8wF@A{H$M*N1xWt*p^>>% z&`{|nDch|o!uT>c@H|Ipue5i^G4QeJ_=EYN(=UN~b*EDf%C??nb}lP95AMBBCsRJ0 zM2(7Rmv(=9lS8qRLY5sWy7y zkg+v6IS!>?iVd4ZxI7acG_r-&KMS~xwRmPI8=giiJax>2ZK$BuQhmS|Gq{WUqCSM% z%W-C%Y0|xSjnjqx5nKD|nKhRtyGmv$aOc!l2a9DRimohRt%AC=>@it^vF$V5RTOwl zayHEEr6Y6;F`fx&%D>Xe+IBXLM%jozh&li6CW^lQpoGVnlUgJXuoZNi#Z4XcvKlA% zj!ICIkF*;~nh|0v&{y}C8`!^`t@G0?p3UB_LS@AGi`zm4$lL~;`HSE9$wSjDK+^q9 zbvD1I;Vy5Z*Ol`b!rV=h4jYc+mdA!E3rv2uAkf64_y zruE`{#+Sc4EsVviwNy$p&d40ul}2?Z=-ECo=RgPxO7cN1MPsGp_33~qJnEJh;u zQ#bsAb&6^E1S%_=k15VyEQyP&`PG=Q>miTv=gIMv4W!kFtI3L}9;tLqKIXB|LXNCD zx{#XaBw(JqLZ7AFg==rL81&!?QTbgh?}QCt(`Dlt;`YTwjZw!W6MpS|e1Y#2S?2Qo z17U7>GuY}i)O0pW(3IbnyvSdER**=D#7tlAx`9<$)BESR+9uVMe%b|?F*sBE$)(jSv zbl?Y_;&=DUPf9%=TTUN!tSXkP@_>Nl9^r8|EWQ21hxj2RrhKe*Cjr)J^h>iaFzCG| z6;-Grs|F(lryYd{s<;%rqwI|)FN?!eS|`viMKxZjZcpGhO1H_sF?F3VeELB!Kzh;n zr0CsKjt*aYe(Xtn{rIN|jC}J(X{w9KGm_BSMov9nH@gZMqdWE^5#(i!u@-%%FL}y^R#vqfW6E zTu%pP*5E5-p|-_V0o_9%FaC2V0WAn5aeyTdB&zB&I+=4#z##m!C1mQ+?Fse_m^|=p zUdoHnHc9aJE0Rdzao9SH0*MmTojZ9c=>bw!R)(tkvaWN^Q!Hl}HCIUL<8rml*-m_W zo5;;d5M=5PxsR+sOh+bK^*mYK7_#WEDF3*UBuIuy_Gsp?;XTsvM4{&i7QSfVPWjbG z^qpyBvCpsXhvEfP{kzyd^uBCB>FP1mJj$KX9K=bxZ+FhlgDn;Xzue@_ZV)f!-!2wC zW|Xeq`xa7D_zblYgV3IaUFakb!bZ5DI1(@>juGEO(rT=@MVg0H7%wrkf20RYPN&cr zt(4o1ARD5&U$s}!<55)^xoh)_f2ul$(3UMCx1K4y|D%D+R3ROR7j|15MKS*M#Ut7CkXwcpSgsUXXb-+;LDNJ%t6zPsojjD~vb1$R@p)!FW?^@`7EW z6m3J@e!`Z?CgskSLpViZgV`Kmr(o{=;60bRh=DCx%_hgn@2%Op;bwYMDRUZaOR6LH zV9xg3vyqd)(B0g`{gzDzwm&)3IaEKL)~))pKwm#CRL-(k#y` zbK@9?Md@Dc4@FDo^!Tk06|yF1;hobjbe(rEPLS+ws9s^bGq2K(3Rzb0f_zHE=`IcP zv?KK6bN^v4W@fFdMEcD&@Rt*2rq;Y)wSl(%PSd{GU{;l-b^u==pv8o{{khA$=qIc5 z!MsOg#>WV>5DRdyxbw6Aanrncmqig7R#o?XEPB*B3e^k-hUbMnxuU~&WK>Q(BQa5S zJlf+dn~ME?KJ{lhDt6F@y(>9;CW4>^;V`}pU537@XicCOpL>mSB0j^Kpuphqk?^nx z8zmun2-sv39{cp|sE(*@aqkth4k|HrJRQO1RVkNoo?_EnSZe{_kPQWWq0?XHP3Ct} z@$}u^(-X6~JRYF^^R-sWH|aDyg5lePAHOc|v_VlHD61p9&8)2_l(Hy$2cq&51qD}X z5v*yeK3d*ZL(|ih<|<{Q_{Bj`OulcF9l|b>b`9b*C6bMky~2W;o}8MN#MG>~2@&$- z8sQA4ikfD*TIxS{0Edx#7WYfJ=a&uI&EK4ec3a-nCb%;;t)xKjzE)QS-v*-Q(-H^DSMO1a^h3gH`$Ku=uD9m0qFu-f zp%Nj3oxaLF59jyXUSv3U0C@=k=m*Bi3=%I0{J z79Q52aI4&vt~?wOjQa3SfqCFCUfD5X@HG|AyE4#U!(j=_s0$)-tUH~@xtQCV#AC~eIO3TozQD}r4IQkW_GGJ0RjoAV%(hb8*{VJ4{ zl$>%MEI+gZ3WhHYcc1ZJDIPrh}iyOoo{qR zRfIZt=V0eVrKIKAKQ+&W<@w<@$-5mqJUkle>Z%yC28X2Bg#~ms*8Jv8h=^{Id@RC` z80s1t9?d?0tI!|HVuWZ5Si2v_xgYAPN%@mZmp(tlNzx*TQr8v#8K+~B9}cA>N)coK zfm*x_6L|@%5}=StJScBFlMUFua8ZmapF%DTDgVf?#{j@PnJ0NMwKlc?f2VcM*)on zY>MxXmZ;M`GgBB(M~R%{sj5{zlLf!$WMQnJ*g^;%bsvt`3rYN4$|Sm#`VgYbkdnnh z_*sIUmYSz~SmLMfPE`ZVeQDiL&$y1*HE7Le4=pvr za5W+n>u{LnN_w!e*I`}hk5k+L*By+RlYEqopaV(MSQ8aEiikwQZr&pby(M#fMYjPR z*|!GjqU>a}xF`1lGj;aY4HOdpD5QPEFAhze!o?3)t*GF@L2g7-4rFlktl}xc=TZv_ zd2d{EXaa?my)04~8LAJWpAcRej&dSv54M7v5o>$52y@8;9#W)`%TL}n%UfhIqj5&K z&fRBLN^*euiUMe}Bu%Sr1}E3FTP67!k9;R9?vVV_*BmrcC3F=kXo(8I-Eht~@S`{) zqq7d&Ru;larZS|ouLEvjSgKN}1#P;k&V6e`XIk9NEG;X~Hfj|{@VB9)cdF9*#>OAi zIspA% zq9q2+K*IScI{!`FR}Op%^Z+O%pC-yFC_ugD=!4BljNr)>15_5pTwavzFf)L|zt(e) zphxQc&6`vK35kpMw<=|_@2-?WyAfFYy|oWnunrpukU{}x^d&AYUoIFa)HV&ma@CnX zp+nIH)Ms1_$S_4M&qw?goIcpt6mVS=6IHw6)W=221+;WX`NU>0ff#j*Lbi=?k)G`o z3nQI_-LL}9xI(ZTJ(4*ol>u1`DC;Dlo7Yn{@E!iV;Ham<%JZ{$21J7O>-t{Sn=jkc zduUPe2UL#?DOQMH@~EmHlbDj}f6YSQba0vQQuSUg^cHtLP0xaYWHy}!&;Dr1vi~7~ z7xFbwBagby#-9$vwZ*Nm_V%(=A97FS%qa+xDyo?kk)}*nHLyQPHN&!Xo1)!|#HYn7tXPpH7P(8j9~yIU8UpDl1hH@-+; zz0kJXfj&Sxh@S-HE?tg|j*kbP=boHcL=6Uw!iKn5$FC&p{gm{Bo{ z?iZ1@E8)>Skq@Gi9>iLRUPZ1B5%6F!-p96op0V?8r1 zk?tqSWpKrCgNxFt7blw&RLDYclJ9j@pHy+qcr#e?@%0!Y{9+=*qC#y6>;P%S>(lLT z4wFpRQJx{3L<2zYJd?Rx>C4d1uuJUv7#evY}t00|lv3J}r?RBXj@e#O|X zkm_WPY(m8=%oeBi=v`wY_cGGW{W#kExN1{~t&MIR-#)~Ziiijlb{Too_kO~npiS4j z2=lDwI>j?vUACgs92PmXX=GaC8xUL8Kxsso>9^=)oYQlh|Kn7YOX2zP)6V-w9kIpC z=YrnQOCwHx?3!1U(aHwT zOKnqcFW>G`1dkPuV8&x2fexCpA75U1K_>BF3(zA%_AZ359b$)8(%nfk1|eg6fngL> z1TLu7f}>+vkv|w-h>4ifI=o;~5@MfzeRko7zzn=!vn%k?+}YWABw!_Nw~oIqWIsT9 zi~3Te8UfUeX{=trrKZ6L%@y5OM5mENlgugf09az(87NVg>hv8)xh?7(2*CIQJoQn8h0om^O*-B_IbN2B&pN)V zv(xM@YE^dm`8nl$unvZ%#1{ra4vFJfj`FHbOsu2q{b#86;S~E%dq8NSeRNnraiANP zyi8%b9MS!@Im5stD{}y6o2lBQ7VD`P_g}Ss=fU>hQ0ArF=z6D)nYV=!A^ahCzZN9i zhFX5#Y-Ae8falU(#Ce~ulPzN@Qyd)vUlylPfe!x~?gZ=<>P~hv*4z&h(WHJCiRR=<_X? zYUSz1=}mpvPmf<@!?w)0Lhgodw!glNf^*{F6xPhC))|Khmp>MYW(R8Fn*ut1lNt>B zTusz()zezXCDU4dr zF>i838Nf^bNxI0n6FReA{N*KX{W)TVD!Xg-`Ez&>nz~FJyI;fnsfbFE^j6*vUxq|P zj*qc|lgwGck0{bkOqX-)^f76aQb|OF4I3FFK)#sk43ffRC)7q&gfHYGVm!w*45FR< zuKVUSanK&93$37Az{`ZPyl1Pt$5-)1eKWxeih*z>p*^lp78ghu)_O4ilV+rZU&D@Z01Vv?8g?2h(tX5_E8~IU1SkW%MRReD`Ac?nm>SJMcGB z4m|i-8R5b<#uo=4r>$`{>R+D$i?m#%3*qtupg0hXv39r{Ou+p7_)M*L==*?oM-oDVMoU(aFWZcb3RtgRWr z^AH8YZ?9U|#o^aUjl#GxM((PAe`YHD7@u1{79R6pkB`UGuVrK|Iu-%uv@!=?+B{Zr(F z5Lu^-UinG#J4(D}eU8dfg!>nJCz zNYe8HAMxQEj+X+1@zMO4rVPxHnKzxC-g7TCeq7Bec_63 z%G-oWe*$HqFS0kGt|1tKM?Mct36Y=# zT}@%f*pFDy3Fx|WUeiVz-HUAD9oc-jgn1zxSyE$GcF(7N5>T?YPGW6$@l`ij z!VW;5dzVX2?!7IU^g^k`78yAYMX*ujKEEFmS*ifeI(e>?o(_&Eoa#j(`NVWQN@M~P zs3b~3)k38#I&;ADq@+%ExhX6toZtiUtG#x-3u`kd3T-1`79J9=j&Q{p7ALP!AHJff z1`#U`I?75hnX~ezKJ^u+gaRS7-Sy?f6ah&KRiy&IYigc zm;Q7XmCTkRkpa}=z~oHPMEla;uKA;@sj@ZSExf zkmSV~F3nnb!Rc}QXT}-^@1_>>LJV9q>o^ytcKKS5wRJi!E4e*%uO_~?_MYnL^OuT9 zNm$yvR-c-af`1zm=KNf9s4P;q?L^bolRof)%}Be2@a*M>iPBSpb@rI$k$5tFUXjPY zUxugiu#``lQL`c^P_Qi_qKWy-uirEBUZttq*AqsvRDH-UXr$1Ke(Sx~1aJ^W0hU85 zNv@y{!?v^y(R;eR_`oKY$y1fmKN(e@Drm{q#-i0W#c)`&6u%J_E?+a~wrsvqTQ%=mzA6E70mT> z*1_o%9yZQitFt4-VJr+Im9h4wUy4V(Y_iQj>r!}EgwBII$ z`tgq556gE*{l}D+E-NO~(5zPg{|6u)tsg8;`h4a;{N2~)+=+AeA>Lr%eLWtW-7hX1 zF5GAdbm1`x`#QX^nmmjhJpV~AM-rcb4}{UVco%7TnWT3VX>p>&@6i|=9?%?QFq z;e2et?@B=+-*6oBh>&AM#?eEfBc(kChR6ayi~RaqRX)f^V9?^3z^pV7I1r0rLp&>H z=kAcX&P(bAB_)T`ZHliHU2m~oVZA2WB4~?UyEqywK4T>8#k*GSncz@~%-&yyt|L8q zmj;aRsIcI=jcJ^DXpEN3-BT}DlY>4UwXAEL z)UO(+MmmXKzcM7D&-{;cG%O!)9n}mL<7XB`yc59U>;u1*Xrv==&_Dcci;+`1HX4&F zKK{az(TQGthW2s#@=(HEx tQ1iEdr#&j?*XaNN From 3059ab835b09fe8ba1c821a05ea896cc0cc616da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Tue, 3 Feb 2015 21:31:04 +0100 Subject: [PATCH 22/54] Fix some policies --- 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 92b99e0..05aee24 100644 --- a/lib/metadata/policies.js +++ b/lib/metadata/policies.js @@ -416,7 +416,7 @@ var policies = { "message": "

This is the number of different colors defined in CSS.

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

", "isOkThreshold": 30, "isBadThreshold": 150, - "isAbnormalThreshold": 300, + "isAbnormalThreshold": 400, "hasOffenders": true, "offendersTransformFn": function(offenders, ruleObject) { var deduplicatedObj = {}; @@ -771,7 +771,7 @@ var policies = { list: offenders.map(function(offender) { var splittedOffender = offendersHelpers.cssOffenderPattern(offender); - var rule = splittedOffender.css; + var rule = splittedOffender.css || ''; var redundanters = [ ['ul', 'li'], ['ol', 'li'], From 0ff35d13e87f22b86de8f2bcf31dc443b305fc27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Thu, 5 Feb 2015 15:23:05 +0100 Subject: [PATCH 23/54] Fix require --- test/api/runsQueueTest.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/api/runsQueueTest.js b/test/api/runsQueueTest.js index 1e8798d..df306cd 100644 --- a/test/api/runsQueueTest.js +++ b/test/api/runsQueueTest.js @@ -1,5 +1,5 @@ var should = require('chai').should(); -var runsQueue = require('../../lib/server/datastores/runsQueue.js'); +var runsQueue = require('../../lib/server/datastores/runsQueue'); describe('runsQueue', function() { From 5bbe4cde77c1140ac97bda8f04d593cab1256e64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Thu, 5 Feb 2015 15:23:59 +0100 Subject: [PATCH 24/54] Separate steps --- lib/runner.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/runner.js b/lib/runner.js index ab6ea6d..adb8864 100644 --- a/lib/runner.js +++ b/lib/runner.js @@ -43,7 +43,11 @@ var Runner = function(params) { delete data.toolsResults.phantomas.metrics.javascriptExecutionTree; delete data.toolsResults.phantomas.offenders.javascriptExecutionTree; - //Finished! + return data; + + }).then(function(data) { + + // Finished! deferred.resolve(data); }).fail(function(err) { From ae47333eef2920c8c8992e08598aa5c60e4d4d2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Thu, 5 Feb 2015 15:25:17 +0100 Subject: [PATCH 25/54] New 'screenshot' option when launching a test --- lib/screenshotHandler.js | 135 ++++++++++++++++++++ lib/server/controllers/apiController.js | 149 ++++++++++++++-------- lib/server/datastores/resultsDatastore.js | 40 ++++-- lib/server/datastores/runsQueue.js | 3 + lib/tools/phantomas/phantomasWrapper.js | 25 ++-- package.json | 4 +- test/api/apiTest.js | 9 +- test/api/screenshotHandlerTest.js | 126 ++++++++++++++++++ test/fixtures/logo-large.png | Bin 0 -> 4193 bytes 9 files changed, 418 insertions(+), 73 deletions(-) create mode 100644 lib/screenshotHandler.js create mode 100644 test/api/screenshotHandlerTest.js create mode 100644 test/fixtures/logo-large.png diff --git a/lib/screenshotHandler.js b/lib/screenshotHandler.js new file mode 100644 index 0000000..c7f725f --- /dev/null +++ b/lib/screenshotHandler.js @@ -0,0 +1,135 @@ +var debug = require('debug')('ylt:screenshotHandler'); +var lwip = require('lwip'); +var tmp = require('temporary'); +var Q = require('q'); +var fs = require('fs'); +var path = require('path'); + + +var screenshotHandler = function() { + + this.getScreenshotTempFile = function() { + + var screenshotTmpFolder = new tmp.Dir(); + var tmpFilePath = path.join(screenshotTmpFolder.path, 'screenshot.jpg'); + var that = this; + + return { + + getTmpFolder: function() { + return screenshotTmpFolder; + }, + + getTmpFilePath: function() { + return tmpFilePath; + }, + + toThumbnail: function(width) { + return that.optimize(tmpFilePath, width); + }, + + deleteTmpFile: function() { + return that.deleteTmpFileAndFolder(tmpFilePath, screenshotTmpFolder); + } + }; + }; + + + this.optimize = function(imagePath, width) { + var that = this; + + debug('Starting screenshot transformation'); + + return this.openImage(imagePath) + + .then(function(image) { + + return that.resizeImage(image, width); + + }) + + .then(this.toBuffer); + }; + + + this.openImage = function(imagePath) { + var deferred = Q.defer(); + + lwip.open(imagePath, function(err, image){ + if (err) { + debug('Could not open imagePath %s', imagePath); + debug(err); + + deferred.reject(err); + } else { + debug('Image correctly open'); + deferred.resolve(image); + } + }); + + return deferred.promise; + }; + + + this.resizeImage = function(image, newWidth) { + var deferred = Q.defer(); + + var currentWidth = image.width(); + var ratio = newWidth / currentWidth; + + image.scale(ratio, function(err, image){ + if (err) { + debug('Could not resize image'); + debug(err); + + deferred.reject(err); + } else { + debug('Image correctly resized'); + deferred.resolve(image); + } + }); + + return deferred.promise; + }; + + + this.toBuffer = function(image) { + var deferred = Q.defer(); + + image.toBuffer('jpg', {quality: 85}, function(err, buffer){ + if (err) { + debug('Could not save image to buffer'); + debug(err); + + deferred.reject(err); + } else { + debug('Image correctly transformed to buffer'); + deferred.resolve(buffer); + } + }); + + return deferred.promise; + }; + + + this.deleteTmpFileAndFolder = function(tmpFilePath, screenshotTmpFolder) { + var deferred = Q.defer(); + + fs.unlink(tmpFilePath, function (err) { + if (err) { + debug('Screenshot file not found, could not be deleted. But it is not a problem.'); + } else { + debug('Screenshot file deleted.'); + } + + screenshotTmpFolder.rmdir(); + debug('Screenshot temp folder deleted'); + + deferred.resolve(); + }); + + return deferred.promise; + }; +}; + +module.exports = new screenshotHandler(); \ No newline at end of file diff --git a/lib/server/controllers/apiController.js b/lib/server/controllers/apiController.js index 4ab4142..d0e0aa7 100644 --- a/lib/server/controllers/apiController.js +++ b/lib/server/controllers/apiController.js @@ -1,6 +1,8 @@ var debug = require('debug')('ylt:server'); +var Q = require('q'); var ylt = require('../../index'); +var ScreenshotHandler = require('../../screenshotHandler'); var RunsQueue = require('../datastores/runsQueue'); var RunsDatastore = require('../datastores/runsDatastore'); var ResultsDatastore = require('../datastores/resultsDatastore'); @@ -24,10 +26,17 @@ var ApiController = function(app) { params: { url: req.body.url, waitForResponse: req.body.waitForResponse !== false && req.body.waitForResponse !== 'false' && req.body.waitForResponse !== 0, - partialResult: req.body.partialResult || null + partialResult: req.body.partialResult || null, + screenshot: req.body.screenshot !== false && req.body.screenshot !== 'false' && req.body.screenshot !== 0 } }; + // Create a temporary folder to save the screenshot + var screenshot; + if (run.params.screenshot) { + screenshot = ScreenshotHandler.getScreenshotTempFile(); + } + // Add test to the testQueue debug('Adding test %s to the queue', run.runId); var queuePromise = queue.push(run.runId); @@ -49,81 +58,121 @@ var ApiController = function(app) { debug('Launching test %s on %s', run.runId, run.params.url); - ylt(run.params.url) + var runOptions = { + screenshot: run.params.screenshot ? screenshot.getTmpFilePath() : false + }; - .then(function(data) { + return ylt(run.params.url, runOptions); - debug('Success'); - + }) + // Phantomas completed, let's save the screenshot if any + .then(function(data) { - // Save result in datastore - data.runId = run.runId; - resultsDatastore.saveResult(data) - .then(function() { + debug('Success'); + data.runId = run.runId; - runsDatastore.markAsComplete(run.runId); - - // Send result if the user was waiting - if (run.params.waitForResponse) { + + // Some conditional steps are made if there is a screenshot + var screenshotPromise = Q.resolve(); - // If the user only wants a portion of the result (partialResult option) - switch(run.params.partialResult) { - case 'generalScores': - res.redirect(302, '/api/results/' + run.runId + '/generalScores'); - break; - case 'rules': - res.redirect(302, '/api/results/' + run.runId + '/rules'); - break; - case 'javascriptExecutionTree': - res.redirect(302, '/api/results/' + run.runId + '/javascriptExecutionTree'); - break; - case 'phantomas': - res.redirect(302, '/api/results/' + run.runId + '/toolsResults/phantomas'); - break; - default: - res.redirect(302, '/api/results/' + run.runId); - } - } - - }) - .fail(function(err) { - debug('Saving results to resultsDatastore failed:'); - debug(err); + if (run.params.screenshot) { + + // Replace the empty promise created earlier with Q.resolve() + screenshotPromise = screenshot.toThumbnail(640) + + // Read screenshot + .then(function(screenshotBuffer) { + + if (screenshotBuffer) { + debug('Image optimized'); + data.screenshotBuffer = screenshotBuffer; - res.status(500).send('Saving results failed'); - }); + // Official path to get the image + data.screenshotUrl = '/result/' + data.runId + '/screenshot.jpg'; + } + delete data.params.options.screenshot; + + }) + // Delete screenshot temporary file + .then(screenshot.deleteTmpFile); + + } + + // Let's continue + screenshotPromise + + // Save results + .then(function() { + return resultsDatastore.saveResult(data); }) + // Mark as the run as complete and send the response if the request is still waiting + .then(function() { + + debug('Result saved in datastore'); + + runsDatastore.markAsComplete(run.runId); + + if (run.params.waitForResponse) { + + // If the user only wants a portion of the result (partialResult option) + switch(run.params.partialResult) { + case 'generalScores': + res.redirect(302, '/api/results/' + run.runId + '/generalScores'); + break; + case 'rules': + res.redirect(302, '/api/results/' + run.runId + '/rules'); + break; + case 'javascriptExecutionTree': + res.redirect(302, '/api/results/' + run.runId + '/javascriptExecutionTree'); + break; + case 'phantomas': + res.redirect(302, '/api/results/' + run.runId + '/toolsResults/phantomas'); + break; + default: + res.redirect(302, '/api/results/' + run.runId); + } + } + + }) .fail(function(err) { - console.error('Test failed for URL: %s', run.params.url); console.error(err.toString()); runsDatastore.markAsFailed(run.runId, err.toString()); - res.status(400).send('Bad request'); - - }) - - .finally(function() { - queue.remove(run.runId); + res.status(500).send('An error occured'); }); - }).fail(function(err) { - console.error('Error or YLT\'s core instanciation'); - console.error(err); - console.error(err.stack); + }) + + .fail(function(err) { + + console.error('Test failed for URL: %s', run.params.url); + console.error(err.toString()); + + runsDatastore.markAsFailed(run.runId, err.toString()); + + res.status(400).send('Bad request'); + + }) + + .finally(function() { + queue.remove(run.runId); }); - // The user doesn't not want to wait for the response, sending the run ID only + + // The user doesn't want to wait for the response, sending the run ID only if (!run.params.waitForResponse) { console.log('Sending response without waiting.'); res.setHeader('Content-Type', 'application/json'); res.send(JSON.stringify({runId: run.runId})); } + }); + // Retrive one run by id app.get('/api/runs/:id', function(req, res) { var runId = req.params.id; diff --git a/lib/server/datastores/resultsDatastore.js b/lib/server/datastores/resultsDatastore.js index 3392337..05cf617 100644 --- a/lib/server/datastores/resultsDatastore.js +++ b/lib/server/datastores/resultsDatastore.js @@ -9,24 +9,28 @@ function ResultsDatastore() { 'use strict'; var resultFileName = 'results.json'; + var resultScreenshotName = 'screenshot.jpg'; var resultsFolderName = 'results'; var resultsDir = path.join(__dirname, '..', '..', '..', resultsFolderName); this.saveResult = function(testResults) { - var promise = createResultFolder(testResults.runId); + + return createResultFolder(testResults.runId) - debug('Saving results to disk...'); + .then(function() { + return saveScreenshotIfExists(testResults); + }) - promise.then(function() { + .then(function() { - var resultFilePath = path.join(resultsDir, testResults.runId, resultFileName); - debug('Destination file is %s', resultFilePath); - - return Q.nfcall(fs.writeFile, resultFilePath, JSON.stringify(testResults, null, 2)); - }); + debug('Saving results to disk...'); - return promise; + var resultFilePath = path.join(resultsDir, testResults.runId, resultFileName); + debug('Destination file is %s', resultFilePath); + + return Q.nfcall(fs.writeFile, resultFilePath, JSON.stringify(testResults, null, 2)); + }); }; @@ -84,6 +88,24 @@ function ResultsDatastore() { return deferred.promise; } + + // If there is a screenshot, save it as screenshot.jpg in the same folder as the results + function saveScreenshotIfExists(testResults) { + var deferred = Q.defer(); + + if (testResults.screenshotBuffer) { + + var screenshotFilePath = path.join(resultsDir, testResults.runId, resultScreenshotName); + fs.writeFile(screenshotFilePath, testResults.screenshotBuffer); + + delete testResults.screenshotBuffer; + + } else { + deferred.resolve(); + } + + return deferred; + } } module.exports = ResultsDatastore; \ No newline at end of file diff --git a/lib/server/datastores/runsQueue.js b/lib/server/datastores/runsQueue.js index 26a6717..e99f1fb 100644 --- a/lib/server/datastores/runsQueue.js +++ b/lib/server/datastores/runsQueue.js @@ -1,4 +1,5 @@ var Q = require('q'); +var debug = require('debug')('ylt:runsQueue'); function RunsQueue() { @@ -11,6 +12,8 @@ function RunsQueue() { var deferred = Q.defer(); var startingPosition = queue.length; + debug('Adding run %s to the queue, position is %d', runId, startingPosition); + if (startingPosition === 0) { // The queue is empty, let's run immediatly diff --git a/lib/tools/phantomas/phantomasWrapper.js b/lib/tools/phantomas/phantomasWrapper.js index 594cde9..c24dd67 100644 --- a/lib/tools/phantomas/phantomasWrapper.js +++ b/lib/tools/phantomas/phantomasWrapper.js @@ -1,9 +1,9 @@ -var async = require('async'); -var Q = require('q'); -var ps = require('ps-node'); -var path = require('path'); -var debug = require('debug')('ylt:phantomaswrapper'); -var phantomas = require('phantomas'); +var async = require('async'); +var Q = require('q'); +var ps = require('ps-node'); +var path = require('path'); +var debug = require('debug')('ylt:phantomaswrapper'); +var phantomas = require('phantomas'); var PhantomasWrapper = function() { @@ -20,17 +20,18 @@ var PhantomasWrapper = function() { this.execute = function(data) { var deferred = Q.defer(); - var task = data.params; + var options = { // Cusomizable options - timeout: task.options.timeout || 60, + 'timeout': task.options.timeout || 60, 'js-deep-analysis': task.options.jsDeepAnalysis || false, 'user-agent': 'Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/27.0.1453.110 Safari/537.36', + 'screenshot': task.options.screenshot || false, // Mandatory - reporter: 'json:pretty', + 'reporter': 'json:pretty', 'analyze-css': true, 'skip-modules': [ 'blockDomains', // not needed @@ -44,7 +45,6 @@ var PhantomasWrapper = function() { 'jQuery', // overridden 'jserrors', // overridden 'pageSource', // not needed - 'screenshot', // not needed for the moment 'waitForSelector', // not needed 'windowPerformance' // overriden ].join(','), @@ -100,7 +100,7 @@ var PhantomasWrapper = function() { debug('Returning from Phantomas'); // Adding some YellowLabTools errors here - if (json && json.metrics && !json.metrics.javascriptExecutionTree) { + if (json && json.metrics && (!json.metrics.javascriptExecutionTree || !json.offenders.javascriptExecutionTree)) { err = 1001; } @@ -130,10 +130,11 @@ var PhantomasWrapper = function() { if (err) { debug('All ' + triesNumber + ' attemps failed for the test'); deferred.reject(err); + } else { - // Success!!! deferred.resolve(json); + } }); diff --git a/package.json b/package.json index e2d31ec..264a7f1 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,12 @@ "cors": "^2.5.2", "debug": "~2.1.0", "express": "~4.10.6", + "lwip": "0.0.6", "phantomas": "1.9.0", "ps-node": "0.0.3", "q": "~1.1.2", - "rimraf": "~2.2.8" + "rimraf": "~2.2.8", + "temporary": "0.0.8" }, "devDependencies": { "chai": "^1.10.0", diff --git a/test/api/apiTest.js b/test/api/apiTest.js index bb20128..50db2ae 100644 --- a/test/api/apiTest.js +++ b/test/api/apiTest.js @@ -131,7 +131,6 @@ describe('api', function() { }, function(error, response, body) { if (!error && response.statusCode === 302) { - console.log(response.headers.location); response.headers.should.have.a.property('location').that.is.a('string'); response.headers.location.should.contain('/rules'); @@ -160,6 +159,14 @@ describe('api', function() { body.should.have.a.property('toolsResults').that.is.an('object'); body.should.have.a.property('javascriptExecutionTree').that.is.an('object'); + // Check if the screenshot temporary file was correctly removed + body.params.options.should.not.have.a.property('screenshot'); + // Check if the screenshot buffer was correctly removed + body.should.not.have.a.property('screenshotBuffer'); + // Check if the screenshot url is here + body.should.have.a.property('screenshotUrl'); + body.screenshotUrl.should.have.string('/result/' + body.runId + '/screenshot.jpg'); + done(); } else { diff --git a/test/api/screenshotHandlerTest.js b/test/api/screenshotHandlerTest.js new file mode 100644 index 0000000..54127f3 --- /dev/null +++ b/test/api/screenshotHandlerTest.js @@ -0,0 +1,126 @@ +var should = require('chai').should(); +var ScreenshotHandler = require('../../lib/screenshotHandler'); + +var fs = require('fs'); +var path = require('path'); + +describe('screenshotHandler', function() { + + var imagePath = path.join(__dirname, '../fixtures/logo-large.png'); + var screenshot, lwipImage; + + + it('should open an image and return an lwip object', function(done) { + ScreenshotHandler.openImage(imagePath) + .then(function(image) { + lwipImage = image; + + lwipImage.should.be.an('object'); + lwipImage.width().should.equal(620); + lwipImage.height().should.equal(104); + + done(); + }) + .fail(function(err) { + done(err); + }); + }); + + + it('should resize an lwip image', function(done) { + ScreenshotHandler.resizeImage(lwipImage, 310) + .then(function(image) { + lwipImage = image; + + lwipImage.width().should.equal(310); + lwipImage.height().should.equal(52); + + done(); + }) + .fail(function(err) { + done(err); + }); + }); + + + it('should transform a lwip image into a buffer', function(done) { + ScreenshotHandler.toBuffer(lwipImage) + .then(function(buffer) { + buffer.should.be.an.instanceof(Buffer); + done(); + }) + .fail(function(err) { + done(err); + }); + }); + + + it('should optimize an image and return a buffered version', function(done) { + ScreenshotHandler.optimize(imagePath, 200) + .then(function(buffer) { + buffer.should.be.an.instanceof(Buffer); + done(); + }) + .fail(function(err) { + done(err); + }); + }); + + + it('should provide a temporary file object', function() { + screenshot = ScreenshotHandler.getScreenshotTempFile(); + + screenshot.should.have.a.property('getTmpFolder').that.is.a('function'); + screenshot.should.have.a.property('getTmpFilePath').that.is.a('function'); + screenshot.should.have.a.property('toThumbnail').that.is.a('function'); + screenshot.should.have.a.property('deleteTmpFile').that.is.a('function'); + }); + + + it('should have created the temporary folder', function() { + var folder = screenshot.getTmpFolder(); + fs.existsSync(folder.path).should.equal(true); + }); + + + it('should respond a temporary file', function() { + var file = screenshot.getTmpFilePath(); + file.should.have.string('/screenshot.jpg'); + }); + + + it('should delete the temp folder when there is no file', function(done) { + var tmpFolderPath = screenshot; + + screenshot.deleteTmpFile() + .then(function() { + fs.existsSync(screenshot.getTmpFolder().path).should.equal(false); + done(); + }) + .fail(function(err) { + done(err); + }); + }); + + it('should delete the temp folder with the screenshot inside', function(done) { + screenshot = ScreenshotHandler.getScreenshotTempFile(); + var tmpFolderPath = screenshot.getTmpFolder().path; + var tmpImagePath = path.join(tmpFolderPath, 'screenshot.jpg'); + + // Copy image + var testImage = fs.readFileSync(imagePath); + fs.writeFileSync(tmpImagePath, testImage); + + fs.existsSync(tmpImagePath).should.equal(true); + + screenshot.deleteTmpFile() + .then(function() { + fs.existsSync(tmpImagePath).should.equal(false); + fs.existsSync(tmpFolderPath).should.equal(false); + done(); + }) + .fail(function(err) { + done(err); + }); + }); +}); \ No newline at end of file diff --git a/test/fixtures/logo-large.png b/test/fixtures/logo-large.png new file mode 100644 index 0000000000000000000000000000000000000000..f739504057983a007144315515a0a431463cce2e GIT binary patch literal 4193 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 Date: Thu, 5 Feb 2015 17:19:39 +0100 Subject: [PATCH 26/54] Fix random test fail --- test/api/screenshotHandlerTest.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/api/screenshotHandlerTest.js b/test/api/screenshotHandlerTest.js index 54127f3..c19d0e0 100644 --- a/test/api/screenshotHandlerTest.js +++ b/test/api/screenshotHandlerTest.js @@ -93,6 +93,7 @@ describe('screenshotHandler', function() { var tmpFolderPath = screenshot; screenshot.deleteTmpFile() + .delay(1000) .then(function() { fs.existsSync(screenshot.getTmpFolder().path).should.equal(false); done(); @@ -114,6 +115,7 @@ describe('screenshotHandler', function() { fs.existsSync(tmpImagePath).should.equal(true); screenshot.deleteTmpFile() + .delay(1000) .then(function() { fs.existsSync(tmpImagePath).should.equal(false); fs.existsSync(tmpFolderPath).should.equal(false); From efa415185060663f340c2daf346d9a08beebf699 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Thu, 5 Feb 2015 17:27:21 +0100 Subject: [PATCH 27/54] New API route to retrieve a screenshot --- lib/server/controllers/apiController.js | 24 +++++++++--- lib/server/datastores/resultsDatastore.js | 9 +++++ test/api/apiTest.js | 41 +++++++++++++++++++- test/api/resultsDatastoreTest.js | 47 +++++++++++++++++++++++ 4 files changed, 114 insertions(+), 7 deletions(-) diff --git a/lib/server/controllers/apiController.js b/lib/server/controllers/apiController.js index d0e0aa7..9c5734f 100644 --- a/lib/server/controllers/apiController.js +++ b/lib/server/controllers/apiController.js @@ -27,7 +27,7 @@ var ApiController = function(app) { url: req.body.url, waitForResponse: req.body.waitForResponse !== false && req.body.waitForResponse !== 'false' && req.body.waitForResponse !== 0, partialResult: req.body.partialResult || null, - screenshot: req.body.screenshot !== false && req.body.screenshot !== 'false' && req.body.screenshot !== 0 + screenshot: req.body.screenshot || false } }; @@ -78,7 +78,7 @@ var ApiController = function(app) { if (run.params.screenshot) { // Replace the empty promise created earlier with Q.resolve() - screenshotPromise = screenshot.toThumbnail(640) + screenshotPromise = screenshot.toThumbnail(600) // Read screenshot .then(function(screenshotBuffer) { @@ -88,11 +88,9 @@ var ApiController = function(app) { data.screenshotBuffer = screenshotBuffer; // Official path to get the image - data.screenshotUrl = '/result/' + data.runId + '/screenshot.jpg'; + data.screenshotUrl = '/api/results/' + data.runId + '/screenshot.jpg'; } - delete data.params.options.screenshot; - }) // Delete screenshot temporary file .then(screenshot.deleteTmpFile); @@ -104,6 +102,7 @@ var ApiController = function(app) { // Save results .then(function() { + delete data.params.options.screenshot; return resultsDatastore.saveResult(data); }) @@ -270,6 +269,21 @@ var ApiController = function(app) { }); } + // Retrive one result by id + app.get('/api/results/:id/screenshot.jpg', function(req, res) { + var runId = req.params.id; + + resultsDatastore.getScreenshot(runId) + .then(function(screenshotBuffer) { + + res.setHeader('Content-Type', 'image/jpeg'); + res.send(screenshotBuffer); + + }).fail(function() { + res.status(404).send('Not found'); + }); + }); + }; module.exports = ApiController; \ No newline at end of file diff --git a/lib/server/datastores/resultsDatastore.js b/lib/server/datastores/resultsDatastore.js index 05cf617..a8119f9 100644 --- a/lib/server/datastores/resultsDatastore.js +++ b/lib/server/datastores/resultsDatastore.js @@ -106,6 +106,15 @@ function ResultsDatastore() { return deferred; } + + this.getScreenshot = function(runId) { + + var screenshotFilePath = path.join(resultsDir, runId, resultScreenshotName); + + debug('Getting screenshot (runID = %s) from disk...', runId); + + return Q.nfcall(fs.readFile, screenshotFilePath); + }; } module.exports = ResultsDatastore; \ No newline at end of file diff --git a/test/api/apiTest.js b/test/api/apiTest.js index 50db2ae..4e960d2 100644 --- a/test/api/apiTest.js +++ b/test/api/apiTest.js @@ -16,6 +16,7 @@ describe('api', function() { var syncRunResultUrl; var asyncRunId; + var screenshotUrl; it('should refuse a query with an invalid key', function(done) { @@ -94,7 +95,8 @@ describe('api', function() { url: serverUrl + '/api/runs', body: { url: wwwUrl + '/simple-page.html', - waitForResponse: true + waitForResponse: true, + screenshot: true }, json: true, headers: { @@ -165,7 +167,9 @@ describe('api', function() { body.should.not.have.a.property('screenshotBuffer'); // Check if the screenshot url is here body.should.have.a.property('screenshotUrl'); - body.screenshotUrl.should.have.string('/result/' + body.runId + '/screenshot.jpg'); + body.screenshotUrl.should.equal('/api/results/' + body.runId + '/screenshot.jpg'); + + screenshotUrl = body.screenshotUrl; done(); @@ -523,4 +527,37 @@ describe('api', function() { }); }); + + it('should retrieve the screenshot', function(done) { + this.timeout(5000); + + request({ + method: 'GET', + url: serverUrl + screenshotUrl + }, function(error, response, body) { + if (!error && response.statusCode === 200) { + response.headers['content-type'].should.equal('image/jpeg'); + done(); + } else { + done(error || response.statusCode); + } + }); + }); + + + it('should fail on a unexistant screenshot', function(done) { + this.timeout(5000); + + request({ + method: 'GET', + url: serverUrl + '/api/results/000000/screenshot.jpg' + }, function(error, response, body) { + if (!error && response.statusCode === 404) { + done(); + } else { + done(error || response.statusCode); + } + }); + }); + }); diff --git a/test/api/resultsDatastoreTest.js b/test/api/resultsDatastoreTest.js index 4e8bdaf..da181e7 100644 --- a/test/api/resultsDatastoreTest.js +++ b/test/api/resultsDatastoreTest.js @@ -1,6 +1,9 @@ var should = require('chai').should(); var resultsDatastore = require('../../lib/server/datastores/resultsDatastore'); +var fs = require('fs'); +var path = require('path'); + describe('resultsDatastore', function() { var datastore = new resultsDatastore(); @@ -71,4 +74,48 @@ describe('resultsDatastore', function() { done(); }); }); + + + var testId3 = '555555'; + var testData3 = { + runId: testId3, + other: { + foo: 'foo', + bar: 2 + }, + screenshotBuffer: fs.readFileSync(path.join(__dirname, '../fixtures/logo-large.png')) + }; + + it('should store a test with a screenshot', function(done) { + + datastore.saveResult(testData3).then(function() { + done(); + }).fail(function(err) { + done(err); + }); + }); + + it('should have a normal result', function(done) { + datastore.getResult(testId3) + .then(function(results) { + + results.should.not.have.a.property('screenshot'); + + done(); + }) + .fail(function(err) { + done(err); + }); + }); + + it('should retrieve the saved image', function() { + datastore.getScreenshot(testId3) + .then(function(imageBuffer) { + imageBuffer.should.be.an.instanceof(Buffer); + done(); + }) + .fail(function(err) { + done(err); + }); + }); }); From 6a844e163b5682ad33612eef78245304c24cfa61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Thu, 5 Feb 2015 18:49:05 +0100 Subject: [PATCH 28/54] Add the screenshot option in front-end --- front/src/js/controllers/dashboardCtrl.js | 3 ++- front/src/js/controllers/indexCtrl.js | 3 ++- front/src/js/controllers/ruleCtrl.js | 3 ++- front/src/js/controllers/timelineCtrl.js | 3 ++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/front/src/js/controllers/dashboardCtrl.js b/front/src/js/controllers/dashboardCtrl.js index c1b2ba1..4243e7b 100644 --- a/front/src/js/controllers/dashboardCtrl.js +++ b/front/src/js/controllers/dashboardCtrl.js @@ -36,7 +36,8 @@ dashboardCtrl.controller('DashboardCtrl', ['$scope', '$rootScope', '$routeParams $scope.testAgain = function() { Runs.save({ url: $scope.result.params.url, - waitForResponse: false + waitForResponse: false, + screenshot: true }, function(data) { $location.path('/queue/' + data.runId); }); diff --git a/front/src/js/controllers/indexCtrl.js b/front/src/js/controllers/indexCtrl.js index bec1da3..e460d83 100644 --- a/front/src/js/controllers/indexCtrl.js +++ b/front/src/js/controllers/indexCtrl.js @@ -5,7 +5,8 @@ indexCtrl.controller('IndexCtrl', ['$scope', '$location', 'Runs', function($scop if ($scope.url) { Runs.save({ url: $scope.url, - waitForResponse: false + waitForResponse: false, + screenshot: true }, function(data) { $location.path('/queue/' + data.runId); }); diff --git a/front/src/js/controllers/ruleCtrl.js b/front/src/js/controllers/ruleCtrl.js index 7ff0622..27e9dcd 100644 --- a/front/src/js/controllers/ruleCtrl.js +++ b/front/src/js/controllers/ruleCtrl.js @@ -31,7 +31,8 @@ ruleCtrl.controller('RuleCtrl', ['$scope', '$rootScope', '$routeParams', '$locat $scope.testAgain = function() { Runs.save({ url: $scope.result.params.url, - waitForResponse: false + waitForResponse: false, + screenshot: true }, function(data) { $location.path('/queue/' + data.runId); }); diff --git a/front/src/js/controllers/timelineCtrl.js b/front/src/js/controllers/timelineCtrl.js index da813f9..2dd1943 100644 --- a/front/src/js/controllers/timelineCtrl.js +++ b/front/src/js/controllers/timelineCtrl.js @@ -134,7 +134,8 @@ timelineCtrl.controller('TimelineCtrl', ['$scope', '$rootScope', '$routeParams', $scope.testAgain = function() { Runs.save({ url: $scope.result.params.url, - waitForResponse: false + waitForResponse: false, + screenshot: true }, function(data) { $location.path('/queue/' + data.runId); }); From b5ac42792e040430682e9efc80a8d2b69a1df055 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Thu, 5 Feb 2015 18:49:42 +0100 Subject: [PATCH 29/54] Reduce screenshot width --- lib/server/controllers/apiController.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/server/controllers/apiController.js b/lib/server/controllers/apiController.js index 9c5734f..940c870 100644 --- a/lib/server/controllers/apiController.js +++ b/lib/server/controllers/apiController.js @@ -78,7 +78,7 @@ var ApiController = function(app) { if (run.params.screenshot) { // Replace the empty promise created earlier with Q.resolve() - screenshotPromise = screenshot.toThumbnail(600) + screenshotPromise = screenshot.toThumbnail(400) // Read screenshot .then(function(screenshotBuffer) { From 52e73ca7cd49ea0b3c824f76bb536ae2b0afed88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Thu, 5 Feb 2015 18:49:59 +0100 Subject: [PATCH 30/54] Display screenshot on the fashboard --- front/src/css/dashboard.css | 46 ++++++++++++++++++++++++++++- front/src/less/dashboard.less | 53 +++++++++++++++++++++++++++++++++- front/src/views/dashboard.html | 17 ++++++++--- 3 files changed, 110 insertions(+), 6 deletions(-) diff --git a/front/src/css/dashboard.css b/front/src/css/dashboard.css index 407887b..dfb9b3d 100644 --- a/front/src/css/dashboard.css +++ b/front/src/css/dashboard.css @@ -5,7 +5,14 @@ text-align: center; } .summary .globalScore { - margin-bottom: 3em; + display: table; + width: 60%; + margin: 3em auto; +} +.summary .globalScore > div { + display: table-cell; + width: 50%; + vertical-align: middle; } .summary .globalScore .globalGrade { margin: 0.5 auto; @@ -22,6 +29,43 @@ font-weight: bold; margin: 0.5em 0 1em; } +.summary .globalScore .screenshotWrapper.desktop { + display: inline-block; + position: relative; + border: 0.2em solid #AAA; + background: #000; + padding: 0.5em; + border-top-left-radius: 0.4em; + border-top-right-radius: 0.4em; +} +.summary .globalScore .screenshotWrapper.desktop:before { + position: absolute; + width: 15em; + height: 0.6em; + bottom: -0.75em; + left: -1em; + background: #CCC; + border-bottom-left-radius: 0.2em; + border-bottom-right-radius: 0.2em; + content: " "; +} +.summary .globalScore .screenshotWrapper.desktop:after { + position: absolute; + width: 0.4em; + height: 0.2em; + bottom: -0.55em; + left: 12.5em; + background: lime; + content: " "; +} +.summary .globalScore .screenshotWrapper.desktop > div { + width: 12em; + height: 6.75em; + overflow: scroll; +} +.summary .globalScore .screenshot { + width: 100%; +} .summary .notations { display: table; width: 80%; diff --git a/front/src/less/dashboard.less b/front/src/less/dashboard.less index 9813f0e..6c451cf 100644 --- a/front/src/less/dashboard.less +++ b/front/src/less/dashboard.less @@ -7,7 +7,16 @@ } .summary .globalScore { - margin-bottom: 3em; + display: table; + width: 60%; + margin: 3em auto; + + > div { + display: table-cell; + width: 50%; + vertical-align: middle; + } + .globalGrade { margin: 0.5 auto; width: 2.5em; @@ -23,6 +32,48 @@ font-weight: bold; margin: 0.5em 0 1em; } + + .screenshotWrapper.desktop { + display: inline-block; + position: relative; + border: 0.2em solid #AAA; + background: #000; + padding: 0.5em; + border-top-left-radius: 0.4em; + border-top-right-radius: 0.4em; + + &:before { + position: absolute; + width: 15em; + height: 0.6em; + bottom: -0.75em; + left: -1em; + background: #CCC; + border-bottom-left-radius: 0.2em; + border-bottom-right-radius: 0.2em; + content: " "; + } + + &:after { + position: absolute; + width: 0.4em; + height: 0.2em; + bottom: -0.55em; + left: 12.5em; + background: lime; + content: " "; + } + + > div { + width: 12em; + height: 6.75em; + overflow: scroll; + } + } + + .screenshot { + width: 100%; + } } .summary .notations { diff --git a/front/src/views/dashboard.html b/front/src/views/dashboard.html index 7d5a7b6..39d54d4 100644 --- a/front/src/views/dashboard.html +++ b/front/src/views/dashboard.html @@ -2,10 +2,19 @@
-

Global score

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

Global score

+
+ +
{{globalScore}}/100
+
+
+
+
+
+ +
+
From 85d1119ad64e5e8e955db2771c4f7f988f423e9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Thu, 5 Feb 2015 19:31:19 +0100 Subject: [PATCH 31/54] Add a screenshot page --- front/src/css/dashboard.css | 37 ------------------- front/src/css/main.css | 37 +++++++++++++++++++ front/src/css/screenshot.css | 7 ++++ front/src/js/app.js | 5 +++ front/src/js/controllers/screenshotCtrl.js | 38 +++++++++++++++++++ front/src/less/dashboard.less | 42 --------------------- front/src/less/main.less | 43 ++++++++++++++++++++++ front/src/less/screenshot.less | 8 ++++ front/src/main.html | 2 + front/src/views/dashboard.html | 10 +++-- front/src/views/screenshot.html | 14 +++++++ lib/screenshotHandler.js | 2 +- lib/server/controllers/frontController.js | 2 +- 13 files changed, 162 insertions(+), 85 deletions(-) create mode 100644 front/src/css/screenshot.css create mode 100644 front/src/js/controllers/screenshotCtrl.js create mode 100644 front/src/less/screenshot.less create mode 100644 front/src/views/screenshot.html diff --git a/front/src/css/dashboard.css b/front/src/css/dashboard.css index dfb9b3d..e1e9515 100644 --- a/front/src/css/dashboard.css +++ b/front/src/css/dashboard.css @@ -29,43 +29,6 @@ font-weight: bold; margin: 0.5em 0 1em; } -.summary .globalScore .screenshotWrapper.desktop { - display: inline-block; - position: relative; - border: 0.2em solid #AAA; - background: #000; - padding: 0.5em; - border-top-left-radius: 0.4em; - border-top-right-radius: 0.4em; -} -.summary .globalScore .screenshotWrapper.desktop:before { - position: absolute; - width: 15em; - height: 0.6em; - bottom: -0.75em; - left: -1em; - background: #CCC; - border-bottom-left-radius: 0.2em; - border-bottom-right-radius: 0.2em; - content: " "; -} -.summary .globalScore .screenshotWrapper.desktop:after { - position: absolute; - width: 0.4em; - height: 0.2em; - bottom: -0.55em; - left: 12.5em; - background: lime; - content: " "; -} -.summary .globalScore .screenshotWrapper.desktop > div { - width: 12em; - height: 6.75em; - overflow: scroll; -} -.summary .globalScore .screenshot { - width: 100%; -} .summary .notations { display: table; width: 80%; diff --git a/front/src/css/main.css b/front/src/css/main.css index 8cab1bf..ead8c72 100644 --- a/front/src/css/main.css +++ b/front/src/css/main.css @@ -113,6 +113,43 @@ h1 span { margin-top: 4em; color: black; } +.screenshotWrapper.desktop { + display: inline-block; + position: relative; + border: 0.2em solid #AAA; + background: #000; + padding: 0.5em; + border-top-left-radius: 0.4em; + border-top-right-radius: 0.4em; +} +.screenshotWrapper.desktop:before { + position: absolute; + width: 15em; + height: 0.6em; + bottom: -0.75em; + left: -1em; + background: #CCC; + border-bottom-left-radius: 0.2em; + border-bottom-right-radius: 0.2em; + content: " "; +} +.screenshotWrapper.desktop:after { + position: absolute; + width: 0.4em; + height: 0.2em; + bottom: -0.55em; + left: 12.5em; + background: lime; + content: " "; +} +.screenshotWrapper.desktop > div { + width: 12em; + height: 6.75em; + overflow: scroll; +} +.screenshotWrapper.desktop .screenshotImage { + width: 100%; +} .star { font-weight: bold; } diff --git a/front/src/css/screenshot.css b/front/src/css/screenshot.css new file mode 100644 index 0000000..55af543 --- /dev/null +++ b/front/src/css/screenshot.css @@ -0,0 +1,7 @@ +.screenshot.board { + text-align: center; +} +.screenshot .screenshotWrapper { + font-size: 2.08333333333333em; + margin-bottom: 0.5em; +} diff --git a/front/src/js/app.js b/front/src/js/app.js index 7ddabb4..4c4f43b 100644 --- a/front/src/js/app.js +++ b/front/src/js/app.js @@ -6,6 +6,7 @@ var yltApp = angular.module('YellowLabTools', [ 'dashboardCtrl', 'queueCtrl', 'ruleCtrl', + 'screenshotCtrl', 'timelineCtrl', 'runsFactory', 'resultsFactory', @@ -46,6 +47,10 @@ yltApp.config(['$routeProvider', '$locationProvider', templateUrl: 'views/timeline.html', controller: 'TimelineCtrl' }). + when('/result/:runId/screenshot', { + templateUrl: 'views/screenshot.html', + controller: 'ScreenshotCtrl' + }). when('/result/:runId/rule/:policy', { templateUrl: 'views/rule.html', controller: 'RuleCtrl' diff --git a/front/src/js/controllers/screenshotCtrl.js b/front/src/js/controllers/screenshotCtrl.js new file mode 100644 index 0000000..78abc21 --- /dev/null +++ b/front/src/js/controllers/screenshotCtrl.js @@ -0,0 +1,38 @@ +var screenshotCtrl = angular.module('screenshotCtrl', ['resultsFactory', 'menuService']); + +screenshotCtrl.controller('ScreenshotCtrl', ['$scope', '$rootScope', '$routeParams', '$location', 'Results', 'Runs', 'Menu', function($scope, $rootScope, $routeParams, $location, Results, Runs, Menu) { + $scope.runId = $routeParams.runId; + $scope.Menu = Menu.setCurrentPage(null, $scope.runId); + + function loadResults() { + // Load result if needed + if (!$rootScope.loadedResult || $rootScope.loadedResult.runId !== $routeParams.runId) { + Results.get({runId: $routeParams.runId}, function(result) { + $rootScope.loadedResult = result; + $scope.result = result; + init(); + }, function(err) { + $scope.error = true; + }); + } else { + $scope.result = $rootScope.loadedResult; + init(); + } + } + + function init() { + + } + + $scope.testAgain = function() { + Runs.save({ + url: $scope.result.params.url, + waitForResponse: false, + screenshot: true + }, function(data) { + $location.path('/queue/' + data.runId); + }); + }; + + loadResults(); +}]); \ No newline at end of file diff --git a/front/src/less/dashboard.less b/front/src/less/dashboard.less index 6c451cf..59b5606 100644 --- a/front/src/less/dashboard.less +++ b/front/src/less/dashboard.less @@ -32,48 +32,6 @@ font-weight: bold; margin: 0.5em 0 1em; } - - .screenshotWrapper.desktop { - display: inline-block; - position: relative; - border: 0.2em solid #AAA; - background: #000; - padding: 0.5em; - border-top-left-radius: 0.4em; - border-top-right-radius: 0.4em; - - &:before { - position: absolute; - width: 15em; - height: 0.6em; - bottom: -0.75em; - left: -1em; - background: #CCC; - border-bottom-left-radius: 0.2em; - border-bottom-right-radius: 0.2em; - content: " "; - } - - &:after { - position: absolute; - width: 0.4em; - height: 0.2em; - bottom: -0.55em; - left: 12.5em; - background: lime; - content: " "; - } - - > div { - width: 12em; - height: 6.75em; - overflow: scroll; - } - } - - .screenshot { - width: 100%; - } } .summary .notations { diff --git a/front/src/less/main.less b/front/src/less/main.less index ae2cc21..9b1bdb5 100644 --- a/front/src/less/main.less +++ b/front/src/less/main.less @@ -113,6 +113,49 @@ h1 span { } +.screenshotWrapper.desktop { + display: inline-block; + position: relative; + border: 0.2em solid #AAA; + background: #000; + padding: 0.5em; + border-top-left-radius: 0.4em; + border-top-right-radius: 0.4em; + + &:before { + position: absolute; + width: 15em; + height: 0.6em; + bottom: -0.75em; + left: -1em; + background: #CCC; + border-bottom-left-radius: 0.2em; + border-bottom-right-radius: 0.2em; + content: " "; + } + + &:after { + position: absolute; + width: 0.4em; + height: 0.2em; + bottom: -0.55em; + left: 12.5em; + background: lime; + content: " "; + } + + > div { + width: 12em; + height: 6.75em; + overflow: scroll; + } + + .screenshotImage { + width: 100%; + } +} + + .star { font-weight: bold; span { diff --git a/front/src/less/screenshot.less b/front/src/less/screenshot.less new file mode 100644 index 0000000..a214659 --- /dev/null +++ b/front/src/less/screenshot.less @@ -0,0 +1,8 @@ +.screenshot.board { + text-align: center; +} + +.screenshot .screenshotWrapper { + font-size: 2.08333333333333em; + margin-bottom: 0.5em; +} \ No newline at end of file diff --git a/front/src/main.html b/front/src/main.html index fe5ec86..5d6f738 100644 --- a/front/src/main.html +++ b/front/src/main.html @@ -12,6 +12,7 @@ + @@ -27,6 +28,7 @@ + diff --git a/front/src/views/dashboard.html b/front/src/views/dashboard.html index 39d54d4..1d163bc 100644 --- a/front/src/views/dashboard.html +++ b/front/src/views/dashboard.html @@ -10,11 +10,13 @@
diff --git a/front/src/views/screenshot.html b/front/src/views/screenshot.html new file mode 100644 index 0000000..558846b --- /dev/null +++ b/front/src/views/screenshot.html @@ -0,0 +1,14 @@ +
+
+

Screenshot

+ +
+
+ +
+
+ +

(scroll on the screenshot to see under the fold)

+ + +
\ No newline at end of file diff --git a/lib/screenshotHandler.js b/lib/screenshotHandler.js index c7f725f..5651a12 100644 --- a/lib/screenshotHandler.js +++ b/lib/screenshotHandler.js @@ -96,7 +96,7 @@ var screenshotHandler = function() { this.toBuffer = function(image) { var deferred = Q.defer(); - image.toBuffer('jpg', {quality: 85}, function(err, buffer){ + image.toBuffer('jpg', {quality: 90}, function(err, buffer){ if (err) { debug('Could not save image to buffer'); debug(err); diff --git a/lib/server/controllers/frontController.js b/lib/server/controllers/frontController.js index 9a19a67..4d5f531 100644 --- a/lib/server/controllers/frontController.js +++ b/lib/server/controllers/frontController.js @@ -7,7 +7,7 @@ var FrontController = function(app) { var cacheDuration = 365 * 24 * 60 * 60 * 1000; // One year var assetsPath = (app.get('env') === 'development') ? '../../../front/src' : '../../../front/build'; - var routes = ['/', '/about', '/result/:runId', '/result/:runId/timeline', '/result/:runId/rule/:policy', '/queue/:runId']; + var routes = ['/', '/about', '/result/:runId', '/result/:runId/timeline', '/result/:runId/screenshot', '/result/:runId/rule/:policy', '/queue/:runId']; routes.forEach(function(route) { app.get(route, function(req, res) { res.setHeader('Cache-Control', 'public, max-age=20'); From 79d0898fb528134c537ec8537678b6e4a0a2273a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Thu, 5 Feb 2015 19:33:58 +0100 Subject: [PATCH 32/54] Add a screenshot page --- front/src/js/controllers/screenshotCtrl.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/front/src/js/controllers/screenshotCtrl.js b/front/src/js/controllers/screenshotCtrl.js index 78abc21..57d7632 100644 --- a/front/src/js/controllers/screenshotCtrl.js +++ b/front/src/js/controllers/screenshotCtrl.js @@ -16,13 +16,12 @@ screenshotCtrl.controller('ScreenshotCtrl', ['$scope', '$rootScope', '$routePara }); } else { $scope.result = $rootScope.loadedResult; - init(); } } - function init() { - - } + $scope.backToDashboard = function() { + $location.path('/result/' + $scope.runId); + }; $scope.testAgain = function() { Runs.save({ From 47f0e1f1bfd75883d442224a78e12fb991409a43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Thu, 5 Feb 2015 23:17:15 +0100 Subject: [PATCH 33/54] Hover effect on the screenshot on dashboard --- front/src/css/dashboard.css | 17 +++++++++++++++++ front/src/css/main.css | 1 + front/src/less/dashboard.less | 19 +++++++++++++++++++ front/src/less/main.less | 1 + 4 files changed, 38 insertions(+) diff --git a/front/src/css/dashboard.css b/front/src/css/dashboard.css index e1e9515..658202e 100644 --- a/front/src/css/dashboard.css +++ b/front/src/css/dashboard.css @@ -29,6 +29,23 @@ font-weight: bold; margin: 0.5em 0 1em; } +.summary .globalScore .screenshotWrapper:hover { + opacity: 0.75; +} +.summary .globalScore .screenshotWrapper:hover:after { + position: absolute; + width: 1.25em; + height: 1.25em; + top: 0.7em; + left: 1.55em; + font-size: 3em; + color: #FFF; + background: #000; + border-radius: 0.2em; + text-align: center; + content: "+"; + opacity: 0.6; +} .summary .notations { display: table; width: 80%; diff --git a/front/src/css/main.css b/front/src/css/main.css index ead8c72..0a6b4bf 100644 --- a/front/src/css/main.css +++ b/front/src/css/main.css @@ -146,6 +146,7 @@ h1 span { width: 12em; height: 6.75em; overflow: scroll; + position: relative; } .screenshotWrapper.desktop .screenshotImage { width: 100%; diff --git a/front/src/less/dashboard.less b/front/src/less/dashboard.less index 59b5606..5b99342 100644 --- a/front/src/less/dashboard.less +++ b/front/src/less/dashboard.less @@ -32,6 +32,25 @@ font-weight: bold; margin: 0.5em 0 1em; } + + .screenshotWrapper:hover { + opacity: 0.75; + + &:after { + position: absolute; + width: 1.25em; + height: 1.25em; + top: 0.7em; + left: 1.55em; + font-size: 3em; + color: #FFF; + background: #000; + border-radius: 0.2em; + text-align: center; + content: "+"; + opacity: 0.6; + } + } } .summary .notations { diff --git a/front/src/less/main.less b/front/src/less/main.less index 9b1bdb5..b949cd8 100644 --- a/front/src/less/main.less +++ b/front/src/less/main.less @@ -148,6 +148,7 @@ h1 span { width: 12em; height: 6.75em; overflow: scroll; + position: relative; } .screenshotImage { From e9259356bd064a671e5d235ee61ae8f42b3122f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Fri, 6 Feb 2015 09:44:26 +0100 Subject: [PATCH 34/54] Change default viewport to 16:9 --- front/src/css/dashboard.css | 2 +- front/src/less/dashboard.less | 2 +- lib/tools/phantomas/phantomasWrapper.js | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/front/src/css/dashboard.css b/front/src/css/dashboard.css index 658202e..884932b 100644 --- a/front/src/css/dashboard.css +++ b/front/src/css/dashboard.css @@ -44,7 +44,7 @@ border-radius: 0.2em; text-align: center; content: "+"; - opacity: 0.6; + opacity: 0.85; } .summary .notations { display: table; diff --git a/front/src/less/dashboard.less b/front/src/less/dashboard.less index 5b99342..e12dd39 100644 --- a/front/src/less/dashboard.less +++ b/front/src/less/dashboard.less @@ -48,7 +48,7 @@ border-radius: 0.2em; text-align: center; content: "+"; - opacity: 0.6; + opacity: 0.85; } } } diff --git a/lib/tools/phantomas/phantomasWrapper.js b/lib/tools/phantomas/phantomasWrapper.js index c24dd67..0202d08 100644 --- a/lib/tools/phantomas/phantomasWrapper.js +++ b/lib/tools/phantomas/phantomasWrapper.js @@ -29,6 +29,7 @@ var PhantomasWrapper = function() { 'js-deep-analysis': task.options.jsDeepAnalysis || false, 'user-agent': 'Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/27.0.1453.110 Safari/537.36', 'screenshot': task.options.screenshot || false, + 'viewport': '1366x768', // Mandatory 'reporter': 'json:pretty', From 44df06fac74d1b3970029f7bf41bca8591b55508 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Fri, 6 Feb 2015 09:49:39 +0100 Subject: [PATCH 35/54] v1.2.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 264a7f1..0c4d97d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "yellowlabtools", - "version": "1.1.0", + "version": "1.2.0", "repository": { "type": "git", "url": "git://github.com/gmetais/YellowLabTools.git" From 955f353a316389f7d05ebaceae056e70f54a12ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Fri, 6 Feb 2015 10:19:28 +0100 Subject: [PATCH 36/54] Add some keywords on the index page for SEO --- front/src/css/index.css | 12 ++++++++++++ front/src/less/index.less | 14 ++++++++++++++ front/src/views/index.html | 20 +++++++++++++++++++- 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/front/src/css/index.css b/front/src/css/index.css index d22b867..f94ce7c 100644 --- a/front/src/css/index.css +++ b/front/src/css/index.css @@ -13,6 +13,18 @@ .launchBtn.disabled { background: #deaca6; } +.features { + display: table; + width: 50%; + margin: 8em auto 0; + font-size: 0.9em; + color: #413; +} +.features > div { + width: 33.3%; + display: table-cell; + padding: 0 1.5em; +} input[type=submit], input.url { padding: 0 0.5em; diff --git a/front/src/less/index.less b/front/src/less/index.less index 5bbd573..3092c69 100644 --- a/front/src/less/index.less +++ b/front/src/less/index.less @@ -16,6 +16,20 @@ } } +.features { + display: table; + width: 50%; + margin: 8em auto 0; + font-size: 0.9em; + color: #413; + + > div { + width: 33.3%; + display: table-cell; + padding: 0 1.5em; + } +} + input[type=submit], input.url { padding: 0 0.5em; margin: 0.5em; diff --git a/front/src/views/index.html b/front/src/views/index.html index 84ba965..d1518b7 100644 --- a/front/src/views/index.html +++ b/front/src/views/index.html @@ -3,4 +3,22 @@
-
\ No newline at end of file + + + +
+
+

Front-end quality

+

Audits quality problems in the HTML, CSS and JS

+
+ +
+

WebPerf

+

Checks if performance good practices are applied

+
+ +
+

JS Profiling

+

Untangles the JavaScript spaghetti code

+
+
\ No newline at end of file From 0e4ffcee6286aaa53a531f562ab2e169e46b5093 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Fri, 6 Feb 2015 10:38:32 +0100 Subject: [PATCH 37/54] Fix error message due to PhantomJS instead of Phantomas --- lib/server/datastores/runsDatastore.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/server/datastores/runsDatastore.js b/lib/server/datastores/runsDatastore.js index da9b5bb..15b4ada 100644 --- a/lib/server/datastores/runsDatastore.js +++ b/lib/server/datastores/runsDatastore.js @@ -65,7 +65,7 @@ function RunsDatastore() { errorMessage = "Error 253: Phantomas config error"; break; case '254': - errorMessage = "Error 254: page loading failed in Phantomas"; + errorMessage = "Error 254: page loading failed in PhantomJS"; break; case '255': errorMessage = "Error 255: Phantomas error"; From 928e8f14ea43defb30991dc104e246dc5f3e67b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Fri, 6 Feb 2015 10:50:36 +0100 Subject: [PATCH 38/54] Fix problem when http:// contains upper case --- lib/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/index.js b/lib/index.js index 00513bb..81819f4 100644 --- a/lib/index.js +++ b/lib/index.js @@ -14,7 +14,7 @@ var yellowLabTools = function(url, options) { } else { - if (url.indexOf('http://') !== 0 && url.indexOf('https://') !== 0) { + if (url.toLowerCase().indexOf('http://') !== 0 && url.toLowerCase().indexOf('https://') !== 0) { url = 'http://' + url; } From 39e6ecc920f9d67e159e7a01f67adb09febda0c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Fri, 6 Feb 2015 10:51:27 +0100 Subject: [PATCH 39/54] Fix url in queue or dashboard pages without http:// --- lib/server/controllers/apiController.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/server/controllers/apiController.js b/lib/server/controllers/apiController.js index 940c870..d087f08 100644 --- a/lib/server/controllers/apiController.js +++ b/lib/server/controllers/apiController.js @@ -20,6 +20,11 @@ var ApiController = function(app) { // Create a new run app.post('/api/runs', function(req, res) { + // Add http to the test URL + if (req.body.url.toLowerCase().indexOf('http://') !== 0 && req.body.url.toLowerCase().indexOf('https://') !== 0) { + req.body.url = 'http://' + req.body.url; + } + // Grab the test parameters and generate a random run ID var run = { runId: (Date.now()*1000 + Math.round(Math.random()*1000)).toString(36), From 5330f0f8e2ac653cfa4778b273608358ba2e8bc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Fri, 6 Feb 2015 11:17:27 +0100 Subject: [PATCH 40/54] Better links when an error occurs --- front/src/css/dashboard.css | 12 ------------ front/src/css/main.css | 12 ++++++++++++ front/src/css/queue.css | 3 --- front/src/less/dashboard.less | 12 ------------ front/src/less/main.less | 13 +++++++++++++ front/src/less/queue.less | 4 ---- front/src/views/dashboard.html | 4 ++-- front/src/views/queue.html | 5 +++-- 8 files changed, 30 insertions(+), 35 deletions(-) diff --git a/front/src/css/dashboard.css b/front/src/css/dashboard.css index 884932b..41e9467 100644 --- a/front/src/css/dashboard.css +++ b/front/src/css/dashboard.css @@ -146,18 +146,6 @@ .summary .fromShare { margin-bottom: 3em; } -.summary .fromShare a { - font-size: 1em; - padding: 0.3em 0.5em; - margin: 0.5em; - line-height: 2em; - border: 0 solid; - border-radius: 0.5em; - box-shadow: 0.1em 0.2em 0 0 #5e2846; - background: #e74c3c; - color: #fff; - text-decoration: none; -} .summary .apiTip { font-size: 0.8em; margin-bottom: 4em; diff --git a/front/src/css/main.css b/front/src/css/main.css index 0a6b4bf..dea34f0 100644 --- a/front/src/css/main.css +++ b/front/src/css/main.css @@ -113,6 +113,18 @@ h1 span { margin-top: 4em; color: black; } +a.linkButton { + font-size: 1em; + padding: 0.3em 0.5em; + margin: 0.5em; + line-height: 2em; + border: 0 solid; + border-radius: 0.5em; + box-shadow: 0.1em 0.2em 0 0 #5e2846; + background: #e74c3c; + color: #fff; + text-decoration: none; +} .screenshotWrapper.desktop { display: inline-block; position: relative; diff --git a/front/src/css/queue.css b/front/src/css/queue.css index e847481..094ee24 100644 --- a/front/src/css/queue.css +++ b/front/src/css/queue.css @@ -6,6 +6,3 @@ font-size: 0.8em; margin-bottom: 6em; } -.queueLink { - color: #FFF; -} diff --git a/front/src/less/dashboard.less b/front/src/less/dashboard.less index e12dd39..379babd 100644 --- a/front/src/less/dashboard.less +++ b/front/src/less/dashboard.less @@ -142,18 +142,6 @@ .summary .fromShare { margin-bottom: 3em; - a { - font-size: 1em; - padding: 0.3em 0.5em; - margin: 0.5em; - line-height: 2em; - border: 0 solid; - border-radius: 0.5em; - box-shadow: 0.1em 0.2em 0 0 #5e2846; - background: #e74c3c; - color: #fff; - text-decoration: none; - } } .summary .apiTip { diff --git a/front/src/less/main.less b/front/src/less/main.less index b949cd8..a22bb81 100644 --- a/front/src/less/main.less +++ b/front/src/less/main.less @@ -112,6 +112,19 @@ h1 span { } } +a.linkButton { + font-size: 1em; + padding: 0.3em 0.5em; + margin: 0.5em; + line-height: 2em; + border: 0 solid; + border-radius: 0.5em; + box-shadow: 0.1em 0.2em 0 0 #5e2846; + background: #e74c3c; + color: #fff; + text-decoration: none; +} + .screenshotWrapper.desktop { display: inline-block; diff --git a/front/src/less/queue.less b/front/src/less/queue.less index dc1181b..f8d2cb2 100644 --- a/front/src/less/queue.less +++ b/front/src/less/queue.less @@ -6,8 +6,4 @@ .statusSubMessage { font-size: 0.8em; margin-bottom: 6em; -} - -.queueLink { - color: #FFF; } \ No newline at end of file diff --git a/front/src/views/dashboard.html b/front/src/views/dashboard.html index 1d163bc..ab85f9f 100644 --- a/front/src/views/dashboard.html +++ b/front/src/views/dashboard.html @@ -60,8 +60,8 @@

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

- See the full report for this page - Test another webpage + See the full report for this page + Test another webpage
diff --git a/front/src/views/queue.html b/front/src/views/queue.html index c34d3b4..f2d9fb5 100644 --- a/front/src/views/queue.html +++ b/front/src/views/queue.html @@ -3,8 +3,9 @@
From 4032468e101d7565b5838d6392333f39fdcd9efb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Fri, 6 Feb 2015 11:18:01 +0100 Subject: [PATCH 41/54] Fix markdown formatting in npm module description --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 0c4d97d..753421f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "yellowlabtools", "version": "1.2.0", + "description": "Online tool audits a webpage for performance and front-end code quality issues", "repository": { "type": "git", "url": "git://github.com/gmetais/YellowLabTools.git" From 4345ccf356158f0c1e126b024de15aab327cf110 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Fri, 6 Feb 2015 11:19:41 +0100 Subject: [PATCH 42/54] Fix recommendation 'as many fonts as possible' --- 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 d4c685c..7fb4413 100644 --- a/lib/metadata/policies.js +++ b/lib/metadata/policies.js @@ -883,7 +883,7 @@ var policies = { "webfontCount": { "tool": "phantomas", "label": "Font count", - "message": "

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

", + "message": "

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

", "isOkThreshold": 0, "isBadThreshold": 3, "isAbnormalThreshold": 5, From d556f849291fc2464e6d050c112c760e7d1f7a36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Fri, 6 Feb 2015 11:35:41 +0100 Subject: [PATCH 43/54] Add a meta description --- front/src/main.html | 1 + package.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/front/src/main.html b/front/src/main.html index 5d6f738..d7304eb 100644 --- a/front/src/main.html +++ b/front/src/main.html @@ -5,6 +5,7 @@ + diff --git a/package.json b/package.json index 753421f..9c0e743 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "yellowlabtools", "version": "1.2.0", - "description": "Online tool audits a webpage for performance and front-end code quality issues", + "description": "Online tool to audit a webpage for performance and front-end quality issues", "repository": { "type": "git", "url": "git://github.com/gmetais/YellowLabTools.git" From 5f2553047d8bb9a813b4cb56495abbcff7439a17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Fri, 6 Feb 2015 12:03:02 +0100 Subject: [PATCH 44/54] Add a warning when max runs per day is reached --- front/src/js/app.js | 1 + front/src/js/controllers/dashboardCtrl.js | 10 ++------- front/src/js/controllers/indexCtrl.js | 11 ++------- front/src/js/controllers/ruleCtrl.js | 10 ++------- front/src/js/controllers/screenshotCtrl.js | 10 ++------- front/src/js/controllers/timelineCtrl.js | 10 ++------- front/src/js/services/apiService.js | 26 ++++++++++++++++++++++ front/src/main.html | 1 + 8 files changed, 38 insertions(+), 41 deletions(-) create mode 100644 front/src/js/services/apiService.js diff --git a/front/src/js/app.js b/front/src/js/app.js index 4c4f43b..c629140 100644 --- a/front/src/js/app.js +++ b/front/src/js/app.js @@ -10,6 +10,7 @@ var yltApp = angular.module('YellowLabTools', [ 'timelineCtrl', 'runsFactory', 'resultsFactory', + 'apiService', 'menuService', 'gradeDirective', 'offendersDirectives' diff --git a/front/src/js/controllers/dashboardCtrl.js b/front/src/js/controllers/dashboardCtrl.js index 4243e7b..d693e7f 100644 --- a/front/src/js/controllers/dashboardCtrl.js +++ b/front/src/js/controllers/dashboardCtrl.js @@ -1,6 +1,6 @@ var dashboardCtrl = angular.module('dashboardCtrl', ['resultsFactory', 'menuService']); -dashboardCtrl.controller('DashboardCtrl', ['$scope', '$rootScope', '$routeParams', '$location', 'Results', 'Runs', 'Menu', function($scope, $rootScope, $routeParams, $location, Results, Runs, Menu) { +dashboardCtrl.controller('DashboardCtrl', ['$scope', '$rootScope', '$routeParams', '$location', 'Results', 'API', 'Menu', function($scope, $rootScope, $routeParams, $location, Results, API, Menu) { $scope.runId = $routeParams.runId; $scope.Menu = Menu.setCurrentPage('dashboard', $scope.runId); $scope.fromSocialShare = $location.search().share; @@ -34,13 +34,7 @@ dashboardCtrl.controller('DashboardCtrl', ['$scope', '$rootScope', '$routeParams }; $scope.testAgain = function() { - Runs.save({ - url: $scope.result.params.url, - waitForResponse: false, - screenshot: true - }, function(data) { - $location.path('/queue/' + data.runId); - }); + API.launchTest($scope.result.params.url); }; /// When comming from a social shared link, the user needs to click on "See full report" button to display the full dashboard. diff --git a/front/src/js/controllers/indexCtrl.js b/front/src/js/controllers/indexCtrl.js index e460d83..bbf4f12 100644 --- a/front/src/js/controllers/indexCtrl.js +++ b/front/src/js/controllers/indexCtrl.js @@ -1,16 +1,9 @@ var indexCtrl = angular.module('indexCtrl', []); -indexCtrl.controller('IndexCtrl', ['$scope', '$location', 'Runs', function($scope, $location, Runs) { +indexCtrl.controller('IndexCtrl', ['$scope', '$location', 'API', function($scope, $location, API) { $scope.launchTest = function() { if ($scope.url) { - Runs.save({ - url: $scope.url, - waitForResponse: false, - screenshot: true - }, function(data) { - $location.path('/queue/' + data.runId); - }); - + API.launchTest(); } }; }]); \ No newline at end of file diff --git a/front/src/js/controllers/ruleCtrl.js b/front/src/js/controllers/ruleCtrl.js index 27e9dcd..d5aec07 100644 --- a/front/src/js/controllers/ruleCtrl.js +++ b/front/src/js/controllers/ruleCtrl.js @@ -1,6 +1,6 @@ var ruleCtrl = angular.module('ruleCtrl', []); -ruleCtrl.controller('RuleCtrl', ['$scope', '$rootScope', '$routeParams', '$location', '$sce', 'Menu', 'Results', 'Runs', function($scope, $rootScope, $routeParams, $location, $sce, Menu, Results, Runs) { +ruleCtrl.controller('RuleCtrl', ['$scope', '$rootScope', '$routeParams', '$location', '$sce', 'Menu', 'Results', 'API', function($scope, $rootScope, $routeParams, $location, $sce, Menu, Results, API) { $scope.runId = $routeParams.runId; $scope.policyName = $routeParams.policy; $scope.Menu = Menu.setCurrentPage(null, $scope.runId); @@ -29,13 +29,7 @@ ruleCtrl.controller('RuleCtrl', ['$scope', '$rootScope', '$routeParams', '$locat }; $scope.testAgain = function() { - Runs.save({ - url: $scope.result.params.url, - waitForResponse: false, - screenshot: true - }, function(data) { - $location.path('/queue/' + data.runId); - }); + API.launchTest($scope.result.params.url); }; loadResults(); diff --git a/front/src/js/controllers/screenshotCtrl.js b/front/src/js/controllers/screenshotCtrl.js index 57d7632..1120d9c 100644 --- a/front/src/js/controllers/screenshotCtrl.js +++ b/front/src/js/controllers/screenshotCtrl.js @@ -1,6 +1,6 @@ var screenshotCtrl = angular.module('screenshotCtrl', ['resultsFactory', 'menuService']); -screenshotCtrl.controller('ScreenshotCtrl', ['$scope', '$rootScope', '$routeParams', '$location', 'Results', 'Runs', 'Menu', function($scope, $rootScope, $routeParams, $location, Results, Runs, Menu) { +screenshotCtrl.controller('ScreenshotCtrl', ['$scope', '$rootScope', '$routeParams', '$location', 'Results', 'API', 'Menu', function($scope, $rootScope, $routeParams, $location, Results, API, Menu) { $scope.runId = $routeParams.runId; $scope.Menu = Menu.setCurrentPage(null, $scope.runId); @@ -24,13 +24,7 @@ screenshotCtrl.controller('ScreenshotCtrl', ['$scope', '$rootScope', '$routePara }; $scope.testAgain = function() { - Runs.save({ - url: $scope.result.params.url, - waitForResponse: false, - screenshot: true - }, function(data) { - $location.path('/queue/' + data.runId); - }); + API.launchTest($scope.result.params.url); }; loadResults(); diff --git a/front/src/js/controllers/timelineCtrl.js b/front/src/js/controllers/timelineCtrl.js index 2dd1943..6ea6a0c 100644 --- a/front/src/js/controllers/timelineCtrl.js +++ b/front/src/js/controllers/timelineCtrl.js @@ -1,6 +1,6 @@ var timelineCtrl = angular.module('timelineCtrl', []); -timelineCtrl.controller('TimelineCtrl', ['$scope', '$rootScope', '$routeParams', '$location', '$timeout', 'Menu', 'Results', 'Runs', function($scope, $rootScope, $routeParams, $location, $timeout, Menu, Results, Runs) { +timelineCtrl.controller('TimelineCtrl', ['$scope', '$rootScope', '$routeParams', '$location', '$timeout', 'Menu', 'Results', 'API', function($scope, $rootScope, $routeParams, $location, $timeout, Menu, Results, API) { $scope.runId = $routeParams.runId; $scope.Menu = Menu.setCurrentPage('timeline', $scope.runId); @@ -132,13 +132,7 @@ timelineCtrl.controller('TimelineCtrl', ['$scope', '$rootScope', '$routeParams', }; $scope.testAgain = function() { - Runs.save({ - url: $scope.result.params.url, - waitForResponse: false, - screenshot: true - }, function(data) { - $location.path('/queue/' + data.runId); - }); + API.launchTest($scope.result.params.url); }; loadResults(); diff --git a/front/src/js/services/apiService.js b/front/src/js/services/apiService.js new file mode 100644 index 0000000..27aaac9 --- /dev/null +++ b/front/src/js/services/apiService.js @@ -0,0 +1,26 @@ +var apiService = angular.module('apiService', []); + +apiService.factory('API', ['$location', 'Runs', 'Results', function($location, Runs, Results) { + + return { + + launchTest: function(url) { + Runs.save({ + url: url, + waitForResponse: false, + screenshot: true + }, function(data) { + $location.path('/queue/' + data.runId); + }, function(response) { + if (response.status === 429) { + alert('Too many requests, you reached the max number of requests allowed in 24h'); + } else { + alert('An error occured...'); + } + }); + } + + + }; + +}]); \ No newline at end of file diff --git a/front/src/main.html b/front/src/main.html index d7304eb..a095988 100644 --- a/front/src/main.html +++ b/front/src/main.html @@ -33,6 +33,7 @@ + From dc9aa4d6b4f6afb1632ed56ed8cc79d29b6d1e77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Fri, 6 Feb 2015 12:15:20 +0100 Subject: [PATCH 45/54] Fix url not sent --- front/src/js/controllers/indexCtrl.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front/src/js/controllers/indexCtrl.js b/front/src/js/controllers/indexCtrl.js index bbf4f12..f9426fa 100644 --- a/front/src/js/controllers/indexCtrl.js +++ b/front/src/js/controllers/indexCtrl.js @@ -3,7 +3,7 @@ var indexCtrl = angular.module('indexCtrl', []); indexCtrl.controller('IndexCtrl', ['$scope', '$location', 'API', function($scope, $location, API) { $scope.launchTest = function() { if ($scope.url) { - API.launchTest(); + API.launchTest($scope.url); } }; }]); \ No newline at end of file From db6fdbc121db61e5b5c5949a831571ef6b25c3eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Fri, 6 Feb 2015 12:32:19 +0100 Subject: [PATCH 46/54] Empty URL crashes the server --- lib/server/controllers/apiController.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/server/controllers/apiController.js b/lib/server/controllers/apiController.js index d087f08..a74d359 100644 --- a/lib/server/controllers/apiController.js +++ b/lib/server/controllers/apiController.js @@ -21,7 +21,7 @@ var ApiController = function(app) { app.post('/api/runs', function(req, res) { // Add http to the test URL - if (req.body.url.toLowerCase().indexOf('http://') !== 0 && req.body.url.toLowerCase().indexOf('https://') !== 0) { + if (req.body.url && req.body.url.toLowerCase().indexOf('http://') !== 0 && req.body.url.toLowerCase().indexOf('https://') !== 0) { req.body.url = 'http://' + req.body.url; } From d3f52360739caf67a177878a9c9f7763f65c60e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Fri, 6 Feb 2015 12:32:54 +0100 Subject: [PATCH 47/54] Add an error message when server reboots --- front/src/js/controllers/queueCtrl.js | 14 +++++++- front/src/views/queue.html | 50 +++++++++++++++++---------- 2 files changed, 44 insertions(+), 20 deletions(-) diff --git a/front/src/js/controllers/queueCtrl.js b/front/src/js/controllers/queueCtrl.js index 909b96f..17a3820 100644 --- a/front/src/js/controllers/queueCtrl.js +++ b/front/src/js/controllers/queueCtrl.js @@ -1,6 +1,6 @@ var queueCtrl = angular.module('queueCtrl', ['runsFactory']); -queueCtrl.controller('QueueCtrl', ['$scope', '$routeParams', '$location', 'Runs', function($scope, $routeParams, $location, Runs) { +queueCtrl.controller('QueueCtrl', ['$scope', '$routeParams', '$location', 'Runs', 'API', function($scope, $routeParams, $location, Runs, API) { $scope.runId = $routeParams.runId; var numberOfTries = 0; @@ -9,6 +9,8 @@ queueCtrl.controller('QueueCtrl', ['$scope', '$routeParams', '$location', 'Runs' Runs.get({runId: $scope.runId}, function(data) { $scope.url = data.params.url; $scope.status = data.status; + $scope.notFound = false; + $scope.connectionLost = false; if (data.status.statusCode === 'running' || data.status.statusCode === 'awaiting') { numberOfTries ++; @@ -21,6 +23,16 @@ queueCtrl.controller('QueueCtrl', ['$scope', '$routeParams', '$location', 'Runs' } else { // Handled by the view } + }, function(response) { + if (response.status === 404) { + $scope.notFound = true; + $scope.connectionLost = false; + } else if (response.status === 0) { + // Connection lost, retry in 10 seconds + setTimeout(getRunStatus, 10000); + $scope.connectionLost = true; + $scope.notFound = false; + } }); } diff --git a/front/src/views/queue.html b/front/src/views/queue.html index f2d9fb5..98bcbf7 100644 --- a/front/src/views/queue.html +++ b/front/src/views/queue.html @@ -1,24 +1,36 @@ -

Tested url:   {{url}}

+

Tested url:   {{url}}

-
-
Test failed
-

{{status.error}}

+
+
+
Test failed
+

{{status.error}}

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

(auto-refresh activated)

+
+
+
Test is running...
+

(auto-refresh activated)

+
+
+
Test complete
+

Opening results...

+
+
+
+
Error 404 (test not found)
+

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

- Report the issue on GitHub New test
-
-
- - -
-

(auto-refresh activated)

-
-
-
Test is running...
-

(auto-refresh activated)

-
-
-
Test complete
-

Opening results...

+
+
Connection lost with server
+

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

\ No newline at end of file From a7df7a5436facad00901b79b6152f7bae95baa51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Fri, 6 Feb 2015 14:17:09 +0100 Subject: [PATCH 48/54] Forgotten part in cssOldPropertyPrefixes offenders --- front/src/views/rule.html | 2 ++ lib/metadata/policies.js | 1 + 2 files changed, 3 insertions(+) diff --git a/front/src/views/rule.html b/front/src/views/rule.html index e954f73..30d07d7 100644 --- a/front/src/views/rule.html +++ b/front/src/views/rule.html @@ -106,6 +106,8 @@
{{offender.rule}} {{{offender.property}}: {{offender.value + '}' }} +
+ {{offender.message}}
diff --git a/lib/metadata/policies.js b/lib/metadata/policies.js index 7fb4413..9aa8410 100644 --- a/lib/metadata/policies.js +++ b/lib/metadata/policies.js @@ -710,6 +710,7 @@ var policies = { rule: parts[1], property: parts[2], value: parts[3], + message: parts[4], file: splittedOffender.file, line: splittedOffender.line, column: splittedOffender.column From 368bf4a6d99582396b3ec7003bad3deb697eb068 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Fri, 6 Feb 2015 14:32:21 +0100 Subject: [PATCH 49/54] Truncate jQuery version, can be long and break display --- lib/metadata/policies.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/metadata/policies.js b/lib/metadata/policies.js index 9aa8410..2406e45 100644 --- a/lib/metadata/policies.js +++ b/lib/metadata/policies.js @@ -312,6 +312,11 @@ var policies = { return null; } + // Truncate version number (can be long sometimes, no clue why but it can...) + if (value.length > 30) { + value = value.substr(0, 28) + '...'; + } + return { value: value, score: score, From e5f917a18593aa47d5d2ecf12ccfc2741197c833 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Fri, 6 Feb 2015 14:34:45 +0100 Subject: [PATCH 50/54] Remove unneeded about page controller --- front/src/js/app.js | 4 +--- front/src/js/controllers/aboutCtrl.js | 5 ----- front/src/main.html | 1 - 3 files changed, 1 insertion(+), 9 deletions(-) delete mode 100644 front/src/js/controllers/aboutCtrl.js diff --git a/front/src/js/app.js b/front/src/js/app.js index c629140..49e1125 100644 --- a/front/src/js/app.js +++ b/front/src/js/app.js @@ -2,7 +2,6 @@ var yltApp = angular.module('YellowLabTools', [ 'ngRoute', 'ngSanitize', 'indexCtrl', - 'aboutCtrl', 'dashboardCtrl', 'queueCtrl', 'ruleCtrl', @@ -37,8 +36,7 @@ yltApp.config(['$routeProvider', '$locationProvider', controller: 'QueueCtrl' }). when('/about', { - templateUrl: 'views/about.html', - controller: 'AboutCtrl' + templateUrl: 'views/about.html' }). when('/result/:runId', { templateUrl: 'views/dashboard.html', diff --git a/front/src/js/controllers/aboutCtrl.js b/front/src/js/controllers/aboutCtrl.js deleted file mode 100644 index 11957f9..0000000 --- a/front/src/js/controllers/aboutCtrl.js +++ /dev/null @@ -1,5 +0,0 @@ -var aboutCtrl = angular.module('aboutCtrl', []); - -aboutCtrl.controller('AboutCtrl', ['$scope', function($scope) { - $scope.about = "this is about YLT"; -}]); \ No newline at end of file diff --git a/front/src/main.html b/front/src/main.html index a095988..1146a0b 100644 --- a/front/src/main.html +++ b/front/src/main.html @@ -25,7 +25,6 @@ - From 9b9e65abd45d2a014bdd13981f3e4ff7990fe856 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Fri, 6 Feb 2015 15:12:48 +0100 Subject: [PATCH 51/54] Add test to NodeJS module screenshot feature --- test/core/indexTest.js | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/test/core/indexTest.js b/test/core/indexTest.js index 2801b85..93b0824 100644 --- a/test/core/indexTest.js +++ b/test/core/indexTest.js @@ -2,6 +2,8 @@ var chai = require('chai'); var sinon = require('sinon'); var sinonChai = require('sinon-chai'); var should = chai.should(); +var path = require('path'); +var fs = require('fs'); var ylt = require('../../lib/index'); chai.use(sinonChai); @@ -99,4 +101,24 @@ describe('index.js', function() { }); }); + it('should take a screenshot', function(done) { + this.timeout(15000); + + var url = 'http://localhost:8388/simple-page.html'; + var screenshotPath = path.join(__dirname, '../../.tmp/indexTestScreenshot.png'); + + ylt(url, {screenshot: screenshotPath}) + .then(function(data) { + + data.params.options.should.have.a.property('screenshot').that.equals(screenshotPath); + data.should.not.have.a.property('screenshotUrl'); + + fs.existsSync(screenshotPath).should.equal(true); + + done(); + }).fail(function(err) { + done(err); + }); + }); + }); \ No newline at end of file From dd1fd3486597cbaa7dec1c9f819a3add28b26daa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Fri, 6 Feb 2015 15:50:15 +0100 Subject: [PATCH 52/54] Add screenshot option to CLI --- bin/cli.js | 47 ++++++++++++++++++++++++++++++++++++++++------- package.json | 1 + 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/bin/cli.js b/bin/cli.js index 6d815cf..71c7174 100644 --- a/bin/cli.js +++ b/bin/cli.js @@ -1,22 +1,55 @@ #!/usr/bin/env node var debug = require('debug')('ylt:cli'); +var meow = require('meow'); +var path = require('path'); var ylt = require('../lib/index'); +var cli = meow({ + help: [ + 'Usage', + ' yellowlabtools ', + '', + 'Options:', + ' --screenshot Will take a screenshot and use this value as the output path. It needs to end with ".png".', + '' + ].join('\n'), + pkg: '../package.json' +}); + + + // Check parameters -if (process.argv.length !== 3) { - console.error('Incorrect parameters'); - console.error('\nUsage: ylt \n'); +if (cli.input.length < 1) { + console.error('Incorrect parameters: url not provided'); process.exit(1); } -var url = process.argv[2]; +var url = cli.input[0]; -(function execute(url) { +var options = {}; + +// Screenshot option +var screenshot = cli.flags.screenshot; +if (screenshot && (typeof screenshot !== 'string' || screenshot.toLowerCase().indexOf('.png', screenshot.length - 4) === -1)) { + console.error('Incorrect parameters: screenshot must be a path that ends with ".png"'); + process.exit(1); +} +if (screenshot) { + if (path.resolve(screenshot) !== path.normalize(screenshot)) { + // It is not an absolute path, so it is relative to the current command-line directory + screenshot = path.join(process.cwd(), screenshot); + } + options.screenshot = cli.flags.screenshot +} + + + +(function execute(url, options) { 'use strict'; - ylt(url). + ylt(url, options). then(function(data) { @@ -32,4 +65,4 @@ var url = process.argv[2]; debug('Test launched...'); -})(url); \ No newline at end of file +})(url, options); \ No newline at end of file diff --git a/package.json b/package.json index 9c0e743..6b29c50 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "debug": "~2.1.0", "express": "~4.10.6", "lwip": "0.0.6", + "meow": "^3.0.0", "phantomas": "1.9.0", "ps-node": "0.0.3", "q": "~1.1.2", From c1b5a8625a3bd520ccb506bd133d3341c63d8d96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Fri, 6 Feb 2015 15:57:13 +0100 Subject: [PATCH 53/54] Missing semicolon --- bin/cli.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/cli.js b/bin/cli.js index 71c7174..a9e3c5d 100644 --- a/bin/cli.js +++ b/bin/cli.js @@ -41,7 +41,7 @@ if (screenshot) { // It is not an absolute path, so it is relative to the current command-line directory screenshot = path.join(process.cwd(), screenshot); } - options.screenshot = cli.flags.screenshot + options.screenshot = cli.flags.screenshot; } From d5fdc3ce634bd786d8c1d5943e3b41c1eb844bfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Fri, 6 Feb 2015 16:57:15 +0100 Subject: [PATCH 54/54] Fix wording --- 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 30d07d7..c62e14f 100644 --- a/front/src/views/rule.html +++ b/front/src/views/rule.html @@ -146,7 +146,7 @@
-

This is the color palette, sized by total occurrences:

+

This is the colors palette, sized by total occurrences:

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