From 792f7d8e21ef31827fa5833a229683031046f110 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20M=C3=A9tais?= Date: Sun, 14 Dec 2014 15:47:58 +0100 Subject: [PATCH] Implements a general score calculator --- Gruntfile.js | 2 +- lib/rulesChecker.js | 1 - lib/runner.js | 28 ++- lib/scoreCalculator.js | 83 +++++++ lib/server/middlewares/apiLimitsMiddleware.js | 4 +- test/api/apiTest.js | 210 +++++++++++++++++- test/core/scoreCalculatorTest.js | 18 ++ test/core/yellowlabtoolsTest.js | 15 +- test/fixtures/scoreInput.json | 68 ++++++ test/fixtures/scoreOutput.json | 34 +++ test/fixtures/scoreProfile.json | 35 +++ 11 files changed, 478 insertions(+), 20 deletions(-) create mode 100644 lib/scoreCalculator.js create mode 100644 test/core/scoreCalculatorTest.js create mode 100644 test/fixtures/scoreInput.json create mode 100644 test/fixtures/scoreOutput.json create mode 100644 test/fixtures/scoreProfile.json diff --git a/Gruntfile.js b/Gruntfile.js index ac5dc6a..81702ea 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -88,7 +88,7 @@ module.exports = function(grunt) { options: { reporter: 'spec', }, - src: ['coverage/test/api/apiTest.js'] + src: ['coverage/test/core/scoreCalculatorTest.js'] }, coverage: { options: { diff --git a/lib/rulesChecker.js b/lib/rulesChecker.js index c576c72..9033f0e 100644 --- a/lib/rulesChecker.js +++ b/lib/rulesChecker.js @@ -7,7 +7,6 @@ var RulesChecker = function() { this.check = function(data, policies) { var results = {}; - var err = null; debug('Starting checking rules'); diff --git a/lib/runner.js b/lib/runner.js index c9083c1..caa81a4 100644 --- a/lib/runner.js +++ b/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) { 'use strict'; @@ -27,6 +29,24 @@ var Runner = function(params) { 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); }).fail(function(err) { diff --git a/lib/scoreCalculator.js b/lib/scoreCalculator.js new file mode 100644 index 0000000..8a0a399 --- /dev/null +++ b/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(); \ No newline at end of file diff --git a/lib/server/middlewares/apiLimitsMiddleware.js b/lib/server/middlewares/apiLimitsMiddleware.js index 7c17015..cce5df2 100644 --- a/lib/server/middlewares/apiLimitsMiddleware.js +++ b/lib/server/middlewares/apiLimitsMiddleware.js @@ -16,7 +16,7 @@ var apiLimitsMiddleware = function(req, res, next) { if (!runsTable.accepts(req.connection.remoteAddress)) { // Sorry :/ 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; } @@ -25,7 +25,7 @@ var apiLimitsMiddleware = function(req, res, next) { if (!callsTable.accepts(req.connection.remoteAddress)) { // Sorry :/ 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; } diff --git a/test/api/apiTest.js b/test/api/apiTest.js index da46653..44c599f 100644 --- a/test/api/apiTest.js +++ b/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'; describe('api', function() { - var runId; + + var syncRunResultUrl; + var asyncRunId; var apiServer; + + // Start the server before(function(done) { apiServer = require('../../bin/server.js'); apiServer.startTests = done; }); + it('should refuse a query with an invalid key', function(done) { this.timeout(5000); request({ method: 'POST', - url: apiUrl + '/runs', + url: serverUrl + '/api/runs', body: { url: wwwUrl + '/simple-page.html', 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); request({ method: 'POST', - url: apiUrl + '/runs', + url: serverUrl + '/api/runs', body: { url: wwwUrl + '/simple-page.html', waitForResponse: false @@ -61,8 +121,35 @@ describe('api', function() { }, function(error, response, body) { 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(); } else { @@ -79,13 +166,16 @@ describe('api', function() { request({ method: 'POST', - url: apiUrl + '/runs', + url: serverUrl + '/api/runs', body: { url: wwwUrl + '/simple-page.html', waitForResponse: false }, json: true }, function(error, response, body) { + + lastRunId = body.runId; + if (error) { deferred.reject(error); } else { @@ -131,6 +221,110 @@ 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); + } + }); + }); + + + // Stop the server after(function() { console.log('Closing the server'); apiServer.close(); diff --git a/test/core/scoreCalculatorTest.js b/test/core/scoreCalculatorTest.js new file mode 100644 index 0000000..171fe93 --- /dev/null +++ b/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); + }); +}); diff --git a/test/core/yellowlabtoolsTest.js b/test/core/yellowlabtoolsTest.js index b1dd85e..eea43e5 100644 --- a/test/core/yellowlabtoolsTest.js +++ b/test/core/yellowlabtoolsTest.js @@ -9,28 +9,28 @@ chai.use(sinonChai); describe('yellowlabtools', function() { - it('returns a promise', function() { + it('should return a promise', function() { var ylt = new YellowLabTools(); ylt.should.have.property('then').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) { err.should.be.a('string').that.equals('URL missing'); 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) { err.should.be.a('string').that.equals('URL missing'); done(); }); }); - it('succeeds on simple-page.html', function(done) { + it('should succeeds on simple-page.html', function(done) { this.timeout(15000); // Check if console.log is called @@ -75,6 +75,13 @@ describe('yellowlabtools', function() { "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*/ console.log.should.not.have.been.called; diff --git a/test/fixtures/scoreInput.json b/test/fixtures/scoreInput.json new file mode 100644 index 0000000..81e6a4f --- /dev/null +++ b/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 + } + } +} \ No newline at end of file diff --git a/test/fixtures/scoreOutput.json b/test/fixtures/scoreOutput.json new file mode 100644 index 0000000..4630bbf --- /dev/null +++ b/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" + ] + } + } +} \ No newline at end of file diff --git a/test/fixtures/scoreProfile.json b/test/fixtures/scoreProfile.json new file mode 100644 index 0000000..8856126 --- /dev/null +++ b/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 + } +} \ No newline at end of file