Bläddra i källkod

Merge pull request #23 from gmetais/restful

RESTful API
Gaël Métais 10 år sedan
förälder
incheckning
0a647264d7

+ 14 - 14
lib/metadata/policies.json

@@ -2,7 +2,7 @@
     "DOMelementsCount": {
     "DOMelementsCount": {
         "tool": "phantomas",
         "tool": "phantomas",
         "label": "DOM elements count",
         "label": "DOM elements count",
-        "message": "<p>A high number of DOM elements means a lot of work for the browser to render the page.</p><p>It also slows down Javascript DOM queries, as there are more elements to search through.</p>",
+        "message": "<p>A high number of DOM elements means a lot of work for the browser to render the page.</p><p>It also slows down JavaScript DOM queries, as there are more elements to search through.</p>",
         "isOkThreshold": 1000,
         "isOkThreshold": 1000,
         "isBadThreshold": 3000,
         "isBadThreshold": 3000,
         "isAbnormalThreshold": 5000
         "isAbnormalThreshold": 5000
@@ -10,7 +10,7 @@
     "DOMelementMaxDepth": {
     "DOMelementMaxDepth": {
         "tool": "phantomas",
         "tool": "phantomas",
         "label": "DOM max depth",
         "label": "DOM max depth",
-        "message": "<p>A deep DOM makes the CSS matching with DOM elements difficult.</p><p>It also slows down Javascript modifications to the DOM because changing the dimensions of an element makes the browser re-calculate the dimensions of it's parents. Same thing for Javascript events, that bubble up to the document root.</p>",
+        "message": "<p>A deep DOM makes the CSS matching with DOM elements difficult.</p><p>It also slows down JavaScript modifications to the DOM because changing the dimensions of an element makes the browser re-calculate the dimensions of it's parents. Same thing for JavaScript events, that bubble up to the document root.</p>",
         "isOkThreshold": 10,
         "isOkThreshold": 10,
         "isBadThreshold": 20,
         "isBadThreshold": 20,
         "isAbnormalThreshold": 30
         "isAbnormalThreshold": 30
@@ -34,7 +34,7 @@
     "DOMinserts": {
     "DOMinserts": {
         "tool": "phantomas",
         "tool": "phantomas",
         "label": "DOM inserts",
         "label": "DOM inserts",
-        "message": "<p>Working with the DOM in Javascript triggers layout calculations and slows down the page.</p><p>Try, as much as possible, to have an HTML page fully generated by the server instead of making changes with JS.</p>",
+        "message": "<p>Working with the DOM in JavaScript triggers layout calculations and slows down the page.</p><p>Try, as much as possible, to have an HTML page fully generated by the server instead of making changes with JS.</p>",
         "isOkThreshold": 10,
         "isOkThreshold": 10,
         "isBadThreshold": 400,
         "isBadThreshold": 400,
         "isAbnormalThreshold": 1000
         "isAbnormalThreshold": 1000
@@ -42,7 +42,7 @@
     "DOMqueries": {
     "DOMqueries": {
         "tool": "phantomas",
         "tool": "phantomas",
         "label": "DOM queries",
         "label": "DOM queries",
-        "message": "<p>DOM queries are like looking in a large catalog of items. Even if the browsers made progress on the performances of queries, websites often make hundreds of them.</p><p>Try to reduce the number of queries by refactoring your Javascript code.</p><p>Avoid also to have a read query between two write queries. To be able to reduce the number repaints and optimize performances, browsers buffer the DOM writing operations and treat them in bulk. But each time a DOM reading is asked, the browser needs to empty the buffer. This can be particularly slow inside a loop.</p>",
+        "message": "<p>DOM queries are like looking in a large catalog of items. Even if the browsers made progress on the performances of queries, websites often make hundreds of them.</p><p>Try to reduce the number of queries by refactoring your JavaScript code.</p><p>Avoid also to have a read query between two write queries. To be able to reduce the number repaints and optimize performances, browsers buffer the DOM writing operations and treat them in bulk. But each time a DOM reading is asked, the browser needs to empty the buffer. This can be particularly slow inside a loop.</p>",
         "isOkThreshold": 50,
         "isOkThreshold": 50,
         "isBadThreshold": 1000,
         "isBadThreshold": 1000,
         "isAbnormalThreshold": 2000
         "isAbnormalThreshold": 2000
@@ -65,7 +65,7 @@
     },
     },
     "jsErrors": {
     "jsErrors": {
         "tool": "phantomas",
         "tool": "phantomas",
-        "label": "Javascript errors",
+        "label": "JavaScript errors",
         "message": "<p>Just to let you know there are some errors on the page.</p><p><b>Please note that some errors only occur in the PhantomJS browser, so you might need to double check on other browsers.</b></p>",
         "message": "<p>Just to let you know there are some errors on the page.</p><p><b>Please note that some errors only occur in the PhantomJS browser, so you might need to double check on other browsers.</b></p>",
         "isOkThreshold": 0,
         "isOkThreshold": 0,
         "isBadThreshold": 1,
         "isBadThreshold": 1,
@@ -106,7 +106,7 @@
     "inBodyDomManipulations": {
     "inBodyDomManipulations": {
         "tool": "ylt",
         "tool": "ylt",
         "label": "DOM manipulations in body",
         "label": "DOM manipulations in body",
-        "message": "<p>This metric counts the number of DOM queries, DOM inserts, binds, etc. made by the Javascript before the DOMContentLoaded event.</p><p>Wait for this event before manipulating the DOM. Do not execute Javascript in the middle of the BODY as it slows down the construction of the DOM and makes a poor maintainability. This is what i call spaghetti code.</p><p>The JS Timeline tab can help you identify what's happening.</p>",
+        "message": "<p>This metric counts the number of DOM queries, DOM inserts, binds, etc. made by the JavaScript before the DOMContentLoaded event.</p><p>Wait for this event before manipulating the DOM. Do not execute JavaScript in the middle of the BODY as it slows down the construction of the DOM and makes a poor maintainability. This is what i call spaghetti code.</p><p>The JS Timeline tab can help you identify what's happening.</p>",
         "isOkThreshold": 10,
         "isOkThreshold": 10,
         "isBadThreshold": 50,
         "isBadThreshold": 50,
         "isAbnormalThreshold": 100
         "isAbnormalThreshold": 100
@@ -125,6 +125,14 @@
         "message": "<p>jQuery is a heavy library. You should <b>never<b> load jQuery more than one on the same page.</p>",
         "message": "<p>jQuery is a heavy library. You should <b>never<b> load jQuery more than one on the same page.</p>",
         "isOkThreshold": 1,
         "isOkThreshold": 1,
         "isBadThreshold": 2,
         "isBadThreshold": 2,
+        "isAbnormalThreshold": 2
+    },
+    "cssParsingErrors": {
+        "tool": "phantomas",
+        "label": "CSS syntax error",
+        "message": "<p>Yellow Lab Tools failed to parse a CSS file. I doubt the problem comes from the css parser.</p><p>Maybe a <a href=\"http://jigsaw.w3.org/css-validator\" target=\"_blank\">CSS validator</a> can help you.</p>",
+        "isOkThreshold": 1,
+        "isBadThreshold": 2,
         "isAbnormalThreshold": 3
         "isAbnormalThreshold": 3
     },
     },
     "cssRules": {
     "cssRules": {
@@ -151,14 +159,6 @@
         "isBadThreshold": 50,
         "isBadThreshold": 50,
         "isAbnormalThreshold": 100
         "isAbnormalThreshold": 100
     },
     },
-    "cssParsingErrors": {
-        "tool": "phantomas",
-        "label": "CSS syntax error",
-        "message": "<p>Yellow Lab Tools failed to parse a CSS file. I doubt the problem comes from the css parser.</p><p>Maybe a <a href=\"http://jigsaw.w3.org/css-validator\" target=\"_blank\">CSS validator</a> can help you.</p>",
-        "isOkThreshold": 1,
-        "isBadThreshold": 2,
-        "isAbnormalThreshold": 3
-    },
     "cssImports": {
     "cssImports": {
         "tool": "phantomas",
         "tool": "phantomas",
         "label": "Uses of @import",
         "label": "Uses of @import",

+ 104 - 1
lib/metadata/scoreProfileGeneric.json

@@ -1,3 +1,106 @@
 {
 {
-    
+    "categories": {
+        "domComplexity": {
+            "label": "DOM complexity",
+            "policies": {
+                "DOMelementsCount": 1,
+                "DOMelementMaxDepth": 1,
+                "iframesCount": 1,
+                "DOMidDuplicated": 1
+            }
+        },
+        "domManipulations": {
+            "label": "DOM manipulations",
+            "policies": {
+                "DOMinserts": 2,
+                "DOMqueries": 1,
+                "DOMqueriesAvoidable": 2,
+                "eventsBound": 1
+            }
+        },
+        "badJavascript": {
+            "label": "Bad JavaScript",
+            "policies": {
+                "jsErrors": 2,
+                "evalCalls": 1,
+                "documentWriteCalls": 2,
+                "consoleMessages": 0.5,
+                "globalVariables": 0.5,
+                "inBodyDomManipulations": 1
+            }
+        },
+        "jQueryVersion": {
+            "label": "jQuery version",
+            "policies": {
+                "jQueryVersion": 1,
+                "jQueryDifferentVersions": 5
+            }
+        },
+        "cssSyntaxError": {
+            "label": "CSS syntax errors",
+            "policies": {
+                "cssParsingErrors": 1
+            }
+        },
+        "cssComplexity": {
+            "label": "CSS complexity",
+            "policies": {
+                "cssRules": 2,
+                "cssComplexSelectors": 2,
+                "cssComplexSelectorsByAttribute": 1.5
+            }
+        },
+        "badCSS": {
+            "label": "Bad CSS",
+            "policies": {
+                "cssImports": 3,
+                "cssDuplicatedSelectors": 2,
+                "cssDuplicatedProperties": 1,
+                "cssEmptyRules": 2,
+                "cssExpressions": 1,
+                "cssImportants": 3,
+                "cssOldIEFixes": 1,
+                "cssOldPropertyPrefixes": 1,
+                "cssUniversalSelectors": 1,
+                "cssRedundantBodySelectors": 1,
+                "cssRedundantChildNodesSelectors": 1
+            }
+        },
+        "requests": {
+            "label": "Requests number",
+            "policies": {
+                "request": 5,
+                "htmlCount": 0,
+                "jsCount": 1,
+                "cssCount": 1,
+                "imageCount": 0,
+                "webfontCount": 2,
+                "videoCount": 0,
+                "jsonCount": 0,
+                "otherCount": 0
+            }
+        },
+        "network": {
+            "label": "Network",
+            "policies": {
+                "notFound": 3,
+                "closedConnections": 3,
+                "multipleRequests": 3,
+                "cachingDisabled": 1,
+                "cachingTooShort": 1,
+                "domains": 1
+            }
+        }
+    },
+    "globalScore": {
+        "domComplexity": 1,
+        "domManipulations": 2,
+        "badJavascript": 1,
+        "jQueryVersion": 1,
+        "cssSyntaxError": 1,
+        "cssComplexity": 1,
+        "badCSS": 1,
+        "requests": 3,
+        "network": 2
+    }
 }
 }

+ 0 - 1
lib/rulesChecker.js

@@ -7,7 +7,6 @@ var RulesChecker = function() {
     this.check = function(data, policies) {
     this.check = function(data, policies) {
 
 
         var results = {};
         var results = {};
-        var err = null;
 
 
         debug('Starting checking rules');
         debug('Starting checking rules');
 
 

+ 24 - 4
lib/runner.js

@@ -1,8 +1,10 @@
-var Q = require('q');
-var debug = require('debug')('ylt:yellowlabtools');
+var Q                   = require('q');
+var debug               = require('debug')('ylt:runner');
+
+var phantomasWrapper    = require('./tools/phantomasWrapper');
+var rulesChecker        = require('./rulesChecker');
+var scoreCalculator     = require('./scoreCalculator');
 
 
-var phantomasWrapper = require('./tools/phantomasWrapper');
-var rulesChecker = require('./rulesChecker');
 
 
 var Runner = function(params) {
 var Runner = function(params) {
     'use strict';
     'use strict';
@@ -27,6 +29,24 @@ var Runner = function(params) {
         data.rules = rulesChecker.check(data, policies);
         data.rules = rulesChecker.check(data, policies);
 
 
 
 
+        // Scores calculator
+        var scoreProfileGeneric = require('./metadata/scoreProfileGeneric.json');
+        data.scoreProfiles = {
+            generic : scoreCalculator.calculate(data, scoreProfileGeneric)
+        };
+
+
+        // Get the JS Execution Tree from offenders and put in the main object
+        try {
+            data.javascriptExecutionTree = JSON.parse(data.toolsResults.phantomas.offenders.javascriptExecutionTree[0]);    
+        } catch(e) {
+            debug('Could not find nor parse phantomas.offenders.javascriptExecutionTree');
+        }
+        
+        delete data.toolsResults.phantomas.metrics.javascriptExecutionTree;
+        delete data.toolsResults.phantomas.offenders.javascriptExecutionTree;
+
+        //Finished!
         deferred.resolve(data);
         deferred.resolve(data);
 
 
     }).fail(function(err) {
     }).fail(function(err) {

+ 83 - 0
lib/scoreCalculator.js

@@ -0,0 +1,83 @@
+var Q = require('q');
+var debug = require('debug')('ylt:scoreCalculator');
+
+var ScoreCalculator = function() {
+    'use strict';
+
+    this.calculate = function(data, profile) {
+
+        var results = {
+            categories: {}
+        };
+        var weight;
+        var categoryName;
+
+        debug('Starting calculating scores');
+
+        // Calculate categories
+        for (categoryName in profile.categories) {
+            var categoryResult = {
+                label: profile.categories[categoryName].label
+            };
+
+            var sum = 0;
+            var totalWeight = 0;
+            var rules = [];
+
+            for (var policyName in profile.categories[categoryName].policies) {
+                weight = profile.categories[categoryName].policies[policyName];
+                
+                if (data.rules[policyName]) {
+                    sum += data.rules[policyName].score * weight;
+                } else {
+                    // Max value if rule is not here
+                    sum += 100 * weight;
+                    debug('Warning: could not find rule %s', policyName);
+                }
+
+                totalWeight += weight;
+                rules.push(policyName);
+            }
+
+            if (totalWeight === 0) {
+                categoryResult.categoryScore = 100;
+            } else {
+                categoryResult.categoryScore = Math.round(sum / totalWeight);
+            }
+
+            categoryResult.rules = rules;
+            results.categories[categoryName] = categoryResult;
+        }
+
+
+        // Calculate general score
+        var globalSum = 0;
+        var globalTotalWeight = 0;
+
+        for (categoryName in profile.globalScore) {
+            weight = profile.globalScore[categoryName];
+
+            if (results.categories[categoryName]) {
+                globalSum += results.categories[categoryName].categoryScore * weight;
+            } else {
+                globalSum += 100 * weight;
+            }
+            globalTotalWeight += profile.globalScore[categoryName];
+        }
+
+        if (globalTotalWeight === 0) {
+            results.globalScore = 100;
+        } else {
+            results.globalScore = Math.round(globalSum / globalTotalWeight);
+        }
+
+
+
+        debug('Score calculation finished:');
+        debug(results);
+
+        return results;
+    };
+};
+
+module.exports = new ScoreCalculator();

+ 45 - 8
lib/server/controllers/apiController.js

@@ -60,7 +60,6 @@ var ApiController = function(app) {
 
 
                     // Send result if the user was waiting
                     // Send result if the user was waiting
                     if (run.params.waitForResponse) {
                     if (run.params.waitForResponse) {
-                        
                         res.redirect(302, '/api/results/' + run.runId);
                         res.redirect(302, '/api/results/' + run.runId);
                     }
                     }
 
 
@@ -133,21 +132,59 @@ var ApiController = function(app) {
 
 
     // Retrive one result by id
     // Retrive one result by id
     app.get('/api/results/:id', function(req, res) {
     app.get('/api/results/:id', function(req, res) {
-        var runId = req.params.id;
+        getPartialResults(req.params.id, res, function(data) {
+            return data;
+        });
+    });
+
+    // Retrieve one result and return only the generalScores part of the response
+    app.get('/api/results/:id/generalScores', function(req, res) {
+        getPartialResults(req.params.id, res, function(data) {
+            return data.scoreProfiles.generic;
+        });
+    });
+
+    app.get('/api/results/:id/generalScores/:scoreProfile', function(req, res) {
+        getPartialResults(req.params.id, res, function(data) {
+            return data.scoreProfiles[req.params.scoreProfile];
+        });
+    });
+
+    app.get('/api/results/:id/rules', function(req, res) {
+        getPartialResults(req.params.id, res, function(data) {
+            return data.rules;
+        });
+    });
+
+    app.get('/api/results/:id/javascriptExecutionTree', function(req, res) {
+        getPartialResults(req.params.id, res, function(data) {
+            return data.javascriptExecutionTree;
+        });
+    });
 
 
+    app.get('/api/results/:id/toolsResults/phantomas', function(req, res) {
+        getPartialResults(req.params.id, res, function(data) {
+            return data.toolsResults.phantomas;
+        });
+    });
+
+    function getPartialResults(runId, res, partialGetterFn) {
         resultsDatastore.getResult(runId)
         resultsDatastore.getResult(runId)
             .then(function(data) {
             .then(function(data) {
-                // This is the pivot format, we might need to clean it first?
-
-                // Hide phantomas results
-                data.toolsResults.phantomas = {};
+                var results = partialGetterFn(data);
                 
                 
+                if (typeof results === 'undefined') {
+                    res.status(404).send('Not found');
+                    return;
+                }
+
                 res.setHeader('Content-Type', 'application/json');
                 res.setHeader('Content-Type', 'application/json');
-                res.send(JSON.stringify(data, null, 2));
+                res.send(JSON.stringify(results, null, 2));
+
             }).fail(function() {
             }).fail(function() {
                 res.status(404).send('Not found');
                 res.status(404).send('Not found');
             });
             });
-    });
+    }
 
 
 };
 };
 
 

+ 2 - 2
lib/server/middlewares/apiLimitsMiddleware.js

@@ -16,7 +16,7 @@ var apiLimitsMiddleware = function(req, res, next) {
             if (!runsTable.accepts(req.connection.remoteAddress)) {
             if (!runsTable.accepts(req.connection.remoteAddress)) {
                 // Sorry :/
                 // Sorry :/
                 debug('Too many tests launched from IP address %s', req.connection.remoteAddress);
                 debug('Too many tests launched from IP address %s', req.connection.remoteAddress);
-                res.status(429).send('Too Many Requests');
+                res.status(429).send('Too many requests');
                 return;
                 return;
             }
             }
 
 
@@ -25,7 +25,7 @@ var apiLimitsMiddleware = function(req, res, next) {
         if (!callsTable.accepts(req.connection.remoteAddress)) {
         if (!callsTable.accepts(req.connection.remoteAddress)) {
             // Sorry :/
             // Sorry :/
             debug('Too many API requests from IP address %s', req.connection.remoteAddress);
             debug('Too many API requests from IP address %s', req.connection.remoteAddress);
-            res.status(429).send('Too Many Requests');
+            res.status(429).send('Too many requests');
             return;
             return;
         }
         }
 
 

+ 330 - 8
test/api/apiTest.js

@@ -8,25 +8,30 @@ var config = {
     }
     }
 };
 };
 
 
-var apiUrl = 'http://localhost:8387/api';
+var serverUrl = 'http://localhost:8387';
 var wwwUrl = 'http://localhost:8388';
 var wwwUrl = 'http://localhost:8388';
 
 
 describe('api', function() {
 describe('api', function() {
 
 
-    var runId;
+
+    var syncRunResultUrl;
+    var asyncRunId;
     var apiServer;
     var apiServer;
 
 
+
+    // Start the server
     before(function(done) {
     before(function(done) {
         apiServer = require('../../bin/server.js');
         apiServer = require('../../bin/server.js');
         apiServer.startTests = done;
         apiServer.startTests = done;
     });
     });
 
 
+
     it('should refuse a query with an invalid key', function(done) {
     it('should refuse a query with an invalid key', function(done) {
         this.timeout(5000);
         this.timeout(5000);
 
 
         request({
         request({
             method: 'POST',
             method: 'POST',
-            url: apiUrl + '/runs',
+            url: serverUrl + '/api/runs',
             body: {
             body: {
                 url: wwwUrl + '/simple-page.html',
                 url: wwwUrl + '/simple-page.html',
                 waitForResponse: false
                 waitForResponse: false
@@ -44,12 +49,67 @@ describe('api', function() {
         });
         });
     });
     });
 
 
-    it('should accept a query with a valid key', function(done) {
+
+    it('should launch a synchronous run', function(done) {
+        this.timeout(15000);
+
+        request({
+            method: 'POST',
+            url: serverUrl + '/api/runs',
+            body: {
+                url: wwwUrl + '/simple-page.html',
+                waitForResponse: true
+            },
+            json: true,
+            headers: {
+                'X-Api-Key': Object.keys(config.authorizedKeys)[0]
+            }
+        }, function(error, response, body) {
+            if (!error && response.statusCode === 302) {
+
+                response.headers.should.have.a.property('location').that.is.a('string');
+                syncRunResultUrl = response.headers.location;
+
+                done();
+            } else {
+                done(error || response.statusCode);
+            }
+        });
+    });
+
+
+    it('should retrieve the results for the synchronous run', function(done) {
+        this.timeout(15000);
+
+        request({
+            method: 'GET',
+            url: serverUrl + syncRunResultUrl,
+            json: true,
+        }, function(error, response, body) {
+            if (!error && response.statusCode === 200) {
+
+                body.should.have.a.property('runId').that.is.a('string');
+                body.should.have.a.property('params').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('toolsResults').that.is.an('object');
+                body.should.have.a.property('javascriptExecutionTree').that.is.an('object');
+
+                done();
+
+            } else {
+                done(error || response.statusCode);
+            }
+        });
+    });
+
+
+    it('should launch a run without waiting for the response', function(done) {
         this.timeout(5000);
         this.timeout(5000);
 
 
         request({
         request({
             method: 'POST',
             method: 'POST',
-            url: apiUrl + '/runs',
+            url: serverUrl + '/api/runs',
             body: {
             body: {
                 url: wwwUrl + '/simple-page.html',
                 url: wwwUrl + '/simple-page.html',
                 waitForResponse: false
                 waitForResponse: false
@@ -61,8 +121,35 @@ describe('api', function() {
         }, function(error, response, body) {
         }, function(error, response, body) {
             if (!error && response.statusCode === 200) {
             if (!error && response.statusCode === 200) {
 
 
-                runId = body.runId;
-                runId.should.be.a('string');
+                asyncRunId = body.runId;
+                asyncRunId.should.be.a('string');
+                done();
+
+            } else {
+                done(error || response.statusCode);
+            }
+        });
+    });
+
+
+    it('should respond run status: running', function(done) {
+        this.timeout(5000);
+
+        request({
+            method: 'GET',
+            url: serverUrl + '/api/runs/' + asyncRunId,
+            json: true,
+            headers: {
+                'X-Api-Key': Object.keys(config.authorizedKeys)[0]
+            }
+        }, function(error, response, body) {
+            if (!error && response.statusCode === 200) {
+
+                body.runId.should.equal(asyncRunId);
+                body.status.should.deep.equal({
+                    statusCode: 'running'
+                });
+
                 done();
                 done();
 
 
             } else {
             } else {
@@ -79,13 +166,16 @@ describe('api', function() {
 
 
             request({
             request({
                 method: 'POST',
                 method: 'POST',
-                url: apiUrl + '/runs',
+                url: serverUrl + '/api/runs',
                 body: {
                 body: {
                     url: wwwUrl + '/simple-page.html',
                     url: wwwUrl + '/simple-page.html',
                     waitForResponse: false
                     waitForResponse: false
                 },
                 },
                 json: true
                 json: true
             }, function(error, response, body) {
             }, function(error, response, body) {
+
+                lastRunId = body.runId;
+
                 if (error) {
                 if (error) {
                     deferred.reject(error);
                     deferred.reject(error);
                 } else {
                 } else {
@@ -131,6 +221,238 @@ describe('api', function() {
         
         
     });
     });
 
 
+
+    it('should respond 404 to unknown runId', function(done) {
+        this.timeout(5000);
+
+        request({
+            method: 'GET',
+            url: serverUrl + '/api/runs/unknown',
+            json: true
+        }, function(error, response, body) {
+            if (!error && response.statusCode === 404) {
+                done();
+            } else {
+                done(error || response.statusCode);
+            }
+        });
+    });
+
+
+    it('should respond 404 to unknown result', function(done) {
+        this.timeout(5000);
+
+        request({
+            method: 'GET',
+            url: serverUrl + '/api/results/unknown',
+            json: true
+        }, function(error, response, body) {
+            if (!error && response.statusCode === 404) {
+                done();
+            } else {
+                done(error || response.statusCode);
+            }
+        });
+    });
+
+    
+    it('should respond status complete to the first run', function(done) {
+        this.timeout(12000);
+
+        function checkStatus() {
+            request({
+                method: 'GET',
+                url: serverUrl + '/api/runs/' + asyncRunId,
+                json: true
+            }, function(error, response, body) {
+                if (!error && response.statusCode === 200) {
+
+                    body.runId.should.equal(asyncRunId);
+                    
+                    if (body.status.statusCode === 'running') {
+                        setTimeout(checkStatus, 250);
+                    } else if (body.status.statusCode === 'complete') {
+                        done();
+                    } else {
+                        done(body.status.statusCode);
+                    }
+
+                } else {
+                    done(error || response.statusCode);
+                }
+            });
+        }
+
+        checkStatus();
+    });
+
+
+    it('should find the result of the async run', function(done) {
+        this.timeout(5000);
+
+        request({
+            method: 'GET',
+            url: serverUrl + '/api/results/' + asyncRunId,
+            json: true,
+        }, function(error, response, body) {
+            if (!error && response.statusCode === 200) {
+
+                body.should.have.a.property('runId').that.equals(asyncRunId);
+                body.should.have.a.property('params').that.is.an('object');
+                body.params.url.should.equal(wwwUrl + '/simple-page.html');
+
+                body.should.have.a.property('scoreProfiles').that.is.an('object');
+                body.scoreProfiles.should.have.a.property('generic').that.is.an('object');
+                body.scoreProfiles.generic.should.have.a.property('globalScore').that.is.a('number');
+                body.scoreProfiles.generic.should.have.a.property('categories').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.toolsResults.should.have.a.property('phantomas').that.is.an('object');
+
+                body.should.have.a.property('javascriptExecutionTree').that.is.an('object');
+                body.javascriptExecutionTree.should.have.a.property('data').that.is.an('object');
+                body.javascriptExecutionTree.data.should.have.a.property('type').that.equals('main');
+
+                done();
+
+            } else {
+                done(error || response.statusCode);
+            }
+        });
+    });
+
+
+    it('should return the generic score object', function(done) {
+        this.timeout(5000);
+
+        request({
+            method: 'GET',
+            url: serverUrl + '/api/results/' + asyncRunId + '/generalScores',
+            json: true,
+        }, function(error, response, body) {
+            if (!error && response.statusCode === 200) {
+                body.should.have.a.property('globalScore').that.is.a('number');
+                body.should.have.a.property('categories').that.is.an('object');
+                done();
+
+            } else {
+                done(error || response.statusCode);
+            }
+        });
+    });
+
+
+    it('should return the generic score object also', function(done) {
+        this.timeout(5000);
+
+        request({
+            method: 'GET',
+            url: serverUrl + '/api/results/' + asyncRunId + '/generalScores/generic',
+            json: true,
+        }, function(error, response, body) {
+            if (!error && response.statusCode === 200) {
+                body.should.have.a.property('globalScore').that.is.a('number');
+                body.should.have.a.property('categories').that.is.an('object');
+                done();
+
+            } else {
+                done(error || response.statusCode);
+            }
+        });
+    });
+
+
+    it('should not find an unknown score object', function(done) {
+        this.timeout(5000);
+
+        request({
+            method: 'GET',
+            url: serverUrl + '/api/results/' + asyncRunId + '/generalScores/unknown',
+            json: true,
+        }, function(error, response, body) {
+            if (!error && response.statusCode === 404) {
+                done();
+            } else {
+                done(error || response.statusCode);
+            }
+        });
+    });
+
+
+    it('should return the rules', function(done) {
+        this.timeout(5000);
+
+        request({
+            method: 'GET',
+            url: serverUrl + '/api/results/' + asyncRunId + '/rules',
+            json: true,
+        }, function(error, response, body) {
+            if (!error && response.statusCode === 200) {
+                
+                var firstRule = body[Object.keys(body)[0]];
+                firstRule.should.have.a.property('policy').that.is.an('object');
+                firstRule.should.have.a.property('value').that.is.a('number');
+                firstRule.should.have.a.property('bad').that.is.a('boolean');
+                firstRule.should.have.a.property('abnormal').that.is.a('boolean');
+                firstRule.should.have.a.property('score').that.is.a('number');
+                firstRule.should.have.a.property('abnormalityScore').that.is.a('number');
+                
+                done();
+
+            } else {
+                done(error || response.statusCode);
+            }
+        });
+    });
+
+
+    it('should return the javascript execution tree', function(done) {
+        this.timeout(5000);
+
+        request({
+            method: 'GET',
+            url: serverUrl + '/api/results/' + asyncRunId + '/javascriptExecutionTree',
+            json: true,
+        }, function(error, response, body) {
+            if (!error && response.statusCode === 200) {
+                
+                body.should.have.a.property('data').that.is.an('object');
+                body.data.should.have.a.property('type').that.equals('main');
+                
+                done();
+
+            } else {
+                done(error || response.statusCode);
+            }
+        });
+    });
+
+
+    it('should return the phantomas results', function(done) {
+        this.timeout(5000);
+
+        request({
+            method: 'GET',
+            url: serverUrl + '/api/results/' + asyncRunId + '/toolsResults/phantomas',
+            json: true,
+        }, function(error, response, body) {
+            if (!error && response.statusCode === 200) {
+                
+                body.should.have.a.property('metrics').that.is.an('object');
+                body.should.have.a.property('offenders').that.is.an('object');
+                
+                done();
+
+            } else {
+                done(error || response.statusCode);
+            }
+        });
+    });
+
+
+    // Stop the server
     after(function() {
     after(function() {
         console.log('Closing the server');
         console.log('Closing the server');
         apiServer.close();
         apiServer.close();

+ 18 - 0
test/core/scoreCalculatorTest.js

@@ -0,0 +1,18 @@
+var should = require('chai').should();
+var scoreCalculator = require('../../lib/scoreCalculator');
+
+describe('scoreCalculator', function() {
+    
+    it('should have a method calculate', function() {
+        scoreCalculator.should.have.property('calculate').that.is.a('function');
+    });
+    
+    it('should produce a nice rules object', function() {
+        var data = require('../fixtures/scoreInput.json');
+        var profile = require('../fixtures/scoreProfile.json');
+        var expected = require('../fixtures/scoreOutput.json');
+
+        var results = scoreCalculator.calculate(data, profile);
+        results.should.deep.equals(expected);
+    });
+});

+ 12 - 5
test/core/yellowlabtoolsTest.js

@@ -9,28 +9,28 @@ chai.use(sinonChai);
 
 
 describe('yellowlabtools', function() {
 describe('yellowlabtools', function() {
 
 
-    it('returns a promise', function() {
+    it('should return a promise', function() {
         var ylt = new YellowLabTools();
         var ylt = new YellowLabTools();
 
 
         ylt.should.have.property('then').that.is.a('function');
         ylt.should.have.property('then').that.is.a('function');
         ylt.should.have.property('fail').that.is.a('function');
         ylt.should.have.property('fail').that.is.a('function');
     });
     });
 
 
-    it('fails an undefined url', function(done) {
+    it('should fail an undefined url', function(done) {
         var ylt = new YellowLabTools().fail(function(err) {
         var ylt = new YellowLabTools().fail(function(err) {
             err.should.be.a('string').that.equals('URL missing');
             err.should.be.a('string').that.equals('URL missing');
             done();
             done();
         });
         });
     });
     });
 
 
-    it('fails with an empty url string', function(done) {
+    it('should fail with an empty url string', function(done) {
         var ylt = new YellowLabTools('').fail(function(err) {
         var ylt = new YellowLabTools('').fail(function(err) {
             err.should.be.a('string').that.equals('URL missing');
             err.should.be.a('string').that.equals('URL missing');
             done();
             done();
         });
         });
     });
     });
 
 
-    it('succeeds on simple-page.html', function(done) {
+    it('should succeeds on simple-page.html', function(done) {
         this.timeout(15000);
         this.timeout(15000);
 
 
         // Check if console.log is called
         // Check if console.log is called
@@ -62,7 +62,7 @@ describe('yellowlabtools', function() {
                     policy: {
                     policy: {
                         "tool": "phantomas",
                         "tool": "phantomas",
                         "label": "DOM max depth",
                         "label": "DOM max depth",
-                        "message": "<p>A deep DOM makes the CSS matching with DOM elements difficult.</p><p>It also slows down Javascript modifications to the DOM because changing the dimensions of an element makes the browser re-calculate the dimensions of it's parents. Same thing for Javascript events, that bubble up to the document root.</p>",
+                        "message": "<p>A deep DOM makes the CSS matching with DOM elements difficult.</p><p>It also slows down JavaScript modifications to the DOM because changing the dimensions of an element makes the browser re-calculate the dimensions of it's parents. Same thing for JavaScript events, that bubble up to the document root.</p>",
                         "isOkThreshold": 10,
                         "isOkThreshold": 10,
                         "isBadThreshold": 20,
                         "isBadThreshold": 20,
                         "isAbnormalThreshold": 30
                         "isAbnormalThreshold": 30
@@ -75,6 +75,13 @@ describe('yellowlabtools', function() {
                     "offenders": ["body > h1[1]"]
                     "offenders": ["body > h1[1]"]
                 });
                 });
 
 
+                // Test javascriptExecutionTree
+                data.toolsResults.phantomas.metrics.should.not.have.a.property('javascriptExecutionTree');
+                data.toolsResults.phantomas.offenders.should.not.have.a.property('javascriptExecutionTree');
+                data.should.have.a.property('javascriptExecutionTree').that.is.an('object');
+                data.javascriptExecutionTree.should.have.a.property('data');
+                data.javascriptExecutionTree.data.should.have.a.property('type').that.equals('main');
+
                 /*jshint expr: true*/
                 /*jshint expr: true*/
                 console.log.should.not.have.been.called;
                 console.log.should.not.have.been.called;
 
 

+ 68 - 0
test/fixtures/scoreInput.json

@@ -0,0 +1,68 @@
+{
+    "rules": {
+        "metric1": {
+            "policy": {
+                "tool": "tool1",
+                "label": "The metric 1",
+                "message": "A great message",
+                "isOkThreshold": 1000,
+                "isBadThreshold": 3000,
+                "isAbnormalThreshold": 5000
+            },
+            "value": 1236,
+            "bad": true,
+            "abnormal": false,
+            "score": 88,
+            "abnormalityScore": 0
+        },
+        "metric2": {
+            "value": 222,
+            "bad": false,
+            "abnormal": false,
+            "score": 100,
+            "abnormalityScore": 0
+        },
+        "metric3": {
+            "value": 6666,
+            "bad": true,
+            "abnormal": true,
+            "score": 0,
+            "abnormalityScore": -42
+        },
+        "metric4": {
+            "value": 1000,
+            "bad": false,
+            "abnormal": false,
+            "score": 100,
+            "abnormalityScore": 0
+        },
+        "metric5": {
+            "value": 3000,
+            "bad": true,
+            "abnormal": false,
+            "score": 0,
+            "abnormalityScore": 0
+        },
+        "metric6": {
+            "value": 0,
+            "bad": false,
+            "abnormal": false,
+            "score": 100,
+            "abnormalityScore": 0
+        },
+        "metric7": {
+            "value": 5000,
+            "bad": true,
+            "abnormal": true,
+            "score": 0,
+            "abnormalityScore": 0
+        },
+        "metric8": {
+            "value": 22,
+            "bad": true,
+            "abnormal": true,
+            "score": 0,
+            "abnormalityScore": -100
+        }
+    }
+}

+ 34 - 0
test/fixtures/scoreOutput.json

@@ -0,0 +1,34 @@
+{
+    "globalScore": 69,
+    "categories": {
+        "category1": {
+            "label": "Category 1",
+            "categoryScore": 87,
+            "rules": [
+                "metric1",
+                "metric2",
+                "metric3",
+                "metric4"
+            ]
+        },
+        "category2": {
+            "label": "Category 2",
+            "categoryScore": 31,
+            "rules": [
+                "metric5",
+                "metric6",
+                "metric7",
+                "metric8",
+                "unexistantMetric1"
+            ]
+        },
+        "category3": {
+            "label": "Category 3",
+            "categoryScore": 100,
+            "rules": [
+                "unexistantMetric1",
+                "unexistantMetric2"
+            ]
+        }
+    }
+}

+ 35 - 0
test/fixtures/scoreProfile.json

@@ -0,0 +1,35 @@
+{
+    "categories": {
+        "category1": {
+            "label": "Category 1",
+            "policies": {
+                "metric1": 2,
+                "metric2": 1,
+                "metric3": 0.5,
+                "metric4": 2
+            }
+        },
+        "category2": {
+            "label": "Category 2",
+            "policies": {
+                "metric5": 2,
+                "metric6": 1,
+                "metric7": 0.5,
+                "metric8": 2,
+                "unexistantMetric1": 1
+            }
+        },
+        "category3": {
+            "label": "Category 3",
+            "policies": {
+                "unexistantMetric1": 2,
+                "unexistantMetric2": 1
+            }
+        }
+    },
+    "globalScore": {
+        "category1": 2,
+        "category2": 1,
+        "category3": 0.1
+    }
+}