Bladeren bron

Merge pull request #42 from gmetais/offenders

Fixes #40.
Gaël Métais 10 jaren geleden
bovenliggende
commit
a6194f2535

+ 9 - 6
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'
     ]);

+ 2 - 1
bower.json

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

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

@@ -82,3 +82,69 @@
   font-size: 3em;
   margin-bottom: 1em;
 }
+.offenders .offenderButton {
+  display: inline-block;
+  position: relative;
+  background: #efe;
+  padding: 0 0.5em;
+  margin: 0.2em 0;
+  border-radius: 0.4em;
+  z-index: 1;
+}
+.offenders .offenderButton.opens {
+  padding-right: 0.75em;
+}
+.offenders .offenderButton.opens:after {
+  position: relative;
+  left: 0.5em;
+  content: '\25BC';
+  font-size: 0.8em;
+}
+.offenders .offenderButton > div {
+  display: none;
+  position: absolute;
+  right: 0;
+  min-width: 100%;
+  background: inherit;
+  border-bottom-left-radius: 0.4em;
+  border-bottom-right-radius: 0.4em;
+  border-top: 1px solid #999;
+}
+.offenders .offenderButton .domTree {
+  text-align: left;
+  white-space: nowrap;
+}
+.offenders .offenderButton .domTree > div {
+  margin: 0.5em;
+}
+.offenders .offenderButton .domTree > div div {
+  margin-left: 1em;
+}
+.offenders .offenderButton .backtrace,
+.offenders .offenderButton .cssFileAndLine {
+  white-space: nowrap;
+  padding: 0.5em;
+}
+.offenders .offenderButton.opens.mouseOver {
+  border-bottom-left-radius: 0;
+  border-bottom-right-radius: 0;
+  background: #ffe0cc;
+  z-index: 2;
+}
+.offenders .offenderButton.opens.mouseOver > div {
+  display: block;
+}
+.offendersHtml {
+  display: inline-block;
+}
+.domTree div {
+  text-align: left;
+  margin-left: 1em;
+}
+.domTree div span:only-child {
+  font-weight: bold;
+}
+.domTree div span:only-child span {
+  font-style: italic;
+  font-weight: normal;
+}

+ 2 - 0
front/src/js/app.js

@@ -1,5 +1,6 @@
 var yltApp = angular.module('YellowLabTools', [
     'ngRoute',
+    'ngSanitize',
     'indexCtrl',
     'aboutCtrl',
     'dashboardCtrl',
@@ -10,6 +11,7 @@ var yltApp = angular.module('YellowLabTools', [
     'resultsFactory',
     'menuService',
     'gradeDirective',
+    'offendersDirectives'
 ]);
 
 yltApp.run(['$rootScope', '$location', function($rootScope, $location) {

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

@@ -22,7 +22,6 @@ ruleCtrl.controller('RuleCtrl', ['$scope', '$rootScope', '$routeParams', '$locat
 
     function init() {
         $scope.rule = $scope.result.rules[$scope.policyName];
-        $scope.message = $sce.trustAsHtml($scope.rule.policy.message);
     }
 
     $scope.backToDashboard = function() {

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

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

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

@@ -88,4 +88,84 @@
         font-size: 3em;
         margin-bottom: 1em;
     }
+}
+
+.offenders {
+    .offenderButton {
+        display: inline-block;
+        position: relative;
+        background: #efe;
+        padding: 0 0.5em;
+        margin: 0.2em 0;
+        border-radius: 0.4em;
+        z-index: 1;
+
+        &.opens {
+            padding-right: 0.75em;
+
+            &:after {
+                position: relative;
+                left: 0.5em;
+                content: '\25BC';
+                font-size: 0.8em;
+            }
+        }
+
+        > div {
+            display: none;
+            position: absolute;
+            right: 0;
+            min-width: 100%;
+            background: inherit;
+            border-bottom-left-radius: 0.4em;
+            border-bottom-right-radius: 0.4em;
+            border-top: 1px solid #999;
+        }
+
+        .domTree {
+            text-align: left;
+            white-space: nowrap;
+
+            > div {
+                margin: 0.5em;
+
+                div {
+                    margin-left: 1em;
+                }
+            }
+        }
+
+        .backtrace, .cssFileAndLine {
+            white-space: nowrap;
+            padding: 0.5em;
+        }
+
+        &.opens.mouseOver {
+            border-bottom-left-radius: 0;
+            border-bottom-right-radius: 0;
+            background: #ffe0cc;
+            z-index: 2;
+
+            > div {
+                display: block;
+            }
+        }
+    }
+}
+
+.offendersHtml {
+    display: inline-block;
+}
+
+.domTree div {
+    text-align: left;
+    margin-left: 1em;
+
+    span:only-child {
+        font-weight: bold;
+        span {
+            font-style: italic;
+            font-weight: normal;
+        }
+    }
 }

+ 2 - 0
front/src/main.html

@@ -20,6 +20,7 @@
     <script src="/bower_components/angular/angular.min.js"></script>
     <script src="/bower_components/angular-route/angular-route.min.js"></script>
     <script src="/bower_components/angular-resource/angular-resource.min.js"></script>
+    <script src="/bower_components/angular-sanitize/angular-sanitize.min.js"></script>
     <script src="/js/app.js"></script>
     <script src="/js/controllers/indexCtrl.js"></script>
     <script src="/js/controllers/aboutCtrl.js"></script>
@@ -31,6 +32,7 @@
     <script src="/js/models/runsFactory.js"></script>
     <script src="/js/services/menuService.js"></script>
     <script src="/js/directives/gradeDirective.js"></script>
+    <script src="/js/directives/offendersDirectives.js"></script>
     <!-- endbuild -->
 <head>
 

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

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

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

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

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


+ 178 - 0
lib/offendersHelpers.js

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

+ 55 - 17
lib/rulesChecker.js

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

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

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

+ 3 - 1
lib/tools/phantomas/phantomasWrapper.js

@@ -34,12 +34,14 @@ var PhantomasWrapper = function() {
             'analyze-css': true,
             'skip-modules': [
                 'blockDomains', // not needed
+                'caching', // overriden
                 'domMutations', // not compatible with webkit
                 'domQueries', // overriden
                 'eventListeners', // overridden
                 'filmStrip', // not needed
                 'har', // not needed for the moment
-                'javaScriptBottlenecks', // needs to be launched after custom module scopeYLT,
+                'javaScriptBottlenecks', // needs to be launched after custom module scopeYLT
+                'jQuery', // overridden
                 'jserrors', // overridden
                 'pageSource', // not needed
                 'screenshot', // not needed for the moment

+ 1 - 2
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"

+ 395 - 1
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": [
+                            "<http://www.sudexpress.com/skin/frontend/sudexpress/default/css/styles.css> (Error: CSS parsing failed: missing '}' @ 4:1)",
+                            "<http://www.sudexpress.com/skin/frontend/sudexpress/default/css/reset.css> (Empty CSS was provided)"
+                        ]
+                    }
+                }
+            }
+        }, policies);
+
+        results.should.have.a.property('cssParsingErrors');
+        results.cssParsingErrors.should.have.a.property('offendersObj').that.deep.equals({
+            "count": 2,
+            "list": [
+                {
+                    "error": "Error: CSS parsing failed: missing '}'",
+                    "file": "http://www.sudexpress.com/skin/frontend/sudexpress/default/css/styles.css",
+                    "line": 4,
+                    "column": 1
+                },
+                {
+                    "error": "Empty CSS was provided",
+                    "file": "http://www.sudexpress.com/skin/frontend/sudexpress/default/css/reset.css",
+                    "line": null,
+                    "column": null
+                }
+            ]
+        });
+    });
+
+    
+    // Enough for the moment, to be complete...
+
 });

+ 10 - 2
test/core/indexTest.js

@@ -65,14 +65,22 @@ describe('index.js', function() {
                         "message": "<p>A deep DOM makes the CSS matching with DOM elements difficult.</p><p>It also slows down JavaScript modifications to the DOM because changing the dimensions of an element makes the browser re-calculate the dimensions of it's parents. Same thing for JavaScript events, that bubble up to the document root.</p>",
                         "isOkThreshold": 10,
                         "isBadThreshold": 20,
-                        "isAbnormalThreshold": 28
+                        "isAbnormalThreshold": 28,
+                        "hasOffenders": true
                     },
                     "value": 1,
                     "bad": false,
                     "abnormal": false,
                     "score": 100,
                     "abnormalityScore": 0,
-                    "offenders": ["body > h1[1]"]
+                    "offendersObj": {
+                        "count": 1,
+                        "tree": {
+                            "body": {
+                                "h1[1]": 1
+                            }
+                        }
+                    }
                 });
 
                 // Test javascriptExecutionTree

+ 283 - 0
test/core/offendersHelpersTest.js

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

+ 1 - 1
test/core/rulesCheckerTest.js

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

+ 26 - 8
test/fixtures/rulesCheckerOutput.json

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

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

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

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