Browse Source

Merge pull request #60 from gmetais/develop

Merge V1.3.0
Gaël Métais 10 years ago
parent
commit
0c3299573f
38 changed files with 1598 additions and 457 deletions
  1. 1 2
      .travis.yml
  2. 14 11
      Gruntfile.js
  3. 1 1
      README.md
  4. 6 1
      bin/cli.js
  5. 4 4
      bower.json
  6. 3 2
      front/src/css/rule.css
  7. 74 15
      front/src/css/timeline.css
  8. 1 0
      front/src/js/app.js
  9. 79 46
      front/src/js/controllers/timelineCtrl.js
  10. 775 101
      front/src/js/directives/offendersDirectives.js
  11. 2 1
      front/src/js/services/apiService.js
  12. 2 1
      front/src/less/rule.less
  13. 94 17
      front/src/less/timeline.less
  14. 1 0
      front/src/main.html
  15. 0 7
      front/src/views/domElementButton.html
  16. 1 1
      front/src/views/queue.html
  17. 13 0
      front/src/views/rule.html
  18. 12 75
      front/src/views/timeline.html
  19. 43 1
      lib/metadata/policies.js
  20. 4 3
      lib/metadata/scoreProfileGeneric.json
  21. 9 0
      lib/offendersHelpers.js
  22. 1 1
      lib/screenshotHandler.js
  23. 13 2
      lib/server/controllers/apiController.js
  24. 27 0
      lib/tools/jsExecutionTransformer.js
  25. 1 1
      lib/tools/phantomas/custom_modules/core/scopeYLT/scopeYLT.js
  26. 0 79
      lib/tools/phantomas/custom_modules/modules/cachYLT/cachYLT.js
  27. 64 11
      lib/tools/phantomas/custom_modules/modules/domQYLT/domQYLT.js
  28. 2 1
      lib/tools/phantomas/custom_modules/modules/eventListYLT/eventListYLT.js
  29. 93 25
      lib/tools/phantomas/custom_modules/modules/jQYLT/jQYLT.js
  30. 25 13
      lib/tools/phantomas/custom_modules/modules/javaScriptBottleYLT/javaScriptBottleYLT.js
  31. 0 2
      lib/tools/phantomas/phantomasWrapper.js
  32. 22 22
      package.json
  33. 5 1
      test/api/apiTest.js
  34. 2 2
      test/api/screenshotHandlerTest.js
  35. 2 2
      test/core/indexTest.js
  36. 8 6
      test/core/phantomasWrapperTest.js
  37. 193 0
      test/www/jquery-page.html
  38. 1 0
      test/www/jquery1.8.3.js

+ 1 - 2
.travis.yml

@@ -4,5 +4,4 @@ node_js:
 before_install:
 before_install:
     - "npm install -g grunt-cli"
     - "npm install -g grunt-cli"
     - "npm install -g phantomjs"
     - "npm install -g phantomjs"
-install: npm install
-before_script: grunt test
+install: npm install

+ 14 - 11
Gruntfile.js

@@ -66,7 +66,9 @@ module.exports = function(grunt) {
                 'app/nodeControllers/*.js',
                 'app/nodeControllers/*.js',
                 'app/public/scripts/*.js',
                 'app/public/scripts/*.js',
                 'phantomas_custom/**/*.js',
                 'phantomas_custom/**/*.js',
-                'test/**/*.js',
+                'test/api/*.js',
+                'test/core/*.js',
+                'test/fixtures/*.js',
                 'front/src/js/**/*.js'
                 'front/src/js/**/*.js'
             ]
             ]
         },
         },
@@ -92,10 +94,10 @@ module.exports = function(grunt) {
             },
             },
             coverage: {
             coverage: {
                 files: [
                 files: [
-                    {src: ['test/**'], dest: 'coverage/'},
-                    {src: ['lib/metadata/**'], dest: 'coverage/'},
-                    {src: ['node_modules/phantomas/**'], dest: 'coverage/'},
-                    {src: ['lib/tools/phantomas/custom_modules/**'], dest: 'coverage/'}
+                    {cwd: 'test', src: '**/*', dest: 'coverage/test', expand: true},
+                    {cwd: 'lib/metadata', src: '**/*', dest: 'coverage/lib/metadata', expand: true},
+                    {cwd: 'node_modules/phantomas', src: '**/*', dest: 'coverage/node_modules/phantomas', expand: true},
+                    {cwd: 'lib/tools/phantomas/custom_modules', src: '**/*', dest: 'coverage/lib/tools/phantomas/custom_modules', expand: true}
                 ]
                 ]
             },
             },
             build: {
             build: {
@@ -156,7 +158,7 @@ module.exports = function(grunt) {
             dev: {
             dev: {
                 NODE_ENV: 'development'
                 NODE_ENV: 'development'
             },
             },
-            builded: {
+            built: {
                 NODE_ENV: 'production'
                 NODE_ENV: 'production'
             }
             }
         },
         },
@@ -169,7 +171,7 @@ module.exports = function(grunt) {
                     showStack: true
                     showStack: true
                 }
                 }
             },
             },
-            builded: {
+            built: {
                 options: {
                 options: {
                     port: 8383,
                     port: 8383,
                     server: './bin/server.js',
                     server: './bin/server.js',
@@ -218,7 +220,8 @@ module.exports = function(grunt) {
         htmlmin: {
         htmlmin: {
             options: {
             options: {
                 removeComments: true,
                 removeComments: true,
-                collapseWhitespace: true
+                collapseWhitespace: true,
+                conservativeCollapse: true
             },
             },
             main: {
             main: {
                 files: [{
                 files: [{
@@ -323,9 +326,9 @@ module.exports = function(grunt) {
         'express:dev'
         'express:dev'
     ]);
     ]);
 
 
-    grunt.registerTask('builded', [
-        'env:builded',
-        'express:builded'
+    grunt.registerTask('built', [
+        'env:built',
+        'express:built'
     ]);
     ]);
 
 
     grunt.registerTask('test', [
     grunt.registerTask('test', [

+ 1 - 1
README.md

@@ -12,7 +12,7 @@ Online tool that lets you test a webpage and detects **performance** and **front
 
 
 ## How it works
 ## How it works
 
 
-The tool loads the given URL via [PhantomasJS](http://phantomjs.org/) (a headless browser) and collects various metrics and statistics with the help of [Phantomas](https://github.com/macbre/phantomas). These metrics are categorized and transformed into scores. It also gives in-depth details so developpers can correct the detected issues.
+The tool loads the given URL via [PhantomJS](http://phantomjs.org/) (a headless browser) and collects various metrics and statistics with the help of [Phantomas](https://github.com/macbre/phantomas). These metrics are categorized and transformed into scores. It also gives in-depth details so developpers can correct the detected issues.
 
 
 By the way, it's free because we are geeks, not businessmen. All we want is a ★ on GitHub, it will boost our motivation to add more awesome features!!!
 By the way, it's free because we are geeks, not businessmen. All we want is a ★ on GitHub, it will boost our motivation to add more awesome features!!!
 
 

+ 6 - 1
bin/cli.js

@@ -12,7 +12,8 @@ var cli = meow({
         '  yellowlabtools <url> <options>',
         '  yellowlabtools <url> <options>',
         '',
         '',
         'Options:',
         'Options:',
-        '  --screenshot     Will take a screenshot and use this value as the output path. It needs to end with ".png".',
+        '  --screenshot         Will take a screenshot and use this value as the output path. It needs to end with ".png".',
+        '  --js-deep-analysis   When activated, the javascriptExecutionTree will contain sub-requests.',
         ''
         ''
     ].join('\n'),
     ].join('\n'),
     pkg: '../package.json'
     pkg: '../package.json'
@@ -44,6 +45,10 @@ if (screenshot) {
     options.screenshot = cli.flags.screenshot;
     options.screenshot = cli.flags.screenshot;
 }
 }
 
 
+// Deep JS analysis option
+if (cli.flags.jsDeepAnalysis === true || cli.flags.jsDeepAnalysis === 'true') {
+    options.jsDeepAnalysis = true;
+}
 
 
 
 
 (function execute(url, options) {
 (function execute(url, options) {

+ 4 - 4
bower.json

@@ -1,10 +1,10 @@
 {
 {
   "name": "yellowlabtools",
   "name": "yellowlabtools",
   "dependencies": {
   "dependencies": {
-    "angular": "~1.3.8",
-    "angular-route": "~1.3.8",
-    "angular-resource": "~1.3.7",
-    "angular-sanitize": "~1.4.0-beta.0"
+    "angular": "~1.3.14",
+    "angular-route": "~1.3.14",
+    "angular-resource": "~1.3.14",
+    "angular-sanitize": "~1.3.14"
   },
   },
   "resolutions": {
   "resolutions": {
     "angular": "~1.3.8"
     "angular": "~1.3.8"

+ 3 - 2
front/src/css/rule.css

@@ -109,6 +109,7 @@
   border-bottom-left-radius: 0.4em;
   border-bottom-left-radius: 0.4em;
   border-bottom-right-radius: 0.4em;
   border-bottom-right-radius: 0.4em;
   border-top: 1px solid #999;
   border-top: 1px solid #999;
+  z-index: 2;
 }
 }
 .offenders .offenderButton .domTree {
 .offenders .offenderButton .domTree {
   text-align: left;
   text-align: left;
@@ -125,13 +126,13 @@
   white-space: nowrap;
   white-space: nowrap;
   padding: 0.5em;
   padding: 0.5em;
 }
 }
-.offenders .offenderButton.opens.mouseOver {
+.offenders .offenderButton.opens:hover {
   border-bottom-left-radius: 0;
   border-bottom-left-radius: 0;
   border-bottom-right-radius: 0;
   border-bottom-right-radius: 0;
   background: #ffe0cc;
   background: #ffe0cc;
   z-index: 2;
   z-index: 2;
 }
 }
-.offenders .offenderButton.opens.mouseOver > div {
+.offenders .offenderButton.opens:hover > div {
   display: block;
   display: block;
 }
 }
 .offendersHtml {
 .offendersHtml {

+ 74 - 15
front/src/css/timeline.css

@@ -2,6 +2,19 @@
 .execution {
 .execution {
   text-align: center;
   text-align: center;
 }
 }
+.selectScript {
+  padding-bottom: 2em;
+  font-size: 0.9em;
+}
+.selectScript select {
+  max-width: 30em;
+}
+.selectScript.empty {
+  font-size: 0.8em;
+}
+.selectScript.empty select {
+  width: 10em;
+}
 .timeline {
 .timeline {
   margin: 2em 0 5em;
   margin: 2em 0 5em;
 }
 }
@@ -39,6 +52,9 @@
   bottom: 0;
   bottom: 0;
   width: 100%;
   width: 100%;
 }
 }
+.timeline .interval .color.clickable {
+  cursor: pointer;
+}
 .timeline div.interval:hover {
 .timeline div.interval:hover {
   background: #9C4274;
   background: #9C4274;
 }
 }
@@ -127,20 +143,6 @@
   border: 1px dotted #aaa;
   border: 1px dotted #aaa;
   text-align: left;
   text-align: left;
 }
 }
-.slowRequestsLimit {
-  width: 3em;
-  font-size: 1em;
-  text-align: right;
-  border: 1px solid #aaa;
-}
-input.textFilter {
-  box-shadow: none;
-  font-size: 1em;
-  padding: 0 0.2em;
-  border: 1px solid #aaa;
-  border-radius: none;
-  width: 15em;
-}
 .table {
 .table {
   display: table;
   display: table;
   width: 100%;
   width: 100%;
@@ -171,12 +173,65 @@ input.textFilter {
 .table > div.showingDetails > div {
 .table > div.showingDetails > div {
   background: #f1c40f;
   background: #f1c40f;
 }
 }
+.table > div.highlight > div.startTime {
+  background-color: #C0F090;
+}
+.table > div.highlight-remove {
+  transition: 3s;
+}
+.table > div.highlight-remove > div.startTime {
+  transition: background-color 3s ease-in;
+}
 .table > div > .index {
 .table > div > .index {
   color: #bbb;
   color: #bbb;
+  word-break: normal;
 }
 }
 .table > div > .type {
 .table > div > .type {
   white-space: nowrap;
   white-space: nowrap;
 }
 }
+.table .children {
+  margin-top: 0.2em;
+  font-size: 0.8em;
+  line-height: 1.6em;
+}
+.table .child {
+  margin-left: 0.5em;
+}
+.table .child > .child {
+  margin-left: 1em;
+}
+.table .child:before {
+  content: "↳";
+}
+.table .child .childArgs {
+  display: none;
+}
+.table .child span {
+  position: relative;
+}
+.table .child span:hover {
+  background: #EBD8E2;
+}
+.table .child span:hover div {
+  display: inline-block;
+}
+.table .child span:hover .childArgs {
+  display: block;
+  position: absolute;
+  padding: 0 1em 0 2em;
+  left: 100%;
+  top: 0;
+  background: #EBD8E2;
+  line-height: 1.3em;
+  height: 1.3em;
+  z-index: 1;
+}
+.table .showingDetails .child span:hover {
+  background: inherit;
+}
+.table .showingDetails .child span:hover .childArgs {
+  display: none;
+}
 .table > div > .value {
 .table > div > .value {
   width: 70%;
   width: 70%;
   word-break: break-all;
   word-break: break-all;
@@ -193,6 +248,7 @@ input.textFilter {
   width: 0.8em;
   width: 0.8em;
 }
 }
 .detailsOverlay {
 .detailsOverlay {
+  display: none;
   position: absolute;
   position: absolute;
   right: 3em;
   right: 3em;
   top: -3em;
   top: -3em;
@@ -202,13 +258,16 @@ input.textFilter {
   background: #fff;
   background: #fff;
   border: 2px solid #f1c40f;
   border: 2px solid #f1c40f;
   border-radius: 0.5em;
   border-radius: 0.5em;
-  z-index: 1;
+  z-index: 2;
 }
 }
 @media screen and (max-width: 1024px) {
 @media screen and (max-width: 1024px) {
   .detailsOverlay {
   .detailsOverlay {
     width: 25em;
     width: 25em;
   }
   }
 }
 }
+.showDetails .detailsOverlay {
+  display: block;
+}
 .detailsOverlay .closeBtn {
 .detailsOverlay .closeBtn {
   position: absolute;
   position: absolute;
   top: 0.5em;
   top: 0.5em;

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

@@ -1,6 +1,7 @@
 var yltApp = angular.module('YellowLabTools', [
 var yltApp = angular.module('YellowLabTools', [
     'ngRoute',
     'ngRoute',
     'ngSanitize',
     'ngSanitize',
+    'ngAnimate',
     'indexCtrl',
     'indexCtrl',
     'dashboardCtrl',
     'dashboardCtrl',
     'queueCtrl',
     'queueCtrl',

+ 79 - 46
front/src/js/controllers/timelineCtrl.js

@@ -19,19 +19,52 @@ timelineCtrl.controller('TimelineCtrl', ['$scope', '$rootScope', '$routeParams',
     }
     }
 
 
     function render() {
     function render() {
+        initScriptFiltering();
         initExecutionTree();
         initExecutionTree();
         initTimeline();
         initTimeline();
         $timeout(initProfiler, 100);
         $timeout(initProfiler, 100);
     }
     }
 
 
+    function initScriptFiltering() {
+        var offenders = $scope.result.rules.jsCount.offendersObj.list;
+        $scope.scripts = [];
+
+        offenders.forEach(function(script) {
+            var filePath = script.file;
+
+            if (filePath.length > 100) {
+                filePath = filePath.substr(0, 98) + '...';
+            }
+
+            var scriptObj = {
+                fullPath: script.file, 
+                shortPath: filePath
+            };
+
+            $scope.scripts.push(scriptObj);
+        });
+    }
+
     function initExecutionTree() {
     function initExecutionTree() {
         var originalExecutions = $scope.result.javascriptExecutionTree.children || [];
         var originalExecutions = $scope.result.javascriptExecutionTree.children || [];
-        $scope.executionTree = [];
+        
+        // Detect the last event of all (before filtering) and read time
+        var lastEvent = originalExecutions[originalExecutions.length - 1];
+        $scope.endTime =  lastEvent.data.timestamp + (lastEvent.data.time || 0);
 
 
+        // Filter
+        $scope.executionTree = [];
         originalExecutions.forEach(function(node) {
         originalExecutions.forEach(function(node) {
-
-            // Prepare a faster angular search by creating a kind of search index
-            node.searchIndex = (node.data.callDetails) ? [node.data.type].concat(node.data.callDetails.arguments).join('°°') : node.data.type;
+            
+            // Filter by script (if enabled)
+            if ($scope.selectedScript) {
+                if (node.data.backtrace && node.data.backtrace.indexOf($scope.selectedScript.fullPath + ':') === -1) {
+                    return;
+                }
+                if (node.data.type === "jQuery loaded" || node.data.type === "jQuery version change") {
+                    return;
+                }
+            }
 
 
             $scope.executionTree.push(node);
             $scope.executionTree.push(node);
         });
         });
@@ -41,8 +74,6 @@ timelineCtrl.controller('TimelineCtrl', ['$scope', '$rootScope', '$routeParams',
 
 
         // Split the timeline into 200 intervals
         // Split the timeline into 200 intervals
         var numberOfIntervals = 199;
         var numberOfIntervals = 199;
-        var lastEvent = $scope.executionTree[$scope.executionTree.length - 1];
-        $scope.endTime =  lastEvent.data.timestamp + (lastEvent.data.time || 0);
         $scope.timelineIntervalDuration = $scope.endTime / numberOfIntervals;
         $scope.timelineIntervalDuration = $scope.endTime / numberOfIntervals;
         
         
         // Pre-fill array of as many elements as there are milleseconds
         // Pre-fill array of as many elements as there are milleseconds
@@ -80,53 +111,31 @@ timelineCtrl.controller('TimelineCtrl', ['$scope', '$rootScope', '$routeParams',
         $scope.profilerData = $scope.executionTree;
         $scope.profilerData = $scope.executionTree;
     }
     }
 
 
-
-    function parseBacktrace(str) {
-        if (!str) {
-            return null;
-        }
-
-        var out = [];
-        var splited = str.split(' / ');
-        splited.forEach(function(trace) {
-            var result = /^(\S*)\s?\(?(https?:\/\/\S+):(\d+)\)?$/g.exec(trace);
-            if (result && result[2].length > 0) {
-                var filePath = result[2];
-                var chunks = filePath.split('/');
-                var fileName = chunks[chunks.length - 1];
-
-                out.push({
-                    fnName: result[1],
-                    fileName: fileName,
-                    filePath: filePath,
-                    line: result[3]
-                });
-            }
-        });
-        return out;
-    }
-
-    $scope.filter = function(textFilter, scriptName) {
-
+    $scope.changeScript = function() {
+        initExecutionTree();
+        initTimeline();
+        initProfiler();
     };
     };
 
 
-    $scope.onNodeDetailsClick = function(node) {
-        var isOpen = node.showDetails;
-        if (!isOpen) {
-            // Close all other nodes
-            $scope.executionTree.forEach(function(currentNode) {
-                currentNode.showDetails = false;
-            });
+    $scope.findLineIndexByTimestamp = function(timestamp) {
+        var lineIndex = 0;
 
 
-            // Parse the backtrace
-            if (!node.parsedBacktrace) {
-                node.parsedBacktrace = parseBacktrace(node.data.backtrace);
+        for (var i = 0; i < $scope.executionTree.length; i ++) {
+            var delta = $scope.executionTree[i].data.timestamp - timestamp;
+            
+            if (delta < $scope.timelineIntervalDuration) {
+                lineIndex = i;
             }
             }
 
 
+            if (delta > 0) {
+                break;
+            }
         }
         }
-        node.showDetails = !isOpen;
+
+        return lineIndex;
     };
     };
 
 
+
     $scope.backToDashboard = function() {
     $scope.backToDashboard = function() {
         $location.path('/result/' + $scope.runId);
         $location.path('/result/' + $scope.runId);
     };
     };
@@ -137,4 +146,28 @@ timelineCtrl.controller('TimelineCtrl', ['$scope', '$rootScope', '$routeParams',
 
 
     loadResults();
     loadResults();
 
 
-}]);
+}]);
+
+timelineCtrl.directive('scrollOnClick', ['$animate', '$timeout', function($animate, $timeout) {
+    return {
+        restrict: 'A',
+        link: function (scope, element, attributes) {            
+            // When the user clicks on the timeline, find the right profiler line and scroll to it
+            element.on('click', function() {
+                var lineIndex = scope.findLineIndexByTimestamp(attributes.scrollOnClick);
+                var lineElement = angular.element(document.getElementById('line_' + lineIndex));
+                
+                // Animate the background color to "flash" the row
+                lineElement.addClass('highlight');
+                $timeout(function() {
+                    $animate.removeClass(lineElement, 'highlight');
+                    scope.$digest();
+                }, 50);
+
+
+                window.scrollTo(0, lineElement[0].offsetTop);
+                console.log(lineElement[0]);
+            });
+        }
+    };
+}]);

+ 775 - 101
front/src/js/directives/offendersDirectives.js

@@ -1,50 +1,735 @@
-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>';
+(function() {
+    "use strict";
+    var offendersDirectives = angular.module('offendersDirectives', []);
+
+    function getdomTreeHTML(tree) {
+        return '<div class="domTree">' + getdomTreeInnerHTML(tree) + '</div>';
+    }
+
+    function getdomTreeInnerHTML(tree) {
+        return recursiveHtmlBuilder(tree);
+    }
+
+    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;
+    }
+
+    offendersDirectives.directive('domTree', function() {
+        return {
+            restrict: 'E',
+            scope: {
+                tree: '='
+            },
+            template: '<div class="domTree"></div>',
+            replace: true,
+            link: function(scope, element) {
+                element.append(getdomTreeInnerHTML(scope.tree));
+            }
+        };
+    });
+
+    function getDomElementButtonHTML(obj, onASingleLine) {
+        if (obj.tree && !onASingleLine) {
+            return '<div class="offenderButton opens">' + getDomElementButtonInnerHTML(obj, onASingleLine) + '</div>';
+        } else {
+            return '<div class="offenderButton">' + getDomElementButtonInnerHTML(obj, onASingleLine) + '</div>';
+        }
+    }
+
+    function getDomElementButtonInnerHTML(obj, onASingleLine) {
+        if (obj.type === 'html' ||
+            obj.type === 'body' ||
+            obj.type === 'head' ||
+            obj.type === 'window' ||
+            obj.type === 'document' ||
+            obj.type === 'fragment') {
+                return obj.type;
+        }
+
+        if (obj.type === 'notAnElement') {
+            return 'Incorrect element';
+        }
+
+        var html = '';
+        if (obj.type === 'domElement') {
+            html = 'DOM element <b>' + obj.element + '</b>';
+        } else if (obj.type === 'fragmentElement') {
+            html = 'Fragment element <b>' + obj.element + '</b>';
+        } else if (obj.type === 'createdElement') {
+            html = 'Created element <b>' + obj.element + '</b>';
+        }
+
+        if (obj.tree && !onASingleLine) {
+            html += getdomTreeHTML(obj.tree);
+        }
+
+        return html;
+    }
+
+    offendersDirectives.directive('domElementButton', function() {
+        return {
+            restrict: 'E',
+            scope: {
+                obj: '='
+            },
+            template: '<div class="offenderButton" ng-class="{opens: obj.tree}"></div>',
+            replace: true,
+            link: function(scope, element) {                
+                element.append(getDomElementButtonInnerHTML(scope.obj));
+            }
+        };
+    });
+
+
+    function getJQueryContextButtonHTML(context, onASingleLine) {
+        if (context.length === 0) {
+            return '<span class="offenderButton">Empty jQuery object</span>';
+        }
+
+        if (context.length === 1) {
+            return getDomElementButtonHTML(context.elements[0], onASingleLine);
+        }
+
+        var html = context.length + ' elements (' + getDomElementButtonHTML(context.elements[0], onASingleLine) + ', ' + getDomElementButtonHTML(context.elements[1], onASingleLine);
+        if (context.length === 3) {
+            html += ', ' + getDomElementButtonHTML(context.elements[0], onASingleLine);
+        } else if (context.length > 3) {
+            html += ' and ' + (context.length - 2) + ' more...';
+        }
+        return html + '}';
+    }
+
+    function isJQuery(node) {
+        return node.data.type.indexOf('jQuery ') === 0;
+    }
+
+    function getNonJQueryHTML(node, onASingleLine) {
+        var type = node.data.type;
+
+        if (!node.data.callDetails) {
+            return '';
+        }
+
+        var args = node.data.callDetails.arguments;
+        var ctxt = node.data.callDetails.context;
+
+
+        switch (type) {
+            case 'getElementById':
+            case 'createElement':
+                return '<b>' + args[0] + '</b>';
+
+            case 'getElementsByClassName':
+            case 'getElementsByTagName':
+            case 'querySelector':
+            case 'querySelectorAll':
+                return '<b>' + args[0] + '</b> on ' + getDomElementButtonHTML(ctxt.elements[0], onASingleLine);
+
+            case 'appendChild':
+                return 'append ' + getDomElementButtonHTML(args[0], onASingleLine) + ' to ' + getDomElementButtonHTML(ctxt.elements[0], onASingleLine);
+
+            case 'insertBefore':
+                return 'insert ' + getDomElementButtonHTML(args[0], onASingleLine) + ' into ' + getDomElementButtonHTML(ctxt.elements[0], onASingleLine) + ' before ' + getDomElementButtonHTML(args[1], onASingleLine);
+
+            case 'addEventListener':
+                return 'bind <b>' + args[0] + '</b> to ' + getDomElementButtonHTML(ctxt.elements[0], onASingleLine);
+
+            case 'error':
+                return args[0];
+
+            default:
+                return '';
+        }
+    }
+
+    function getJQueryHTML(node, onASingleLine) {
+        var type = node.data.type;
+        var args = node.data.callDetails.arguments;
+        var ctxt = node.data.callDetails.context;
+        
+        // escape HTML in args
+        for (var i = 0 ; i < 4 ; i ++) {
+            if (args[i]) {
+                args[i] = escapeHTML(args[i]);
+            }
+        }
+
+        if (type === 'jQuery loaded' || type === 'jQuery version change') {
+            return args[0];
+        }
+
+        switch (type) {
+            case 'jQuery - onDOMReady':
+            case 'jQuery - windowOnLoad':
+                return '(function)';
+
+            case 'jQuery - Sizzle call':
+                return '<b>' + args[0] + '</b> on ' + getDomElementButtonHTML(ctxt.elements[0], onASingleLine);
+
+            case 'jQuery - find':
+                if (ctxt && ctxt.length === 1 && ctxt.elements[0].type !== 'document') {
+                    return '<b>' + args[0] + '</b> on ' + getJQueryContextButtonHTML(ctxt, onASingleLine);
+                } else {
+                    return '<b>' + args[0] + '</b>';
+                }
+                break;
+
+            case 'jQuery - html':
+                if (args[0] !== undefined) {
+                    return 'set content "<b>' + args[0] + '</b>" to ' + getJQueryContextButtonHTML(ctxt, onASingleLine);
+                } else {
+                    return 'get content from ' + getJQueryContextButtonHTML(ctxt, onASingleLine);
+                }
+                break;
+
+            case 'jQuery - append':
+                return 'append ' + joinArgs(args) + ' to ' + getJQueryContextButtonHTML(ctxt, onASingleLine);
+
+            case 'jQuery - appendTo':
+                return 'append' + getJQueryContextButtonHTML(ctxt, onASingleLine) + ' to <b>' + args[0] + '</b>';
+
+            case 'jQuery - prepend':
+                return 'prepend ' + joinArgs(args) + ' to ' + getJQueryContextButtonHTML(ctxt, onASingleLine);
+
+            case 'jQuery - prependTo':
+                return 'prepend ' + getJQueryContextButtonHTML(ctxt, onASingleLine) + ' to <b>' + args[0] + '</b>';
+
+            case 'jQuery - before':
+                return 'insert ' + joinArgs(args) + ' before ' + getJQueryContextButtonHTML(ctxt, onASingleLine);
+
+            case 'jQuery - insertBefore':
+                return 'insert ' + getJQueryContextButtonHTML(ctxt, onASingleLine) + ' before <b>' + args[0] + '</b>';
+
+            case 'jQuery - after':
+                return 'insert ' + joinArgs(args) + ' after ' + getJQueryContextButtonHTML(ctxt, onASingleLine);
+
+            case 'jQuery - insertAfter':
+                return 'insert ' + getJQueryContextButtonHTML(ctxt, onASingleLine) + ' after <b>' + args[0] + '</b>';
+
+            case 'jQuery - remove':
+            case 'jQuery - detach':
+                if (args[0]) {
+                    return getJQueryContextButtonHTML(ctxt, onASingleLine) + ' filtered by <b>' + args[0] + '</b>';
+                } else {
+                    return getJQueryContextButtonHTML(ctxt, onASingleLine);
+                }
+                break;
+
+            case 'jQuery - empty':
+            case 'jQuery - clone':
+            case 'jQuery - unwrap':
+            case 'jQuery - show':
+            case 'jQuery - hide':
+            case 'jQuery - animate':
+            case 'jQuery - fadeIn':
+            case 'jQuery - fadeOut':
+            case 'jQuery - fadeTo':
+            case 'jQuery - fadeToggle':
+            case 'jQuery - slideDown':
+            case 'jQuery - slideUp':
+            case 'jQuery - slideToggle':
+                return getJQueryContextButtonHTML(ctxt, onASingleLine);
+
+            case 'jQuery - replaceWith':
+                return 'replace ' + getJQueryContextButtonHTML(ctxt, onASingleLine) + ' with <b>' + args[0] + '</b>';
+
+            case 'jQuery - replaceAll':
+                return 'replace <b>' + args[0] + '</b> with ' + getJQueryContextButtonHTML(ctxt, onASingleLine);
+
+            case 'jQuery - text':
+                if (args[0]) {
+                    return 'set text "<b>' + args[0] + '</b>" to ' + getJQueryContextButtonHTML(ctxt, onASingleLine);
+                } else {
+                    return 'get text from ' + getJQueryContextButtonHTML(ctxt, onASingleLine);
+                }
+                break;
+
+            case 'jQuery - wrap':
+            case 'jQuery - wrapAll':
+                return 'wrap ' + getJQueryContextButtonHTML(ctxt, onASingleLine) + ' within <b>' + args[0] + '</b>';
+
+            case 'jQuery - wrapInner':
+                return 'wrap the content of ' + getJQueryContextButtonHTML(ctxt, onASingleLine) + ' within <b>' + args[0] + '</b>';
+
+            case 'jQuery - css':
+            case 'jQuery - attr':
+            case 'jQuery - prop':
+                if (isStringOfObject(args[0])) {
+                    return 'set <b>' + args[0] + '</b> on ' + getJQueryContextButtonHTML(ctxt, onASingleLine);
+                } else if (args[1]) {
+                    return 'set <b>' + args[0] + '</b> : <b>' + args[1] + '</b> on ' + getJQueryContextButtonHTML(ctxt, onASingleLine);
+                } else {
+                    return 'get <b>' + args[0] + '</b> from ' + getJQueryContextButtonHTML(ctxt, onASingleLine);
+                }
+                break;
+
+            case 'jQuery - offset':
+            case 'jQuery - height':
+            case 'jQuery - innerHeight':
+            case 'jQuery - outerHeight':
+            case 'jQuery - width':
+            case 'jQuery - innerWidth':
+            case 'jQuery - outerWidth':
+            case 'jQuery - scrollLeft':
+            case 'jQuery - scrollTop':
+            case 'jQuery - position':
+                if (args[0]) {
+                    return 'set <b>' + args[0] + '</b> on ' + getJQueryContextButtonHTML(ctxt, onASingleLine);
+                } else {
+                    return 'get from ' + getJQueryContextButtonHTML(ctxt, onASingleLine);
+                }
+                break;
+
+            case 'jQuery - toggle':
+                if (args[0] === 'true') {
+                    return getJQueryContextButtonHTML(ctxt, onASingleLine) + ' to visible';
+                } else if (args[0] === 'false') {
+                    return getJQueryContextButtonHTML(ctxt, onASingleLine) + ' to hidden';
+                } else {
+                    return getJQueryContextButtonHTML(ctxt, onASingleLine);
+                }
+                break;
+
+            case 'jQuery - on':
+            case 'jQuery - one':
+                if (args[1]) {
+                    return 'bind <b>' + args[0] + '</b> on ' + getJQueryContextButtonHTML(ctxt, onASingleLine) + '\'s children filtered by <b>' + args[1] + '</b>';
+                } else {
+                    return 'bind <b>' + args[0] + '</b> on ' + getJQueryContextButtonHTML(ctxt, onASingleLine);
+                }
+                break;
+
+            case 'jQuery - off':
+                if (args[0]) {
+                    if (args[1]) {
+                        return 'unbind <b>' + args[0] + '</b> from ' + getJQueryContextButtonHTML(ctxt, onASingleLine) + '\'s children filtered by <b>' + args[1] + '</b>';
+                    } else {
+                        return 'unbind <b>' + args[0] + '</b> from ' + getJQueryContextButtonHTML(ctxt, onASingleLine);
+                    }
+                } else {
+                    return 'unbind all events';
+                }
+                break;
+
+            case 'jQuery - live':
+            case 'jQuery - bind':
+                return 'bind <b>' + args[0] + '</b> on ' + getJQueryContextButtonHTML(ctxt, onASingleLine);
+
+            case 'jQuery - die':
+            case 'jQuery - unbind':
+                if (args[0]) {
+                    return 'unbind <b>' + args[0] + '</b> from ' + getJQueryContextButtonHTML(ctxt, onASingleLine);
+                } else {
+                    return 'unbind all events';
+                }
+                break;
+
+            case 'jQuery - delegate':
+                return 'bind <b>' + args[1] + '</b> on ' + getJQueryContextButtonHTML(ctxt, onASingleLine) + '\'s children filtered by <b>' + args[0] + '</b>';
+
+            case 'jQuery - undelegate':
+                if (args[0]) {
+                    if (args[1]) {
+                        return 'unbind <b>' + args[1] + '</b> from ' + getJQueryContextButtonHTML(ctxt, onASingleLine) + '\'s children filtered by <b>' + args[0] + '</b>';
+                    } else {
+                        return 'unbind namespace <b>' + args[0] + '</b>';
+                    }
+                } else {
+                    return 'unbind all events';
+                }
+                break;
+
+            case 'jQuery - blur':
+            case 'jQuery - change':
+            case 'jQuery - click':
+            case 'jQuery - dblclick':
+            case 'jQuery - error':
+            case 'jQuery - focus':
+            case 'jQuery - focusin':
+            case 'jQuery - focusout':
+            case 'jQuery - hover':
+            case 'jQuery - keydown':
+            case 'jQuery - keypress':
+            case 'jQuery - keyup':
+            case 'jQuery - load':
+            case 'jQuery - mousedown':
+            case 'jQuery - mouseenter':
+            case 'jQuery - mouseleave':
+            case 'jQuery - mousemove':
+            case 'jQuery - mouseout':
+            case 'jQuery - mouseover':
+            case 'jQuery - mouseup':
+            case 'jQuery - resize':
+            case 'jQuery - scroll':
+            case 'jQuery - select':
+            case 'jQuery - submit':
+            case 'jQuery - unload':
+                return 'bind on ' + getJQueryContextButtonHTML(ctxt, onASingleLine);
+
+            case 'jQuery - removeAttr':
+            case 'jQuery - removeProp':
+                return 'remove <b>' + args[0] + '</b> from ' + getJQueryContextButtonHTML(ctxt, onASingleLine);
+
+            case 'jQuery - val':
+                if (args[0]) {
+                    return 'set value <b>' + args[0] + '</b> to ' + getJQueryContextButtonHTML(ctxt, onASingleLine);
+                } else {
+                    return 'get value from ' + getJQueryContextButtonHTML(ctxt, onASingleLine);
+                }
+                break;
+
+            case 'jQuery - hasClass':
+            case 'jQuery - addClass':
+            case 'jQuery - removeClass':
+                return '<b>' + args[0] + '</b> on ' + getJQueryContextButtonHTML(ctxt, onASingleLine);
+
+            case 'jQuery - toggleClass':
+                if (args[0]) {
+                    if (args[1]) {
+                        return 'toggle <b>' + args[0] + '</b> on ' + getJQueryContextButtonHTML(ctxt, onASingleLine) + ' to <b>' + args[1] + '</b>';
                     } else {
                     } else {
-                        html += '<div><span>' + key + '</span></div>';
+                        return 'toggle <b>' + args[0] + '</b> on ' + getJQueryContextButtonHTML(ctxt, onASingleLine);
                     }
                     }
+                } else {
+                    return 'magic no-argument toggleClass';
+                }
+                break;
+
+            case 'jQuery - children':
+                if (args[0]) {
+                    return 'of ' + getJQueryContextButtonHTML(ctxt, onASingleLine) + ' filtered by <b>' + args[0] + '</b>';
+                } else {
+                    return 'of ' + getJQueryContextButtonHTML(ctxt, onASingleLine);
+                }
+                break;
+
+            case 'jQuery - closest':
+                if (args[1]) {
+                    return 'closest <b>' + args[0] + '</b> from ' + getJQueryContextButtonHTML(ctxt, onASingleLine) + ' in context <b>' + args[1] + '</b>';
+                } else {
+                    return 'closest <b>' + args[0] + '</b> from ' + getJQueryContextButtonHTML(ctxt, onASingleLine);
+                }
+                break;
+
+            case 'jQuery - next':
+            case 'jQuery - nextAll':
+                if (args[0]) {
+                    return 'after ' + getJQueryContextButtonHTML(ctxt, onASingleLine) + ' matching <b>' + args[0] + '</b>';
+                } else {
+                    return 'after ' + getJQueryContextButtonHTML(ctxt, onASingleLine);
+                }
+                break;
+
+            case 'jQuery - nextUntil':
+                if (args[0]) {
+                    if (args[1]) {
+                        return 'after ' + getJQueryContextButtonHTML(ctxt, onASingleLine) + ' until <b>' + args[0] + '</b> and matching <b>' + args[1] + '</b>';
+                    } else {
+                        return 'after ' + getJQueryContextButtonHTML(ctxt, onASingleLine) + ' until <b>' + args[0] + '</b>';
+                    }
+                } else {
+                    return 'after ' + getJQueryContextButtonHTML(ctxt, onASingleLine);
+                }
+                break;
+
+            case 'jQuery - offsetParent':
+                return 'of ' + getJQueryContextButtonHTML(ctxt, onASingleLine);
+
+            case 'jQuery - prev':
+            case 'jQuery - prevAll':
+                if (args[0]) {
+                    return 'before ' + getJQueryContextButtonHTML(ctxt, onASingleLine) + ' matching <b>' + args[0] + '</b>';
+                } else {
+                    return 'before ' + getJQueryContextButtonHTML(ctxt, onASingleLine);
+                }
+                break;
+
+            case 'jQuery - prevUntil':
+                if (args[0]) {
+                    if (args[1]) {
+                        return 'before ' + getJQueryContextButtonHTML(ctxt, onASingleLine) + ' until <b>' + args[0] + '</b> and matching <b>' + args[1] + '</b>';
+                    } else {
+                        return 'before ' + getJQueryContextButtonHTML(ctxt, onASingleLine) + ' until <b>' + args[0] + '</b>';
+                    }
+                } else {
+                    return 'before ' + getJQueryContextButtonHTML(ctxt, onASingleLine);
+                }
+                break;
+
+            case 'jQuery - parent':
+            case 'jQuery - parents':
+                if (args[0]) {
+                    return 'of ' + getJQueryContextButtonHTML(ctxt, onASingleLine) + ' matching <b>' + args[0] + '</b>';
+                } else {
+                    return 'of ' + getJQueryContextButtonHTML(ctxt, onASingleLine);
+                }
+                break;
+
+            case 'jQuery - parentsUntil':
+                if (args[0]) {
+                    if (args[1]) {
+                        return 'of ' + getJQueryContextButtonHTML(ctxt, onASingleLine) + ' until <b>' + args[0] + '</b> and matching <b>' + args[1] + '</b>';
+                    } else {
+                        return 'of ' + getJQueryContextButtonHTML(ctxt, onASingleLine) + ' until <b>' + args[0] + '</b>';
+                    }
+                } else {
+                    return 'of ' + getJQueryContextButtonHTML(ctxt, onASingleLine);
+                }
+                break;
+
+            case 'jQuery - siblings':
+                if (args[0]) {
+                    return 'near ' + getJQueryContextButtonHTML(ctxt, onASingleLine) + ' matching <b>' + args[0] + '</b>';
+                } else {
+                    return 'near ' + getJQueryContextButtonHTML(ctxt, onASingleLine);
+                }
+                break;
+
+            case 'jQuery - onDOMReady':
+                return '(function)';
+
+            default:
+                return '';
+        }
+    }
+
+    function escapeHTML(html) {
+        var entityMap = {
+            "&": "&amp;",
+            "<": "&lt;",
+            ">": "&gt;",
+            '"': '&quot;',
+            "'": '&#39;',
+            "/": '&#x2F;'
+        };
+
+        return String(html).replace(/[&<>"'\/]/g, function (s) {
+            return entityMap[s];
+        });
+    }
+
+    function joinArgs(args) {
+        var html = '<b>' + args[0] + '</b>';
+        if (args[1]) {
+            html += ', <b>' + args[1] + '</b>';
+            if (args[2]) {
+                html += ', <b>' + args[2] + '</b>';
+                if (args[3]) {
+                    html += ', and more...';
+                }
+            }
+        }
+        return html;
+    }
+
+   function isStringOfObject(str) {
+        return typeof str === 'string' && str[0] === '{' && str[str.length - 1] === '}';
+    }
+
+    function isPureString(str) {
+        return typeof str === 'string' && str[0] !== '{' && str !== '(function)' && str !== '[Object]' && str !== '[Array]' && str !== 'true' && str !== 'false' && str !== 'undefined' && str !== 'unknown';
+    }
+
+    function getTimelineParamsHTML(node, onASingleLine) {
+        if (isJQuery(node)) {
+            return getJQueryHTML(node, onASingleLine);
+        } else {
+            return getNonJQueryHTML(node, onASingleLine);
+        }
+    }
+
+    function getBacktraceHTML(backtrace) {
+        var html = '';
+        var parsedBacktrace = parseBacktrace(backtrace);
+
+        if (!parsedBacktrace || parsedBacktrace.length === 0) {
+            html += '<div><div>can\'t find any backtrace :/</div></div>';
+        } else {
+            for (var i = 0 ; i < parsedBacktrace.length ; i++) {
+                html += '<div>';
+                html += '<div>' + (parsedBacktrace[i].fnName || '(anonymous function)') + '</div>';
+                html += '<div class="trace">' + getUrlLink(parsedBacktrace[i].filePath, 40) + ':' + parsedBacktrace[i].line + '</div>';
+                html += '</div>';
+            }
+        }
+
+        return html;
+    }
+
+    function parseBacktrace(str) {
+        if (!str) {
+            return null;
+        }
+
+        var out = [];
+        var splited = str.split(' / ');
+        
+        try {
+
+            splited.forEach(function(trace) {
+                var fnName = null, fileAndLine;
+
+                var withFnResult = /^([^\s\(]+) \((.+:\d+)\)$/.exec(trace);
+                if (withFnResult === null) {
+                    fileAndLine = trace;
+                } else {
+                    fnName = withFnResult[1];
+                    fileAndLine = withFnResult[2];
+                }
+
+                var fileAndLineSplit = /^(.*):(\d+)$/.exec(fileAndLine);
+                var filePath = fileAndLineSplit[1];
+                var line = fileAndLineSplit[2];
+
+                out.push({
+                    fnName: fnName,
+                    filePath: filePath,
+                    line: line
                 });
                 });
+            });
+
+        } catch(e) {
+            return null;
+        }
 
 
-                return html;
+        return out;
+    }
+
+    function getTimelineDetailsHTML(node) {
+        var html = '';
+
+        if (node.data.type != 'jQuery loaded' && node.data.type != 'jQuery version change' && !node.windowPerformance) {
+            if (node.warning || node.error) {
+                html += '<div class="icon-warning"></div>';
+            } else {
+                html += '<div class="icon-question"></div>';
+            }
+
+            html += '<div class="detailsOverlay">';
+            html += '<div class="closeBtn">✖</div>';
+
+            if (node.data.callDetails.context && node.data.callDetails.context.length === 0) {
+                html += '<h4>Called on 0 jQuery element</h4><p class="advice">Useless function call, as the jQuery object is empty.</p>';
+            } else if (node.data.type === 'jQuery - bind' && node.data.callDetails.context.length > 5) {
+                html += '<p class="advice">The .bind() method attaches the event listener to each jQuery element one by one. Using the .on() method is preferable if available (from v1.7).</p>';
+            }
+
+            if (node.data.resultsNumber === 0) {
+                html += '<p class="advice">The query returned 0 results. Could it be unused or dead code?</p>';
+            } else if (node.data.resultsNumber > 0) {
+                html += '<p>The query returned ' + node.data.resultsNumber + ' ' + (node.data.resultsNumber > 1 ? 'results' : 'result') + '.</p>';
+            }
+
+            if (node.data.backtrace) {
+                html += '<h4>Backtrace</h4>';
+                html += '<div class="table">';
+                html += getBacktraceHTML(node.data.backtrace);
+                html += '</div>';
+            }
+
+            html += '</div>';
+        }
+        
+        return html;
+    }
+
+    
+    offendersDirectives.directive('profilerLine', ['$filter', function($filter) {
+        
+        var numberWithCommas = $filter('number');
+
+        function getProfilerLineHTML(index, node) {
+            return  '<div class="index">' + (index + 1) + '</div>' +
+                    '<div class="type">' + node.data.type + (node.children ? '<div class="children">' + recursiveChildrenHTML(node) + '</div>' : '') + '</div>' +
+                    '<div class="value offenders">' + getTimelineParamsHTML(node, false) + '</div>' +
+                    '<div class="details">' + getTimelineDetailsHTML(node) + '</div>' +
+                    '<div class="startTime ' + node.data.loadingStep + '">' + numberWithCommas(node.data.timestamp, 0) + ' ms</div>';
+        }
+
+        function recursiveChildrenHTML(node) {
+            var html = '';
+            
+            if (node.children) {
+                node.children.forEach(function(child) {
+                    html += '<div class="child"><span>' + child.data.type + '<div class="childArgs">' + getTimelineParamsHTML(child, true) + '</div></span>' + recursiveChildrenHTML(child) + '</div>';
+                });
+            }
+
+            return html;
+        }
+
+        function onDetailsClick(row) {
+            // Close if it's alreay open
+            if (row.classList.contains('showDetails')) {
+                closeDetails(row);
+                return;
+            }
+
+            // Close any other open details overlay
+            var openOnes = document.getElementsByClassName('showDetails');
+            if (openOnes.length > 0) {
+                openOnes[0].classList.remove('showDetails');
             }
             }
 
 
-            element.append(recursiveHtmlBuilder(scope.tree));
+            // Make it appear
+            row.classList.add('showDetails');
+
+            // Bind the close button
+            row.querySelector('.closeBtn').addEventListener('click', function() {
+                closeDetails(row);
+            });
         }
         }
-    };
-});
 
 
-offendersDirectives.directive('domElementButton', function() {
-    return {
-        restrict: 'E',
-        scope: {
-            obj: '='
-        },
-        templateUrl: 'views/domElementButton.html',
-        replace: true
-    };
-});
+        function closeDetails(row) {
+            row.classList.remove('showDetails');
 
 
-offendersDirectives.filter('shortenUrl', function() {
-    return function(url, maxLength) {
+            // Unbind the close button
+            row.querySelector('.closeBtn').removeEventListener('click', closeDetails);   
+        }
+
+        return {
+            restrict: 'E',
+            scope: {
+                index: '=',
+                node: '='
+            },
+            template: '<div id="line_{{index}}"></div>',
+            replace: true,
+            link: function(scope, element) {
+                
+                if (scope.node.error) {
+                    element.addClass('jsError');
+                } else if (scope.node.windowPerformance) {
+                    element.addClass('windowPerformance');
+                }
+
+                element.append(getProfilerLineHTML(scope.index, scope.node));
+
+                // Bind click on the details icon
+                var detailsIcon = element[0].querySelector('.details div');
+                if (detailsIcon) {
+                    detailsIcon.addEventListener('click', function() {
+                        onDetailsClick(this.parentNode.parentNode);
+                    });
+                }
+            }
+        };
+    }]);
+
+    function shortenUrl(url, maxLength) {
         if (!maxLength) {
         if (!maxLength) {
             maxLength = 110;
             maxLength = 110;
         }
         }
@@ -54,67 +739,56 @@ offendersDirectives.filter('shortenUrl', function() {
         var rightLength = Math.ceil((maxLength - 5) / 2.1);
         var rightLength = Math.ceil((maxLength - 5) / 2.1);
 
 
         return (url.length > maxLength) ? url.substr(0, leftLength) + ' ... ' + url.substr(-rightLength) : url;
         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();
-            });
-        }
-    };
-});
+    offendersDirectives.filter('shortenUrl', function() {
+        return shortenUrl;
+    });
+
+    function getUrlLink(url, maxLength) {
+        return '<a href="' + url + '" target="_blank" title="' + url + '">' + shortenUrl(url, maxLength) + '</a>';
+    }
+
+    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><span ng-if="line !== null && column !== null"> @ {{line}}:{{column}}</span></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
+        };
+    });
+
+})();

+ 2 - 1
front/src/js/services/apiService.js

@@ -8,7 +8,8 @@ apiService.factory('API', ['$location', 'Runs', 'Results', function($location, R
             Runs.save({
             Runs.save({
                 url: url,
                 url: url,
                 waitForResponse: false,
                 waitForResponse: false,
-                screenshot: true
+                screenshot: true,
+                jsTimeline: true
             }, function(data) {
             }, function(data) {
                 $location.path('/queue/' + data.runId);
                 $location.path('/queue/' + data.runId);
             }, function(response) {
             }, function(response) {

+ 2 - 1
front/src/less/rule.less

@@ -120,6 +120,7 @@
             border-bottom-left-radius: 0.4em;
             border-bottom-left-radius: 0.4em;
             border-bottom-right-radius: 0.4em;
             border-bottom-right-radius: 0.4em;
             border-top: 1px solid #999;
             border-top: 1px solid #999;
+            z-index: 2;
         }
         }
 
 
         .domTree {
         .domTree {
@@ -140,7 +141,7 @@
             padding: 0.5em;
             padding: 0.5em;
         }
         }
 
 
-        &.opens.mouseOver {
+        &.opens:hover {
             border-bottom-left-radius: 0;
             border-bottom-left-radius: 0;
             border-bottom-right-radius: 0;
             border-bottom-right-radius: 0;
             background: #ffe0cc;
             background: #ffe0cc;

+ 94 - 17
front/src/less/timeline.less

@@ -14,6 +14,23 @@
     text-align: center;
     text-align: center;
 }
 }
 
 
+.selectScript {
+    padding-bottom: 2em;
+    font-size: 0.9em;
+
+    select {
+        max-width: 30em;
+    }
+
+    &.empty {
+        font-size: 0.8em;
+
+        select {
+            width: 10em;
+        }
+    }
+}
+
 .timeline {
 .timeline {
     margin: 2em 0 5em;
     margin: 2em 0 5em;
 }
 }
@@ -49,6 +66,10 @@
     position: absolute;
     position: absolute;
     bottom: 0;
     bottom: 0;
     width: 100%;
     width: 100%;
+
+    &.clickable {
+        cursor: pointer;
+    }
 }
 }
 .timeline div.interval:hover {
 .timeline div.interval:hover {
     background: #9C4274;
     background: #9C4274;
@@ -141,22 +162,6 @@
     text-align: left;
     text-align: left;
 }
 }
 
 
-.slowRequestsLimit {
-    width: 3em;
-    font-size: 1em;
-    text-align: right;
-    border: 1px solid #aaa;
-}
-
-input.textFilter {
-    box-shadow: none;
-    font-size: 1em;
-    padding: 0 0.2em;
-    border: 1px solid #aaa;
-    border-radius: none;
-    width: 15em;
-}
-
 .table {
 .table {
     display: table;
     display: table;
     width: 100%;
     width: 100%;
@@ -189,14 +194,82 @@ input.textFilter {
     background: #f1c40f;
     background: #f1c40f;
 }
 }
 
 
+.table > div.highlight {
+    > div.startTime {
+        background-color: #C0F090;
+    }
+}
+.table > div.highlight-remove {
+    transition: 3s;
+
+    > div.startTime {
+        transition: background-color 3s ease-in;
+    }
+}
+
 .table > div > .index {
 .table > div > .index {
     color: #bbb;
     color: #bbb;
+    word-break: normal;
 }
 }
 
 
 .table > div > .type {
 .table > div > .type {
     white-space:nowrap;
     white-space:nowrap;
 }
 }
 
 
+.table .children {
+    margin-top: 0.2em;
+    font-size: 0.8em;
+    line-height: 1.6em;
+}
+
+.table .child {
+    margin-left: 0.5em;
+
+    > .child {
+        margin-left: 1em;
+    }
+
+    &:before {
+        content: "↳";
+    }
+
+    .childArgs {
+        display: none;
+    }
+
+    span {
+        position: relative;
+    }
+
+    span:hover {
+        background: #EBD8E2;
+
+        div {
+            display: inline-block;
+        }
+
+        .childArgs {
+            display: block;
+            position: absolute;
+            padding: 0 1em 0 2em;
+            left: 100%;
+            top: 0;
+            background: #EBD8E2;
+            line-height: 1.3em;
+            height: 1.3em;
+            z-index: 1;
+        }
+    }
+}
+
+.table .showingDetails .child span:hover {
+    background: inherit;
+
+    .childArgs {
+        display: none;
+    }
+}
+
 .table > div > .value {
 .table > div > .value {
     width: 70%;
     width: 70%;
     word-break: break-all;
     word-break: break-all;
@@ -215,6 +288,7 @@ input.textFilter {
 }
 }
 
 
 .detailsOverlay {
 .detailsOverlay {
+    display: none;
     position: absolute;
     position: absolute;
     right: 3em;
     right: 3em;
     top: -3em;
     top: -3em;
@@ -224,13 +298,16 @@ input.textFilter {
     background: #fff;
     background: #fff;
     border: 2px solid #f1c40f;
     border: 2px solid #f1c40f;
     border-radius: 0.5em;
     border-radius: 0.5em;
-    z-index: 1;
+    z-index: 2;
 }
 }
 @media screen and (max-width: 1024px) {
 @media screen and (max-width: 1024px) {
     .detailsOverlay {
     .detailsOverlay {
         width: 25em;
         width: 25em;
     }
     }
 }
 }
+.showDetails .detailsOverlay {
+    display: block;
+}
 .detailsOverlay .closeBtn {
 .detailsOverlay .closeBtn {
     position: absolute;
     position: absolute;
     top: 0.5em;
     top: 0.5em;

+ 1 - 0
front/src/main.html

@@ -23,6 +23,7 @@
     <script src="/bower_components/angular-route/angular-route.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-resource/angular-resource.min.js"></script>
     <script src="/bower_components/angular-sanitize/angular-sanitize.min.js"></script>
     <script src="/bower_components/angular-sanitize/angular-sanitize.min.js"></script>
+    <script src="/bower_components/angular-animate/angular-animate.min.js"></script>
     <script src="/js/app.js"></script>
     <script src="/js/app.js"></script>
     <script src="/js/controllers/indexCtrl.js"></script>
     <script src="/js/controllers/indexCtrl.js"></script>
     <script src="/js/controllers/dashboardCtrl.js"></script>
     <script src="/js/controllers/dashboardCtrl.js"></script>

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

@@ -1,7 +0,0 @@
-<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>

+ 1 - 1
front/src/views/queue.html

@@ -26,7 +26,7 @@
 </div>
 </div>
 <div ng-if="notFound == true">
 <div ng-if="notFound == true">
     <div class="status">Error 404 (test not found)</div>
     <div class="status">Error 404 (test not found)</div>
-    <p class="statusSubMessage">The server probably rebooted. We are very sorry about that, please try to launch the test again.</p>
+    <p class="statusSubMessage">The server probably just rebooted. We are very sorry about that, please try to launch the test again.</p>
     
     
     <a class="linkButton" href="/">New test</a>
     <a class="linkButton" href="/">New test</a>
 </div>
 </div>

+ 13 - 0
front/src/views/rule.html

@@ -62,6 +62,19 @@
                         </div>
                         </div>
                     </div>
                     </div>
 
 
+                    <div ng-if="policyName === 'documentWriteCalls'">
+                        <b>{{offender.writeFn}}</b>
+                        <span ng-if="offender.from">
+                            called from
+                            <span ng-if="offender.from.functionName">{{offender.from.functionName}}()</span>
+                            <url-link url="offender.from.file" max-length="50"></url-link>
+                            line {{offender.from.line}}
+                        </span>
+                        <span ng-if="!offender.from">
+                            called from (no backtrace available)
+                        </span>
+                    </div>
+
                     <div ng-if="policyName === 'cssParsingErrors'">
                     <div ng-if="policyName === 'cssParsingErrors'">
                         <b>{{offender.error}}</b>
                         <b>{{offender.error}}</b>
                         <file-and-line file="offender.file" line="offender.line" column="offender.column"></file-and-line>
                         <file-and-line file="offender.file" line="offender.line" column="offender.column"></file-and-line>

+ 12 - 75
front/src/views/timeline.html

@@ -1,5 +1,12 @@
 <div ng-include="'views/resultSubHeader.html'"></div>
 <div ng-include="'views/resultSubHeader.html'"></div>
 <div class="execution board">
 <div class="execution board">
+    <div class="selectScript" ng-class="{empty:!selectedScript}">
+        Filter timeline and profiler by script:
+        <select ng-model="selectedScript" ng-options="script.shortPath for script in scripts" ng-change="changeScript()">
+            <option value="">All (no filter)</option>
+        </select>
+    </div>
+
     <h2>Javascript Timeline</h2>
     <h2>Javascript Timeline</h2>
     <p>This graph gives a quick view of when the Javascript interactions with the DOM occur during the loading of the page.</p>
     <p>This graph gives a quick view of when the Javascript interactions with the DOM occur during the loading of the page.</p>
 
 
@@ -18,9 +25,11 @@
                             && $index * timelineIntervalDuration < result.toolsResults.phantomas.metrics.domComplete,
                             && $index * timelineIntervalDuration < result.toolsResults.phantomas.metrics.domComplete,
                         domComplete: $index * timelineIntervalDuration >= result.toolsResults.phantomas.metrics.domComplete
                         domComplete: $index * timelineIntervalDuration >= result.toolsResults.phantomas.metrics.domComplete
                      }">
                      }">
-                    <div style="height: {{100 * duration / timelineMax | number: 0}}px" class="color"></div>
+                    <div style="height: {{100 * duration / timelineMax | number: 0}}px" class="color" ng-class="{clickable: duration > 0}" scroll-on-click="{{$index * timelineIntervalDuration}}"></div>
                     <div class="tooltip detailsOverlay">
                     <div class="tooltip detailsOverlay">
-                        <div>Timestamp: {{$index * timelineIntervalDuration | number: 0}} ms</div>
+                        <div>
+                            Timestamp: {{$index * timelineIntervalDuration | number: 0}} ms
+                        </div>
                     </div>
                     </div>
                 </div>
                 </div>
             </div>
             </div>
@@ -54,15 +63,8 @@
             <input type="checkbox" ng-model="warningsFilterOn" id="warningsFilterOn" />
             <input type="checkbox" ng-model="warningsFilterOn" id="warningsFilterOn" />
             <label for="warningsFilterOn">Show warnings only</label>
             <label for="warningsFilterOn">Show warnings only</label>
         </div>
         </div>
-        <div>
-            <input type="checkbox" ng-model="textFilterOn" />
-            Filter by
-            <input type="text" ng-model="textFilter" placeholder="search..." class="textFilter" ng-change="textFilterOn = true" />
-        </div>
     </div>
     </div>
     <div class="table">
     <div class="table">
-
-        <toto data-info="mavariable"></toto>
         <div class="headers">
         <div class="headers">
             <div><!-- index --></div>
             <div><!-- index --></div>
             <div>Type</div>
             <div>Type</div>
@@ -70,74 +72,9 @@
             <div><!-- details --></div>
             <div><!-- details --></div>
             <div>Timestamp</div>
             <div>Timestamp</div>
         </div>
         </div>
-        <div ng-if="(!warningsFilterOn || node.warning || node.error) && (!textFilterOn || !textFilter.length || node.searchIndex.indexOf(textFilter) >= 0)"
-             ng-repeat="node in profilerData" ng-class="{
-                showingDetails: node.showDetails,
-                jsError: node.error,
-                windowPerformance: node.windowPerformance
-            }">
-
-            <div class="index">{{$index + 1}}</div>
-            <div class="type">{{node.data.type}}</div>
-
-            <div class="value">
-                {{node.data.callDetails.arguments[0]}}
-                <span ng-if="node.data.callDetails.arguments.length > 1"> : {{node.data.callDetails.arguments[1]}}</span>
-                <span ng-if="node.data.callDetails.arguments.length > 2"> : {{node.data.callDetails.arguments[2]}}</span>
-                <span ng-if="node.data.callDetails.arguments.length > 3"> : {{node.data.callDetails.arguments[3]}}</span>
-            </div>
-            
-            <div class="details">
-                <div ng-class="{'icon-question': !node.warning && !node.error, 'icon-warning': node.warning || node.error}"
-                     ng-click="onNodeDetailsClick(node)"
-                     ng-if="node.data.type != 'jQuery loaded' && node.data.type != 'jQuery version change' && !node.windowPerformance"></div>
-                
-                <div class="detailsOverlay" ng-if="node.showDetails">
-                    <div class="closeBtn" ng-click="onNodeDetailsClick(node)">✖</div>
-
-                    <div ng-if="node.data.callDetails.context.domElement">
-                        <h4>Called on DOM element</h4>
-                        <div>{{node.data.callDetails.context.domElement}}</div>
-                    </div>
 
 
-                    <div ng-if="node.data.callDetails.context.length === 0">
-                        <h4>Called on 0 jQuery element</h4>
-                        <p class="advice">Useless function call, as the jQuery object is empty.</p>
-                    </div>
-
-                    <div ng-if="node.data.callDetails.context.length == 1 && node.data.callDetails.context.firstElementPath">
-                        <h4>Called on 1 jQuery element</h4>
-                        <div>{{node.data.callDetails.context.firstElementPath}}</div>
-                    </div>
-
-                    <div ng-if="node.data.callDetails.context.length > 1">
-                        <h4>Called on {{node.data.callDetails.context.length}} jQuery elements</h4>
-                        <p class="advice" ng-if="node.data.type == 'jQuery - bind' && node.data.callDetails.context.length > 5">
-                            The .bind() method attaches the event listener to each jQuery element one by one. Using the .on() method is preferable if available (from v1.7).
-                        </p>
-                        <p ng-if="node.data.callDetails.context.firstElementPath"><b>First one is:</b> {{node.data.callDetails.context.firstElementPath}}</p>
-                    </div>
-
-                    <p class="advice" ng-if="node.data.resultsNumber === 0">
-                        The query returned 0 results. Could it be unused or dead code?
-                    </p>
+        <profiler-line ng-repeat="node in profilerData" data-index="$index" node="node"></profiler-line>
 
 
-                    <div ng-if="node.parsedBacktrace">
-                        <h4>Backtrace</h4>
-                        <div class="table">
-                            <div ng-repeat="trace in node.parsedBacktrace track by $index">
-                                <div>{{trace.fnName || '(anonymous)'}}</div>
-                                <div class="trace"><a href="{{trace.filePath}}" title="{{trace.filePath}}" target="_blank">{{trace.fileName || 'HTML'}}</a>:{{trace.line}}</div>
-                            </div>
-                            <div ng-if="node.parsedBacktrace.length == 0 && node.data.type != 'script loaded'">
-                                <div>can't find any backtrace :/</div>
-                            </div>
-                        </div>
-                    </div>
-                </div>
-            </div>
-            <div class="startTime" ng-class="node.data.loadingStep">{{node.data.timestamp | number: 0}} ms</div>
-        </div>
     </div>
     </div>
 
 
     <div class="backToDashboard"><a href="#" ng-click="backToDashboard()">Back to dashboard</a></div>
     <div class="backToDashboard"><a href="#" ng-click="backToDashboard()">Back to dashboard</a></div>

+ 43 - 1
lib/metadata/policies.js

@@ -242,7 +242,49 @@ var policies = {
         "isOkThreshold": 0,
         "isOkThreshold": 0,
         "isBadThreshold": 10,
         "isBadThreshold": 10,
         "isAbnormalThreshold": 20,
         "isAbnormalThreshold": 20,
-        "hasOffenders": false
+        "hasOffenders": true,
+        "offendersTransformFn": function(offenders) {
+            return {
+                count: offenders.length,
+                list: offenders.map(function(offender) {
+                    var parts = /^document.write(ln)?\(\) used from (.*)$/.exec(offender);
+
+                    if (parts) {
+
+                        var writeFn = 'document.write' + (parts[1] || '');
+
+                        var methodParts = /^([^\s]+) \((.+):(\d+)\)$/.exec(parts[2]);
+                        if (methodParts) {
+                            return {
+                                writeFn: writeFn,
+                                from: {
+                                    functionName: methodParts[1],
+                                    file: methodParts[2],
+                                    line: methodParts[3]
+                                }
+                            };
+                        } else {
+                            var noMethodParts = /^(.+):(\d+)$/.exec(parts[2]);
+
+                            if (noMethodParts) {
+                                return {
+                                    writeFn: writeFn,
+                                    from: {
+                                        file: noMethodParts[1],
+                                        line: noMethodParts[2]
+                                    }
+                                };
+                            }
+                        }
+                    }
+
+                    debug('documentWriteCalls offenders transform function error with "%s"', offender);
+                    return {
+                        parseError: offender
+                    };
+                })
+            };
+        }
     },
     },
     "consoleMessages": {
     "consoleMessages": {
         "tool": "phantomas",
         "tool": "phantomas",

+ 4 - 3
lib/metadata/scoreProfileGeneric.json

@@ -91,9 +91,10 @@
         "network": {
         "network": {
             "label": "Network",
             "label": "Network",
             "policies": {
             "policies": {
-                "notFound": 3,
-                "closedConnections": 3,
-                "multipleRequests": 3,
+                "notFound": 2,
+                "closedConnections": 2,
+                "multipleRequests": 2,
+                "cachingNotSpecified": 1,
                 "cachingDisabled": 1,
                 "cachingDisabled": 1,
                 "cachingTooShort": 1,
                 "cachingTooShort": 1,
                 "domains": 1
                 "domains": 1

+ 9 - 0
lib/offendersHelpers.js

@@ -32,6 +32,15 @@ var OffendersHelpers = function() {
     };
     };
 
 
     this.domPathToDomElementObj = function(domPath) {
     this.domPathToDomElementObj = function(domPath) {
+
+        if (typeof domPath === 'boolean') {
+            return {
+                // Not a normal element path
+                type: 'notAnElement',
+                element: domPath
+            };
+        }
+
         var domArray = this.domPathToArray(domPath);
         var domArray = this.domPathToArray(domPath);
         var domTree = this.listOfDomArraysToTree([this.domPathToArray(domPath)]);
         var domTree = this.listOfDomArraysToTree([this.domPathToArray(domPath)]);
 
 

+ 1 - 1
lib/screenshotHandler.js

@@ -11,7 +11,7 @@ var screenshotHandler = function() {
     this.getScreenshotTempFile = function() {
     this.getScreenshotTempFile = function() {
         
         
         var screenshotTmpFolder = new tmp.Dir();
         var screenshotTmpFolder = new tmp.Dir();
-        var tmpFilePath = path.join(screenshotTmpFolder.path, 'screenshot.jpg');
+        var tmpFilePath = path.join(screenshotTmpFolder.path, 'screenshot.png');
         var that = this;
         var that = this;
         
         
         return {
         return {

+ 13 - 2
lib/server/controllers/apiController.js

@@ -32,7 +32,8 @@ var ApiController = function(app) {
                 url: req.body.url,
                 url: req.body.url,
                 waitForResponse: req.body.waitForResponse !== false && req.body.waitForResponse !== 'false' && req.body.waitForResponse !== 0,
                 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
+                screenshot: req.body.screenshot || false,
+                jsTimeline: req.body.jsTimeline || false
             }
             }
         };
         };
 
 
@@ -64,7 +65,8 @@ var ApiController = function(app) {
             debug('Launching test %s on %s', run.runId, run.params.url);
             debug('Launching test %s on %s', run.runId, run.params.url);
 
 
             var runOptions = {
             var runOptions = {
-                screenshot: run.params.screenshot ? screenshot.getTmpFilePath() : false
+                screenshot: run.params.screenshot ? screenshot.getTmpFilePath() : false,
+                jsDeepAnalysis: run.params.jsTimeline
             };
             };
 
 
             return ylt(run.params.url, runOptions);
             return ylt(run.params.url, runOptions);
@@ -107,7 +109,16 @@ var ApiController = function(app) {
 
 
                 // Save results
                 // Save results
                 .then(function() {
                 .then(function() {
+                    // Remove uneeded temp screenshot path
                     delete data.params.options.screenshot;
                     delete data.params.options.screenshot;
+
+                    // Empty javascriptExecutionTree if not needed
+                    if (!run.params.jsTimeline) {
+                        data.javascriptExecutionTree = {};
+                    }
+
+                    // Remove tools results if not needed
+
                     return resultsDatastore.saveResult(data);
                     return resultsDatastore.saveResult(data);
                 })
                 })
 
 

+ 27 - 0
lib/tools/jsExecutionTransformer.js

@@ -1,5 +1,7 @@
 var debug = require('debug')('ylt:jsExecutionTransformer');
 var debug = require('debug')('ylt:jsExecutionTransformer');
 
 
+var offendersHelpers = require('../offendersHelpers');
+
 var jsExecutionTransformer = function() {
 var jsExecutionTransformer = function() {
 
 
     this.transform = function(data) {
     this.transform = function(data) {
@@ -46,6 +48,22 @@ var jsExecutionTransformer = function() {
                             data.toolsResults.phantomas.metrics.domComplete = node.data.timestamp;
                             data.toolsResults.phantomas.metrics.domComplete = node.data.timestamp;
                             break;
                             break;
                     }
                     }
+
+                    // Change the list of dom paths into a tree
+                    treeRecursiveParser(node, function(node) {
+                        
+                        if (node.data.callDetails && node.data.callDetails.context && node.data.callDetails.context.length > 0) {
+                            node.data.callDetails.context.elements = node.data.callDetails.context.elements.map(offendersHelpers.domPathToDomElementObj, offendersHelpers);
+                        }
+
+                        if (node.data.type === 'appendChild' || node.data.type === 'insertBefore') {
+                            node.data.callDetails.arguments[0] = offendersHelpers.domPathToDomElementObj(node.data.callDetails.arguments[0]);
+                        }
+
+                        if (node.data.type === 'insertBefore') {
+                            node.data.callDetails.arguments[1] = offendersHelpers.domPathToDomElementObj(node.data.callDetails.arguments[1]);
+                        }
+                    });
                 });
                 });
             }
             }
 
 
@@ -57,6 +75,15 @@ var jsExecutionTransformer = function() {
 
 
         return javascriptExecutionTree;
         return javascriptExecutionTree;
     };
     };
+
+    function treeRecursiveParser(node, fn) {
+        if (node.children) {
+            node.children.forEach(function(child) {
+                treeRecursiveParser(child, fn);
+            });
+        }
+        fn(node);
+    }
 };
 };
 
 
 module.exports = new jsExecutionTransformer();
 module.exports = new jsExecutionTransformer();

+ 1 - 1
lib/tools/phantomas/custom_modules/core/scopeYLT/scopeYLT.js

@@ -36,7 +36,7 @@ exports.module = function(phantomas) {
                     phantomas.log('Overwritting phantomas spy function');
                     phantomas.log('Overwritting phantomas spy function');
 
 
                     function spy(obj, fn, callbackBefore, callbackAfter) {
                     function spy(obj, fn, callbackBefore, callbackAfter) {
-                        var origFn = obj[fn];
+                        var origFn = obj && obj[fn];
 
 
                         if (typeof origFn !== 'function') {
                         if (typeof origFn !== 'function') {
                             return false;
                             return false;

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

@@ -1,79 +0,0 @@
-/**
- * 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');
-            }
-        }
-    });
-};

+ 64 - 11
lib/tools/phantomas/custom_modules/modules/domQYLT/domQYLT.js

@@ -33,9 +33,6 @@ exports.module = function(phantomas) {
                     phantomas.enterContext({
                     phantomas.enterContext({
                         type: 'getElementById',
                         type: 'getElementById',
                         callDetails: {
                         callDetails: {
-                            context: {
-                                domElement: '#document'
-                            },
                             arguments: ['#' + id]
                             arguments: ['#' + id]
                         },
                         },
                         backtrace: phantomas.getBacktrace()
                         backtrace: phantomas.getBacktrace()
@@ -65,7 +62,8 @@ exports.module = function(phantomas) {
                         type: 'getElementsByClassName',
                         type: 'getElementsByClassName',
                         callDetails: {
                         callDetails: {
                             context: {
                             context: {
-                                domElement: context
+                                length: 1,
+                                elements: [context]
                             },
                             },
                             arguments: ['.' + className]
                             arguments: ['.' + className]
                         },
                         },
@@ -103,7 +101,8 @@ exports.module = function(phantomas) {
                         type: 'getElementsByTagName',
                         type: 'getElementsByTagName',
                         callDetails: {
                         callDetails: {
                             context: {
                             context: {
-                                domElement: context
+                                length: 1,
+                                elements: [context]
                             },
                             },
                             arguments: [tagName]
                             arguments: [tagName]
                         },
                         },
@@ -145,7 +144,8 @@ exports.module = function(phantomas) {
                         type: 'querySelector',
                         type: 'querySelector',
                         callDetails: {
                         callDetails: {
                             context: {
                             context: {
-                                domElement: context
+                                length: 1,
+                                elements: [context]
                             },
                             },
                             arguments: [selector]
                             arguments: [selector]
                         },
                         },
@@ -177,7 +177,8 @@ exports.module = function(phantomas) {
                         type: 'querySelectorAll',
                         type: 'querySelectorAll',
                         callDetails: {
                         callDetails: {
                             context: {
                             context: {
-                                domElement: context
+                                length: 1,
+                                elements: [context]
                             },
                             },
                             arguments: [selector]
                             arguments: [selector]
                         },
                         },
@@ -237,7 +238,8 @@ exports.module = function(phantomas) {
                         type: 'appendChild',
                         type: 'appendChild',
                         callDetails: {
                         callDetails: {
                             context: {
                             context: {
-                                domElement: context
+                                length: 1,
+                                elements: [context]
                             },
                             },
                             arguments: [appended]
                             arguments: [appended]
                         },
                         },
@@ -245,20 +247,25 @@ exports.module = function(phantomas) {
                     });
                     });
                 }
                 }
 
 
-                function insertBeforeSpyBefore(child) {
+                function insertBeforeSpyBefore(child, refElement) {
                     /*jshint validthis: true */
                     /*jshint validthis: true */
                     
                     
                     var context = phantomas.getDOMPath(this);
                     var context = phantomas.getDOMPath(this);
                     var appended = phantomas.getDOMPath(child);
                     var appended = phantomas.getDOMPath(child);
+                    var referent = phantomas.getDOMPath(refElement);
                     appendChild(child, this, context, appended);
                     appendChild(child, this, context, appended);
 
 
                     phantomas.enterContext({
                     phantomas.enterContext({
                         type: 'insertBefore',
                         type: 'insertBefore',
                         callDetails: {
                         callDetails: {
                             context: {
                             context: {
-                                domElement: context
+                                length: 1,
+                                elements: [context]
                             },
                             },
-                            arguments: [appended]
+                            arguments: [
+                                appended,
+                                referent
+                            ]
                         },
                         },
                         backtrace: phantomas.getBacktrace()
                         backtrace: phantomas.getBacktrace()
                     });
                     });
@@ -270,6 +277,52 @@ exports.module = function(phantomas) {
                 phantomas.spy(Node.prototype, 'insertBefore', insertBeforeSpyBefore, function(result) {
                 phantomas.spy(Node.prototype, 'insertBefore', insertBeforeSpyBefore, function(result) {
                     phantomas.leaveContext();
                     phantomas.leaveContext();
                 });
                 });
+
+
+                phantomas.spy(Document.prototype, 'createElement', function(tagName) {
+                    
+                    phantomas.enterContext({
+                        type: 'createElement',
+                        callDetails: {
+                            arguments: [tagName]
+                        },
+                        backtrace: phantomas.getBacktrace()
+                    });
+
+                }, function(result, args) {
+                    phantomas.leaveContext();
+                });
+
+
+                phantomas.spy(Document.prototype, 'createTextNode', function(text) {
+                    
+                    phantomas.enterContext({
+                        type: 'createTextNode',
+                        callDetails: {
+                            arguments: [text]
+                        },
+                        backtrace: phantomas.getBacktrace()
+                    });
+
+                }, function(result, args) {
+                    phantomas.leaveContext();
+                });
+
+
+                phantomas.spy(Document.prototype, 'createDocumentFragment', function() {
+                    
+                    phantomas.enterContext({
+                        type: 'createDocumentFragment',
+                        callDetails: {
+                            arguments: []
+                        },
+                        backtrace: phantomas.getBacktrace()
+                    });
+
+                }, function(result, args) {
+                    phantomas.leaveContext();
+                });
+
             })(window.__phantomas);
             })(window.__phantomas);
         });
         });
     });
     });

+ 2 - 1
lib/tools/phantomas/custom_modules/modules/eventListYLT/eventListYLT.js

@@ -27,7 +27,8 @@ exports.module = function(phantomas) {
                         type: 'addEventListener',
                         type: 'addEventListener',
                         callDetails: {
                         callDetails: {
                             context: {
                             context: {
-                                domElement: path
+                                length: 1,
+                                elements: [path]
                             },
                             },
                             arguments: [eventType]
                             arguments: [eventType]
                         },
                         },

+ 93 - 25
lib/tools/phantomas/custom_modules/modules/jQYLT/jQYLT.js

@@ -17,7 +17,7 @@ exports.module = function(phantomas) {
     phantomas.setMetric('jQueryOnDOMReadyFunctions'); // @desc number of functions bound to onDOMReady event
     phantomas.setMetric('jQueryOnDOMReadyFunctions'); // @desc number of functions bound to onDOMReady event
     phantomas.setMetric('jQueryWindowOnLoadFunctions'); // @desc number of functions bound to windowOnLoad 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('jQuerySizzleCalls'); // @desc number of calls to Sizzle (including those that will be resolved using querySelectorAll)
-    //phantomas.setMetric('jQueryEventTriggers'); // @desc number of jQuery event triggers
+    phantomas.setMetric('jQueryEventTriggers'); // @desc number of jQuery event triggers
 
 
     var jQueryFunctions = [
     var jQueryFunctions = [
         // DOM manipulations
         // DOM manipulations
@@ -55,7 +55,20 @@ exports.module = function(phantomas) {
         'scrollLeft',
         'scrollLeft',
         'scrollTop',
         'scrollTop',
 
 
-        // generic events
+        // Animations
+        'hide',
+        'show',
+        'toggle',
+        'animate',
+        'fadeIn',
+        'fadeOut',
+        'fadeTo',
+        'fadeToggle',
+        'slideDown',
+        'slideUp',
+        'slideToggle',
+
+        // Generic events
         'on',
         'on',
         'off',
         'off',
         'live',
         'live',
@@ -66,7 +79,7 @@ exports.module = function(phantomas) {
         'bind',
         'bind',
         'unbind',
         'unbind',
 
 
-        // more events
+        // More events
         'blur',
         'blur',
         'change',
         'change',
         'click',
         'click',
@@ -91,10 +104,9 @@ exports.module = function(phantomas) {
         'scroll',
         'scroll',
         'select',
         'select',
         'submit',
         'submit',
-        'toggle',
         'unload',
         'unload',
 
 
-        // attributes
+        // Attributes
         'attr',
         'attr',
         'prop',
         'prop',
         'removeAttr',
         'removeAttr',
@@ -103,12 +115,31 @@ exports.module = function(phantomas) {
         'hasClass',
         'hasClass',
         'addClass',
         'addClass',
         'removeClass',
         'removeClass',
-        'toggleClass'
+        'toggleClass',
+    ];
+
+    var jQueryTraversalFunctions = [
+        'children',
+        'closest',
+        'find',
+        'next',
+        'nextAll',
+        'nextUntil',
+        'offsetParent',
+        'parent',
+        'parents',
+        'parentsUntil',
+        'prev',
+        'prevAll',
+        'prevUntil',
+        'siblings'
     ];
     ];
 
 
+    jQueryFunctions = jQueryFunctions.concat(jQueryTraversalFunctions);
+
     // spy calls to jQuery functions
     // spy calls to jQuery functions
     phantomas.once('init', function() {
     phantomas.once('init', function() {
-        phantomas.evaluate(function(jQueryFunctions) {
+        phantomas.evaluate(function(jQueryFunctions, jQueryTraversalFunctions) {
             (function(phantomas) {
             (function(phantomas) {
                 var jQuery;
                 var jQuery;
                 var oldJQuery;
                 var oldJQuery;
@@ -166,11 +197,11 @@ exports.module = function(phantomas) {
                         phantomas.addOffender('jQuerySizzleCalls', '%s (in %s)', selector, (phantomas.getDOMPath(context) || 'unknown'));
                         phantomas.addOffender('jQuerySizzleCalls', '%s (in %s)', selector, (phantomas.getDOMPath(context) || 'unknown'));
                         
                         
                         phantomas.enterContext({
                         phantomas.enterContext({
-                            type: 'jQuery - find',
+                            type: 'jQuery - Sizzle call',
                             callDetails: {
                             callDetails: {
                                 context: {
                                 context: {
-                                    length: this.length,
-                                    firstElementPath: phantomas.getDOMPath(context)
+                                    length: 1,
+                                    elements: [phantomas.getDOMPath(context)]
                                 },
                                 },
                                 arguments: [selector]
                                 arguments: [selector]
                             },
                             },
@@ -184,7 +215,37 @@ exports.module = function(phantomas) {
                         phantomas.leaveContext(moreData);
                         phantomas.leaveContext(moreData);
                     }) || phantomas.log('jQuery: can not measure jQuerySizzleCalls (jQuery used on the page is too old)!');
                     }) || phantomas.log('jQuery: can not measure jQuerySizzleCalls (jQuery used on the page is too old)!');
 
 
-                    /*if (!jQuery.event) {
+
+                    phantomas.spy(jQuery.fn, 'init', function(selector, context) {
+                        if (typeof selector === 'string' && /^#([\w\-]*)$/.exec(selector) !== null && !context) {
+
+                            phantomas.enterContext({
+                                type: 'jQuery - find',
+                                callDetails: {
+                                    arguments: [selector]
+                                },
+                                backtrace: phantomas.getBacktrace()
+                            });
+
+                        }
+
+                    }, function(result) {
+                        var data = phantomas.getContextData();
+
+                        if (data.type === 'jQuery - find' &&
+                                !data.callDetails.context &&
+                                data.callDetails.arguments.length === 1 &&
+                                /^#([\w\-]*)$/.exec(data.callDetails.arguments[0]) !== null) {
+
+                            var moreData = {
+                                resultsNumber : (result && result.length) ? result.length : 0
+                            };
+                            phantomas.leaveContext(moreData);
+                        }
+                    });
+
+
+                    if (!jQuery.event) {
                         phantomas.spy(jQuery.event, 'trigger', function(ev, data, elem) {
                         phantomas.spy(jQuery.event, 'trigger', function(ev, data, elem) {
                             var path = phantomas.getDOMPath(elem),
                             var path = phantomas.getDOMPath(elem),
                                 type = ev.type || ev;
                                 type = ev.type || ev;
@@ -194,27 +255,18 @@ exports.module = function(phantomas) {
                             phantomas.incrMetric('jQueryEventTriggers');
                             phantomas.incrMetric('jQueryEventTriggers');
                             phantomas.addOffender('jQueryEventTriggers', '"%s" on "%s"', type, path);
                             phantomas.addOffender('jQueryEventTriggers', '"%s" on "%s"', type, path);
                         });
                         });
-                    }*/
+                    }
 
 
                     // jQuery events bound to window' onLoad event (#451)
                     // jQuery events bound to window' onLoad event (#451)
                     phantomas.spy(jQuery.fn, 'on', function(eventName, func) {
                     phantomas.spy(jQuery.fn, 'on', function(eventName, func) {
                         if ((eventName === 'load') && (this[0] === window)) {
                         if ((eventName === 'load') && (this[0] === window)) {
                             phantomas.incrMetric('jQueryWindowOnLoadFunctions');
                             phantomas.incrMetric('jQueryWindowOnLoadFunctions');
                             phantomas.addOffender('jQueryWindowOnLoadFunctions', phantomas.getCaller(2));
                             phantomas.addOffender('jQueryWindowOnLoadFunctions', phantomas.getCaller(2));
-
-                            phantomas.pushContext({
-                                type: 'jQuery - windowOnLoad',
-                                callDetails: {
-                                    arguments: [func]
-                                },
-                                backtrace: phantomas.getBacktrace()
-                            });
                         }
                         }
                     });
                     });
 
 
                     // Add spys on many jQuery functions
                     // Add spys on many jQuery functions
                     jQueryFunctions.forEach(function(functionName) {
                     jQueryFunctions.forEach(function(functionName) {
-                        var capitalizedName = functionName.substring(0,1).toUpperCase() + functionName.substring(1);
                         
                         
                         phantomas.spy(jQuery.fn, functionName, function(args) {
                         phantomas.spy(jQuery.fn, functionName, function(args) {
 
 
@@ -222,6 +274,11 @@ exports.module = function(phantomas) {
                             args = [].slice.call(arguments);
                             args = [].slice.call(arguments);
                             args.forEach(function(arg, index) {
                             args.forEach(function(arg, index) {
                                 
                                 
+                                
+                                if (arg instanceof Array) {
+                                    arg = '[Array]';
+                                }
+
                                 if (arg instanceof Object) {
                                 if (arg instanceof Object) {
                                     
                                     
                                     if (arg instanceof jQuery || (arg.jquery && arg.jquery.length > 0)) {
                                     if (arg instanceof jQuery || (arg.jquery && arg.jquery.length > 0)) {
@@ -274,13 +331,17 @@ exports.module = function(phantomas) {
                                 args[index] = arg;
                                 args[index] = arg;
                             });
                             });
 
 
+                            var elements = [];
+                            for (var i = 0 ; i < this.length ; i++) {
+                                elements.push(phantomas.getDOMPath(this[i]));
+                            }
 
 
                             phantomas.enterContext({
                             phantomas.enterContext({
                                 type: 'jQuery - ' + functionName,
                                 type: 'jQuery - ' + functionName,
                                 callDetails: {
                                 callDetails: {
                                     context: {
                                     context: {
                                         length: this.length,
                                         length: this.length,
-                                        firstElementPath: phantomas.getDOMPath(this[0])
+                                        elements: elements
                                     },
                                     },
                                     arguments: args
                                     arguments: args
                                 },
                                 },
@@ -288,12 +349,19 @@ exports.module = function(phantomas) {
                             });
                             });
 
 
                         }, function(result) {
                         }, function(result) {
-                            phantomas.leaveContext();
-                        }) || phantomas.log('jQuery: can not track jQuery - ' + capitalizedName + ' (this version of jQuery doesn\'t support it)');
+                            if (jQueryTraversalFunctions.indexOf(functionName) >= 0) {
+                                var moreData = {
+                                    resultsNumber : (result && result.length) ? result.length : 0
+                                };
+                                phantomas.leaveContext(moreData);
+                            } else {
+                                phantomas.leaveContext();
+                            }
+                        }) || phantomas.log('jQuery: can not track jQuery - ' + functionName + ' (this version of jQuery doesn\'t support it)');
                     });
                     });
                 });
                 });
             })(window.__phantomas);
             })(window.__phantomas);
-        }, jQueryFunctions);
+        }, jQueryFunctions, jQueryTraversalFunctions);
     });
     });
 
 
 
 

+ 25 - 13
lib/tools/phantomas/custom_modules/modules/javaScriptBottleYLT/javaScriptBottleYLT.js

@@ -4,44 +4,56 @@
  * @see http://www.nczonline.net/blog/2013/06/25/eval-isnt-evil-just-misunderstood/
  * @see http://www.nczonline.net/blog/2013/06/25/eval-isnt-evil-just-misunderstood/
  * @see http://www.quirksmode.org/blog/archives/2005/06/three_javascrip_1.html
  * @see http://www.quirksmode.org/blog/archives/2005/06/three_javascrip_1.html
  * @see http://www.stevesouders.com/blog/2012/04/10/dont-docwrite-scripts/
  * @see http://www.stevesouders.com/blog/2012/04/10/dont-docwrite-scripts/
+ *
+ * Run phantomas with --spy-eval to count eval() calls (see issue #467)
  */
  */
 /* global document: true, window: true */
 /* global document: true, window: true */
 
 
-exports.version = '0.1.a';
+exports.version = '0.2';
 
 
 exports.module = function(phantomas) {
 exports.module = function(phantomas) {
     'use strict';
     'use strict';
+    
+    phantomas.setMetric('documentWriteCalls'); //@desc number of calls to either document.write or document.writeln @offenders
+    phantomas.setMetric('evalCalls'); // @desc number of calls to eval (either direct or via setTimeout / setInterval) @offenders
 
 
-    phantomas.setMetric('documentWriteCalls'); //@desc number of calls to either document.write or document.writeln
-    phantomas.setMetric('evalCalls'); // @desc number of calls to eval (either direct or via setTimeout / setInterval)
+    // spy calls to eval only when requested (issue #467)
+    var spyEval = phantomas.getParam('spy-eval') === true;
+    if (!spyEval) {
+        phantomas.log('javaScriptBottlenecks: to spy calls to eval() run phantomas with --spy-eval option');
+    }
 
 
     phantomas.once('init', function() {
     phantomas.once('init', function() {
-        phantomas.evaluate(function() {
+        phantomas.evaluate(function(spyEval) {
             (function(phantomas) {
             (function(phantomas) {
                 function report(msg, caller, backtrace, metric) {
                 function report(msg, caller, backtrace, metric) {
                     phantomas.log(msg + ': from ' + caller + '!');
                     phantomas.log(msg + ': from ' + caller + '!');
                     phantomas.log('Backtrace: ' + backtrace);
                     phantomas.log('Backtrace: ' + backtrace);
+
                     phantomas.incrMetric(metric);
                     phantomas.incrMetric(metric);
+                    phantomas.addOffender(metric, "%s from %s", msg, caller);
                 }
                 }
 
 
                 // spy calls to eval()
                 // spy calls to eval()
-                /*phantomas.spy(window, 'eval', function(code) {
-                    report('eval() called directly', phantomas.getCaller(), phantomas.getBacktrace(), 'evalCalls');
-                    phantomas.log('eval\'ed code: ' + (code || '').substring(0, 150) + '(...)');
-                });*/
+                if (spyEval) {
+                    phantomas.spy(window, 'eval', function(code) {
+                        report('eval() called directly', phantomas.getCaller(), phantomas.getBacktrace(), 'evalCalls');
+                        phantomas.log('eval\'ed code: ' + (code || '').substring(0, 150) + '(...)');
+                    });
+                }
 
 
                 // spy calls to setTimeout / setInterval with string passed instead of a function
                 // spy calls to setTimeout / setInterval with string passed instead of a function
-                /*phantomas.spy(window, 'setTimeout', function(fn, interval) {
+                phantomas.spy(window, 'setTimeout', function(fn, interval) {
                     if (typeof fn !== 'string') return;
                     if (typeof fn !== 'string') return;
 
 
                     report('eval() called via setTimeout("' + fn + '")', phantomas.getCaller(), phantomas.getBacktrace(), 'evalCalls');
                     report('eval() called via setTimeout("' + fn + '")', phantomas.getCaller(), phantomas.getBacktrace(), 'evalCalls');
-                });*/
+                });
 
 
-                /*phantomas.spy(window, 'setInterval', function(fn, interval) {
+                phantomas.spy(window, 'setInterval', function(fn, interval) {
                     if (typeof fn !== 'string') return;
                     if (typeof fn !== 'string') return;
 
 
                     report('eval() called via setInterval("' + fn + '")', phantomas.getCaller(), phantomas.getBacktrace(), 'evalCalls');
                     report('eval() called via setInterval("' + fn + '")', phantomas.getCaller(), phantomas.getBacktrace(), 'evalCalls');
-                });*/
+                });
 
 
                 // spy document.write(ln)
                 // spy document.write(ln)
                 phantomas.spy(document, 'write', function(arg) {
                 phantomas.spy(document, 'write', function(arg) {
@@ -52,6 +64,6 @@ exports.module = function(phantomas) {
                     report('document.writeln() used', phantomas.getCaller(), phantomas.getBacktrace(), 'documentWriteCalls');
                     report('document.writeln() used', phantomas.getCaller(), phantomas.getBacktrace(), 'documentWriteCalls');
                 });
                 });
             })(window.__phantomas);
             })(window.__phantomas);
-        });
+        }, spyEval);
     });
     });
 };
 };

+ 0 - 2
lib/tools/phantomas/phantomasWrapper.js

@@ -29,14 +29,12 @@ var PhantomasWrapper = function() {
             'js-deep-analysis': task.options.jsDeepAnalysis || false,
             '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',
             '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,
             'screenshot': task.options.screenshot || false,
-            'viewport': '1366x768',
 
 
             // Mandatory
             // Mandatory
             'reporter': 'json:pretty',
             'reporter': 'json:pretty',
             'analyze-css': true,
             'analyze-css': true,
             'skip-modules': [
             'skip-modules': [
                 'blockDomains', // not needed
                 'blockDomains', // not needed
-                'caching', // overriden
                 'domMutations', // not compatible with webkit
                 'domMutations', // not compatible with webkit
                 'domQueries', // overriden
                 'domQueries', // overriden
                 'eventListeners', // overridden
                 'eventListeners', // overridden

+ 22 - 22
package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "yellowlabtools",
   "name": "yellowlabtools",
-  "version": "1.2.0",
+  "version": "1.3.0",
   "description": "Online tool to audit a webpage for performance and front-end quality issues",
   "description": "Online tool to audit a webpage for performance and front-end quality issues",
   "repository": {
   "repository": {
     "type": "git",
     "type": "git",
@@ -12,45 +12,45 @@
   "main": "./lib/index.js",
   "main": "./lib/index.js",
   "dependencies": {
   "dependencies": {
     "async": "~0.9.0",
     "async": "~0.9.0",
-    "body-parser": "~1.10.0",
-    "compression": "~1.2.2",
-    "cors": "^2.5.2",
-    "debug": "~2.1.0",
-    "express": "~4.10.6",
+    "body-parser": "~1.12.1",
+    "compression": "~1.4.3",
+    "cors": "^2.5.3",
+    "debug": "~2.1.3",
+    "express": "~4.12.2",
     "lwip": "0.0.6",
     "lwip": "0.0.6",
     "meow": "^3.0.0",
     "meow": "^3.0.0",
-    "phantomas": "1.9.0",
+    "phantomas": "1.10.0",
     "ps-node": "0.0.3",
     "ps-node": "0.0.3",
     "q": "~1.1.2",
     "q": "~1.1.2",
-    "rimraf": "~2.2.8",
+    "rimraf": "~2.3.2",
     "temporary": "0.0.8"
     "temporary": "0.0.8"
   },
   },
   "devDependencies": {
   "devDependencies": {
-    "chai": "^1.10.0",
+    "chai": "^2.1.2",
     "grunt": "^0.4.5",
     "grunt": "^0.4.5",
     "grunt-blanket": "^0.0.8",
     "grunt-blanket": "^0.0.8",
     "grunt-contrib-clean": "^0.6.0",
     "grunt-contrib-clean": "^0.6.0",
-    "grunt-contrib-concat": "^0.5.0",
-    "grunt-contrib-copy": "^0.7.0",
-    "grunt-contrib-cssmin": "^0.11.0",
-    "grunt-contrib-htmlmin": "^0.3.0",
-    "grunt-contrib-jshint": "^0.10.0",
-    "grunt-contrib-less": "^0.12.0",
-    "grunt-contrib-uglify": "^0.7.0",
-    "grunt-env": "^0.4.2",
+    "grunt-contrib-concat": "^0.5.1",
+    "grunt-contrib-copy": "^0.8.0",
+    "grunt-contrib-cssmin": "^0.12.2",
+    "grunt-contrib-htmlmin": "^0.4.0",
+    "grunt-contrib-jshint": "^0.11.0",
+    "grunt-contrib-less": "^1.0.0",
+    "grunt-contrib-uglify": "^0.8.0",
+    "grunt-env": "^0.4.4",
     "grunt-express": "^1.4.1",
     "grunt-express": "^1.4.1",
     "grunt-filerev": "^2.1.2",
     "grunt-filerev": "^2.1.2",
     "grunt-fontsmith": "^0.9.1",
     "grunt-fontsmith": "^0.9.1",
     "grunt-inline-angular-templates": "^0.1.5",
     "grunt-inline-angular-templates": "^0.1.5",
     "grunt-line-remover": "^0.0.2",
     "grunt-line-remover": "^0.0.2",
-    "grunt-mocha-test": "^0.12.4",
+    "grunt-mocha-test": "^0.12.7",
     "grunt-replace": "^0.8.0",
     "grunt-replace": "^0.8.0",
     "grunt-usemin": "^3.0.0",
     "grunt-usemin": "^3.0.0",
     "matchdep": "^0.3.0",
     "matchdep": "^0.3.0",
-    "mocha": "^2.1.0",
-    "request": "^2.51.0",
-    "sinon": "^1.12.1",
-    "sinon-chai": "^2.6.0"
+    "mocha": "^2.2.1",
+    "request": "^2.53.0",
+    "sinon": "^1.14.0",
+    "sinon-chai": "^2.7.0"
   },
   },
   "scripts": {
   "scripts": {
     "test": "grunt test"
     "test": "grunt test"

+ 5 - 1
test/api/apiTest.js

@@ -159,7 +159,10 @@ describe('api', function() {
                 body.should.have.a.property('scoreProfiles').that.is.an('object');
                 body.should.have.a.property('scoreProfiles').that.is.an('object');
                 body.should.have.a.property('rules').that.is.an('object');
                 body.should.have.a.property('rules').that.is.an('object');
                 body.should.have.a.property('toolsResults').that.is.an('object');
                 body.should.have.a.property('toolsResults').that.is.an('object');
+
+                // javascriptExecutionTree should only be filled if option jsTimeline is true
                 body.should.have.a.property('javascriptExecutionTree').that.is.an('object');
                 body.should.have.a.property('javascriptExecutionTree').that.is.an('object');
+                body.javascriptExecutionTree.should.deep.equal({});
 
 
                 // Check if the screenshot temporary file was correctly removed
                 // Check if the screenshot temporary file was correctly removed
                 body.params.options.should.not.have.a.property('screenshot');
                 body.params.options.should.not.have.a.property('screenshot');
@@ -188,7 +191,8 @@ describe('api', function() {
             url: serverUrl + '/api/runs',
             url: serverUrl + '/api/runs',
             body: {
             body: {
                 url: wwwUrl + '/simple-page.html',
                 url: wwwUrl + '/simple-page.html',
-                waitForResponse: false
+                waitForResponse: false,
+                jsTimeline: true
             },
             },
             json: true,
             json: true,
             headers: {
             headers: {

+ 2 - 2
test/api/screenshotHandlerTest.js

@@ -85,7 +85,7 @@ describe('screenshotHandler', function() {
 
 
     it('should respond a temporary file', function() {
     it('should respond a temporary file', function() {
         var file = screenshot.getTmpFilePath();
         var file = screenshot.getTmpFilePath();
-        file.should.have.string('/screenshot.jpg');
+        file.should.have.string('/screenshot.png');
     });
     });
 
 
 
 
@@ -106,7 +106,7 @@ describe('screenshotHandler', function() {
     it('should delete the temp folder with the screenshot inside', function(done) {
     it('should delete the temp folder with the screenshot inside', function(done) {
         screenshot = ScreenshotHandler.getScreenshotTempFile();
         screenshot = ScreenshotHandler.getScreenshotTempFile();
         var tmpFolderPath = screenshot.getTmpFolder().path;
         var tmpFolderPath = screenshot.getTmpFolder().path;
-        var tmpImagePath = path.join(tmpFolderPath, 'screenshot.jpg');
+        var tmpImagePath = path.join(tmpFolderPath, 'screenshot.png');
 
 
         // Copy image
         // Copy image
         var testImage = fs.readFileSync(imagePath);
         var testImage = fs.readFileSync(imagePath);

+ 2 - 2
test/core/indexTest.js

@@ -54,7 +54,7 @@ describe('index.js', function() {
                 data.toolsResults.phantomas.should.have.a.property('offenders').that.is.an('object');
                 data.toolsResults.phantomas.should.have.a.property('offenders').that.is.an('object');
                 data.toolsResults.phantomas.offenders.should.have.a.property('DOMelementMaxDepth');
                 data.toolsResults.phantomas.offenders.should.have.a.property('DOMelementMaxDepth');
                 data.toolsResults.phantomas.offenders.DOMelementMaxDepth.should.have.length(1);
                 data.toolsResults.phantomas.offenders.DOMelementMaxDepth.should.have.length(1);
-                data.toolsResults.phantomas.offenders.DOMelementMaxDepth[0].should.equal('body > h1[1]');
+                data.toolsResults.phantomas.offenders.DOMelementMaxDepth[0].should.equal('body > h1[0]');
 
 
                 // Test rules
                 // Test rules
                 data.should.have.a.property('rules').that.is.an('object');
                 data.should.have.a.property('rules').that.is.an('object');
@@ -79,7 +79,7 @@ describe('index.js', function() {
                         "count": 1,
                         "count": 1,
                         "tree": {
                         "tree": {
                             "body": {
                             "body": {
-                                "h1[1]": 1
+                                "h1[0]": 1
                             }
                             }
                         }
                         }
                     }
                     }

+ 8 - 6
test/core/phantomasWrapperTest.js

@@ -18,14 +18,15 @@ describe('phantomasWrapper', function() {
                 options: {}
                 options: {}
             }
             }
         }).then(function(data) {
         }).then(function(data) {
+            /*jshint -W030 */
 
 
             data.should.be.an('object');
             data.should.be.an('object');
             data.should.have.a.property('generator');
             data.should.have.a.property('generator');
             data.generator.should.contain('phantomas');
             data.generator.should.contain('phantomas');
             data.should.have.a.property('url').that.equals(url);
             data.should.have.a.property('url').that.equals(url);
-            data.should.have.a.property('metrics').that.is.an('object').not.empty();
-            data.should.have.a.property('offenders').that.is.an('object').not.empty();
-            data.offenders.should.have.a.property('javascriptExecutionTree').that.is.a('array').not.empty();
+            data.should.have.a.property('metrics').that.is.an('object').not.empty;
+            data.should.have.a.property('offenders').that.is.an('object').not.empty;
+            data.offenders.should.have.a.property('javascriptExecutionTree').that.is.a('array').not.empty;
 
 
             done();
             done();
         }).fail(function(err) {
         }).fail(function(err) {
@@ -68,14 +69,15 @@ describe('phantomasWrapper', function() {
                 }
                 }
             }
             }
         }).then(function(data) {
         }).then(function(data) {
+            /*jshint -W030 */
             
             
             data.should.be.an('object');
             data.should.be.an('object');
             data.should.have.a.property('generator');
             data.should.have.a.property('generator');
             data.generator.should.contain('phantomas');
             data.generator.should.contain('phantomas');
             data.should.have.a.property('url').that.equals(url);
             data.should.have.a.property('url').that.equals(url);
-            data.should.have.a.property('metrics').that.is.an('object').not.empty();
-            data.should.have.a.property('offenders').that.is.an('object').not.empty();
-            data.offenders.should.have.a.property('javascriptExecutionTree').that.is.a('array').not.empty();
+            data.should.have.a.property('metrics').that.is.an('object').not.empty;
+            data.should.have.a.property('offenders').that.is.an('object').not.empty;
+            data.offenders.should.have.a.property('javascriptExecutionTree').that.is.a('array').not.empty;
 
 
             done();
             done();
         }).fail(function(err) {
         }).fail(function(err) {

+ 193 - 0
test/www/jquery-page.html

@@ -0,0 +1,193 @@
+<html>
+<head>
+    <title>Simple page</title>
+    <script src="jquery1.8.3.js"></script>
+</head>
+<body>
+    <h1>Simple page</h1>
+
+    <ul>
+        <li>li 1</li>
+        <li>li 2</li>
+        <li>li 3</li>
+    </ul>
+
+    <ul id="foo">
+        <li>li4</li>
+    </ul>
+
+    <script>
+
+        // Let's start the spaghetti code!!!
+
+        $('ul').find('li');
+        $('li', $('#foo'));
+        var foo = document.getElementById('foo');
+        $('li', foo);
+        foo.getElementsByClassName('bar');
+        document.getElementsByClassName('bar');
+        foo.querySelector('ul li');
+        document.querySelector('ul li');
+        foo.querySelectorAll('ul li');
+        document.querySelectorAll('ul li');
+
+        var li = document.createElement('li');
+        foo.insertBefore(li, foo.children[0]);
+
+        li.addEventListener('click', function() {});
+
+        var $li = $(li);
+        $li.html();
+        $li.html('some content');
+        $('li').html();
+        $li.html('');
+        $('select').html();
+
+        $li.append('<div>woot</div>');
+        $li.append(document.createElement('span'));
+        $li.append($('<b>what</b>'));
+        $li.append(['<div>test1</div>', document.createElement('div')]);
+        $li.append('<div>test3</div>', '<div>test4</div>');
+
+        $('<div>test4</div>').appendTo($li);
+        $('<div>test5</div>').appendTo('<div class="bar"></div>');
+
+        $('li').prepend('<span class="prepended">prepended!</span>');
+        $('<div>test0</div>').prependTo($li);
+
+        $('<div>before bar</div>').before('.bar');
+        $('<div>before $li</div>').insertBefore($li);
+
+        $li.after('textnode after');
+        $('<div>after $li</div>').insertAfter($li);
+
+        $('.prepended').remove('.someClass');
+        $('.prepended').remove();
+        $('.prepended').detach();
+
+        $li.find('div').empty();
+        var $ulClones = $('ul').clone();
+
+        $('span').replaceWith('<span class="newspan">New span</span>');
+        $('<span class="newnewspan">New new span</span>').replaceAll('.newspan');
+
+        $('li').text();
+        $('li').eq(0).text('New li 1');
+
+        $('li').eq(0).wrap('<div></div>');
+        $('ul').wrapAll('<div class="ulWrapper"></div>');
+        $('.ulWrapper').wrapInner('<div class="doubleUlWrapper"></div>');
+        $('.doubleUlWrapper').unwrap();
+
+        $li.css('color', '#F00');
+        $li.css('color');
+        $li.css({'font-size': '1.2em', 'background': '#BFF'});
+        $li.offset();
+        $li.offset({left: 12, top: 23});
+        $li.offset(function() {return {left: 55, top: 44};});
+        $li.height();
+
+        $li.hide();
+        $li.hide(120);
+        $li.hide({duration: 133, done: function() {console.log(12);}});
+        $li.hide(222, 'swing', function() {console.log(9);});
+        $li.toggle();
+        $li.toggle(true);
+        $li.toggle(false);
+        $li.toggle(200);
+
+        var handler = function() {};
+        var $tbody = $('#dataTable tbody');
+        var $tbodytr = $('#dataTable tbody tr');
+        $tbodytr.on('click', handler);
+        $tbody.on('click', 'tr', handler);
+        $tbody.on('click', {someData: 'Joe'}, handler);
+        $tbodytr.off('click', handler);
+        $tbodytr.off('click', 'tr');
+        $tbodytr.off('click');
+        $tbodytr.off();
+        $tbody.delegate('tr', 'click', handler);
+        $tbody.undelegate('tr', 'click');
+        $tbody.undelegate('someNamespage');
+        $tbody.undelegate();
+        $tbody.one('zoop', handler);
+        $tbody.bind('zoop', handler);
+        $tbody.unbind('zoop', handler);
+        $tbody.bind();
+
+        $tbody.blur(handler);
+        $tbody.change(handler);
+        $tbody.click(handler);
+        $tbody.dblclick(handler);        
+        $tbody.error(handler);
+        $tbody.focus(handler);
+        $tbody.focusin(handler);
+        $tbody.focusout(handler);
+        $tbody.hover(handler);
+        $tbody.keydown(handler);
+        $tbody.keypress(handler);
+        $tbody.keyup(handler);
+        $tbody.load(handler);
+        $tbody.mousedown(handler);
+        $tbody.mouseenter(handler);
+        $tbody.mouseleave(handler);
+        $tbody.mousemove(handler);
+        $tbody.mouseout(handler);
+        $tbody.mouseover(handler);
+        $tbody.mouseup(handler);
+        $tbody.resize(handler);
+        $tbody.scroll(handler);
+        $tbody.select(handler);
+        $tbody.submit(handler);
+        $tbody.unload(handler);
+
+        $tbody.attr({alt: "Beijing Brush Seller", title: "photo by Kelly Clark"});
+        $tbody.attr('alt');
+        $tbody.attr('alt', 'An alternative alt');
+        $tbody.attr('alt', '');
+        $tbody.removeAttr('alt');
+
+        $tbody.prop({checked: true});
+        $tbody.prop('checked');
+        $tbody.prop('checked', true);
+        $tbody.prop('checked', '');
+        $tbody.removeProp('checked');
+
+        var $input = $('<input type="text"></input>');
+        $input.val('some value');
+        $input.val();
+
+        $tbody.addClass(function() {return 'foo';});
+        $tbody.hasClass('foo');
+        $tbody.removeClass('foo');
+        $tbody.toggleClass('foo');
+        $tbody.toggleClass('foo', true);
+        $tbody.toggleClass();
+
+        $li.children();
+        $li.children('span');
+        $li.closest('ul');
+        $li.closest('ul', document.getElementById('foo'));
+        $li.find('span');
+        $li.find(document.getElementById('foo'));
+        $li.find($li);
+        $li.next();
+        $li.next('li');
+        $li.nextAll();
+        $li.nextAll('li');
+        $li.nextUntil('.theEnd', 'li');
+        $li.offsetParent();
+        $li.prevAll();
+        $li.prevAll('li');
+        $li.prevUntil('.theBeginning', 'li');
+        $li.parent();
+        $li.parent('ul');
+        $li.parents();
+        $li.parents('div');
+        $li.parentsUntil('ul');
+        $li.parentsUntil('body', 'div');
+        $li.siblings();
+        $li.siblings('li');
+    </script>
+</body>
+</html>

File diff suppressed because it is too large
+ 1 - 0
test/www/jquery1.8.3.js


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