diff --git a/.gitignore b/.gitignore index 37db380..6fe25bb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,9 @@ node_modules bower_components +.tmp .vagrant results/* coverage +front/build har.json \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index fd3aacd..9577d83 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,8 @@ language: node_js node_js: - - "0.10" + - "0.10.33" before_install: - "npm install -g grunt-cli" - "npm install -g phantomjs" install: npm install -before_script: grunt build \ No newline at end of file +before_script: grunt test \ No newline at end of file diff --git a/Gruntfile.js b/Gruntfile.js index 70f9c5c..197fe33 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -1,60 +1,131 @@ module.exports = function(grunt) { + // Load all grunt modules + require('matchdep').filterDev('grunt-*').forEach(grunt.loadNpmTasks); + + // Tell our Express server that Grunt launched it + process.env.GRUNTED = true; + // Project configuration. grunt.initConfig({ pkg: grunt.file.readJSON('package.json'), + settings: grunt.file.readJSON('./server_config/settings.json'), font: { icons: { - src: ['app/public/fonts/svg-icons/*.svg'], - destCss: 'app/public/styles/less/icons.less', - destFonts: 'app/public/fonts/icons.woff', + src: ['front/src/fonts/svg-icons/*.svg'], + destCss: 'front/src/less/icons.less', + destFonts: 'front/src/fonts/icons.woff', // Optional: Custom routing of font filepaths for CSS cssRouter: function (fontpath) { var pathArray = fontpath.split('/'); var fileName = pathArray[pathArray.length - 1]; - return '/public/fonts/' + fileName; + return '/fonts/' + fileName; } } }, less: { all: { - files: { - 'app/public/styles/main.css': [ 'app/public/styles/less/main.less' ], - 'app/public/styles/index.css': [ 'app/public/styles/less/index.less' ], - 'app/public/styles/launchTest.css': [ 'app/public/styles/less/launchTest.less' ], - 'app/public/styles/results.css': [ 'app/public/styles/less/results.less' ] - } + files: [ + { + expand: true, + cwd: 'front/src/less/', + src: ['**/*.less'], + dest: 'front/src/css/', + ext: '.css' + } + ] + } + }, + replace: { + dist: { + options: { + patterns: [ + { + match: 'googleAnalyticsId', + replacement: '<%= settings.googleAnalyticsId %>' + }, + { + match: 'version', + replacement: 'v<%= pkg.version %>' + } + ] + }, + files: [ + {expand: true, flatten: true, src: ['front/src/main.html'], dest: 'front/build/'} + ] } }, jshint: { all: [ '*.js', - 'app/lib/*', + 'app/lib/*.js', + 'bin/*.js', + 'lib/**/*.js', 'app/nodeControllers/*.js', 'app/public/scripts/*.js', - 'phantomas_custom/**/*.js' + 'phantomas_custom/**/*.js', + 'test/**/*.js', + 'front/src/js/**/*.js' ] }, clean: { - icons: { - src: ['tmp'] + tmp: { + src: ['.tmp'] + }, + dev: { + src: ['front/src/css'] }, coverage: { - src: ['coverage/'] + src: ['.tmp', 'coverage/'] + }, + build: { + src: ['front/build'] } }, copy: { + beforeCoverage: { + files: [ + {src: ['bin/server.js'], dest: '.tmp/'} + ] + }, coverage: { - src: ['test/**'], - dest: 'coverage/' + files: [ + {src: ['test/**'], dest: 'coverage/'}, + {src: ['lib/metadata/**'], dest: 'coverage/'} + ] + }, + build: { + files: [ + {src: ['./front/src/fonts/icons.woff'], dest: './front/build/fonts/icons.woff'}, + {src: ['./front/src/img/favicon.png'], dest: './front/build/img/favicon.png'}, + {src: ['./front/src/img/logo-large.png'], dest: './front/build/img/logo-large.png'}, + ] + } + }, + lineremover: { + beforeCoverage: { + files: { + '.tmp/bin/cli.js': 'bin/cli.js' + }, + options: { + exclusionPattern: /#!\/usr\/bin\/env node/ + } } }, blanket: { - coverage: { + coverageApp: { src: ['app/'], dest: 'coverage/app/' + }, + coverageLib: { + src: ['lib/'], + dest: 'coverage/lib/' + }, + coverageBin: { + src: ['.tmp/bin/'], + dest: 'coverage/bin/' } }, mochaTest: { @@ -62,44 +133,221 @@ module.exports = function(grunt) { options: { reporter: 'spec', }, - src: ['coverage/test/server/*.js'] + src: ['coverage/test/core/*.js', 'coverage/test/api/*.js'] + }, + 'test-current-work': { + options: { + reporter: 'spec', + }, + src: ['coverage/test/api/apiTest.js'] }, coverage: { options: { reporter: 'html-cov', - // use the quiet flag to suppress the mocha console output quiet: true, - // specify a destination file to capture the mocha - // output (the quiet option does not suppress this) captureFile: 'coverage/coverage.html' }, - src: ['coverage/test/server/*.js'] + src: ['coverage/test/core/*.js', 'coverage/test/api/*.js'] + } + }, + env: { + dev: { + NODE_ENV: 'development' + }, + builded: { + NODE_ENV: 'production' + } + }, + express: { + dev: { + options: { + port: 8383, + server: './bin/server.js', + serverreload: true, + showStack: true + } + }, + builded: { + options: { + port: 8383, + server: './bin/server.js', + serverreload: true, + showStack: true + } + }, + test: { + options: { + port: 8387, + server: './coverage/bin/server.js', + showStack: true + } + }, + testSuite: { + options: { + port: 8388, + bases: 'test/www' + } + } + }, + useminPrepare: { + html: './front/src/main.html', + options: { + dest: './front/build', + root: ['./', './front/src'] + } + }, + usemin: { + html: './front/build/main.html', + css: './front/build/css/*.css', + options: { + assetsDirs: ['front/build'], + patterns: { + css: [[/(\/fonts\/icons\.woff)/gm, 'Replacing reference to icons.woff']] + } + } + }, + htmlmin: { + options: { + removeComments: true, + collapseWhitespace: true + }, + main: { + files: [{ + expand: true, + cwd: './front/build/', + src: 'main.html', + flatten: true, + dest: './front/build' + }] + }, + views: { + files: [{ + expand: true, + cwd: './front/src/views', + src: '*.html', + flatten: true, + dest: '.tmp/views/' + }] + } + }, + inline_angular_templates: { + build: { + options: { + base: '.tmp', + method: 'append' + }, + files: { + './front/build/main.html': ['.tmp/views/*.html'] + } + } + }, + filerev: { + options: { + algorithm: 'md5', + length: 8 + }, + assets: { + src: './front/build/*/*.*' } } }); - require('matchdep').filterDev('grunt-*').forEach(grunt.loadNpmTasks); + + // Custom task: copies the test settings.json file to the coverage folder, and checks if there's no missing fields + grunt.registerTask('copy-test-server-settings', function() { + var mainSettingsFile = './server_config/settings.json'; + var testSettingsFile = './test/fixtures/settings.json'; + + var mainSettings = grunt.file.readJSON(mainSettingsFile); + var testSettings = grunt.file.readJSON(testSettingsFile); + + // Recursively compare keys of two objects (not the values) + function compareKeys(original, copy, context) { + for (var key in original) { + if (!copy[key] && copy[key] !== '' && copy[key] !== 0) { + grunt.fail.warn('Settings file ' + testSettingsFile + ' doesn\'t contain key ' + context + '.' + key); + } + if (original[key] !== null && typeof original[key] === 'object') { + compareKeys(original[key], copy[key], context + '.' + key); + } + } + } + + compareKeys(mainSettings, testSettings, 'settings'); + + var outputFile = './coverage/server_config/settings.json'; + grunt.file.write(outputFile, JSON.stringify(testSettings, null, 4)); + grunt.log.ok('File ' + outputFile + ' created'); + }); + grunt.registerTask('icons', [ 'font:icons', 'less', - 'clean:icons' + 'clean:tmp' ]); grunt.registerTask('build', [ 'jshint', - 'less' + 'clean:build', + 'copy:build', + 'less', + 'useminPrepare', + 'concat', + 'uglify', + 'cssmin', + 'replace', + 'htmlmin:views', + 'inline_angular_templates', + 'filerev', + 'usemin', + 'htmlmin:main', + 'clean:tmp' ]); grunt.registerTask('hint', [ 'jshint' ]); + grunt.registerTask('dev', [ + 'env:dev', + 'express:dev' + ]); + + grunt.registerTask('builded', [ + 'env:builded', + 'express:builded' + ]); + grunt.registerTask('test', [ + 'build', + 'jshint', + 'express:testSuite', 'clean:coverage', + 'copy-test-server-settings', + 'lineremover:beforeCoverage', + 'copy:beforeCoverage', 'blanket', 'copy:coverage', - 'mochaTest' + 'express:test', + 'mochaTest:test', + 'mochaTest:coverage', + 'clean:tmp' + ]); + + grunt.registerTask('test-current-work', [ + 'build', + 'jshint', + 'express:testSuite', + 'clean:coverage', + 'copy-test-server-settings', + 'lineremover:beforeCoverage', + 'copy:beforeCoverage', + 'blanket', + 'copy:coverage', + 'express:test', + 'mochaTest:test-current-work', + 'clean:tmp' ]); }; \ No newline at end of file diff --git a/README.md b/README.md index d19b146..dcba58f 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,57 @@ # Yellow Lab Tools -Online tool designed to help Front-End developers optimize their website +Online tool that lets you test a webpage and detects **performance** and **front-end code quality** issues. [![Dependency Status](https://gemnasium.com/gmetais/YellowLabTools.svg)](https://gemnasium.com/gmetais/YellowLabTools) [![Build Status](https://travis-ci.org/gmetais/YellowLabTools.svg?branch=master)](https://travis-ci.org/gmetais/YellowLabTools) ### Access the tool here: [http://yellowlab.tools](http://yellowlab.tools) +![example dashboard screenshot](screenshot.png) + ## How it works -The tool is based on the fabulous [Phantomas](https://github.com/macbre/phantomas) by Maciej Brencz, that loads a page and collects many web performance metrics. -I rewrote some modules to deeper analyze Javascript interactions with the DOM (especially with jQuery). -And then it is wrapped inside a small NodeJS server to get an interface (inspired by my favorite tool: [WebPageTest](http://www.webpagetest.org/)). +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. -**There are so many things left to do, your help would be greatly appreciated! Please report bugs, ask for evolutions and come code with me.** +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!!! + + +## Different ways to use YLT + +#### The web interface: +This is the best way to discover the tool: [http://yellowlab.tools](http://yellowlab.tools). +Please note the server is hosted in Baltimore, USA. + +#### The Command Line Client: +Might be useful if you want to build an automation tool. The documentation is [here](https://github.com/gmetais/YellowLabTools/wiki/Command-Line-Interface). + +#### The NPM module: +Can be used to build automation tools in NodeJS. The documentation is [here](https://github.com/gmetais/YellowLabTools/wiki/NodeJS-module). + +#### The Public API: +Hosted on our http://yellowlab.tools server, it is a RESTful API that allows you to launch distant tests. The documentation is [here](https://github.com/gmetais/YellowLabTools/wiki/Public-API). + + +## Install your own private instance + +If your project is not accessible from outside, or if you want to fork and improve the tool, you can build your own instance. The documentation is [here](https://github.com/gmetais/YellowLabTools/wiki/Install-your-private-server). ## Help needed -I'm currently looking for help in correcting the english wordings in the tool, as it is not my native language. - - -## Install your own instance - -If your project is not accessible from outside, or if you want to fork and improve the tool, you can build your own instance. The documentation is [here](https://github.com/gmetais/YellowLabTools/wiki/Install-your-own-instance). +There are so many things left to do, **your help would be greatly appreciated**! Please report bugs, ask for evolutions and come code with me. ## License -Please read the [license](LICENSE). - +Please read the [license](LICENSE). Icons are the property of [IcoMoon.io](https://icomoon.io/) ## Author -Gaël Métais. I'm a webperf freelance based in Paris. If you understand french, you can visit [my website](http://www.gaelmetais.com). \ No newline at end of file +Gaël Métais. I'm a webperf freelance based in Paris. If you understand french, you can visit [my website](http://www.gaelmetais.com). + + +## Contributors +- Achraf Ben Younes [achrafbenyounes](https://github.com/achrafbenyounes) + diff --git a/app/lib/phantomasWrapper.js b/app/lib/phantomasWrapper.js deleted file mode 100644 index 3b71204..0000000 --- a/app/lib/phantomasWrapper.js +++ /dev/null @@ -1,112 +0,0 @@ -/** - * Yellow Lab Tools main file - */ - -var async = require('async'); -var phantomas = require('phantomas'); - -var PhantomasWrapper = function() { - 'use strict'; - - /** - * This is the phantomas launcher. It merges user chosen options into the default options - * Available options : - * - * - timeout : in seconds (default 60) - * - jsDeepAnalysis : should we inspect subrequests in the javascript execution tree (reported durations of main tasks will be slower than usual) - * - */ - this.execute = function(task, callback) { - - var options = { - // Cusomizable options - timeout: task.options.timeout || 60, - 'js-deep-analysis': task.options.jsDeepAnalysis || false, - 'user-agent': 'Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/27.0.1453.110 Safari/537.36', - - // Mandatory - reporter: 'json:pretty', - 'analyze-css': true, - 'skip-modules': [ - 'analyzeCss', // overriden - 'blockDomains', // not needed - 'domComplexity', // overriden - 'domMutations', // not compatible with webkit - 'domQueries', // overriden - 'eventListeners', // overridden - 'filmStrip', // not needed - 'har', // not needed for the moment - 'pageSource', // not needed - 'screenshot', // not needed for the moment - 'waitForSelector', // not needed - 'windowPerformance' // overriden - ].join(','), - 'include-dirs': [ - 'phantomas_custom/core', - 'phantomas_custom/modules' - ].join(',') - }; - - // Output the command line for debugging purpose - console.log('If you want to reproduce the phantomas task only, copy the following command line:'); - var optionsString = ''; - for (var opt in options) { - - var value = options[opt]; - if ((typeof value === 'string' || value instanceof String) && value.indexOf(' ') >= 0) { - value = '"' + value + '"'; - } - - optionsString += ' ' + '--' + opt + '=' + value; - } - console.log('node node_modules/phantomas/bin/phantomas.js --url=' + task.url + optionsString + ' --verbose'); - - // Kill the application if nothing happens for 10 minutes - var killer = setTimeout(function() { - console.log('Killing the server because the test ' + task.testId + ' on ' + task.url + ' was launched 10 minutes ago'); - // Forever will restart the server - process.exit(1); - }, 600000); - - // It's time to launch the test!!! - var triesNumber = 3; - - async.retry(triesNumber, function(cb) { - phantomas(task.url, options, function(err, json, results) { - console.log('Returning from Phantomas'); - - // Adding some YellowLabTools errors here - if (json && json.metrics && !json.metrics.javascriptExecutionTree) { - err = 1001; - } - - if (!err && (!json || !json.metrics)) { - err = 1002; - } - - // Don't cancel test if it is a timeout and we've got some results - if (err === 252 && json) { - console.log('Timeout after ' + options.timeout + ' seconds. But it\'s not a problem, the test is valid.'); - err = null; - } - - if (err) { - console.log('Attempt failed for test id ' + task.testId + '. Error code ' + err); - } - - cb(err, {json: json, results: results}); - }); - }, function(err, data) { - - clearTimeout(killer); - - if (err) { - console.log('All ' + triesNumber + ' attemps failed for test id ' + task.testId); - } - callback(err, data.json, data.results); - }); - - }; -}; - -module.exports = new PhantomasWrapper(); \ No newline at end of file diff --git a/app/lib/strReplace.js b/app/lib/strReplace.js deleted file mode 100644 index 4b36abb..0000000 --- a/app/lib/strReplace.js +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Alternative to the standard String.prototype.replace function - * Avoids problems with $$, $1, $2, ... - */ - -module.exports = function(str, searched, replacement) { - return str.split(searched).join(replacement); -}; \ No newline at end of file diff --git a/app/lib/testQueue.js b/app/lib/testQueue.js deleted file mode 100644 index f271ccb..0000000 --- a/app/lib/testQueue.js +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Creation of a queue and it's worker function - */ - -var util = require('util'); -var EventEmitter = require('events').EventEmitter; -var async = require('async'); -var phantomasWrapper = require('./phantomasWrapper'); - - -var testQueue = function() { - 'use strict'; - - var currentTask = null; - var self = this; - - var queue = async.queue(function(task, callback) { - currentTask = task; - - console.log('Starting test ' + task.testId); - - phantomasWrapper.execute(task, function(err, json, results) { - console.log('Test ' + task.testId + ' complete'); - currentTask = null; - callback(err, json, results); - self.emit('queueMoving'); - }); - }); - - - // Use this method to add a test to the queue - this.push = queue.push; - - - // Gives the position of a task in the queue - // Returns 0 if it is the current running task - // Returns -1 if not found - this.indexOf = function(testId) { - if (currentTask && currentTask.testId === testId) { - return 0; - } - - var position = -1; - if (queue.length() > 0) { - queue.tasks.forEach(function(task, index) { - if (task.data.testId === testId) { - position = index + 1; - } - }); - } - return position; - }; - - this.testComplete = function(testId) { - self.emit('testComplete', testId); - }; - - this.testFailed = function(testId) { - self.emit('testFailed', testId); - }; -}; - -// extend the EventEmitter class -util.inherits(testQueue, EventEmitter); - -module.exports = new testQueue(); \ No newline at end of file diff --git a/app/node_controllers/indexController.js b/app/node_controllers/indexController.js deleted file mode 100644 index 88d1913..0000000 --- a/app/node_controllers/indexController.js +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Yellow Lab Tools home page controller - */ - -var async = require('async'); -var fs = require ('fs'); -var strReplace = require('../lib/strReplace'); - -var indexController = function(req, res, googleAnalyticsId, version) { - 'use strict'; - - async.parallel({ - - htmlTemplate: function(callback) { - fs.readFile('./app/node_views/index.html', {encoding: 'utf8'}, callback); - } - - }, function(err, results) { - var html = results.htmlTemplate; - html = strReplace(html, '%%GA_ID%%', googleAnalyticsId); - html = strReplace(html, '%%VERSION%%', version); - - res.setHeader('Content-Type', 'text/html'); - res.send(html); - }); - }; - - module.exports = indexController; \ No newline at end of file diff --git a/app/node_controllers/launchTestController.js b/app/node_controllers/launchTestController.js deleted file mode 100644 index 5ea78d8..0000000 --- a/app/node_controllers/launchTestController.js +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Controller for the test launching page (the waiting page, after the user submited a test on the index page) - */ - -var async = require('async'); -var fs = require ('fs'); -var strReplace = require('../lib/strReplace'); - -var launchTestController = function(req, res, testQueue, googleAnalyticsId) { - 'use strict'; - - // Generate test id - var testId = (Date.now()*1000 + Math.round(Math.random()*1000)).toString(36); - - var resultsPath = 'results/' + testId; - var phantomasResultsPath = resultsPath + '/results.json'; - - var url = req.body.url; - if (url.indexOf('http://') !== 0 && url.indexOf('https://') !== 0) { - url = 'http://' + url; - } - - var options = {}; - if (req.body.timeout) { - options.timeout = req.body.timeout; - } - - async.waterfall([ - - function htmlTemplate(callback) { - fs.readFile('./app/node_views/launchTest.html', {encoding: 'utf8'}, callback); - }, - - function sendResponse(html, callback) { - - html = strReplace(html, '%%TEST_URL%%', url); - html = strReplace(html, '%%TEST_ID%%', testId); - html = strReplace(html, '%%GA_ID%%', googleAnalyticsId); - - res.setHeader('Content-Type', 'text/html'); - res.send(html); - - callback(); - }, - - function createFolder(callback) { - // Create results folder - fs.mkdir(resultsPath, callback); - }, - - function executePhantomas(callback) { - console.log('Adding test ' + testId + ' on ' + url + ' to the queue'); - - var task = { - testId: testId, - url: url, - options: options - }; - - testQueue.push(task, callback); - }, - - function writeResults(json, resultsObject, callback) { - console.log('Saving Phantomas results file to ' + phantomasResultsPath); - fs.writeFile(phantomasResultsPath, JSON.stringify(json, null, 4), callback); - } - - ], function(err) { - if (err) { - console.log('An error occured in the phantomas test: ', err); - - fs.writeFile(phantomasResultsPath, JSON.stringify({url: url, error: err}, null, 4), function(err) { - if (err) { - console.log('Could not even write an error message on file ' + phantomasResultsPath); - console.log(err); - } - }); - testQueue.testFailed(testId); - } else { - testQueue.testComplete(testId); - } - }); - }; - - module.exports = launchTestController; \ No newline at end of file diff --git a/app/node_controllers/resultsController.js b/app/node_controllers/resultsController.js deleted file mode 100644 index d1cdb97..0000000 --- a/app/node_controllers/resultsController.js +++ /dev/null @@ -1,47 +0,0 @@ -/** - * The page that dispays the results - */ - -var async = require('async'); -var fs = require('fs'); -var strReplace = require('../lib/strReplace'); - -var resultsController = function(req, res, googleAnalyticsId) { - 'use strict'; - - var testId = req.params.testId; - var resultsPath = 'results/' + testId; - var phantomasResultsPath = resultsPath + '/results.json'; - - console.log('Opening test ' + testId + ' results as HTML'); - - async.parallel({ - - htmlTemplate: function(callback) { - fs.readFile('./app/node_views/results.html', {encoding: 'utf8'}, callback); - }, - - phantomasResults: function(callback) { - fs.readFile(phantomasResultsPath, {encoding: 'utf8'}, callback); - } - - }, function(err, results) { - if (err) { - console.log(err); - return res.status(404).send('Sorry, test not found...'); - } - - // Escape "" because it can interfer with the HTML parser - var phantomasResults = results.phantomasResults; - phantomasResults = phantomasResults.replace(/<\/script>/g, '\\u003c/script>'); - - var html = results.htmlTemplate; - html = strReplace(html, '%%RESULTS%%', phantomasResults); - html = strReplace(html, '%%GA_ID%%', googleAnalyticsId); - - res.setHeader('Content-Type', 'text/html'); - res.send(html); - }); -}; - -module.exports = resultsController; \ No newline at end of file diff --git a/app/node_controllers/waitingQueueSocket.js b/app/node_controllers/waitingQueueSocket.js deleted file mode 100644 index b8fd30f..0000000 --- a/app/node_controllers/waitingQueueSocket.js +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Socket.io handler - */ - -var fs = require('fs'); - -var waitingQueueSocket = function(socket, testQueue) { - - socket.on('waiting', function(testId) { - console.log('User waiting for test id ' + testId); - - sendTestStatus(testId); - - testQueue.on('testComplete', function(id) { - if (testId === id) { - socket.emit('complete'); - console.log('Sending complete event to test id ' + testId); - } - }); - - testQueue.on('testFailed', function(id) { - if (testId === id) { - socket.emit('failed'); - console.log('Sending failed event to test id ' + testId); - } - }); - - testQueue.on('queueMoving', function() { - var positionInQueue = testQueue.indexOf(testId); - if (positionInQueue >= 0) { - socket.emit('position', positionInQueue); - console.log('Sending position to test id ' + testId); - } - }); - }); - - // Finds the status of a test and send it to the client - function sendTestStatus(testId) { - // Check task position in queue - var positionInQueue = testQueue.indexOf(testId); - - if (positionInQueue >= 0) { - socket.emit('position', positionInQueue); - } else { - // Find in results files - var exists = fs.exists('results/' + testId + '/results.json', function(exists) { - if (exists) { - // TODO : find a way to make sure the file is completely written - setTimeout(function() { - socket.emit('complete'); - }, 4000); - } else { - socket.emit('404'); - } - }); - } - } -}; - -module.exports = waitingQueueSocket; \ No newline at end of file diff --git a/app/node_views/index.html b/app/node_views/index.html deleted file mode 100644 index f1a4c18..0000000 --- a/app/node_views/index.html +++ /dev/null @@ -1,43 +0,0 @@ - - - - - Yellow Lab Tools - - - - - -

Yellow Lab Tools

-

Free online test to help speeding up heavy web pages

-
- - -
- -
-

If you want to learn more...

- What is Yellow Lab Tools? -
- Learn how to deeply analyze your JavaScript -
- - - - - - \ No newline at end of file diff --git a/app/node_views/launchTest.html b/app/node_views/launchTest.html deleted file mode 100644 index 9d31ffc..0000000 --- a/app/node_views/launchTest.html +++ /dev/null @@ -1,97 +0,0 @@ - - - - Yellow Lab Tools - Awaiting - - - - - - -

Yellow Lab Tools

- -
- -
%%TEST_URL%%
- - - - - - \ No newline at end of file diff --git a/app/node_views/results.html b/app/node_views/results.html deleted file mode 100644 index 6cab6db..0000000 --- a/app/node_views/results.html +++ /dev/null @@ -1,959 +0,0 @@ - - - - Yellow Lab Tools - Results page - - - - - - - - - - -

Yellow Lab Tools

- -
Untangling and counting the spaghettis...
- -
-
Tested url:   {{phantomasResults.url}}
- -
-
New test
- - -
- -
-

Error: {{phantomasResults.error}}

-
Phantomas timed out
-
Phantomas config error
-
Phantomas failed to load page
-
Phantomas internal error
-
Javascript execution tree error
-
JSON undefined error
-
- -
-

Grades

- -
-
-
{{notations.domComplexity}}
-
DOM complexity
-
-
-
-
DOM elements count
-
- {{phantomasResults.metrics.DOMelementsCount}} -
-
-
- -

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

-

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

-
-
-
-
-
DOM max depth
-
- {{phantomasResults.metrics.DOMelementMaxDepth}} - -
-
-
- -

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

-

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

-
-
-
-
-
Number of iframes
-
- {{phantomasResults.metrics.iframesCount}} -
-
-
- -

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

-
-
-
-
-
IDs duplicated
-
- {{phantomasResults.metrics.DOMidDuplicated}} - -
-
-
- -

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

-
-
-
-
-
-
-
-
{{notations.jsDomManipulations}}
-
DOM manipulations
-
-
-
-
DOM inserts
-
- {{phantomasResults.metrics.DOMinserts}} - -
-
-
- -

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

-

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

-
-
-
-
-
DOM queries
-
- {{phantomasResults.metrics.DOMqueries}} -
-
-
- -

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

-

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

-

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

-
-
-
-
-
Avoidable queries
-
- {{phantomasResults.metrics.DOMqueriesAvoidable}} - -
-
-
- -

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

-

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

-
-
-
-
-
Events bound
-
- {{phantomasResults.metrics.eventsBound}} - -
-
-
- -

Binding too many events has a cost.

-

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

-
-
-
-
-
-
-
-
{{notations.jsBadPractices}}
-
Bad Javascript
-
-
-
-
Javascript errors
-
- {{phantomasResults.metrics.jsErrors}} - -
-
-
- -

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

-

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

-
-
-
-
-
eval calls
-
{{phantomasResults.metrics.evalCalls}}
-
-
- -

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

-
-
-
-
-
document.write calls
-
{{phantomasResults.metrics.documentWriteCalls}}
-
-
- -

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

-

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

-
-
-
-
-
Console messages
-
{{phantomasResults.metrics.consoleMessages}}
-
-
- -

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

-

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

-

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

-
-
-
-
-
Global variables
-
- {{phantomasResults.metrics.globalVariables}} - -
-
-
- -

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

-

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

-
-
-
-
-
DOM manipulations in body
-
- {{inBodyDomManipulations}} -
-
-
- -

This metric counts the number of DOM queries, DOM inserts, binds, etc. made by the Javascript before the DOMContentLoaded event.

-

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.

-

The JS Timeline tab can help you identify what's happening.

-
-
-
-
-
-
-
-
{{notations.jQueryLoading}}
-
jQuery version
-
-
-
-
jQuery version
-
{{phantomasResults.metrics.jQueryVersion}}
-
-
- -

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

-

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

-
-
-
-
-
{{phantomasResults.metrics.jQueryDifferentVersions}} versions loaded
-
- {{version}} & -
-
-
- -

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

-
-
-
-
-
-
-
-
A
-
F
-
CSS syntax errors
-
-
-
-
-
CSS files with syntax errors
-
- {{phantomasResults.metrics.cssParsingErrors}} - -
-
-
- -

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

-

- Direct links to the W3C CSS Validator for the following stylesheet(s): -

-
-
-
-
-
-
-
-
{{notations.cssComplexity}}
-
CSS complexity
-
-
-
-
Rules count
-
- {{phantomasResults.metrics.cssRules}} -
-
-
- -

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

-

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

-
-
-
-
-
Complex selectors
-
- {{phantomasResults.metrics.cssComplexSelectors}} - -
-
-
- -

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

-

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

-
-
-
-
-
Complex attributes selector
-
- {{phantomasResults.metrics.cssComplexSelectorsByAttribute}} - -
-
-
- -

Complex attributes selectors are one of these: -

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

-

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

-
-
-
-
-
( couldn't be parsed)
-
-
-
-
-
-
-
-
{{notations.badCss}}
-
Bad CSS
-
-
-
-
Uses of @import
-
- {{phantomasResults.metrics.cssImports}} - -
-
-
- -

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

-

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

-
-
-
-
-
Duplicated selectors
-
- {{phantomasResults.metrics.cssDuplicatedSelectors}} - -
-
-
- -

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

-
-
-
-
-
Duplicated properties
-
- {{phantomasResults.metrics.cssDuplicatedProperties}} - -
-
-
- -

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

-
-
-
-
-
Empty rules
-
- {{phantomasResults.metrics.cssEmptyRules}} - -
-
-
- -

Very easy to fix.

-
-
-
-
-
CSS expressions
-
- {{phantomasResults.metrics.cssExpressions}} - -
-
-
- -

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

-

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

-
-
-
-
-
Uses of !important
-
- {{phantomasResults.metrics.cssImportants}} - -
-
-
- -

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

-
-
-
-
-
Old IE fixes
-
- {{phantomasResults.metrics.cssOldIEFixes}} - -
-
-
- -

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

-

IE6: -

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

-

IE7: -

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

-

IE9: -

    -
  • -ms-filter
  • -
  • progid:DXImageTransform.Microsoft
  • -
-

-
-
-
-
-
Old prefixes
-
- {{phantomasResults.metrics.cssOldPropertyPrefixes}} - -
-
-
- -

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

-
-
-
-
-
Universal selectors
-
- {{phantomasResults.metrics.cssUniversalSelectors}} - -
-
-
- -

Universal selectors are the most expensive CSS selectors.

-

More informations here.

-
-
-
-
-
Redundant body selectors
-
- {{phantomasResults.metrics.cssRedundantBodySelectors}} - -
-
-
- -

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

-
-
-
-
-
Redundant tags selectors
-
- {{phantomasResults.metrics.cssRedundantChildNodesSelectors}} - -
-
-
- -

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

-

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

-
-
-
-
-
( couldn't be parsed)
-
-
-
-
-
-
-
-
{{notations.requests}}
-
Requests number
-
-
-
-
Total requests
-
- {{phantomasResults.metrics.requests}} -
-
-
- -

This is one of the most important performance rule. Every request is slowing down the page loading.

-

There are several technics to reduce their number: -

    -
  • Concatenate JS files
  • -
  • Concatenate CSS files
  • -
  • Embed or inline small JS or CSS files in the HTML
  • -
  • Create sprites or icon fonts
  • -
  • Base64 encode small images in HTML or stylesheets
  • -
  • Use lazyloading for images
  • -
-

-
-
-
-
-
Documents
-
- {{phantomasResults.metrics.htmlCount}} - -
-
-
-
-
Scripts
-
- {{phantomasResults.metrics.jsCount}} - -
-
-
-
-
Stylesheets
-
- {{phantomasResults.metrics.cssCount}} - -
-
-
-
-
Images
-
- {{phantomasResults.metrics.imageCount}} - -
-
-
-
-
Fonts
-
- {{phantomasResults.metrics.webfontCount}} - -
-
-
-
-
Videos
-
- {{phantomasResults.metrics.videoCount}} - -
-
-
-
-
JSON
-
- {{phantomasResults.metrics.jsonCount}} - -
-
-
-
-
Other
-
- {{phantomasResults.metrics.otherCount}} - -
-
-
-
-
-
-
-
{{notations.network}}
-
Network
-
-
-
-
404 not found
-
- {{phantomasResults.metrics.notFound}} - -
-
-
- -

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

-
-
-
-
-
Connections closed
-
- {{phantomasResults.metrics.closedConnections}} - -
-
-
- -

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

-

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

-

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

-
-
-
-
-
Duplicated requests
-
- {{phantomasResults.metrics.multipleRequests}} - -
-
-
- -

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

-
-
-
-
-
Caching disabled
-
- {{phantomasResults.metrics.cachingDisabled}} - -
-
-
- -

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

-

Fix immediatly if on static assets.

-
-
-
-
-
Caching not specified
-
- {{phantomasResults.metrics.cachingNotSpecified}} - -
-
-
- -

Responses with no caching header sent (either Cache-Control or Expires).

-

Every request should have a cache time specified. If you really don't want cache, specify "max-age=0", otherwise some browsers will try to cache.

-
-
-
-
-
Caching too short
-
- {{phantomasResults.metrics.cachingTooShort}} - -
-
-
- -

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

-

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

-
-
-
-
-
Different domains
-
- {{phantomasResults.metrics.domains}} - -
-
-
- -

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

-

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

-
-
-
-
-
-
-
-
- -
-

Javascript Timeline

-

- This graph gives a quick view of when the Javascript interactions with the DOM occur during the loading of the page. -

-
-
-
-
-
-
-
Timestamp: {{$index * timelineIntervalDuration | number: 0}} ms
-
-
-
-
0 ms
-
{{endTime | number: 0}} ms
-
-
-
-
DOM creation
-
DOM interactive
-
DOM content loaded event
-
Page completion
-
Page is complete
-
-
-
Executing Javascript and DOM queries here is a bad practice and slows down the DOM construction.
-
Some frameworks do things here, but it's not reliable and should be avoided.
-
Also known as "document ready". This is where you should execute top-priority scripts, like binding action buttons or launch a video player.
-
Here you can execute mid-priority tasks. Loading a script with createElement('script') is one way to do so.
-
The page is considered loaded, it's time for low priority things : trackers, social plugins, easter egg...
-
-
-
- -

Javascript Profiler

-

- The table below shows the interactions between Javascript and the DOM. It is useful to understand what happens while the page loads. -

-
-
- - -
-
- - Filter by - -
-
-
- - -
-
-
Type
-
Params
-
-
Timestamp
-
-
-
{{$index + 1}}
-
{{node.data.type}}
- -
- {{node.data.callDetails.arguments[0]}} - : {{node.data.callDetails.arguments[1]}} - : {{node.data.callDetails.arguments[2]}} - : {{node.data.callDetails.arguments[3]}} -
- -
-
-
-
- -
-

Called on DOM element

-
{{node.data.callDetails.context.domElement}}
-
- -
-

Called on 0 jQuery element

-

Useless function call, as the jQuery object is empty.

-
- -
-

Called on 1 jQuery element

-
{{node.data.callDetails.context.firstElementPath}}
-
- -
-

Called on {{node.data.callDetails.context.length}} jQuery elements

-

- 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). -

-

First one is: {{node.data.callDetails.context.firstElementPath}}

-
- -

- The query returned 0 results. Could it be unused or dead code? -

- -
-

Backtrace

-
-
-
{{trace.fnName || '(anonymous)'}}
- -
-
-
can't find any backtrace :/
-
-
-
- -
-

Sub processes

-
-
-
-
Type
-
Params
-
Duration
-
-
-
{{$index}}
-
{{node.data.type}}
-
- {{node.data.callDetails.arguments[0]}} - : {{node.data.callDetails.arguments[1]}} - : {{node.data.callDetails.arguments[2]}} - : {{node.data.callDetails.arguments[3]}} -
-
{{node.data.time}} ms
-
-
-
-
-
-
-
{{node.data.timestamp | number: 0}} ms
-
-
-
-
- - - - - - \ No newline at end of file diff --git a/app/public/fonts/icons.woff b/app/public/fonts/icons.woff deleted file mode 100644 index 832a8a8..0000000 Binary files a/app/public/fonts/icons.woff and /dev/null differ diff --git a/app/public/scripts/app.js b/app/public/scripts/app.js deleted file mode 100644 index f8b9dd7..0000000 --- a/app/public/scripts/app.js +++ /dev/null @@ -1,5 +0,0 @@ -angular.module('YellowLabTools', [ - 'Results', - 'ngModal', - 'ShowOffendersDirective' -]); \ No newline at end of file diff --git a/app/public/scripts/resultsCtrl.js b/app/public/scripts/resultsCtrl.js deleted file mode 100644 index 8cc9542..0000000 --- a/app/public/scripts/resultsCtrl.js +++ /dev/null @@ -1,417 +0,0 @@ -var app = angular.module('Results', []); - -app.controller('ResultsCtrl', function ($scope) { - // Grab results from nodeJS served page - $scope.phantomasResults = window._phantomas_results; - - $scope.view = 'execution'; - - if ($scope.phantomasResults.metrics && $scope.phantomasResults.offenders && $scope.phantomasResults.offenders.javascriptExecutionTree) { - - // Get the execution tree from the offenders - $scope.javascript = JSON.parse($scope.phantomasResults.offenders.javascriptExecutionTree); - - // Sort globalVariables offenders alphabetically - if ($scope.phantomasResults.offenders.globalVariables) { - $scope.phantomasResults.offenders.globalVariables.sort(); - } - - - initSummaryView(); - initJSTimelineView(); - - } - - $scope.setView = function(viewName) { - $scope.view = viewName; - }; - - $scope.onNodeDetailsClick = function(node) { - var isOpen = node.data.showDetails; - if (!isOpen) { - // Close all other nodes - $scope.javascript.children.forEach(function(currentNode) { - currentNode.data.showDetails = false; - }); - - // Parse the backtrace - if (!node.data.parsedBacktrace) { - node.data.parsedBacktrace = parseBacktrace(node.data.backtrace); - } - - } - node.data.showDetails = !isOpen; - }; - - function initSummaryView() { - - // Read the main elements of the tree and sum the total time - $scope.totalJSTime = 0; - $scope.inBodyDomManipulations = 0; - treeRunner($scope.javascript, function(node) { - if (node.data.time) { - $scope.totalJSTime += node.data.time; - } - - if (node.data.timestamp < $scope.phantomasResults.metrics.domInteractive && - node.data.type !== 'jQuery - onDOMReady') { - $scope.inBodyDomManipulations ++; - } - - if (node.data.type !== 'main') { - // Don't check the children - return false; - } - }); - - // If there are some CSS parsing errors, prepare the W3C CSS Validator direct URLs - if ($scope.phantomasResults.offenders.cssParsingErrors) { - $scope.cssW3cDirectUrls = []; - $scope.phantomasResults.offenders.cssParsingErrors.forEach(function(errorString, index) { - var stylesheet = errorString.split(' ')[0]; - var w3cUrl = 'http://jigsaw.w3.org/css-validator/validator?profile=css3&usermedium=all&warning=no&vextwarning=true&lang=en&uri=' + encodeURIComponent(stylesheet); - $scope.cssW3cDirectUrls.push({ - url: stylesheet, - w3c: w3cUrl - }); - }); - } - - // Grab the notes - $scope.notations = { - domComplexity: getDomComplexityScore(), - jsDomManipulations: getJsDomManipulationsScore(), - jsBadPractices: getJSBadPracticesScore(), - jQueryLoading: getJQueryLoadingScore(), - cssComplexity: getCSSComplexityScore(), - badCss: getBadCssScore(), - requests: requestsScore(), - network: networkScore() - }; - } - - function initJSTimelineView() { - - if (!$scope.javascript.children) { - return; - } - - // Read the execution tree and adjust the navigation timings (cause their not very well synchronised) - treeRunner($scope.javascript, function(node) { - switch(node.data.type) { - case 'domInteractive': - $scope.phantomasResults.metrics.domInteractive = node.data.timestamp; - break; - case 'domContentLoaded': - $scope.phantomasResults.metrics.domContentLoaded = node.data.timestamp; - break; - case 'domContentLoadedEnd': - $scope.phantomasResults.metrics.domContentLoadedEnd = node.data.timestamp; - break; - case 'domComplete': - $scope.phantomasResults.metrics.domComplete = node.data.timestamp; - break; - } - - if (node.data.type !== 'main') { - // Don't check the children - return false; - } - }); - - - // Now read the tree and display it on a timeline - - // Split the timeline into 200 intervals - var numberOfIntervals = 199; - var lastEvent = $scope.javascript.children[$scope.javascript.children.length - 1]; - $scope.endTime = lastEvent.data.timestamp + (lastEvent.data.time || 0); - $scope.timelineIntervalDuration = $scope.endTime / numberOfIntervals; - - // Pre-fill array of as many elements as there are milleseconds - var millisecondsArray = Array.apply(null, new Array($scope.endTime + 1)).map(Number.prototype.valueOf,0); - - // Create the milliseconds array from the execution tree - treeRunner($scope.javascript, function(node) { - - if (node.data.time !== undefined) { - - // Ignore artefacts (durations > 100ms) - var time = Math.min(node.data.time, 100) || 1; - - for (var i=node.data.timestamp, max=node.data.timestamp + time ; i 1000) { - note = 'B'; - } - if (score > 1500) { - note = 'C'; - } - if (score > 2000) { - note = 'D'; - } - if (score > 3000) { - note = 'E'; - } - if (score > 4000) { - note = 'F'; - } - return note; - } - - function getJsDomManipulationsScore() { - var note = 'A'; - var score = $scope.phantomasResults.metrics.DOMinserts * 2 + - $scope.phantomasResults.metrics.DOMqueries + - $scope.phantomasResults.metrics.DOMqueriesAvoidable * 2 + - $scope.phantomasResults.metrics.eventsBound; - if (score > 300) { - note = 'B'; - } - if (score > 500) { - note = 'C'; - } - if (score > 700) { - note = 'D'; - } - if (score > 1000) { - note = 'E'; - } - if (score > 1400) { - note = 'F'; - } - return note; - } - - function getJSBadPracticesScore() { - var note = 'A'; - var score = $scope.phantomasResults.metrics.documentWriteCalls * 3 + - $scope.phantomasResults.metrics.evalCalls * 2 + - $scope.phantomasResults.metrics.jsErrors * 10 + - $scope.phantomasResults.metrics.consoleMessages / 2 + - $scope.phantomasResults.metrics.globalVariables / 20 + - Math.sqrt($scope.inBodyDomManipulations); - if (score > 10) { - note = 'B'; - } - if (score > 15) { - note = 'C'; - } - if (score > 20) { - note = 'D'; - } - if (score > 30) { - note = 'E'; - } - if (score > 45) { - note = 'F'; - } - return note; - } - - function getJQueryLoadingScore() { - var note = 'NA'; - if ($scope.phantomasResults.metrics.jQueryDifferentVersions > 1) { - note = 'F'; - } else if ($scope.phantomasResults.metrics.jQueryVersion) { - if ($scope.phantomasResults.metrics.jQueryVersion.indexOf('1.11.') === 0 || - $scope.phantomasResults.metrics.jQueryVersion.indexOf('1.12.') === 0 || - $scope.phantomasResults.metrics.jQueryVersion.indexOf('2.1.') === 0 || - $scope.phantomasResults.metrics.jQueryVersion.indexOf('2.2.') === 0) { - note = 'A'; - } else if ($scope.phantomasResults.metrics.jQueryVersion.indexOf('1.9.') === 0 || - $scope.phantomasResults.metrics.jQueryVersion.indexOf('1.10.') === 0 || - $scope.phantomasResults.metrics.jQueryVersion.indexOf('2.0.') === 0) { - note = 'B'; - } else if ($scope.phantomasResults.metrics.jQueryVersion.indexOf('1.7.') === 0 || - $scope.phantomasResults.metrics.jQueryVersion.indexOf('1.8.') === 0) { - note = 'C'; - } else if ($scope.phantomasResults.metrics.jQueryVersion.indexOf('1.5.') === 0 || - $scope.phantomasResults.metrics.jQueryVersion.indexOf('1.6.') === 0) { - note = 'D'; - } else if ($scope.phantomasResults.metrics.jQueryVersion.indexOf('1.2.') === 0 || - $scope.phantomasResults.metrics.jQueryVersion.indexOf('1.3.') === 0 || - $scope.phantomasResults.metrics.jQueryVersion.indexOf('1.4.') === 0) { - note = 'E'; - } - } - return note; - } - - function getCSSComplexityScore() { - if (!$scope.phantomasResults.metrics.cssRules) { - return 'NA'; - } - - var note = 'A'; - var score = $scope.phantomasResults.metrics.cssRules + - $scope.phantomasResults.metrics.cssComplexSelectors * 5 + - $scope.phantomasResults.metrics.cssComplexSelectorsByAttribute * 10; - if (score > 800) { - note = 'B'; - } - if (score > 1200) { - note = 'C'; - } - if (score > 2500) { - note = 'D'; - } - if (score > 4000) { - note = 'E'; - } - if (score > 6000) { - note = 'F'; - } - return note; - } - - function getBadCssScore() { - if (!$scope.phantomasResults.metrics.cssRules) { - return 'NA'; - } - - var note = 'A'; - var score = $scope.phantomasResults.metrics.cssDuplicatedSelectors + - $scope.phantomasResults.metrics.cssDuplicatedProperties + - $scope.phantomasResults.metrics.cssEmptyRules + - $scope.phantomasResults.metrics.cssExpressions * 10 + - $scope.phantomasResults.metrics.cssImportants * 2 + - $scope.phantomasResults.metrics.cssOldIEFixes * 10 + - $scope.phantomasResults.metrics.cssOldPropertyPrefixes + - $scope.phantomasResults.metrics.cssUniversalSelectors * 5 + - $scope.phantomasResults.metrics.cssRedundantBodySelectors * 0.5 + - $scope.phantomasResults.metrics.cssRedundantChildNodesSelectors * 0.5 + - $scope.phantomasResults.metrics.cssImports * 50; - if (score > 50) { - note = 'B'; - } - if (score > 100) { - note = 'C'; - } - if (score > 200) { - note = 'D'; - } - if (score > 500) { - note = 'E'; - } - if (score > 1000) { - note = 'F'; - } - return note; - } - - function requestsScore() { - var note = 'A'; - var score = $scope.phantomasResults.metrics.requests; - if (score > 30) { - note = 'B'; - } - if (score > 45) { - note = 'C'; - } - if (score > 60) { - note = 'D'; - } - if (score > 80) { - note = 'E'; - } - if (score > 100) { - note = 'F'; - } - return note; - } - - function networkScore() { - var note = 'A'; - var score = $scope.phantomasResults.metrics.notFound * 25 + - $scope.phantomasResults.metrics.closedConnections * 10 + - $scope.phantomasResults.metrics.multipleRequests * 10 + - $scope.phantomasResults.metrics.cachingDisabled * 2 + - $scope.phantomasResults.metrics.cachingNotSpecified + - $scope.phantomasResults.metrics.cachingTooShort / 2 + - $scope.phantomasResults.metrics.domains; - if (score > 20) { - note = 'B'; - } - if (score > 40) { - note = 'C'; - } - if (score > 60) { - note = 'D'; - } - if (score > 80) { - note = 'E'; - } - if (score > 100) { - note = 'F'; - } - return note; - } - - - - 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; - } - - // Goes on every node of the tree and calls the function fn. If fn returns false on a node, its children won't be checked. - function treeRunner(node, fn) { - if (fn(node) !== false && node.children) { - node.children.forEach(function(child) { - treeRunner(child, fn); - }); - } - } - -}); \ No newline at end of file diff --git a/app/public/scripts/showOffenders.js b/app/public/scripts/showOffenders.js deleted file mode 100644 index b4e16c8..0000000 --- a/app/public/scripts/showOffenders.js +++ /dev/null @@ -1,17 +0,0 @@ -var app = angular.module("ShowOffendersDirective", []); - -app.directive('showOffenders', function() { - return { - restrict: 'E', - transclude: true, - scope: { - modalTitle: "@", - metricName: "@", - phantomasResults: "=" - }, - controller: function($scope, $element, $attrs, $location) { - $scope.dialogShown = false; - }, - template: ' ' - }; -}); \ No newline at end of file diff --git a/app/public/styles/launchTest.css b/app/public/styles/launchTest.css deleted file mode 100644 index 47bcde3..0000000 --- a/app/public/styles/launchTest.css +++ /dev/null @@ -1,25 +0,0 @@ -#status { - margin-top: 2em; - font-size: 2.5em; -} -@-webkit-keyframes rotating { - from { - -webkit-transform: rotate(0deg); - } - to { - -webkit-transform: rotate(360deg); - } -} -@keyframes rotating { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} -.waiting .icon-lab { - -webkit-animation: rotating 3s linear 0s infinite; - -webkit-transform: translateZ(0); - animation: rotating 3s linear 0s infinite; -} diff --git a/app/public/styles/less/launchTest.less b/app/public/styles/less/launchTest.less deleted file mode 100644 index 92788fc..0000000 --- a/app/public/styles/less/launchTest.less +++ /dev/null @@ -1,19 +0,0 @@ -#status { - margin-top: 2em; - font-size: 2.5em; -} - -@-webkit-keyframes rotating { - from { -webkit-transform: rotate(0deg); } - to { -webkit-transform: rotate(360deg); } -} -@keyframes rotating { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } -} - -.waiting .icon-lab { - -webkit-animation: rotating 3s linear 0s infinite; - -webkit-transform: translateZ(0); - animation: rotating 3s linear 0s infinite; -} \ No newline at end of file diff --git a/app/public/styles/less/main.less b/app/public/styles/less/main.less deleted file mode 100644 index 79f6dd2..0000000 --- a/app/public/styles/less/main.less +++ /dev/null @@ -1,66 +0,0 @@ -@import "icons.less"; - -html { - margin: 100px 50px; -} - -body { - margin: 0 auto; - max-width: 1280px; - background: #9c4274; - color: #fff; - font-size: 16px; - text-align: center; -} - -body, input[type=submit], input[type=text], input[type=url], input[type=number], button { - font-family: 'Century Gothic', helvetica, arial, sans-serif; -} - -input[type=submit] { - cursor: pointer; -} - -h1 span { - display: inline-block; - height: 1em; - width: 1em; - color: #ffa319; -} - -.footer { - padding: 3em; - color: #4e1836; - a { - color: inherit; - } - .star { - font-weight: bold; - span { - font-size: 1.2em; - } - } -} - -/* Icons */ -.icon-lab { - .icon(@lab); -} -.icon-question { - .icon(@question); -} -.icon-warning { - .icon(@warning); -} -.icon-back { - .icon(@arrow-left3); -} -.icon-summary { - .icon(@list); -} -.icon-spaghetti { - .icon(@bars); -} -.icon-eye { - .icon(@eye); -} \ No newline at end of file diff --git a/bin/cli.js b/bin/cli.js new file mode 100644 index 0000000..6d815cf --- /dev/null +++ b/bin/cli.js @@ -0,0 +1,35 @@ +#!/usr/bin/env node + +var debug = require('debug')('ylt:cli'); + +var ylt = require('../lib/index'); + +// Check parameters +if (process.argv.length !== 3) { + console.error('Incorrect parameters'); + console.error('\nUsage: ylt \n'); + process.exit(1); +} + +var url = process.argv[2]; + +(function execute(url) { + 'use strict'; + + ylt(url). + + then(function(data) { + + debug('Success'); + console.log(JSON.stringify(data, null, 2)); + + }).fail(function(err) { + + debug('Test failed for %s', url); + console.error(err); + + }); + + debug('Test launched...'); + +})(url); \ No newline at end of file diff --git a/bin/server.js b/bin/server.js new file mode 100644 index 0000000..a345910 --- /dev/null +++ b/bin/server.js @@ -0,0 +1,38 @@ +var express = require('express'); +var app = express(); +var server = require('http').createServer(app); +var bodyParser = require('body-parser'); +var compress = require('compression'); +var cors = require('cors'); + +var authMiddleware = require('../lib/server/middlewares/authMiddleware'); +var apiLimitsMiddleware = require('../lib/server/middlewares/apiLimitsMiddleware'); + + +// Middlewares +app.use(compress()); +app.use(bodyParser.json()); +app.use(cors()); +app.use(authMiddleware); +app.use(apiLimitsMiddleware); + + +// Initialize the controllers +var apiController = require('../lib/server/controllers/apiController')(app); +var frontController = require('../lib/server/controllers/frontController')(app); + + +// Let's start the server! +if (!process.env.GRUNTED) { + var settings = require('../server_config/settings.json'); + server.listen(settings.serverPort, function() { + console.log('Listening on port %d', server.address().port); + + // For the tests + if (server.startTests) { + server.startTests(); + } + }); +} + +module.exports = app; \ No newline at end of file diff --git a/bower.json b/bower.json index f248d80..8609ca8 100644 --- a/bower.json +++ b/bower.json @@ -1,7 +1,8 @@ { "name": "yellowlabtools", "dependencies": { - "angular": "~1.3.1", - "ngModal": "git://github.com/gmetais/ngModal.git#1.2.3" + "angular": "~1.3.8", + "angular-route": "~1.3.8", + "angular-resource": "~1.3.7" } } diff --git a/front/src/css/about.css b/front/src/css/about.css new file mode 100644 index 0000000..0436d82 --- /dev/null +++ b/front/src/css/about.css @@ -0,0 +1,10 @@ +.about { + margin: 3em auto; + width: 50%; +} +.about p { + margin: 2em; +} +.about a { + color: #FFF; +} diff --git a/front/src/css/dashboard.css b/front/src/css/dashboard.css new file mode 100644 index 0000000..407887b --- /dev/null +++ b/front/src/css/dashboard.css @@ -0,0 +1,167 @@ +.testedUrl { + color: inherit; +} +.summary { + text-align: center; +} +.summary .globalScore { + margin-bottom: 3em; +} +.summary .globalScore .globalGrade { + margin: 0.5 auto; + width: 2.5em; + height: 2.5em; + line-height: 2.5em; + border-radius: 0.5em; + font-size: 3em; + font-weight: bold; + vertical-align: middle; +} +.summary .globalScore .on100 { + font-size: 1.2em; + font-weight: bold; + margin: 0.5em 0 1em; +} +.summary .notations { + display: table; + width: 80%; + margin: 0 10% 1.5em; + border-spacing: 1em; +} +.summary .notations > div { + display: table-row; +} +.summary .notations > div > div { + display: table-cell; + height: 2.5em; + vertical-align: middle; +} +.summary .notations .category { + font-weight: bold; + text-align: center; + width: 20%; +} +.summary .notations .criteria { + font-weight: normal; + width: 75%; +} +.summary .notations .A.categoryScore, +.summary .notations .B.categoryScore, +.summary .notations .C.categoryScore, +.summary .notations .D.categoryScore, +.summary .notations .E.categoryScore, +.summary .notations .F.categoryScore, +.summary .notations .NA.categoryScore { + width: 2.5em; + max-width: 2.5em; + min-width: 2.5em; + font-size: 2em; + text-align: center; + border-radius: 0.5em; + font-weight: bold; +} +.summary .notations .grade .A, +.summary .notations .grade .B, +.summary .notations .grade .C, +.summary .notations .grade .D, +.summary .notations .grade .E, +.summary .notations .grade .F, +.summary .notations .grade .NA { + width: 1em; + height: 1em; + font-size: 1em; + color: transparent; + margin: 0 auto; + border-radius: 0.5em; +} +.summary .notations .criteria .table { + width: 100%; +} +.summary .notations .criteria .table > div:hover > div { + background: #EBD8E2; + cursor: pointer; +} +.summary .notations .criteria .table > div:hover > div.info { + background: #FFF; +} +.summary .notations .criteria .table > div:hover > div.info .icon-question { + color: #EBD8E2; +} +.summary .notations .criteria .grade { + width: 10%; + padding-left: 0.5em; + padding-right: 0.5em; + vertical-align: middle; +} +.summary .notations .criteria .label { + width: 70%; +} +.summary .notations .criteria .result { + width: 18%; + font-weight: bold; + white-space: nowrap; + text-align: center; + vertical-align: middle; +} +.summary .notations .warning .label, +.summary .notations .warning .result, +.summary .notations .icon-warning { + color: #FF1919; +} +.summary .notations .criteria .info { + width: 2%; + text-align: center; + vertical-align: middle; + background: #FFF; + padding-left: 0.1em; + padding-right: 0.1em; +} +.summary .notations .criteria .icon-question { + color: transparent; +} +.summary .fromShare { + margin-bottom: 3em; +} +.summary .fromShare a { + font-size: 1em; + padding: 0.3em 0.5em; + margin: 0.5em; + line-height: 2em; + border: 0 solid; + border-radius: 0.5em; + box-shadow: 0.1em 0.2em 0 0 #5e2846; + background: #e74c3c; + color: #fff; + text-decoration: none; +} +.summary .apiTip { + font-size: 0.8em; + margin-bottom: 4em; + color: #413; +} +.summary .apiTip a { + color: inherit; +} +.summary .tweet .tweetText { + color: #413; + background: #F2F2F2; + border: none; + width: 25em; + padding: 0.4em; + border-radius: 0.5em; + box-shadow: 0.05em 0.1em 0 0 #999; +} +.summary .tweet .tweetButton, +.summary .tweet .linkedinButton { + color: #413; + background: #F2F2F2; + margin-right: 0; +} +.summary .tweet .tweetButton:hover, +.summary .tweet .linkedinButton:hover { + color: #F2F2F2; + background: #e74c3c; +} +.summary .tweet input { + font-size: 0.9em; +} diff --git a/front/src/css/icons.css b/front/src/css/icons.css new file mode 100644 index 0000000..ac51e0c --- /dev/null +++ b/front/src/css/icons.css @@ -0,0 +1,6 @@ +@font-face { + font-family: "fontsmith-icons"; + src: url("/fonts/icons.woff") format("woff"); + font-weight: normal; + font-style: normal; +} diff --git a/app/public/styles/index.css b/front/src/css/index.css similarity index 78% rename from app/public/styles/index.css rename to front/src/css/index.css index 40ecc27..d22b867 100644 --- a/app/public/styles/index.css +++ b/front/src/css/index.css @@ -10,16 +10,8 @@ background: #e74c3c; color: #fff; } -.readings { - margin-top: 5em; - font-size: 0.8em; -} -.readings a { - color: inherit; - line-height: 1.7em; -} -.version { - font-size: 0.7em; +.launchBtn.disabled { + background: #deaca6; } input[type=submit], input.url { diff --git a/app/public/styles/main.css b/front/src/css/main.css similarity index 64% rename from app/public/styles/main.css rename to front/src/css/main.css index 5a8669b..8cab1bf 100644 --- a/app/public/styles/main.css +++ b/front/src/css/main.css @@ -1,6 +1,6 @@ @font-face { font-family: "fontsmith-icons"; - src: url("/public/fonts/icons.woff") format("woff"); + src: url("/fonts/icons.woff") format("woff"); font-weight: normal; font-style: normal; } @@ -32,18 +32,102 @@ h1 span { width: 1em; color: #ffa319; } +.resultsMenu { + margin-top: 2em; +} +.resultsMenu .menuItem { + display: inline-block; + margin: 1em; + width: 8em; + height: 7em; + color: #fff; + border: 3px solid #fff; + border-radius: 0.5em; + cursor: pointer; + text-decoration: none; +} +.resultsMenu .menuItem.back, +.resultsMenu .menuItem.restart { + color: #413; + border-color: #413; +} +.resultsMenu .menuItem div { + padding-top: 0.5em; + font-size: 3em; +} +.resultsMenu .active, +.resultsMenu .menuItem.active:hover { + color: #ffa319; + border-color: #ffa319; +} +.resultsMenu .menuItem:hover { + color: #ffa319; +} +.resultsMenu span { + position: relative; + top: 0.5em; +} +/* Grade colors */ +.A { + /* green */ + background: #0C4; +} +.B { + /* green */ + background: #CD0; +} +.C { + /* yellow */ + background: #FD2; +} +.D { + /* orange */ + background: #FA2; +} +.E { + /* red */ + background: #F60; +} +.F { + /* red */ + background: #F22; +} +.NA { + /* Non applicable */ + background: #CCC; +} +.board { + margin-top: 2em; + padding: 1em; + background: #fff; + color: #000; + border-radius: 0.5em; + text-align: left; +} +.backToDashboard { + text-align: center; +} +.backToDashboard a { + font-size: 0.9em; + display: block; + margin-top: 4em; + color: black; +} +.star { + font-weight: bold; +} +.star span { + font-size: 1.2em; +} .footer { padding: 3em; - color: #4e1836; + color: #413; } .footer a { color: inherit; } -.footer .star { - font-weight: bold; -} -.footer .star span { - font-size: 1.2em; +.footer .version { + font-size: 0.7em; } /* Icons */ .icon-lab { @@ -57,7 +141,7 @@ h1 span { -webkit-font-smoothing: antialiased; } .icon-lab:before { - content: "\e003"; + content: "\e004"; } .icon-question { font-family: "fontsmith-icons"; @@ -109,7 +193,7 @@ h1 span { -webkit-font-smoothing: antialiased; } .icon-summary:before { - content: "\e002"; + content: "\e003"; } .icon-spaghetti { font-family: "fontsmith-icons"; @@ -124,7 +208,7 @@ h1 span { .icon-spaghetti:before { content: "\e005"; } -.icon-eye { +.icon-loop { font-family: "fontsmith-icons"; speak: none; font-style: normal; @@ -134,6 +218,6 @@ h1 span { line-height: 1; -webkit-font-smoothing: antialiased; } -.icon-eye:before { - content: "\e004"; +.icon-loop:before { + content: "\e002"; } diff --git a/front/src/css/queue.css b/front/src/css/queue.css new file mode 100644 index 0000000..e847481 --- /dev/null +++ b/front/src/css/queue.css @@ -0,0 +1,11 @@ +.status { + margin-top: 2em; + font-size: 2.5em; +} +.statusSubMessage { + font-size: 0.8em; + margin-bottom: 6em; +} +.queueLink { + color: #FFF; +} diff --git a/front/src/css/rule.css b/front/src/css/rule.css new file mode 100644 index 0000000..9fea690 --- /dev/null +++ b/front/src/css/rule.css @@ -0,0 +1,84 @@ +.rule.board { + text-align: center; +} +.rule .ruleTable { + display: table; + border-spacing: 1em; + width: 90%; + margin: 2em auto; + background: #f2f2f2; + border: 1px dashed #666; + border-radius: 0.5em; +} +.rule .ruleTable > div { + display: table-cell; + vertical-align: middle; +} +.rule .ruleTable .left { + width: 33%; + font-weight: bold; +} +.rule .ruleTable .right { + width: 67%; +} +.rule .score { + font-size: 2.5em; + line-height: 2em; + height: 2em; + width: 2em; + border-radius: 0.5em; + margin: 0 auto 0.5em; +} +.rule .message { + width: 80%; + margin: 0 auto; +} +.rule .message p { + margin: 0.5em; +} +.rule .message ul { + list-style-type: none; + padding-left: 0; +} +.rule .message li:before { + content: '\25e6'; + margin-right: 0.3em; + font-size: 1.2em; + position: relative; + top: 0.1em; +} +.rule .warning { + width: 90%; + margin: -1em auto 2em; + background: #FEE; + border: 1px dashed #e74c3c; + color: #e74c3c; + border-radius: 0.5em; +} +.rule .offendersTable { + display: table; + border-spacing: 0 0.25em; + margin: 0 auto; + min-width: 10%; + max-width: 90%; +} +.rule .offendersTable > div { + display: table-row; +} +.rule .offendersTable > div > div { + display: table-cell; + background: #f2f2f2; + padding: 0 1em; + word-wrap: break-word; + word-break: break-all; +} +.rule .offendersTable > div > div:hover { + background: #EBD8E2; +} +.rule .notFound { + font-size: 1em; +} +.rule .notFound h2 { + font-size: 3em; + margin-bottom: 1em; +} diff --git a/app/public/styles/results.css b/front/src/css/timeline.css similarity index 54% rename from app/public/styles/results.css rename to front/src/css/timeline.css index 05a0ac7..43304d1 100644 --- a/app/public/styles/results.css +++ b/front/src/css/timeline.css @@ -1,144 +1,7 @@ /* Timeline colors, related to Window Performances */ -.resultsMenu { - margin-top: 2em; -} -.resultsMenu .menuItem { - display: inline-block; - margin: 1em; - width: 8em; - height: 7em; - color: #fff; - border: 3px solid #fff; - border-radius: 0.5em; - cursor: pointer; - text-decoration: none; -} -.resultsMenu .back { - color: #5e2846; - border-color: #5e2846; -} -.resultsMenu .menuItem div { - padding-top: 0.5em; - font-size: 3em; -} -.resultsMenu .active, -.resultsMenu .menuItem.active:hover { - color: #ffa319; - border-color: #ffa319; -} -.resultsMenu .menuItem:hover { - color: #ffa319; -} -.resultsMenu span { - position: relative; - top: 0.5em; -} -.testedUrl { - color: inherit; -} -h4 { - margin-bottom: 0.5em; -} -.summary, -.metrics, .execution { - margin-top: 2em; - padding: 1em; - background: #fff; - color: #000; - border-radius: 0.5em; - text-align: left; -} -.notations { - display: table; - width: 90%; - margin: 0 10%; - border-spacing: 1em; -} -.notations > div { - display: table-row; -} -.notations > div > div { - display: table-cell; - height: 2.5em; - vertical-align: middle; -} -.notations .notation { - font-weight: bold; text-align: center; } -.notations .criteria { - font-weight: normal; -} -.notations .A, -.notations .B, -.notations .C, -.notations .D, -.notations .E, -.notations .F, -.notations .NA { - width: 2.5em; - font-size: 2em; - text-align: center; - border-radius: 0.5em; - font-weight: bold; -} -.notations .A { - /* green */ - background: #00DB61; -} -.notations .B { - /* green */ - background: #CAD63D; -} -.notations .C { - /* yellow */ - background: #FFD119; -} -.notations .D { - /* orange */ - background: #FFA319; -} -.notations .E { - /* red */ - background: #FF6600; -} -.notations .F { - /* red */ - background: #FF1919; -} -.notations .NA { - /* Non applicable */ - background: #CCC; -} -.notations .icon-eye { - color: #9c4274; - cursor: pointer; -} -.notations .criteria .table { - width: 75%; -} -.notations .criteria .label { - width: 70%; -} -.notations .criteria .result { - width: 20%; - font-weight: bold; - white-space: nowrap; - text-align: center; -} -.notations .warning .label, -.notations .warning .result { - color: #FF1919; -} -.notations .criteria .info { - width: 10%; - text-align: center; -} -.notations .criteria .icon-question { - color: #f1c40f; - cursor: pointer; -} .timeline { margin: 2em 0 5em; } @@ -256,31 +119,13 @@ h4 { width: 1.5em; border-radius: 0.2em; } -.metrics h4 { - padding-left: 2em; -} -.metrics .module { - padding-left: 4em; - padding-top: 0.5em; -} -.metrics .legend { - font-style: italic; - color: #aaa; -} -.metrics .offenders { - padding-left: 0em; - font-size: 0.8em; -} -.metrics .offenders div { - cursor: pointer; -} -.metrics .offenders ul { - margin-top: 0.5em; -} .filters { - margin: 1em 0; + margin: 1em auto; padding: 0.5em; + min-width: 30em; + width: 30%; border: 1px dotted #aaa; + text-align: left; } .slowRequestsLimit { width: 3em; @@ -343,8 +188,9 @@ input.textFilter { color: #f1c40f; cursor: pointer; } -.table .details .icon-warning { - cursor: pointer; +.table .icon-warning { + display: inline-block; + width: 0.8em; } .detailsOverlay { position: absolute; @@ -397,69 +243,7 @@ input.textFilter { .table > div > .startTime.domCreation { background: #ffe0cc; } -.table .icon-warning { +.execution .icon-warning { color: #e74c3c; -} -/**** NgModal popin (have a look inside bower_components) ****/ -.ng-modal { - position: fixed; - z-index: 9999; - top: 0; - left: 0; - width: 100%; - height: 100%; - text-align: left; -} -.ng-modal-overlay { - position: absolute; - z-index: 9999; - top: 0; - left: 0; - width: 100%; - height: 100%; - background-color: #000; - opacity: 0.5; -} -.ng-modal-dialog { - z-index: 10000; - position: absolute; - top: 50%; - left: 50%; - width: 50%; - transform: translate(-50%, -50%); - -webkit-transform: translate(-50%, -50%); - background-color: #fff; - padding: 10px; - border: 3px solid #f1c40f; - border-radius: 0.5em; - color: #000; -} -.ng-modal-dialog-content { - overflow-x: hidden; - overflow-y: scroll; - word-wrap: break-word; - max-height: 20em; - font-weight: normal; - white-space: normal; -} -.ng-modal-close { - position: absolute; - top: 3px; - right: 5px; cursor: pointer; - font-size: 120%; - padding: 5px; - display: inline-block; -} -.ng-modal-close-x { - font-weight: bold; - font-family: Arial, sans-serif; -} -.ng-modal-title { - font-weight: bold; - font-size: 1.5em; - display: block; - margin-bottom: 10px; - padding-bottom: 7px; - border-bottom: solid 1px #999; } diff --git a/front/src/fonts/icons.woff b/front/src/fonts/icons.woff new file mode 100644 index 0000000..9611c2f Binary files /dev/null and b/front/src/fonts/icons.woff differ diff --git a/app/public/fonts/svg-icons/arrow-left3.svg b/front/src/fonts/svg-icons/arrow-left3.svg old mode 100755 new mode 100644 similarity index 100% rename from app/public/fonts/svg-icons/arrow-left3.svg rename to front/src/fonts/svg-icons/arrow-left3.svg diff --git a/app/public/fonts/svg-icons/bars.svg b/front/src/fonts/svg-icons/bars.svg old mode 100755 new mode 100644 similarity index 100% rename from app/public/fonts/svg-icons/bars.svg rename to front/src/fonts/svg-icons/bars.svg diff --git a/app/public/fonts/svg-icons/lab.svg b/front/src/fonts/svg-icons/lab.svg old mode 100755 new mode 100644 similarity index 100% rename from app/public/fonts/svg-icons/lab.svg rename to front/src/fonts/svg-icons/lab.svg diff --git a/app/public/fonts/svg-icons/list.svg b/front/src/fonts/svg-icons/list.svg old mode 100755 new mode 100644 similarity index 100% rename from app/public/fonts/svg-icons/list.svg rename to front/src/fonts/svg-icons/list.svg diff --git a/app/public/fonts/svg-icons/eye.svg b/front/src/fonts/svg-icons/loop.svg similarity index 74% rename from app/public/fonts/svg-icons/eye.svg rename to front/src/fonts/svg-icons/loop.svg index 59ff3c2..11e65f9 100755 --- a/app/public/fonts/svg-icons/eye.svg +++ b/front/src/fonts/svg-icons/loop.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/app/public/fonts/svg-icons/question.svg b/front/src/fonts/svg-icons/question.svg old mode 100755 new mode 100644 similarity index 100% rename from app/public/fonts/svg-icons/question.svg rename to front/src/fonts/svg-icons/question.svg diff --git a/app/public/fonts/svg-icons/warning.svg b/front/src/fonts/svg-icons/warning.svg old mode 100755 new mode 100644 similarity index 100% rename from app/public/fonts/svg-icons/warning.svg rename to front/src/fonts/svg-icons/warning.svg diff --git a/app/public/img/favicon.png b/front/src/img/favicon.png similarity index 100% rename from app/public/img/favicon.png rename to front/src/img/favicon.png diff --git a/app/public/img/logo-large.png b/front/src/img/logo-large.png similarity index 100% rename from app/public/img/logo-large.png rename to front/src/img/logo-large.png diff --git a/front/src/js/app.js b/front/src/js/app.js new file mode 100644 index 0000000..a7f73d3 --- /dev/null +++ b/front/src/js/app.js @@ -0,0 +1,58 @@ +var yltApp = angular.module('YellowLabTools', [ + 'ngRoute', + 'indexCtrl', + 'aboutCtrl', + 'dashboardCtrl', + 'queueCtrl', + 'ruleCtrl', + 'timelineCtrl', + 'runsFactory', + 'resultsFactory', + 'menuService', + 'gradeDirective', +]); + +yltApp.run(['$rootScope', '$location', function($rootScope, $location) { + $rootScope.loadedRunId = null; + + // Google Analytics + $rootScope.$on('$routeChangeSuccess', function(){ + ga('send', 'pageview', {'page': $location.path()}); + }); +}]); + +yltApp.config(['$routeProvider', '$locationProvider', + function($routeProvider, $locationProvider) { + $routeProvider. + when('/', { + templateUrl: 'views/index.html', + controller: 'IndexCtrl' + }). + when('/queue/:runId', { + templateUrl: 'views/queue.html', + controller: 'QueueCtrl' + }). + when('/about', { + templateUrl: 'views/about.html', + controller: 'AboutCtrl' + }). + when('/result/:runId', { + templateUrl: 'views/dashboard.html', + controller: 'DashboardCtrl' + }). + when('/result/:runId/timeline', { + templateUrl: 'views/timeline.html', + controller: 'TimelineCtrl' + }). + when('/result/:runId/rule/:policy', { + templateUrl: 'views/rule.html', + controller: 'RuleCtrl' + }). + otherwise({ + redirectTo: '/' + }); + + $locationProvider.html5Mode(true); + } +]); + diff --git a/front/src/js/controllers/aboutCtrl.js b/front/src/js/controllers/aboutCtrl.js new file mode 100644 index 0000000..11957f9 --- /dev/null +++ b/front/src/js/controllers/aboutCtrl.js @@ -0,0 +1,5 @@ +var aboutCtrl = angular.module('aboutCtrl', []); + +aboutCtrl.controller('AboutCtrl', ['$scope', function($scope) { + $scope.about = "this is about YLT"; +}]); \ No newline at end of file diff --git a/front/src/js/controllers/dashboardCtrl.js b/front/src/js/controllers/dashboardCtrl.js new file mode 100644 index 0000000..c1b2ba1 --- /dev/null +++ b/front/src/js/controllers/dashboardCtrl.js @@ -0,0 +1,73 @@ +var dashboardCtrl = angular.module('dashboardCtrl', ['resultsFactory', 'menuService']); + +dashboardCtrl.controller('DashboardCtrl', ['$scope', '$rootScope', '$routeParams', '$location', 'Results', 'Runs', 'Menu', function($scope, $rootScope, $routeParams, $location, Results, Runs, Menu) { + $scope.runId = $routeParams.runId; + $scope.Menu = Menu.setCurrentPage('dashboard', $scope.runId); + $scope.fromSocialShare = $location.search().share; + + function loadResults() { + // Load result if needed + if (!$rootScope.loadedResult || $rootScope.loadedResult.runId !== $routeParams.runId) { + Results.get({runId: $routeParams.runId}, function(result) { + $rootScope.loadedResult = result; + $scope.result = result; + init(); + }, function(err) { + $scope.error = true; + }); + } else { + $scope.result = $rootScope.loadedResult; + init(); + } + } + + function init() { + // By default, Angular sorts object's attributes alphabetically. Countering this problem by retrieving the keys order here. + $scope.categoriesOrder = Object.keys($scope.result.scoreProfiles.generic.categories); + + $scope.globalScore = Math.max($scope.result.scoreProfiles.generic.globalScore, 0); + $scope.tweetText = 'My website\'s score is ' + $scope.globalScore + '/100 on #YellowLabTools!'; + } + + $scope.showRulePage = function(ruleName) { + $location.path('/result/' + $scope.runId + '/rule/' + ruleName); + }; + + $scope.testAgain = function() { + Runs.save({ + url: $scope.result.params.url, + waitForResponse: false + }, function(data) { + $location.path('/queue/' + data.runId); + }); + }; + + /// When comming from a social shared link, the user needs to click on "See full report" button to display the full dashboard. + $scope.seeFullReport = function() { + $scope.fromSocialShare = false; + $location.search({}); + }; + + $scope.shareOnTwitter = function(message) { + openSocialPopup('https://twitter.com/intent/tweet?url=' + document.URL + '%3Fshare&text=' + encodeURIComponent(message)); + }; + + $scope.shareOnLinkedin = function(message) { + openSocialPopup('https://www.linkedin.com/shareArticle?mini=true&url=' + document.URL + '%3Fshare&title=' + encodeURIComponent(message) + '&summary=' + encodeURIComponent('YellowLabTools is a free online tool that analyzes performance and front-end quality of a webpage.')); + }; + + function openSocialPopup(url) { + var winHeight = 400; + var winWidth = 600; + var winTop = (screen.height / 2) - (winHeight / 2); + var winLeft = (screen.width / 2) - (winWidth / 2); + window.open(url, 'sharer', 'top=' + winTop + ',left=' + winLeft + ',toolbar=0,status=0,width=' + winWidth + ',height=' + winHeight); + } + + // Returns the URL of the JSON result + $scope.getAPIUrl = function() { + return '/api/results/' + $scope.runId; + }; + + loadResults(); +}]); \ No newline at end of file diff --git a/front/src/js/controllers/indexCtrl.js b/front/src/js/controllers/indexCtrl.js new file mode 100644 index 0000000..bec1da3 --- /dev/null +++ b/front/src/js/controllers/indexCtrl.js @@ -0,0 +1,15 @@ +var indexCtrl = angular.module('indexCtrl', []); + +indexCtrl.controller('IndexCtrl', ['$scope', '$location', 'Runs', function($scope, $location, Runs) { + $scope.launchTest = function() { + if ($scope.url) { + Runs.save({ + url: $scope.url, + waitForResponse: false + }, function(data) { + $location.path('/queue/' + data.runId); + }); + + } + }; +}]); \ No newline at end of file diff --git a/front/src/js/controllers/queueCtrl.js b/front/src/js/controllers/queueCtrl.js new file mode 100644 index 0000000..909b96f --- /dev/null +++ b/front/src/js/controllers/queueCtrl.js @@ -0,0 +1,30 @@ +var queueCtrl = angular.module('queueCtrl', ['runsFactory']); + +queueCtrl.controller('QueueCtrl', ['$scope', '$routeParams', '$location', 'Runs', function($scope, $routeParams, $location, Runs) { + $scope.runId = $routeParams.runId; + + var numberOfTries = 0; + + function getRunStatus () { + Runs.get({runId: $scope.runId}, function(data) { + $scope.url = data.params.url; + $scope.status = data.status; + + if (data.status.statusCode === 'running' || data.status.statusCode === 'awaiting') { + numberOfTries ++; + + // Retrying in 2 seconds (and increasing the delay a bit more each time) + setTimeout(getRunStatus, 2000 + (numberOfTries * 100)); + + } else if (data.status.statusCode === 'complete') { + $location.path('/result/' + $scope.runId).replace(); + } else { + // Handled by the view + } + }); + } + + getRunStatus(); +}]); + + \ No newline at end of file diff --git a/front/src/js/controllers/ruleCtrl.js b/front/src/js/controllers/ruleCtrl.js new file mode 100644 index 0000000..63fa2ef --- /dev/null +++ b/front/src/js/controllers/ruleCtrl.js @@ -0,0 +1,42 @@ +var ruleCtrl = angular.module('ruleCtrl', []); + +ruleCtrl.controller('RuleCtrl', ['$scope', '$rootScope', '$routeParams', '$location', '$sce', 'Menu', 'Results', 'Runs', function($scope, $rootScope, $routeParams, $location, $sce, Menu, Results, Runs) { + $scope.runId = $routeParams.runId; + $scope.policyName = $routeParams.policy; + $scope.Menu = Menu.setCurrentPage(null, $scope.runId); + $scope.rule = null; + + function loadResults() { + // Load result if needed + if (!$rootScope.loadedResult || $rootScope.loadedResult.runId !== $routeParams.runId) { + Results.get({runId: $routeParams.runId}, function(result) { + $rootScope.loadedResult = result; + $scope.result = result; + init(); + }); + } else { + $scope.result = $rootScope.loadedResult; + init(); + } + } + + function init() { + $scope.rule = $scope.result.rules[$scope.policyName]; + $scope.message = $sce.trustAsHtml($scope.rule.policy.message); + } + + $scope.backToDashboard = function() { + $location.path('/result/' + $scope.runId); + }; + + $scope.testAgain = function() { + Runs.save({ + url: $scope.result.params.url, + waitForResponse: false + }, function(data) { + $location.path('/queue/' + data.runId); + }); + }; + + loadResults(); +}]); \ No newline at end of file diff --git a/front/src/js/controllers/timelineCtrl.js b/front/src/js/controllers/timelineCtrl.js new file mode 100644 index 0000000..da813f9 --- /dev/null +++ b/front/src/js/controllers/timelineCtrl.js @@ -0,0 +1,145 @@ +var timelineCtrl = angular.module('timelineCtrl', []); + +timelineCtrl.controller('TimelineCtrl', ['$scope', '$rootScope', '$routeParams', '$location', '$timeout', 'Menu', 'Results', 'Runs', function($scope, $rootScope, $routeParams, $location, $timeout, Menu, Results, Runs) { + $scope.runId = $routeParams.runId; + $scope.Menu = Menu.setCurrentPage('timeline', $scope.runId); + + function loadResults() { + // Load result if needed + if (!$rootScope.loadedResult || $rootScope.loadedResult.runId !== $routeParams.runId) { + Results.get({runId: $routeParams.runId}, function(result) { + $rootScope.loadedResult = result; + $scope.result = result; + render(); + }); + } else { + $scope.result = $rootScope.loadedResult; + render(); + } + } + + function render() { + initExecutionTree(); + initTimeline(); + $timeout(initProfiler, 100); + } + + function initExecutionTree() { + var originalExecutions = $scope.result.javascriptExecutionTree.children || []; + $scope.executionTree = []; + + 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; + + $scope.executionTree.push(node); + }); + } + + function initTimeline() { + + // Split the timeline into 200 intervals + 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; + + // Pre-fill array of as many elements as there are milleseconds + var millisecondsArray = Array.apply(null, new Array($scope.endTime + 1)).map(Number.prototype.valueOf,0); + + // Create the milliseconds array from the execution tree + $scope.executionTree.forEach(function(node) { + if (node.data.time !== undefined) { + + // Ignore artefacts (durations > 100ms) + var time = Math.min(node.data.time, 100) || 1; + + for (var i=node.data.timestamp, max=node.data.timestamp + time ; i 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.onNodeDetailsClick = function(node) { + var isOpen = node.showDetails; + if (!isOpen) { + // Close all other nodes + $scope.executionTree.forEach(function(currentNode) { + currentNode.showDetails = false; + }); + + // Parse the backtrace + if (!node.parsedBacktrace) { + node.parsedBacktrace = parseBacktrace(node.data.backtrace); + } + + } + node.showDetails = !isOpen; + }; + + $scope.backToDashboard = function() { + $location.path('/result/' + $scope.runId); + }; + + $scope.testAgain = function() { + Runs.save({ + url: $scope.result.params.url, + waitForResponse: false + }, function(data) { + $location.path('/queue/' + data.runId); + }); + }; + + loadResults(); + +}]); \ No newline at end of file diff --git a/front/src/js/directives/gradeDirective.js b/front/src/js/directives/gradeDirective.js new file mode 100644 index 0000000..99cad49 --- /dev/null +++ b/front/src/js/directives/gradeDirective.js @@ -0,0 +1,33 @@ +var gradeDirective = angular.module('gradeDirective', []); + +gradeDirective.directive('grade', function() { + + return { + restrict: 'E', + scope: { + score: '=score' + }, + template: '
{{getGrade(score)}}
', + replace: true, + controller : ['$scope', function($scope) { + $scope.getGrade = function(score) { + if (score > 80) { + return 'A'; + } + if (score > 60) { + return 'B'; + } + if (score > 40) { + return 'C'; + } + if (score > 20) { + return 'D'; + } + if (score > 0) { + return 'E'; + } + return 'F'; + }; + }] + }; +}); \ No newline at end of file diff --git a/front/src/js/models/resultsFactory.js b/front/src/js/models/resultsFactory.js new file mode 100644 index 0000000..33b286e --- /dev/null +++ b/front/src/js/models/resultsFactory.js @@ -0,0 +1,7 @@ +var resultsFactory = angular.module('resultsFactory', ['ngResource']); + +resultsFactory.factory('Results', ['$resource', function($resource) { + return $resource('/api/results/:runId', { + + }); +}]); \ No newline at end of file diff --git a/front/src/js/models/runsFactory.js b/front/src/js/models/runsFactory.js new file mode 100644 index 0000000..8f4bb8e --- /dev/null +++ b/front/src/js/models/runsFactory.js @@ -0,0 +1,7 @@ +var runsFactory = angular.module('runsFactory', ['ngResource']); + +runsFactory.factory('Runs', ['$resource', function($resource) { + return $resource('/api/runs/:runId', { + + }); +}]); \ No newline at end of file diff --git a/front/src/js/services/menuService.js b/front/src/js/services/menuService.js new file mode 100644 index 0000000..7b56c74 --- /dev/null +++ b/front/src/js/services/menuService.js @@ -0,0 +1,34 @@ +var menuService = angular.module('menuService', []); + +menuService.factory('Menu', ['$location', function($location) { + + var currentPage, currentRunId; + + return { + getCurrentPage: function() { + return currentPage; + }, + setCurrentPage: function(page, runId) { + currentPage = page; + currentRunId = runId; + + return this; + }, + changePage: function(page) { + switch (page) { + case 'index': + $location.path('/'); + break; + case 'dashboard': + $location.path('/result/' + currentRunId); + break; + case 'timeline': + $location.path('/result/' + currentRunId + '/timeline'); + break; + default: + console.err('Undefined Menu.changePage() destination'); + } + } + }; + +}]); \ No newline at end of file diff --git a/front/src/less/about.less b/front/src/less/about.less new file mode 100644 index 0000000..67de419 --- /dev/null +++ b/front/src/less/about.less @@ -0,0 +1,12 @@ +.about { + margin: 3em auto; + width: 50%; +} + +.about p { + margin: 2em; +} + +.about a { + color: #FFF; +} \ No newline at end of file diff --git a/front/src/less/dashboard.less b/front/src/less/dashboard.less new file mode 100644 index 0000000..9813f0e --- /dev/null +++ b/front/src/less/dashboard.less @@ -0,0 +1,162 @@ +.testedUrl { + color: inherit; +} + +.summary { + text-align: center; +} + +.summary .globalScore { + margin-bottom: 3em; + .globalGrade { + margin: 0.5 auto; + width: 2.5em; + height: 2.5em; + line-height: 2.5em; + border-radius: 0.5em; + font-size: 3em; + font-weight: bold; + vertical-align: middle; + } + .on100 { + font-size: 1.2em; + font-weight: bold; + margin: 0.5em 0 1em; + } +} + +.summary .notations { + display: table; + width: 80%; + margin: 0 10% 1.5em; + border-spacing: 1em; +} +.summary .notations > div { + display: table-row; +} +.summary .notations > div > div { + display: table-cell; + height: 2.5em; + vertical-align: middle; +} +.summary .notations .category { + font-weight: bold; + text-align: center; + width: 20%; +} +.summary .notations .criteria { + font-weight: normal; + width: 75%; +} +.A, .B, .C, .D, .E, .F, .NA { + .summary .notations &.categoryScore { + width: 2.5em; + max-width: 2.5em; + min-width: 2.5em; + font-size: 2em; + text-align: center; + border-radius: 0.5em; + font-weight: bold; + } + .summary .notations .grade & { + width: 1em; + height: 1em; + font-size: 1em; + color: transparent; + margin: 0 auto; + border-radius: 0.5em; + } +} + +.summary .notations .criteria .table { + width: 100%; + > div:hover > div { + background: #EBD8E2; + cursor: pointer; + &.info { + background: #FFF; + .icon-question { + color: #EBD8E2; + } + } + } +} +.summary .notations .criteria .grade { + width: 10%; + padding-left: 0.5em; + padding-right: 0.5em; + vertical-align: middle; +} +.summary .notations .criteria .label { + width: 70%; +} +.summary .notations .criteria .result { + width: 18%; + font-weight: bold; + white-space: nowrap; + text-align: center; + vertical-align: middle; +} +.summary .notations .warning .label, .summary .notations .warning .result, .summary .notations .icon-warning { + color: #FF1919; +} +.summary .notations .criteria .info { + width: 2%; + text-align: center; + vertical-align: middle; + background: #FFF; + padding-left: 0.1em; + padding-right: 0.1em; +} +.summary .notations .criteria .icon-question { + color: transparent; +} + +.summary .fromShare { + margin-bottom: 3em; + a { + font-size: 1em; + padding: 0.3em 0.5em; + margin: 0.5em; + line-height: 2em; + border: 0 solid; + border-radius: 0.5em; + box-shadow: 0.1em 0.2em 0 0 #5e2846; + background: #e74c3c; + color: #fff; + text-decoration: none; + } +} + +.summary .apiTip { + font-size: 0.8em; + margin-bottom: 4em; + color: #413; + a { + color: inherit; + } +} + +.summary .tweet { + .tweetText { + color: #413; + background: #F2F2F2; + border: none; + width: 25em; + padding: 0.4em; + border-radius: 0.5em; + box-shadow: 0.05em 0.1em 0 0 #999; + } + .tweetButton, .linkedinButton { + color: #413; + background: #F2F2F2; + margin-right: 0; + &:hover { + color: #F2F2F2; + background: #e74c3c; + } + } + input { + font-size: 0.9em; + } +} \ No newline at end of file diff --git a/app/public/styles/less/icons.less b/front/src/less/icons.less similarity index 78% rename from app/public/styles/less/icons.less rename to front/src/less/icons.less index dbdc9f3..7603317 100644 --- a/app/public/styles/less/icons.less +++ b/front/src/less/icons.less @@ -1,24 +1,24 @@ -@eye-font-family: "fontsmith-icons"; -@eye-value: "\e004"; -@eye: '"fontsmith-icons"' '"\\e004"'; -@lab-font-family: "fontsmith-icons"; -@lab-value: "\e003"; -@lab: '"fontsmith-icons"' '"\\e003"'; -@bars-font-family: "fontsmith-icons"; -@bars-value: "\e005"; -@bars: '"fontsmith-icons"' '"\\e005"'; @warning-font-family: "fontsmith-icons"; @warning-value: "\e000"; @warning: '"fontsmith-icons"' '"\\e000"'; -@arrow-left3-font-family: "fontsmith-icons"; -@arrow-left3-value: "\e006"; -@arrow-left3: '"fontsmith-icons"' '"\\e006"'; @question-font-family: "fontsmith-icons"; @question-value: "\e001"; @question: '"fontsmith-icons"' '"\\e001"'; +@lab-font-family: "fontsmith-icons"; +@lab-value: "\e004"; +@lab: '"fontsmith-icons"' '"\\e004"'; @list-font-family: "fontsmith-icons"; -@list-value: "\e002"; -@list: '"fontsmith-icons"' '"\\e002"'; +@list-value: "\e003"; +@list: '"fontsmith-icons"' '"\\e003"'; +@bars-font-family: "fontsmith-icons"; +@bars-value: "\e005"; +@bars: '"fontsmith-icons"' '"\\e005"'; +@arrow-left3-font-family: "fontsmith-icons"; +@arrow-left3-value: "\e006"; +@arrow-left3: '"fontsmith-icons"' '"\\e006"'; +@loop-font-family: "fontsmith-icons"; +@loop-value: "\e002"; +@loop: '"fontsmith-icons"' '"\\e002"'; .icon-font-family(@char) { font-family: ~`@{char}[0]`; @@ -49,7 +49,7 @@ @font-face { font-family: "fontsmith-icons"; - src:url("/public/fonts/icons.woff") format("woff"), + src:url("/fonts/icons.woff") format("woff"), ; font-weight: normal; font-style: normal; diff --git a/app/public/styles/less/index.less b/front/src/less/index.less similarity index 78% rename from app/public/styles/less/index.less rename to front/src/less/index.less index 018e946..5bbd573 100644 --- a/app/public/styles/less/index.less +++ b/front/src/less/index.less @@ -11,21 +11,11 @@ .launchBtn { background: #e74c3c; color: #fff; -} - -.readings { - margin-top: 5em; - font-size: 0.8em; - & a { - color: inherit; - line-height: 1.7em; + &.disabled { + background: #deaca6; } } -.version { - font-size: 0.7em; -} - input[type=submit], input.url { padding: 0 0.5em; margin: 0.5em; diff --git a/front/src/less/main.less b/front/src/less/main.less new file mode 100644 index 0000000..ae2cc21 --- /dev/null +++ b/front/src/less/main.less @@ -0,0 +1,155 @@ +@import "icons.less"; + +html { + margin: 100px 50px; +} + +body { + margin: 0 auto; + max-width: 1280px; + background: #9c4274; + color: #fff; + font-size: 16px; + text-align: center; +} + +body, input[type=submit], input[type=text], input[type=url], input[type=number], button { + font-family: 'Century Gothic', helvetica, arial, sans-serif; +} + +input[type=submit] { + cursor: pointer; +} + +h1 span { + display: inline-block; + height: 1em; + width: 1em; + color: #ffa319; +} + +.resultsMenu { + margin-top: 2em; +} +.resultsMenu .menuItem { + display: inline-block; + margin: 1em; + width: 8em; + height: 7em; + color: #fff; + border: 3px solid #fff; + border-radius: 0.5em; + cursor: pointer; + text-decoration: none; + &.back, &.restart { + color: #413; + border-color: #413; + } +} +.resultsMenu .menuItem div { + padding-top: 0.5em; + font-size: 3em; +} +.resultsMenu .active, .resultsMenu .menuItem.active:hover { + color: #ffa319; + border-color: #ffa319; +} +.resultsMenu .menuItem:hover { + color: #ffa319; +} +.resultsMenu span { + position: relative; + top: 0.5em; +} + +/* Grade colors */ +.A { + /* green */ + background: #0C4; +} +.B { + /* green */ + background: #CD0; +} +.C { + /* yellow */ + background: #FD2; +} +.D { + /* orange */ + background: #FA2; +} +.E { + /* red */ + background: #F60; +} +.F { + /* red */ + background: #F22; +} +.NA { + /* Non applicable */ + background: #CCC; +} + +.board { + margin-top: 2em; + padding: 1em; + background: #fff; + color: #000; + border-radius: 0.5em; + text-align: left; +} + +.backToDashboard { + text-align: center; + + a { + font-size: 0.9em; + display: block; + margin-top: 4em; + color: black; + } +} + + +.star { + font-weight: bold; + span { + font-size: 1.2em; + } +} + +.footer { + padding: 3em; + color: #413; + a { + color: inherit; + } + .version { + font-size: 0.7em; + } +} + +/* Icons */ +.icon-lab { + .icon(@lab); +} +.icon-question { + .icon(@question); +} +.icon-warning { + .icon(@warning); +} +.icon-back { + .icon(@arrow-left3); +} +.icon-summary { + .icon(@list); +} +.icon-spaghetti { + .icon(@bars); +} +.icon-loop { + .icon(@loop); +} \ No newline at end of file diff --git a/front/src/less/queue.less b/front/src/less/queue.less new file mode 100644 index 0000000..dc1181b --- /dev/null +++ b/front/src/less/queue.less @@ -0,0 +1,13 @@ +.status { + margin-top: 2em; + font-size: 2.5em; +} + +.statusSubMessage { + font-size: 0.8em; + margin-bottom: 6em; +} + +.queueLink { + color: #FFF; +} \ No newline at end of file diff --git a/front/src/less/rule.less b/front/src/less/rule.less new file mode 100644 index 0000000..a12463a --- /dev/null +++ b/front/src/less/rule.less @@ -0,0 +1,91 @@ +.rule.board { + text-align: center; +} + +.rule .ruleTable { + display: table; + border-spacing: 1em; + width: 90%; + margin: 2em auto; + background: #f2f2f2; + border: 1px dashed #666; + border-radius: 0.5em; + > div { + display: table-cell; + vertical-align: middle; + } + .left { + width: 33%; + font-weight: bold; + } + .right { + width: 67%; + } +} + +.rule .score { + font-size: 2.5em; + line-height: 2em; + height: 2em; + width: 2em; + border-radius: 0.5em; + margin: 0 auto 0.5em; +} + +.rule .message { + width: 80%; + margin: 0 auto; + p { + margin: 0.5em; + } +} + +.rule .message ul { + list-style-type: none; + padding-left: 0; +} +.rule .message li:before { + content:'\25e6'; + margin-right: 0.3em; + font-size: 1.2em; + position: relative; + top: 0.1em; +} + +.rule .warning { + width: 90%; + margin: -1em auto 2em; + background: #FEE; + border: 1px dashed #e74c3c; + color: #e74c3c; + border-radius: 0.5em; +} + +.rule .offendersTable { + display: table; + border-spacing: 0 0.25em; + margin: 0 auto; + min-width: 10%; + max-width: 90%; + > div { + display: table-row; + > div { + display: table-cell; + background: #f2f2f2; + padding: 0 1em; + word-wrap: break-word; + word-break: break-all; + &:hover { + background: #EBD8E2; + } + } + } +} + +.rule .notFound { + font-size: 1em; + h2 { + font-size: 3em; + margin-bottom: 1em; + } +} \ No newline at end of file diff --git a/app/public/styles/less/results.less b/front/src/less/timeline.less similarity index 56% rename from app/public/styles/less/results.less rename to front/src/less/timeline.less index fbd9d64..3c7a541 100644 --- a/app/public/styles/less/results.less +++ b/front/src/less/timeline.less @@ -1,4 +1,3 @@ - /* Timeline colors, related to Window Performances */ @domCreationColor: #FF6600; @domCreationBg: #FFE0CC; @@ -11,145 +10,9 @@ @domInteractiveColor: #FFE433; @domInteractiveBg: #FFFCCC; - -.resultsMenu { - margin-top: 2em; -} - -.resultsMenu .menuItem { - display: inline-block; - margin: 1em; - width: 8em; - height: 7em; - color: #fff; - border: 3px solid #fff; - border-radius: 0.5em; - cursor: pointer; - text-decoration: none; -} -.resultsMenu .back { - color: #5e2846; - border-color: #5e2846; -} -.resultsMenu .menuItem div { - padding-top: 0.5em; - font-size: 3em; -} -.resultsMenu .active, .resultsMenu .menuItem.active:hover { - color: #ffa319; - border-color: #ffa319; -} -.resultsMenu .menuItem:hover { - color: #ffa319; -} - -.resultsMenu span { - position: relative; - top: 0.5em; -} - -.testedUrl { - color: inherit; -} - -h4 { - margin-bottom: 0.5em; -} - -.summary, .metrics, .execution { - margin-top: 2em; - padding: 1em; - background: #fff; - color: #000; - border-radius: 0.5em; - text-align: left; -} - -.notations { - display: table; - width: 90%; - margin: 0 10%; - border-spacing: 1em; -} -.notations > div { - display: table-row; -} -.notations > div > div { - display: table-cell; - height: 2.5em; - vertical-align: middle; -} -.notations .notation { - font-weight: bold; +.execution { text-align: center; } -.notations .criteria { - font-weight: normal; -} -.notations .A, .notations .B, .notations .C, .notations .D, .notations .E, .notations .F, .notations .NA { - width: 2.5em; - font-size: 2em; - text-align: center; - border-radius: 0.5em; - font-weight: bold; -} -.notations .A { - /* green */ - background: #00DB61; -} -.notations .B { - /* green */ - background: #CAD63D; -} -.notations .C { - /* yellow */ - background: #FFD119; -} -.notations .D { - /* orange */ - background: #FFA319; -} -.notations .E { - /* red */ - background: #FF6600; -} -.notations .F { - /* red */ - background: #FF1919; -} -.notations .NA { - /* Non applicable */ - background: #CCC; -} -.notations .icon-eye { - color: #9c4274; - cursor: pointer; -} - -.notations .criteria .table { - width: 75%; -} -.notations .criteria .label { - width: 70%; -} -.notations .criteria .result { - width: 20%; - font-weight: bold; - white-space: nowrap; - text-align: center; -} -.notations .warning .label, .notations .warning .result { - color: #FF1919; -} -.notations .criteria .info { - width: 10%; - text-align: center; -} -.notations .criteria .icon-question { - color: #f1c40f; - cursor: pointer; -} - .timeline { margin: 2em 0 5em; @@ -269,38 +132,13 @@ h4 { border-radius: 0.2em; } - -.metrics h4 { - padding-left: 2em; -} - -.metrics .module { - padding-left: 4em; - padding-top: 0.5em; -} - -.metrics .legend { - font-style: italic; - color: #aaa; -} - -.metrics .offenders { - padding-left: 0em; - font-size: 0.8em; -} - -.metrics .offenders div { - cursor: pointer; -} - -.metrics .offenders ul { - margin-top: 0.5em; -} - .filters { - margin: 1em 0; + margin: 1em auto; padding: 0.5em; + min-width: 30em; + width: 30%; border: 1px dotted #aaa; + text-align: left; } .slowRequestsLimit { @@ -371,8 +209,9 @@ input.textFilter { color: #f1c40f; cursor: pointer; } -.table .details .icon-warning { - cursor: pointer; +.table .icon-warning { + display: inline-block; + width: 0.8em; } .detailsOverlay { @@ -426,72 +265,7 @@ input.textFilter { .table > div > .startTime.domCreation { background: @domCreationBg; } - -.table .icon-warning { +.execution .icon-warning { color: #e74c3c; -} - - -/**** NgModal popin (have a look inside bower_components) ****/ -.ng-modal { - position: fixed; - z-index: 9999; - top: 0; - left: 0; - width: 100%; - height: 100%; - text-align: left; -} -.ng-modal-overlay { - position: absolute; - z-index: 9999; - top: 0; - left: 0; - width: 100%; - height: 100%; - background-color: #000; - opacity: 0.5; -} -.ng-modal-dialog { - z-index: 10000; - position: absolute; - top: 50%; - left: 50%; - width: 50%; - transform: translate(-50%, -50%); - -webkit-transform: translate(-50%, -50%); - background-color: #fff; - padding: 10px; - border: 3px solid #f1c40f; - border-radius: 0.5em; - color: #000; -} -.ng-modal-dialog-content { - overflow-x: hidden; - overflow-y: scroll; - word-wrap: break-word; - max-height: 20em; - font-weight: normal; - white-space: normal; -} -.ng-modal-close { - position: absolute; - top: 3px; - right: 5px; cursor: pointer; - font-size: 120%; - padding: 5px; - display: inline-block; -} -.ng-modal-close-x { - font-weight: bold; - font-family: Arial, sans-serif; -} -.ng-modal-title { - font-weight: bold; - font-size: 1.5em; - display: block; - margin-bottom: 10px; - padding-bottom: 7px; - border-bottom: solid 1px #999; -} +} \ No newline at end of file diff --git a/front/src/main.html b/front/src/main.html new file mode 100644 index 0000000..d966016 --- /dev/null +++ b/front/src/main.html @@ -0,0 +1,49 @@ + + + + Yellow Lab Tools + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + \ No newline at end of file diff --git a/front/src/views/about.html b/front/src/views/about.html new file mode 100644 index 0000000..7824c61 --- /dev/null +++ b/front/src/views/about.html @@ -0,0 +1,9 @@ +
+

Yellow Lab Tools is an open source project by Gaël Métais. It lets you test a webpage (via an URL) and detects performance and front-end code quality issues.

+ +

This is done by loading the webpage via PhantomJS and collecting various metrics and statistics with the help of 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!!!

+ +


Back to index

+
\ No newline at end of file diff --git a/front/src/views/dashboard.html b/front/src/views/dashboard.html new file mode 100644 index 0000000..e95421d --- /dev/null +++ b/front/src/views/dashboard.html @@ -0,0 +1,59 @@ +
+
+ +
+

Global score

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

Score details

+
+
+ +
{{category.label}}
+
+
+
+
+ +
+
{{rule.policy.label}}
+
+ {{rule.value}} + + + +
+
+
+
+
+
+
+ +
+ Did you know? Yellow Lab Tools now has an API! Here is the JSON output for this run. Checkout the API doc. +
+ +
+
+ + + +
+
+ +
+

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

+ See the full report for this page + Test another webpage +
+ +
+

Run failed / Run not found

+
+
\ No newline at end of file diff --git a/front/src/views/index.html b/front/src/views/index.html new file mode 100644 index 0000000..84ba965 --- /dev/null +++ b/front/src/views/index.html @@ -0,0 +1,6 @@ +

Free online test to help speeding up heavy web pages

+ +
+ + +
\ No newline at end of file diff --git a/front/src/views/queue.html b/front/src/views/queue.html new file mode 100644 index 0000000..c34d3b4 --- /dev/null +++ b/front/src/views/queue.html @@ -0,0 +1,23 @@ +

Tested url:   {{url}}

+ +
+
Test failed
+

{{status.error}}

+

Report a bug on GitHub

+

Back to index

+
+
+
+ + +
+

(auto-refresh activated)

+
+
+
Test is running...
+

(auto-refresh activated)

+
+
+
Test complete
+

Opening results...

+
\ No newline at end of file diff --git a/front/src/views/resultSubHeader.html b/front/src/views/resultSubHeader.html new file mode 100644 index 0000000..af20dd5 --- /dev/null +++ b/front/src/views/resultSubHeader.html @@ -0,0 +1,8 @@ +
Tested url:   {{result.params.url}}
+ +
+
New test
+
Test again
+ + +
\ No newline at end of file diff --git a/front/src/views/rule.html b/front/src/views/rule.html new file mode 100644 index 0000000..ee540ce --- /dev/null +++ b/front/src/views/rule.html @@ -0,0 +1,37 @@ +
+
+ + +
+
+

{{rule.policy.label}}

+ +
{{rule.score}}/100
+
+
+

Value: {{rule.value}}

+
+
+
+
+

Warning

+

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

+
+
+

+ + +

+
+
+
{{offender}}
+
+
+
+
+

404

+ Rule "{{policyName}}"" not found +
+ + +
\ No newline at end of file diff --git a/front/src/views/timeline.html b/front/src/views/timeline.html new file mode 100644 index 0000000..71210ab --- /dev/null +++ b/front/src/views/timeline.html @@ -0,0 +1,144 @@ +
+
+

Javascript Timeline

+

This graph gives a quick view of when the Javascript interactions with the DOM occur during the loading of the page.

+ +
+
+
+
+
+
+
Timestamp: {{$index * timelineIntervalDuration | number: 0}} ms
+
+
+
+
0 ms
+
{{endTime | number: 0}} ms
+
+
+
+
DOM creation
+
DOM interactive
+
DOM content loaded event
+
Page completion
+
Page is complete
+
+
+
Executing Javascript and DOM queries here is a bad practice and slows down the DOM construction.
+
Some frameworks do things here, but it's not reliable and should be avoided.
+
Also known as "document ready". This is where you should execute top-priority scripts, like binding action buttons or launch a video player.
+
Here you can execute mid-priority tasks. Loading a script with createElement('script') is one way to do so.
+
The page is considered loaded, it's time for low priority things : trackers, social plugins, easter egg...
+
+
+
+ +

Javascript Profiler

+

+ The table below shows the interactions between Javascript and the DOM. It is useful to understand what happens while the page loads. +

+
+
+ + +
+
+ + Filter by + +
+
+
+ + +
+
+
Type
+
Params
+
+
Timestamp
+
+
+ +
{{$index + 1}}
+
{{node.data.type}}
+ +
+ {{node.data.callDetails.arguments[0]}} + : {{node.data.callDetails.arguments[1]}} + : {{node.data.callDetails.arguments[2]}} + : {{node.data.callDetails.arguments[3]}} +
+ +
+
+ +
+
+ +
+

Called on DOM element

+
{{node.data.callDetails.context.domElement}}
+
+ +
+

Called on 0 jQuery element

+

Useless function call, as the jQuery object is empty.

+
+ +
+

Called on 1 jQuery element

+
{{node.data.callDetails.context.firstElementPath}}
+
+ +
+

Called on {{node.data.callDetails.context.length}} jQuery elements

+

+ 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). +

+

First one is: {{node.data.callDetails.context.firstElementPath}}

+
+ +

+ The query returned 0 results. Could it be unused or dead code? +

+ +
+

Backtrace

+
+
+
{{trace.fnName || '(anonymous)'}}
+ +
+
+
can't find any backtrace :/
+
+
+
+
+
+
{{node.data.timestamp | number: 0}} ms
+
+
+ + +
\ No newline at end of file diff --git a/lib/index.js b/lib/index.js new file mode 100644 index 0000000..00513bb --- /dev/null +++ b/lib/index.js @@ -0,0 +1,38 @@ +var Q = require('q'); + +var Runner = require('./runner'); + + +var yellowLabTools = function(url, options) { + 'use strict'; + + var deferred = Q.defer(); + + if (!url) { + + deferred.reject('URL missing'); + + } else { + + if (url.indexOf('http://') !== 0 && url.indexOf('https://') !== 0) { + url = 'http://' + url; + } + + var params = { + url: url, + options: options || {} + }; + + var runner = new Runner(params) + .then(function(data) { + deferred.resolve(data); + }) + .fail(function(err) { + deferred.reject(err); + }); + } + + return deferred.promise; +}; + +module.exports = yellowLabTools; \ No newline at end of file diff --git a/lib/metadata/policies.js b/lib/metadata/policies.js new file mode 100644 index 0000000..b2b70fd --- /dev/null +++ b/lib/metadata/policies.js @@ -0,0 +1,453 @@ +var debug = require('debug')('ylt:policies'); + +var policies = { + "DOMelementsCount": { + "tool": "phantomas", + "label": "DOM elements count", + "message": "

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

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

", + "isOkThreshold": 1000, + "isBadThreshold": 2500, + "isAbnormalThreshold": 4000 + }, + "DOMelementMaxDepth": { + "tool": "phantomas", + "label": "DOM max depth", + "message": "

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

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

", + "isOkThreshold": 10, + "isBadThreshold": 20, + "isAbnormalThreshold": 28 + }, + "iframesCount": { + "tool": "phantomas", + "label": "Number of iframes", + "message": "

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

", + "isOkThreshold": 2, + "isBadThreshold": 15, + "isAbnormalThreshold": 30 + }, + "DOMidDuplicated": { + "tool": "phantomas", + "label": "IDs duplicated", + "message": "

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

", + "isOkThreshold": 0, + "isBadThreshold": 5, + "isAbnormalThreshold": 10 + }, + "DOMinserts": { + "tool": "phantomas", + "label": "DOM inserts", + "message": "

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

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

", + "isOkThreshold": 10, + "isBadThreshold": 400, + "isAbnormalThreshold": 1000 + }, + "DOMqueries": { + "tool": "phantomas", + "label": "DOM queries", + "message": "

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

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

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

", + "isOkThreshold": 50, + "isBadThreshold": 1000, + "isAbnormalThreshold": 2000 + }, + "DOMqueriesAvoidable": { + "tool": "phantomas", + "label": "Duplicated DOM queries", + "message": "

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

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

", + "isOkThreshold": 0, + "isBadThreshold": 200, + "isAbnormalThreshold": 500, + "takeOffendersFrom": "DOMqueriesDuplicated" + }, + "DOMqueriesWithoutResults": { + "tool": "phantomas", + "label": "DOM queries without result", + "message": "

Number of queries that return no result.

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

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

", + "isOkThreshold": 0, + "isBadThreshold": 100, + "isAbnormalThreshold": 200 + }, + "eventsBound": { + "tool": "phantomas", + "label": "Events bound", + "message": "

Binding too many events has a cost.

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

", + "isOkThreshold": 100, + "isBadThreshold": 800, + "isAbnormalThreshold": 1500 + }, + "jsErrors": { + "tool": "phantomas", + "label": "JavaScript errors", + "message": "

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

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

", + "isOkThreshold": 0, + "isBadThreshold": 1, + "isAbnormalThreshold": 4 + }, + "evalCalls": { + "tool": "phantomas", + "label": "eval calls", + "message": "

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

", + "isOkThreshold": 0, + "isBadThreshold": 10, + "isAbnormalThreshold": 20 + }, + "documentWriteCalls": { + "tool": "phantomas", + "label": "document.write calls", + "message": "

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

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

", + "isOkThreshold": 0, + "isBadThreshold": 10, + "isAbnormalThreshold": 20 + }, + "consoleMessages": { + "tool": "phantomas", + "label": "Console messages", + "message": "

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

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

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

", + "isOkThreshold": 0, + "isBadThreshold": 10, + "isAbnormalThreshold": 25 + }, + "globalVariables": { + "tool": "phantomas", + "label": "Global variables", + "message": "

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

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

", + "isOkThreshold": 10, + "isBadThreshold": 50, + "isAbnormalThreshold": 200 + }, + "jQueryVersion": { + "label": "jQuery version", + "message": "

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

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

", + "scoreFn": function(data) { + var differentVersions = data.toolsResults.phantomas.metrics.jQueryDifferentVersions; + + if (differentVersions === 0 || differentVersions > 1) { + // Not applicable + return null; + } else { + var value = data.toolsResults.phantomas.metrics.jQueryVersion; + var score; + + if (value.indexOf('1.11.') === 0 || + value.indexOf('1.12.') === 0 || + value.indexOf('2.1.') === 0 || + value.indexOf('2.2.') === 0 || + value.indexOf('3.0.') === 0) { + score = 100; + } else if (value.indexOf('1.10.') === 0 || + value.indexOf('2.0.') === 0) { + score = 90; + } else if (value.indexOf('1.9.') === 0) { + score = 70; + } else if (value.indexOf('1.8.') === 0) { + score = 50; + } else if (value.indexOf('1.7.') === 0) { + score = 40; + } else if (value.indexOf('1.6.') === 0) { + score = 30; + } else if (value.indexOf('1.5.') === 0) { + score = 20; + } else if (value.indexOf('1.4.') === 0) { + score = 10; + } else if (value.indexOf('1.3.') === 0) { + score = 0; + } else if (value.indexOf('1.2.') === 0) { + score = 0; + } else { + debug('Unknown jQuery version "%s"', value); + return null; + } + + return { + value: value, + score: score, + bad: value < 100, + abnormal: false, + abnormalityScore: 0 + }; + } + } + }, + "jQueryDifferentVersions": { + "tool": "phantomas", + "label": "Several versions loaded", + "message": "

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

", + "isOkThreshold": 1, + "isBadThreshold": 2, + "isAbnormalThreshold": 2 + }, + "cssParsingErrors": { + "tool": "phantomas", + "label": "CSS syntax error", + "message": "

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

Maybe a CSS validator can help you.

", + "isOkThreshold": 0, + "isBadThreshold": 1, + "isAbnormalThreshold": 1 + }, + "cssRules": { + "tool": "phantomas", + "label": "Rules count", + "message": "

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

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

", + "isOkThreshold": 500, + "isBadThreshold": 2500, + "isAbnormalThreshold": 4000 + }, + "cssComplexSelectors": { + "tool": "phantomas", + "label": "Complex selectors", + "message": "

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

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

", + "isOkThreshold": 0, + "isBadThreshold": 500, + "isAbnormalThreshold": 2000 + }, + "cssComplexSelectorsByAttribute": { + "tool": "phantomas", + "label": "Complex attributes selector", + "message": "

Complex attributes selectors are one of these:

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

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

", + "isOkThreshold": 0, + "isBadThreshold": 50, + "isAbnormalThreshold": 100 + }, + "cssImports": { + "tool": "phantomas", + "label": "Uses of @import", + "message": "

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

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

", + "isOkThreshold": 0, + "isBadThreshold": 1, + "isAbnormalThreshold": 1 + }, + "cssDuplicatedSelectors": { + "tool": "phantomas", + "label": "Duplicated selectors", + "message": "

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

", + "isOkThreshold": 0, + "isBadThreshold": 40, + "isAbnormalThreshold": 80 + }, + "cssDuplicatedProperties": { + "tool": "phantomas", + "label": "Duplicated properties", + "message": "

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

", + "isOkThreshold": 0, + "isBadThreshold": 50, + "isAbnormalThreshold": 100 + }, + "cssEmptyRules": { + "tool": "phantomas", + "label": "Empty rules", + "message": "

Very easy to fix: remove all empty rules.

", + "isOkThreshold": 0, + "isBadThreshold": 40, + "isAbnormalThreshold": 100 + }, + "cssExpressions": { + "tool": "phantomas", + "label": "CSS expressions", + "message": "

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

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

", + "isOkThreshold": 0, + "isBadThreshold": 1, + "isAbnormalThreshold": 20 + }, + "cssImportants": { + "tool": "phantomas", + "label": "Uses of !important", + "message": "

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

", + "isOkThreshold": 0, + "isBadThreshold": 50, + "isAbnormalThreshold": 150 + }, + "cssOldIEFixes": { + "tool": "phantomas", + "label": "Old IE fixes", + "message": "

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

IE6:

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

IE7:

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

IE9:

  • -ms-filter
  • progid:DXImageTransform.Microsoft

", + "isOkThreshold": 0, + "isBadThreshold": 50, + "isAbnormalThreshold": 300 + }, + "cssOldPropertyPrefixes": { + "tool": "phantomas", + "label": "Old prefixes", + "message": "

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

", + "isOkThreshold": 0, + "isBadThreshold": 50, + "isAbnormalThreshold": 300 + }, + "cssUniversalSelectors": { + "tool": "phantomas", + "label": "Universal selectors", + "message": "

Universal selectors are the most expensive CSS selectors.

More informations here.

", + "isOkThreshold": 0, + "isBadThreshold": 40, + "isAbnormalThreshold": 150 + }, + "cssRedundantBodySelectors": { + "tool": "phantomas", + "label": "Redundant body selectors", + "message": "

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

", + "isOkThreshold": 0, + "isBadThreshold": 50, + "isAbnormalThreshold": 200 + }, + "cssRedundantChildNodesSelectors": { + "tool": "phantomas", + "label": "Redundant tags selectors", + "message": "

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

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

", + "isOkThreshold": 0, + "isBadThreshold": 50, + "isAbnormalThreshold": 200 + }, + "requests": { + "tool": "phantomas", + "label": "Total requests number", + "message": "

This is one of the most important performance rule. Every request is slowing down the page loading.

There are several technics to reduce their number:

  • Concatenate JS files
  • Concatenate CSS files
  • Embed or inline small JS or CSS files in the HTML
  • Create sprites or icon fonts
  • Base64 encode small images in HTML or stylesheets
  • Use lazyloading for images

", + "isOkThreshold": 15, + "isBadThreshold": 100, + "isAbnormalThreshold": 200, + "takeOffendersFrom": ["htmlCount", "jsCount", "cssCount", "imageCount", "webfontCount", "videoCount", "jsonCount", "jsonCount"] + }, + "htmlCount": { + "tool": "phantomas", + "label": "Document count", + "message": "

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

", + "isOkThreshold": 10, + "isBadThreshold": 20, + "isAbnormalThreshold": 30 + }, + "jsCount": { + "tool": "phantomas", + "label": "Script count", + "message": "

Reduce the number of scripts by concatenating them.

", + "isOkThreshold": 5, + "isBadThreshold": 15, + "isAbnormalThreshold": 30 + }, + "cssCount": { + "tool": "phantomas", + "label": "CSS count", + "message": "

Reduce the number of stylesheets by concatenating them.

", + "isOkThreshold": 3, + "isBadThreshold": 10, + "isAbnormalThreshold": 22 + }, + "imageCount": { + "tool": "phantomas", + "label": "Image count", + "message": "

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

", + "isOkThreshold": 15, + "isBadThreshold": 40, + "isAbnormalThreshold": 70 + }, + "webfontCount": { + "tool": "phantomas", + "label": "Font count", + "message": "

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

", + "isOkThreshold": 0, + "isBadThreshold": 3, + "isAbnormalThreshold": 5 + }, + "videoCount": { + "tool": "phantomas", + "label": "Videos count", + "message": "

The number of videos loaded.

", + "isOkThreshold": 1, + "isBadThreshold": 5, + "isAbnormalThreshold": 15 + }, + "jsonCount": { + "tool": "phantomas", + "label": "JSON count", + "message": "

The number of AJAX requests to JSON files or webservices.

", + "isOkThreshold": 2, + "isBadThreshold": 10, + "isAbnormalThreshold": 25 + }, + "otherCount": { + "tool": "phantomas", + "label": "Other types of requests", + "message": "

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

", + "isOkThreshold": 5, + "isBadThreshold": 20, + "isAbnormalThreshold": 40 + }, + "smallJsFiles": { + "tool": "phantomas", + "label": "Small JS files", + "message": "

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

", + "isOkThreshold": 2, + "isBadThreshold": 10, + "isAbnormalThreshold": 16 + }, + "smallCssFiles": { + "tool": "phantomas", + "label": "Small CSS files", + "message": "

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

", + "isOkThreshold": 0, + "isBadThreshold": 8, + "isAbnormalThreshold": 12 + }, + "smallImages": { + "tool": "phantomas", + "label": "Small images", + "message": "

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

", + "isOkThreshold": 2, + "isBadThreshold": 17, + "isAbnormalThreshold": 30 + }, + "notFound": { + "tool": "phantomas", + "label": "404 not found", + "message": "

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

", + "isOkThreshold": 0, + "isBadThreshold": 1, + "isAbnormalThreshold": 1 + }, + "closedConnections": { + "tool": "phantomas", + "label": "Connections closed", + "message": "

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

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

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

", + "isOkThreshold": 0, + "isBadThreshold": 8, + "isAbnormalThreshold": 20 + }, + "multipleRequests": { + "tool": "phantomas", + "label": "Duplicated requests", + "message": "

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

", + "isOkThreshold": 0, + "isBadThreshold": 5, + "isAbnormalThreshold": 10 + }, + "cachingDisabled": { + "tool": "phantomas", + "label": "Caching disabled", + "message": "

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

Fix immediatly if on static assets.

", + "isOkThreshold": 0, + "isBadThreshold": 12, + "isAbnormalThreshold": 25 + }, + "cachingNotSpecified": { + "tool": "phantomas", + "label": "Caching not specified", + "message": "

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

", + "isOkThreshold": 5, + "isBadThreshold": 20, + "isAbnormalThreshold": 40 + }, + "cachingTooShort": { + "tool": "phantomas", + "label": "Caching too short", + "message": "

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

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

", + "isOkThreshold": 5, + "isBadThreshold": 20, + "isAbnormalThreshold": 40 + }, + "domains": { + "tool": "phantomas", + "label": "Different domains", + "message": "

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

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

", + "isOkThreshold": 10, + "isBadThreshold": 25, + "isAbnormalThreshold": 50 + } +}; + +module.exports = policies; diff --git a/lib/metadata/scoreProfileGeneric.json b/lib/metadata/scoreProfileGeneric.json new file mode 100644 index 0000000..48a81bb --- /dev/null +++ b/lib/metadata/scoreProfileGeneric.json @@ -0,0 +1,114 @@ +{ + "categories": { + "domComplexity": { + "label": "DOM complexity", + "policies": { + "DOMelementsCount": 1, + "DOMelementMaxDepth": 1, + "iframesCount": 1, + "DOMidDuplicated": 1 + } + }, + "domManipulations": { + "label": "DOM manipulations", + "policies": { + "DOMinserts": 2, + "DOMqueries": 1, + "DOMqueriesWithoutResults": 2, + "DOMqueriesAvoidable": 2, + "eventsBound": 1 + } + }, + "badJavascript": { + "label": "Bad JavaScript", + "policies": { + "jsErrors": 1, + "documentWriteCalls": 2, + "consoleMessages": 0.5, + "globalVariables": 0.5 + } + }, + "jQueryVersion": { + "label": "jQuery version", + "policies": { + "jQueryVersion": 5, + "jQueryDifferentVersions": 0.1 + } + }, + "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": { + "requests": 5, + "htmlCount": 0, + "jsCount": 1, + "cssCount": 1, + "imageCount": 0, + "webfontCount": 2, + "videoCount": 0, + "jsonCount": 0, + "otherCount": 0 + } + }, + "smallRequests": { + "label": "Small requests", + "policies": { + "smallJsFiles": 1, + "smallCssFiles": 1, + "smallImages": 1 + } + }, + "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, + "smallRequests": 1, + "network": 2 + } +} \ No newline at end of file diff --git a/lib/rulesChecker.js b/lib/rulesChecker.js new file mode 100644 index 0000000..f8eabd1 --- /dev/null +++ b/lib/rulesChecker.js @@ -0,0 +1,98 @@ +var debug = require('debug')('ylt:ruleschecker'); + +var RulesChecker = function() { + 'use strict'; + + this.check = function(data, policies) { + /*jshint loopfunc:true */ + + var results = {}; + + debug('Starting checking rules'); + + for (var metricName in policies) { + var policy = policies[metricName]; + var rule; + + if (policy.tool && + data.toolsResults[policy.tool] && + data.toolsResults[policy.tool].metrics && + (data.toolsResults[policy.tool].metrics[metricName] || data.toolsResults[policy.tool].metrics[metricName] === 0)) { + + rule = { + value: data.toolsResults[policy.tool].metrics[metricName], + policy: policy + }; + + // Take DOMqueriesAvoidable's offenders from DOMqueriesDuplicated, for example. + if (policy.takeOffendersFrom) { + var fromList = policy.takeOffendersFrom; + var offenders = []; + + // takeOffendersFrom option can be a string or an array of strings. + if (typeof fromList === 'string') { + fromList = [fromList]; + } + + fromList.forEach(function(from) { + offenders = offenders.concat(data.toolsResults[policy.tool].offenders[from]); + }); + + data.toolsResults[policy.tool].offenders[metricName] = offenders; + } + + if (data.toolsResults[policy.tool].offenders && + data.toolsResults[policy.tool].offenders[metricName] && + data.toolsResults[policy.tool].offenders[metricName].length > 0) { + rule.offenders = data.toolsResults[policy.tool].offenders[metricName]; + } + + rule.bad = rule.value > policy.isOkThreshold; + rule.abnormal = policy.isAbnormalThreshold && rule.value >= policy.isAbnormalThreshold; + + // A value between 0 (bad) and 100 (very good). + var score = (policy.isBadThreshold - rule.value) * 100 / (policy.isBadThreshold - policy.isOkThreshold); + rule.score = Math.min(Math.max(Math.round(score), 0), 100); + + // A value between 0 (abnormal) and negative-infinity (your website is a blackhole) + var abnormalityScore = (policy.isAbnormalThreshold - rule.value) * 100 / (policy.isAbnormalThreshold - policy.isOkThreshold); + rule.abnormalityScore = Math.min(Math.round(abnormalityScore), 0); + + results[metricName] = rule; + debug('Metric %s calculated. Score: %d', metricName, rule.score); + + + } else if (policy.scoreFn) { + + debug('Custom score function for %s', metricName); + + // Custom score function + rule = policy.scoreFn(data); + + // Check returned values (if the result is null, just don't save) + if (rule) { + rule.policy = { + label: policy.label, + message: policy.message + }; + + results[metricName] = rule; + debug('Metric %s calculated. Score: %d', metricName, rule.score); + } else { + debug('Metric %s is null. Ignored.', metricName); + } + + } else { + + debug('Metric %s not found for tool %s', metricName, policy.tool); + + } + } + + debug('Rules checking finished'); + + return results; + }; +}; + +module.exports = new RulesChecker(); \ No newline at end of file diff --git a/lib/runner.js b/lib/runner.js new file mode 100644 index 0000000..ab6ea6d --- /dev/null +++ b/lib/runner.js @@ -0,0 +1,60 @@ +var Q = require('q'); +var debug = require('debug')('ylt:runner'); + +var phantomasWrapper = require('./tools/phantomas/phantomasWrapper'); +var jsExecutionTransformer = require('./tools/jsExecutionTransformer'); +var rulesChecker = require('./rulesChecker'); +var scoreCalculator = require('./scoreCalculator'); + + +var Runner = function(params) { + 'use strict'; + + var deferred = Q.defer(); + + // The pivot format + var data = { + params: params, + toolsResults: {} + }; + + // Execute Phantomas first + phantomasWrapper.execute(data).then(function(phantomasResults) { + data.toolsResults.phantomas = phantomasResults; + + // Treat the JS Execution Tree from offenders + data.javascriptExecutionTree = jsExecutionTransformer.transform(data); + + // Other tools go here + + + // Rules checker + var policies = require('./metadata/policies'); + data.rules = rulesChecker.check(data, policies); + + + // Scores calculator + var scoreProfileGeneric = require('./metadata/scoreProfileGeneric.json'); + data.scoreProfiles = { + generic : scoreCalculator.calculate(data, scoreProfileGeneric) + }; + + + delete data.toolsResults.phantomas.metrics.javascriptExecutionTree; + delete data.toolsResults.phantomas.offenders.javascriptExecutionTree; + + //Finished! + deferred.resolve(data); + + }).fail(function(err) { + debug('Run failed'); + debug(err); + + deferred.reject(err); + }); + + + return deferred.promise; +}; + +module.exports = Runner; \ No newline at end of file diff --git a/lib/scoreCalculator.js b/lib/scoreCalculator.js new file mode 100644 index 0000000..2d8dfaf --- /dev/null +++ b/lib/scoreCalculator.js @@ -0,0 +1,87 @@ +var Q = require('q'); +var debug = require('debug')('ylt:scoreCalculator'); + +var ScoreCalculator = function() { + 'use strict'; + + this.calculate = function(data, profile) { + + var results = { + categories: {} + }; + var categoryScore; + var categoryName; + var weight; + + debug('Starting calculating scores'); + + // Calculate categories + for (categoryName in profile.categories) { + var categoryResult = { + label: profile.categories[categoryName].label + }; + + categoryScore = new ScoreMerger(); + var rules = []; + var policyScore; + + for (var policyName in profile.categories[categoryName].policies) { + weight = profile.categories[categoryName].policies[policyName]; + + if (data.rules[policyName]) { + policyScore = data.rules[policyName].score + (data.rules[policyName].abnormalityScore * 2); + categoryScore.push(policyScore, weight); + } else { + debug('Warning: could not find rule %s', policyName); + } + + rules.push(policyName); + } + + categoryResult.categoryScore = categoryScore.getScore(); + + categoryResult.rules = rules; + results.categories[categoryName] = categoryResult; + } + + + // Calculate general score + var globalScore = new ScoreMerger(); + + for (categoryName in profile.globalScore) { + weight = profile.globalScore[categoryName]; + + if (results.categories[categoryName]) { + globalScore.push(results.categories[categoryName].categoryScore, weight); + } + } + + results.globalScore = Math.round(globalScore.getScore()); + + + debug('Score calculation finished:'); + debug(results); + + return results; + }; + + + var ScoreMerger = function() { + var sum = 0; + var totalWeight = 0; + + this.push = function(score, weight) { + sum += (100 - score) * weight; + totalWeight += weight; + }; + + this.getScore = function() { + if (totalWeight === 0) { + return 100; + } + return Math.round(100 - (sum / totalWeight)); + }; + }; +}; + +module.exports = new ScoreCalculator(); \ No newline at end of file diff --git a/lib/server/controllers/apiController.js b/lib/server/controllers/apiController.js new file mode 100644 index 0000000..4ab4142 --- /dev/null +++ b/lib/server/controllers/apiController.js @@ -0,0 +1,226 @@ +var debug = require('debug')('ylt:server'); + +var ylt = require('../../index'); +var RunsQueue = require('../datastores/runsQueue'); +var RunsDatastore = require('../datastores/runsDatastore'); +var ResultsDatastore = require('../datastores/resultsDatastore'); + + +var ApiController = function(app) { + 'use strict'; + + var queue = new RunsQueue(); + var runsDatastore = new RunsDatastore(); + var resultsDatastore = new ResultsDatastore(); + + + + // Create a new run + app.post('/api/runs', function(req, res) { + + // Grab the test parameters and generate a random run ID + var run = { + runId: (Date.now()*1000 + Math.round(Math.random()*1000)).toString(36), + params: { + url: req.body.url, + waitForResponse: req.body.waitForResponse !== false && req.body.waitForResponse !== 'false' && req.body.waitForResponse !== 0, + partialResult: req.body.partialResult || null + } + }; + + // Add test to the testQueue + debug('Adding test %s to the queue', run.runId); + var queuePromise = queue.push(run.runId); + + + // Save the run to the datastore + runsDatastore.add(run, queuePromise.startingPosition); + + + // Listening for position updates + queuePromise.progress(function(position) { + runsDatastore.updatePosition(run.runId, position); + }); + + // Let's start the run + queuePromise.then(function() { + + runsDatastore.updatePosition(run.runId, 0); + + debug('Launching test %s on %s', run.runId, run.params.url); + + ylt(run.params.url) + + .then(function(data) { + + debug('Success'); + + + // Save result in datastore + data.runId = run.runId; + resultsDatastore.saveResult(data) + .then(function() { + + runsDatastore.markAsComplete(run.runId); + + // Send result if the user was waiting + if (run.params.waitForResponse) { + + // If the user only wants a portion of the result (partialResult option) + switch(run.params.partialResult) { + case 'generalScores': + res.redirect(302, '/api/results/' + run.runId + '/generalScores'); + break; + case 'rules': + res.redirect(302, '/api/results/' + run.runId + '/rules'); + break; + case 'javascriptExecutionTree': + res.redirect(302, '/api/results/' + run.runId + '/javascriptExecutionTree'); + break; + case 'phantomas': + res.redirect(302, '/api/results/' + run.runId + '/toolsResults/phantomas'); + break; + default: + res.redirect(302, '/api/results/' + run.runId); + } + } + + }) + .fail(function(err) { + debug('Saving results to resultsDatastore failed:'); + debug(err); + + res.status(500).send('Saving results failed'); + }); + + }) + + .fail(function(err) { + + console.error('Test failed for URL: %s', run.params.url); + console.error(err.toString()); + + runsDatastore.markAsFailed(run.runId, err.toString()); + + res.status(400).send('Bad request'); + + }) + + .finally(function() { + queue.remove(run.runId); + }); + + }).fail(function(err) { + console.error('Error or YLT\'s core instanciation'); + console.error(err); + console.error(err.stack); + }); + + // The user doesn't not want to wait for the response, sending the run ID only + if (!run.params.waitForResponse) { + console.log('Sending response without waiting.'); + res.setHeader('Content-Type', 'application/json'); + res.send(JSON.stringify({runId: run.runId})); + } + }); + + // Retrive one run by id + app.get('/api/runs/:id', function(req, res) { + var runId = req.params.id; + + var run = runsDatastore.get(runId); + + if (run) { + res.setHeader('Content-Type', 'application/json'); + res.send(JSON.stringify(run, null, 2)); + } else { + res.status(404).send('Not found'); + } + }); + + // Retrieve the list of all runs + /*app.get('/api/runs', function(req, res) { + // NOT YET + });*/ + + // Delete one run by id + /*app.delete('/api/runs/:id', function(req, res) { + deleteRun() + });*/ + + // Delete all + /*app.delete('/api/runs', function(req, res) { + purgeRuns() + }); + + // List all + app.get('/api/runs', function(req, res) { + listRuns() + }); + + // Exists + app.head('/api/runs/:id', function(req, res) { + existsX(); + // Returns 200 if the result exists or 404 if not + }); + */ + + // Retrive one result by id + app.get('/api/results/:id', function(req, res) { + 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) + .then(function(data) { + var results = partialGetterFn(data); + + if (typeof results === 'undefined') { + res.status(404).send('Not found'); + return; + } + + res.setHeader('Content-Type', 'application/json'); + res.send(JSON.stringify(results, null, 2)); + + }).fail(function() { + res.status(404).send('Not found'); + }); + } + +}; + +module.exports = ApiController; \ No newline at end of file diff --git a/lib/server/controllers/frontController.js b/lib/server/controllers/frontController.js new file mode 100644 index 0000000..9a19a67 --- /dev/null +++ b/lib/server/controllers/frontController.js @@ -0,0 +1,26 @@ +var path = require('path'); +var express = require('express'); + +var FrontController = function(app) { + 'use strict'; + + var cacheDuration = 365 * 24 * 60 * 60 * 1000; // One year + var assetsPath = (app.get('env') === 'development') ? '../../../front/src' : '../../../front/build'; + + var routes = ['/', '/about', '/result/:runId', '/result/:runId/timeline', '/result/:runId/rule/:policy', '/queue/:runId']; + routes.forEach(function(route) { + app.get(route, function(req, res) { + res.setHeader('Cache-Control', 'public, max-age=20'); + res.sendFile(path.join(__dirname, assetsPath, 'main.html')); + }); + }); + + app.use('/css', express.static(path.join(__dirname, assetsPath, 'css'), { maxAge: cacheDuration })); + app.use('/fonts', express.static(path.join(__dirname, assetsPath, 'fonts'), { maxAge: cacheDuration })); + app.use('/img', express.static(path.join(__dirname, assetsPath, 'img'), { maxAge: cacheDuration })); + app.use('/js', express.static(path.join(__dirname, assetsPath, 'js'), { maxAge: cacheDuration })); + app.use('/views', express.static(path.join(__dirname, assetsPath, 'views'), { maxAge: cacheDuration })); + app.use('/bower_components', express.static(path.join(__dirname, '../../../bower_components'), { maxAge: cacheDuration })); +}; + +module.exports = FrontController; \ No newline at end of file diff --git a/lib/server/datastores/resultsDatastore.js b/lib/server/datastores/resultsDatastore.js new file mode 100644 index 0000000..3392337 --- /dev/null +++ b/lib/server/datastores/resultsDatastore.js @@ -0,0 +1,89 @@ +var fs = require('fs'); +var rimraf = require('rimraf'); +var path = require('path'); +var Q = require('q'); +var debug = require('debug')('ylt:resultsDatastore'); + + +function ResultsDatastore() { + 'use strict'; + + var resultFileName = 'results.json'; + var resultsFolderName = 'results'; + var resultsDir = path.join(__dirname, '..', '..', '..', resultsFolderName); + + + this.saveResult = function(testResults) { + var promise = createResultFolder(testResults.runId); + + debug('Saving results to disk...'); + + promise.then(function() { + + var resultFilePath = path.join(resultsDir, testResults.runId, resultFileName); + debug('Destination file is %s', resultFilePath); + + return Q.nfcall(fs.writeFile, resultFilePath, JSON.stringify(testResults, null, 2)); + }); + + return promise; + }; + + + this.getResult = function(runId) { + + var resultFilePath = path.join(resultsDir, runId, resultFileName); + + debug('Reading results (runID = %s) from disk...', runId); + + return Q.nfcall(fs.readFile, resultFilePath, {encoding: 'utf8'}).then(function(data) { + return JSON.parse(data); + }); + }; + + + this.deleteResult = function(runId) { + var folder = path.join(resultsDir, runId); + + debug('Deleting results (runID = %s) from disk...', runId); + + return Q.nfcall(rimraf, folder); + }; + + + // The folder /results/folderName/ + function createResultFolder(folderName) { + var folder = path.join(resultsDir, folderName); + + debug('Creating the folder %s', folderName); + + return createGlobalFolder().then(function() { + return Q.nfcall(fs.mkdir, folder); + }); + } + + // The folder /results/ + function createGlobalFolder() { + var deferred = Q.defer(); + + // Create the results folder if it doesn't exist + fs.exists(resultsDir, function(exists) { + if (exists) { + deferred.resolve(); + } else { + debug('Creating the global results folder', resultsDir); + fs.mkdir(resultsDir, function(err) { + if (err) { + deferred.reject(err); + } else { + deferred.resolve(); + } + }); + } + }); + + return deferred.promise; + } +} + +module.exports = ResultsDatastore; \ No newline at end of file diff --git a/lib/server/datastores/runsDatastore.js b/lib/server/datastores/runsDatastore.js new file mode 100644 index 0000000..da9b5bb --- /dev/null +++ b/lib/server/datastores/runsDatastore.js @@ -0,0 +1,106 @@ + + +function RunsDatastore() { + 'use strict'; + + // NOT PERSISTING RUNS + // For the moment, maybe one day + var runs = {}; + + var STATUS_AWAITING = 'awaiting'; + var STATUS_RUNNING = 'running'; + var STATUS_COMPLETE = 'complete'; + var STATUS_FAILED = 'failed'; + + + this.add = function(run, position) { + runs[run.runId] = run; + this.updatePosition(run.runId, position); + }; + + + this.get = function(runId) { + return runs[runId]; + }; + + + this.updatePosition = function(runId, position) { + var run = runs[runId]; + + if (position > 0) { + run.status = { + statusCode: STATUS_AWAITING, + position: position + }; + } else { + run.status = { + statusCode: STATUS_RUNNING + }; + } + + runs[runId] = run; + }; + + + this.markAsComplete = function(runId) { + var run = runs[runId]; + + run.status = { + statusCode: STATUS_COMPLETE + }; + + runs[runId] = run; + }; + + + this.markAsFailed = function(runId, err) { + var run = runs[runId]; + + var errorMessage; + switch(err) { + case '252': + errorMessage = "Error 252: page timeout in Phantomas"; + break; + case '253': + errorMessage = "Error 253: Phantomas config error"; + break; + case '254': + errorMessage = "Error 254: page loading failed in Phantomas"; + break; + case '255': + errorMessage = "Error 255: Phantomas error"; + break; + case '1001': + errorMessage = "Error 1001: JavaScript profiling failed"; + break; + case '1002': + errorMessage = "Error 1002: missing Phantomas metrics"; + break; + default: + errorMessage = err; + } + + run.status = { + statusCode: STATUS_FAILED, + error: errorMessage + }; + + runs[runId] = run; + }; + + + this.delete = function(runId) { + delete runs[runId]; + }; + + + this.list = function() { + var runsArray = []; + Object.keys(runs).forEach(function(key) { + runsArray.push(runs[key]); + }); + return runsArray; + }; +} + +module.exports = RunsDatastore; \ No newline at end of file diff --git a/lib/server/datastores/runsQueue.js b/lib/server/datastores/runsQueue.js new file mode 100644 index 0000000..26a6717 --- /dev/null +++ b/lib/server/datastores/runsQueue.js @@ -0,0 +1,80 @@ +var Q = require('q'); + + +function RunsQueue() { + 'use strict'; + + var queue = []; + + + this.push = function(runId) { + var deferred = Q.defer(); + var startingPosition = queue.length; + + if (startingPosition === 0) { + + // The queue is empty, let's run immediatly + queue.push({ + runId: runId + }); + + deferred.resolve(); + + } else { + + queue.push({ + runId: runId, + positionChangedCallback: function(position) { + deferred.notify(position); + }, + itIsTimeCallback: function() { + deferred.resolve(); + } + }); + } + + var promise = deferred.promise; + promise.startingPosition = startingPosition; + return promise; + }; + + + this.getPosition = function(runId) { + // Position 0 means it's a work in progress (a run is removed AFTER it is finished, not before) + var position = -1; + + queue.some(function(run, index) { + if (run.runId === runId) { + position = index; + return true; + } + return false; + }); + + return position; + }; + + + this.remove = function(runId) { + var position = this.getPosition(runId); + if (position >= 0) { + queue.splice(position, 1); + } + + // Update other runs' positions + queue.forEach(function(run, index) { + if (index === 0 && run.itIsTimeCallback) { + run.itIsTimeCallback(); + } else if (index > 0 && run.positionChangedCallback) { + run.positionChangedCallback(index); + } + }); + + }; + + this.length = function() { + return queue.length; + }; +} + +module.exports = RunsQueue; \ No newline at end of file diff --git a/lib/server/middlewares/apiLimitsMiddleware.js b/lib/server/middlewares/apiLimitsMiddleware.js new file mode 100644 index 0000000..cce5df2 --- /dev/null +++ b/lib/server/middlewares/apiLimitsMiddleware.js @@ -0,0 +1,85 @@ +var config = require('../../../server_config/settings.json'); + +var debug = require('debug')('apiLimitsMiddleware'); + + +var apiLimitsMiddleware = function(req, res, next) { + 'use strict'; + + debug('Entering API Limits Middleware with IP address %s', req.connection.remoteAddress); + + if (req.path.indexOf('/api/') === 0 && !res.locals.hasApiKey) { + + + if (req.path === '/api/runs') { + + 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'); + return; + } + + } + + 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'); + return; + } + + debug('Not blocked by the API limits'); + // It's ok for the moment + } + + next(); +}; + + +var RecordTable = function(maxPerDay) { + var table = {}; + + // Check if the user overpassed the limit and save its visit + this.accepts = function(ipAddress) { + if (table[ipAddress]) { + + this.cleanEntry(ipAddress); + + debug('%d visits in the last 24 hours', table[ipAddress].length); + + if (table[ipAddress].length >= maxPerDay) { + return false; + } else { + table[ipAddress].push(Date.now()); + } + + } else { + table[ipAddress] = []; + table[ipAddress].push(Date.now()); + } + + return true; + }; + + // Clean the table for this guy + this.cleanEntry = function(ipAddress) { + table[ipAddress] = table[ipAddress].filter(function(date) { + return date > Date.now() - 1000*60*60*24; + }); + }; + + // Clean the entire table once in a while + this.removeOld = function() { + for (var ipAddress in table) { + this.cleanEntry(ipAddress); + } + }; + +}; + +// Init the records tables +var runsTable = new RecordTable(config.maxAnonymousRunsPerDay); +var callsTable = new RecordTable(config.maxAnonymousCallsPerDay); + +module.exports = apiLimitsMiddleware; \ No newline at end of file diff --git a/lib/server/middlewares/authMiddleware.js b/lib/server/middlewares/authMiddleware.js new file mode 100644 index 0000000..e5335e6 --- /dev/null +++ b/lib/server/middlewares/authMiddleware.js @@ -0,0 +1,42 @@ +var config = require('../../../server_config/settings.json'); + +var debug = require('debug')('authMiddleware'); + + +var authMiddleware = function(req, res, next) { + 'use strict'; + + if (req.path.indexOf('/api/') === 0) { + + + if (req.headers && req.headers['x-api-key']) { + + // Test if it's an authorized key + if (isApiKeyValid(req.headers['x-api-key'])) { + + // Come in! + debug('Authorized key: %s', req.headers['x-api-key']); + res.locals.hasApiKey = true; + + } else { + + // Sorry :/ + debug('Unauthorized key %s', req.headers['x-api-key']); + res.status(401).send('Unauthorized'); + return; + } + } else { + debug('No authorization key'); + // It's ok for the moment but you might be blocked by the apiLimitsMiddleware, dude + } + } + + next(); +}; + + +function isApiKeyValid(apiKey) { + return (config.authorizedKeys[apiKey]) ? true : false; +} + +module.exports = authMiddleware; \ No newline at end of file diff --git a/lib/tools/jsExecutionTransformer.js b/lib/tools/jsExecutionTransformer.js new file mode 100644 index 0000000..c7801e3 --- /dev/null +++ b/lib/tools/jsExecutionTransformer.js @@ -0,0 +1,62 @@ +var debug = require('debug')('ylt:jsExecutionTransformer'); + +var jsExecutionTransformer = function() { + + this.transform = function(data) { + var javascriptExecutionTree = {}; + + debug('Starting JS execution transformation'); + + try { + javascriptExecutionTree = JSON.parse(data.toolsResults.phantomas.offenders.javascriptExecutionTree[0]); + + if (javascriptExecutionTree.children) { + javascriptExecutionTree.children.forEach(function(node) { + + // Mark abnormal things with a warning flag + var contextLenght = (node.data.callDetails && node.data.callDetails.context) ? node.data.callDetails.context.length : null; + if ((node.data.type === 'jQuery - bind' && contextLenght > 5) || + node.data.resultsNumber === 0 || + contextLenght === 0) { + node.warning = true; + } + + // Mark errors with an error flag + if (node.data.type === 'error' || node.data.type === 'jQuery version change') { + node.error = true; + } + + // Mark a performance flag + if (['domInteractive', 'domContentLoaded', 'domContentLoadedEnd', 'domComplete'].indexOf(node.data.type) >= 0) { + node.windowPerformance = true; + } + + // Read the execution tree and adjust the navigation timings (cause their not very well synchronised) + switch(node.data.type) { + case 'domInteractive': + data.toolsResults.phantomas.metrics.domInteractive = node.data.timestamp; + break; + case 'domContentLoaded': + data.toolsResults.phantomas.metrics.domContentLoaded = node.data.timestamp; + break; + case 'domContentLoadedEnd': + data.toolsResults.phantomas.metrics.domContentLoadedEnd = node.data.timestamp; + break; + case 'domComplete': + data.toolsResults.phantomas.metrics.domComplete = node.data.timestamp; + break; + } + }); + } + + debug('JS execution transformation complete'); + + } catch(err) { + throw err; + } + + return javascriptExecutionTree; + }; +}; + +module.exports = new jsExecutionTransformer(); \ No newline at end of file diff --git a/phantomas_custom/core/scopeYLT/scopeYLT.js b/lib/tools/phantomas/custom_modules/core/scopeYLT/scopeYLT.js similarity index 93% rename from phantomas_custom/core/scopeYLT/scopeYLT.js rename to lib/tools/phantomas/custom_modules/core/scopeYLT/scopeYLT.js index 9ed515a..b4a97e9 100644 --- a/phantomas_custom/core/scopeYLT/scopeYLT.js +++ b/lib/tools/phantomas/custom_modules/core/scopeYLT/scopeYLT.js @@ -7,7 +7,7 @@ */ /* global document: true, window: true */ -exports.version = '0.1'; +exports.version = '0.2'; exports.module = function(phantomas) { 'use strict'; @@ -42,10 +42,11 @@ exports.module = function(phantomas) { return false; } - phantomas.log('Attaching a spy to "' + fn + '" function...'); + phantomas.log('Attaching a YLT spy to "' + fn + '" function...'); obj[fn] = function() { var result; + var err; // Before if (enabled) { @@ -54,15 +55,28 @@ exports.module = function(phantomas) { // Execute try { + result = origFn.apply(this, arguments); + } catch(e) { + + // Catching the err for the moment, because we need to make sure the callbackAfter function is called. + phantomas.log('Error catched on spyed function "' + fn + '": ' + e); phantomas.log(arguments); + + err = e; + } finally { // After if (enabled && callbackAfter) { - callbackAfter.call(this, result); + callbackAfter.call(this, result, arguments); + } + + if (err) { + phantomas.log('Re-throwing the error'); + throw err; } } diff --git a/phantomas_custom/modules/domQYLT/domQYLT.js b/lib/tools/phantomas/custom_modules/modules/domQYLT/domQYLT.js similarity index 82% rename from phantomas_custom/modules/domQYLT/domQYLT.js rename to lib/tools/phantomas/custom_modules/modules/domQYLT/domQYLT.js index 8f0e4c9..81985f4 100644 --- a/phantomas_custom/modules/domQYLT/domQYLT.js +++ b/lib/tools/phantomas/custom_modules/modules/domQYLT/domQYLT.js @@ -3,12 +3,13 @@ */ /* global Element: true, Document: true, Node: true, window: true */ -exports.version = '0.9.a'; +exports.version = '0.10.a'; exports.module = function(phantomas) { 'use strict'; phantomas.setMetric('DOMqueries'); // @desc number of all DOM queries @offenders + phantomas.setMetric('DOMqueriesWithoutResults'); // @desc number of DOM queries that retutned nothing @offenders phantomas.setMetric('DOMqueriesById'); // @desc number of document.getElementById calls phantomas.setMetric('DOMqueriesByClassName'); // @desc number of document.getElementsByClassName calls phantomas.setMetric('DOMqueriesByTagName'); // @desc number of document.getElementsByTagName calls @@ -21,14 +22,13 @@ exports.module = function(phantomas) { phantomas.once('init', function() { phantomas.evaluate(function() { (function(phantomas) { - function querySpy(type, query, fnName, context) { - phantomas.emit('domQuery', type, query, fnName, context); // @desc DOM query has been made + function querySpy(type, query, fnName, context, hasNoResults) { + phantomas.emit('domQuery', type, query, fnName, context, hasNoResults); // @desc DOM query has been made } phantomas.spy(Document.prototype, 'getElementById', function(id) { phantomas.incrMetric('DOMqueriesById'); phantomas.addOffender('DOMqueriesById', '#%s (in %s)', id, '#document'); - querySpy('id', '#' + id, 'getElementById', '#document'); phantomas.enterContext({ type: 'getElementById', @@ -41,9 +41,13 @@ exports.module = function(phantomas) { backtrace: phantomas.getBacktrace() }); - }, function(result) { + }, function(result, args) { + var id = args[0]; + + querySpy('id', '#' + id, 'getElementById', '#document', (result === null)); + var moreData = { - resultsNumber : result ? 1 : 0 + resultsNumber : (result === null) ? 0 : 1 }; phantomas.leaveContext(moreData); }); @@ -56,7 +60,6 @@ exports.module = function(phantomas) { phantomas.incrMetric('DOMqueriesByClassName'); phantomas.addOffender('DOMqueriesByClassName', '.%s (in %s)', className, context); - querySpy('class', '.' + className, 'getElementsByClassName', context); phantomas.enterContext({ type: 'getElementsByClassName', @@ -70,7 +73,14 @@ exports.module = function(phantomas) { }); } - function selectorClassNameAfter(result) { + function selectorClassNameAfter(result, args) { + /*jshint validthis: true */ + + var className = args[0]; + var context = phantomas.getDOMPath(this); + + querySpy('class', '.' + className, 'getElementsByClassName', context, (result.length === 0)); + var moreData = { resultsNumber : (result && result.length > 0) ? result.length : 0 }; @@ -88,7 +98,6 @@ exports.module = function(phantomas) { phantomas.incrMetric('DOMqueriesByTagName'); phantomas.addOffender('DOMqueriesByTagName', '%s (in %s)', tagName, context); - querySpy('tag name', tagName.toLowerCase(), 'getElementsByTagName', context); phantomas.enterContext({ type: 'getElementsByTagName', @@ -102,7 +111,14 @@ exports.module = function(phantomas) { }); } - function selectorTagNameSpyAfter(result) { + function selectorTagNameSpyAfter(result, args) { + /*jshint validthis: true */ + + var tagName = args[0]; + var context = phantomas.getDOMPath(this); + + querySpy('tag name', tagName.toLowerCase(), 'getElementsByTagName', context, (result.length === 0)); + var moreData = { resultsNumber : (result && result.length > 0) ? result.length : 0 }; @@ -112,11 +128,11 @@ exports.module = function(phantomas) { phantomas.spy(Document.prototype, 'getElementsByTagName', selectorTagNameSpyBefore, selectorTagNameSpyAfter); phantomas.spy(Element.prototype, 'getElementsByTagName', selectorTagNameSpyBefore, selectorTagNameSpyAfter); + // selector queries function selectorQuerySpy(selector, context) { phantomas.incrMetric('DOMqueriesByQuerySelectorAll'); phantomas.addOffender('DOMqueriesByQuerySelectorAll', '%s (in %s)', selector, context); - querySpy('selector', selector, 'querySelectorAll', context); } function selectorQuerySpyBefore(selector) { @@ -137,7 +153,14 @@ exports.module = function(phantomas) { }); } - function selectorQuerySpyAfter(result) { + function selectorQuerySpyAfter(result, args) { + /*jshint validthis: true */ + + var selector = args[0]; + var context = phantomas.getDOMPath(this); + + querySpy('selector', selector, 'querySelectorAll', context, (!result || result.length === 0)); + var moreData = { resultsNumber : result ? 1 : 0 }; @@ -162,7 +185,14 @@ exports.module = function(phantomas) { }); } - function selectorAllQuerySpryAfter(result) { + function selectorAllQuerySpryAfter(result, args) { + /*jshint validthis: true */ + + var selector = args[0]; + var context = phantomas.getDOMPath(this); + + querySpy('selector', selector, 'querySelectorAll', context, (!result || result.length === 0)); + var moreData = { resultsNumber : (result && result.length > 0) ? result.length : 0 }; @@ -244,9 +274,22 @@ exports.module = function(phantomas) { }); }); + // report DOM queries that return no results (issue #420) + phantomas.on('domQuery', function(type, query, fnName, context, hasNoResults) { + // ignore DOM queries within DOM fragments (used internally by jQuery) + if (context.indexOf('body') !== 0 && context.indexOf('#document') !== 0) { + return; + } + + if (hasNoResults === true) { + phantomas.incrMetric('DOMqueriesWithoutResults'); + phantomas.addOffender('DOMqueriesWithoutResults', '%s (in %s) using %s', query, context, fnName); + } + }); + // count DOM queries by either ID, tag name, class name and selector query // @see https://dvcs.w3.org/hg/domcore/raw-file/tip/Overview.html#dom-document-doctype - var Collection = require('../../../node_modules/phantomas/lib/collection'), + var Collection = require('../../../../../../node_modules/phantomas/lib/collection'), DOMqueries = new Collection(); phantomas.on('domQuery', function(type, query, fnName, context) { diff --git a/phantomas_custom/modules/eventListYLT/eventListYLT.js b/lib/tools/phantomas/custom_modules/modules/eventListYLT/eventListYLT.js similarity index 100% rename from phantomas_custom/modules/eventListYLT/eventListYLT.js rename to lib/tools/phantomas/custom_modules/modules/eventListYLT/eventListYLT.js diff --git a/phantomas_custom/modules/jQYLT/jQYLT.js b/lib/tools/phantomas/custom_modules/modules/jQYLT/jQYLT.js similarity index 99% rename from phantomas_custom/modules/jQYLT/jQYLT.js rename to lib/tools/phantomas/custom_modules/modules/jQYLT/jQYLT.js index e33e1b6..a850312 100644 --- a/phantomas_custom/modules/jQYLT/jQYLT.js +++ b/lib/tools/phantomas/custom_modules/modules/jQYLT/jQYLT.js @@ -276,7 +276,7 @@ exports.module = function(phantomas) { // count Sizzle calls to detect duplicated queries - var Collection = require('../../../node_modules/phantomas/lib/collection'), + var Collection = require('../../../../../../node_modules/phantomas/lib/collection'), sizzleCalls = new Collection(), jQueryLoading = new Collection(); diff --git a/lib/tools/phantomas/custom_modules/modules/javaScriptBottleYLT/javaScriptBottleYLT.js b/lib/tools/phantomas/custom_modules/modules/javaScriptBottleYLT/javaScriptBottleYLT.js new file mode 100644 index 0000000..ae459f9 --- /dev/null +++ b/lib/tools/phantomas/custom_modules/modules/javaScriptBottleYLT/javaScriptBottleYLT.js @@ -0,0 +1,57 @@ +/** + * Reports the use of functions known to be serious performance bottlenecks in JS + * + * @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.stevesouders.com/blog/2012/04/10/dont-docwrite-scripts/ + */ +/* global document: true, window: true */ + +exports.version = '0.1.a'; + +exports.module = function(phantomas) { + 'use strict'; + + 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) + + phantomas.once('init', function() { + phantomas.evaluate(function() { + (function(phantomas) { + function report(msg, caller, backtrace, metric) { + phantomas.log(msg + ': from ' + caller + '!'); + phantomas.log('Backtrace: ' + backtrace); + phantomas.incrMetric(metric); + } + + // 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) + '(...)'); + });*/ + + // spy calls to setTimeout / setInterval with string passed instead of a function + /*phantomas.spy(window, 'setTimeout', function(fn, interval) { + if (typeof fn !== 'string') return; + + report('eval() called via setTimeout("' + fn + '")', phantomas.getCaller(), phantomas.getBacktrace(), 'evalCalls'); + });*/ + + /*phantomas.spy(window, 'setInterval', function(fn, interval) { + if (typeof fn !== 'string') return; + + report('eval() called via setInterval("' + fn + '")', phantomas.getCaller(), phantomas.getBacktrace(), 'evalCalls'); + });*/ + + // spy document.write(ln) + phantomas.spy(document, 'write', function(arg) { + report('document.write() used', phantomas.getCaller(), phantomas.getBacktrace(), 'documentWriteCalls'); + }); + + phantomas.spy(document, 'writeln', function(arg) { + report('document.writeln() used', phantomas.getCaller(), phantomas.getBacktrace(), 'documentWriteCalls'); + }); + })(window.__phantomas); + }); + }); +}; diff --git a/phantomas_custom/modules/jsErrYLT/jsErrYLT.js b/lib/tools/phantomas/custom_modules/modules/jsErrYLT/jsErrYLT.js similarity index 100% rename from phantomas_custom/modules/jsErrYLT/jsErrYLT.js rename to lib/tools/phantomas/custom_modules/modules/jsErrYLT/jsErrYLT.js diff --git a/phantomas_custom/modules/jsFileLoadYLT/jsFileLoadYLT.js b/lib/tools/phantomas/custom_modules/modules/jsFileLoadYLT/jsFileLoadYLT.js similarity index 100% rename from phantomas_custom/modules/jsFileLoadYLT/jsFileLoadYLT.js rename to lib/tools/phantomas/custom_modules/modules/jsFileLoadYLT/jsFileLoadYLT.js diff --git a/phantomas_custom/modules/jsTreeYLT/jsTreeYLT.js b/lib/tools/phantomas/custom_modules/modules/jsTreeYLT/jsTreeYLT.js similarity index 100% rename from phantomas_custom/modules/jsTreeYLT/jsTreeYLT.js rename to lib/tools/phantomas/custom_modules/modules/jsTreeYLT/jsTreeYLT.js diff --git a/phantomas_custom/modules/windowPerfYLT/windowPerfYLT.js b/lib/tools/phantomas/custom_modules/modules/windowPerfYLT/windowPerfYLT.js similarity index 100% rename from phantomas_custom/modules/windowPerfYLT/windowPerfYLT.js rename to lib/tools/phantomas/custom_modules/modules/windowPerfYLT/windowPerfYLT.js diff --git a/lib/tools/phantomas/phantomasWrapper.js b/lib/tools/phantomas/phantomasWrapper.js new file mode 100644 index 0000000..c25c67f --- /dev/null +++ b/lib/tools/phantomas/phantomasWrapper.js @@ -0,0 +1,133 @@ +var async = require('async'); +var Q = require('q'); +var ps = require('ps-node'); +var debug = require('debug')('ylt:phantomaswrapper'); +var phantomas = require('phantomas'); + + +var PhantomasWrapper = function() { + 'use strict'; + + /** + * This is the phantomas launcher. It merges user chosen options into the default options + * Available options : + * + * - timeout : in seconds (default 60) + * - jsDeepAnalysis : should we inspect subrequests in the javascript execution tree? + * + */ + this.execute = function(data) { + + var deferred = Q.defer(); + + var task = data.params; + + var options = { + // Cusomizable options + timeout: task.options.timeout || 60, + 'js-deep-analysis': task.options.jsDeepAnalysis || false, + + // Mandatory + reporter: 'json:pretty', + 'analyze-css': true, + 'skip-modules': [ + 'blockDomains', // not needed + 'domMutations', // not compatible with webkit + 'domQueries', // overriden + 'eventListeners', // overridden + 'filmStrip', // not needed + 'har', // not needed for the moment + 'javaScriptBottlenecks', // needs to be launched after custom module scopeYLT, + 'jserrors', // overridden + 'pageSource', // not needed + 'screenshot', // not needed for the moment + 'waitForSelector', // not needed + 'windowPerformance' // overriden + ].join(','), + 'include-dirs': [ + 'lib/tools/phantomas/custom_modules/core', + 'lib/tools/phantomas/custom_modules/modules' + ].join(',') + }; + + // Output the command line for debugging purpose + debug('If you want to reproduce the phantomas task only, copy the following command line:'); + var optionsString = ''; + for (var opt in options) { + optionsString += ' ' + '--' + opt + '=' + options[opt]; + } + debug('node node_modules/phantomas/bin/phantomas.js --url=' + task.url + optionsString + ' --verbose'); + + // Kill the application if nothing happens + var phantomasPid; + var killer = setTimeout(function() { + debug('Killing the app because the test on %s was launched %d seconds ago', task.url, 5*options.timeout); + // If in server mode, forever will restart the server + + // Kill the Phantomas process first + if (phantomasPid) { + ps.kill(phantomasPid, function(err) { + if (err) { + debug('Could not kill Phantomas process %s', phantomasPid); + } + else { + debug('Phantomas process %s was correctly killed', phantomasPid); + } + + // Then suicide. + process.exit(1); + }); + } + + }, 5*options.timeout*1000); + + // It's time to launch the test!!! + var triesNumber = 2; + + async.retry(triesNumber, function(cb) { + var process = phantomas(task.url, options, function(err, json, results) { + debug('Returning from Phantomas'); + + // Adding some YellowLabTools errors here + if (json && json.metrics && !json.metrics.javascriptExecutionTree) { + err = 1001; + } + + if (!err && (!json || !json.metrics)) { + err = 1002; + } + + // Don't cancel test if it is a timeout and we've got some results + if (err === 252 && json) { + debug('Timeout after ' + options.timeout + ' seconds. But it\'s not a problem, the test is valid.'); + err = null; + } + + if (err) { + debug('Attempt failed. Error code ' + err); + } + + cb(err, json); + }); + + phantomasPid = process.pid; + + }, function(err, json) { + + clearTimeout(killer); + + if (err) { + debug('All ' + triesNumber + ' attemps failed for the test'); + deferred.reject(err); + } else { + + // Success!!! + deferred.resolve(json); + } + }); + + return deferred.promise; + }; +}; + +module.exports = new PhantomasWrapper(); \ No newline at end of file diff --git a/package.json b/package.json index d2d7b80..fa04850 100644 --- a/package.json +++ b/package.json @@ -1,31 +1,53 @@ { "name": "yellowlabtools", - "version": "1.0.1", + "version": "1.1.0", "repository": { "type": "git", "url": "git://github.com/gmetais/YellowLabTools.git" }, + "bin": { + "yellowlabtools": "./bin/cli.js" + }, + "main": "./lib/index.js", "dependencies": { - "phantomas": "1.7.0", - "express": "~4.10.1", "async": "~0.9.0", - "socket.io": "~1.2.0", - "body-parser": "~1.9.2", - "compression": "~1.2.0" + "body-parser": "~1.10.0", + "compression": "~1.2.2", + "cors": "^2.5.2", + "debug": "~2.1.0", + "express": "~4.10.6", + "phantomas": "1.8.0", + "ps-node": "0.0.3", + "q": "~1.1.2", + "rimraf": "~2.2.8" }, "devDependencies": { + "chai": "^1.10.0", "grunt": "^0.4.5", - "grunt-contrib-jshint": "^0.10.0", - "matchdep": "^0.3.0", - "grunt-mocha-test": "^0.12.2", - "grunt-contrib-clean": "^0.6.0", - "grunt-contrib-copy": "^0.7.0", "grunt-blanket": "^0.0.8", - "chai": "^1.9.2", - "mocha": "^2.0.1", - "phantomjs": "^1.9.10", + "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-express": "^1.4.1", + "grunt-filerev": "^2.1.2", "grunt-fontsmith": "^0.9.1", - "grunt-contrib-less": "^0.12.0" + "grunt-inline-angular-templates": "^0.1.5", + "grunt-line-remover": "^0.0.2", + "grunt-mocha-test": "^0.12.4", + "grunt-replace": "^0.8.0", + "grunt-usemin": "^3.0.0", + "matchdep": "^0.3.0", + "mocha": "^2.1.0", + "phantomjs": "^1.9.13", + "request": "^2.51.0", + "sinon": "^1.12.1", + "sinon-chai": "^2.6.0" }, "scripts": { "test": "grunt test" diff --git a/phantomas_custom/modules/analyzeStyleYLT/analyzeStyleYLT.js b/phantomas_custom/modules/analyzeStyleYLT/analyzeStyleYLT.js deleted file mode 100644 index 1c5f04e..0000000 --- a/phantomas_custom/modules/analyzeStyleYLT/analyzeStyleYLT.js +++ /dev/null @@ -1,138 +0,0 @@ -/** - * Adds CSS related metrics using analyze-css NPM module - * - * @see https://github.com/macbre/analyze-css - * - * Run phantomas with --analyze-css option to use this module - * - * setMetric('cssBase64Length') @desc total length of base64-encoded data in CSS source (will warn about base64-encoded data bigger than 4 kB) @optional @offenders - * setMetric('cssRedundantBodySelectors') @desc number of redundant body selectors (e.g. body .foo, section body h2, but not body > h1) @optional @offenders - * setMetric('redundantChildNodesSelectors') @desc number of redundant child nodes selectors @optional @offenders - * setMetric('cssComments') @desc number of comments in CSS source @optional @offenders - * setMetric('cssCommentsLength') @desc length of comments content in CSS source @optional - * setMetric('cssComplexSelectors') @desc number of complex selectors (consisting of more than three expressions, e.g. header ul li .foo) @optional @offenders - * setMetric('cssComplexSelectorsByAttribute') @desc [number] number of selectors with complex matching by attribute (e.g. [class$="foo"]) @optional @offenders - * setMetric('cssDuplicatedSelectors') @desc number of CSS selectors defined more than once in CSS source @optional @offenders - * setMetric('cssDuplicatedProperties') @desc number of CSS property definitions duplicated within a selector @optional @offenders - * setMetric('cssEmptyRules') @desc number of rules with no properties (e.g. .foo { }) @optional @offenders - * setMetric('cssExpressions') @desc number of rules with CSS expressions (e.g. expression( document.body.clientWidth > 600 ? "600px" : "auto" )) @optional @offenders - * setMetric('cssOldIEFixes') @desc number of fixes for old versions of Internet Explorer (e.g. * html .foo {} and .foo { *zoom: 1 }) @optional @offenders - * setMetric('cssImports') @desc number of @import rules @optional @offenders - * setMetric('cssImportants') @desc number of properties with value forced by !important @optional @offenders - * setMetric('cssMediaQueries') @desc number of media queries (e.g. @media screen and (min-width: 1370px)) @optional @offenders - * setMetric('cssOldPropertyPrefixes') @desc number of properties with no longer needed vendor prefix, powered by data provided by autoprefixer (e.g. --moz-border-radius) @optional @offenders - * setMetric('cssQualifiedSelectors') @desc number of qualified selectors (e.g. header#nav, .foo#bar, h1.title) @optional @offenders - * setMetric('cssSpecificityIdAvg') @desc average specificity for ID @optional - * setMetric('cssSpecificityIdTotal') @desc total specificity for ID @optional - * setMetric('cssSpecificityClassAvg') @desc average specificity for class, pseudo-class or attribute @optional - * setMetric('cssSpecificityClassTotal') @desc total specificity for class, pseudo-class or attribute @optional - * setMetric('cssSpecificityTagAvg') @desc average specificity for element @optional - * setMetric('cssSpecificityTagTotal') @desc total specificity for element @optional - * setMetric('cssSelectorsByAttribute') @desc number of selectors by attribute (e.g. .foo[value=bar]) @optional - * setMetric('cssSelectorsByClass') @desc number of selectors by class @optional - * setMetric('cssSelectorsById') @desc number of selectors by ID @optional - * setMetric('cssSelectorsByPseudo') @desc number of pseudo-selectors (e,g. :hover) @optional - * setMetric('cssSelectorsByTag') @desc number of selectors by tag name @optional - * setMetric('cssUniversalSelectors') @desc number of selectors trying to match every element (e.g. .foo > *) @optional @offenders - * setMetric('cssLength') @desc length of CSS source (in bytes) @optional - * setMetric('cssRules') @desc number of rules (e.g. .foo, .bar { color: red } is counted as one rule) @optional - * setMetric('cssSelectors') @desc number of selectors (e.g. .foo, .bar { color: red } is counted as two selectors - .foo and .bar) @optional - * setMetric('cssDeclarations') @desc number of declarations (e.g. .foo, .bar { color: red } is counted as one declaration - color: red) @optional - */ - -exports.version = '0.3.a'; - -exports.module = function(phantomas) { - 'use strict'; - - if (!phantomas.getParam('analyze-css')) { - phantomas.log('To enable CSS in-depth metrics please run phantomas with --analyze-css option'); - return; - } - - function ucfirst(str) { - // http://kevin.vanzonneveld.net - // + original by: Kevin van Zonneveld (http://kevin.vanzonneveld.net) - // + bugfixed by: Onno Marsman - // + improved by: Brett Zamir (http://brett-zamir.me) - // * example 1: ucfirst('kevin van zonneveld'); - // * returns 1: 'Kevin van zonneveld' - str += ''; - var f = str.charAt(0).toUpperCase(); - return f + str.substr(1); - } - - var isWindows = (require('system').os.name === 'windows'), - binary = isWindows ? 'analyze-css.cmd' : 'analyze-css'; - - phantomas.setMetric('cssParsingErrors'); // @desc number of CSS files (or embeded CSS) that failed to be parse by analyze-css @optional - - var runningScripts = 0; - - phantomas.on('recv', function(entry, res) { - if (entry.isCSS) { - phantomas.log('CSS: analyzing <%s>...', entry.url); - - // run analyze-css "binary" installed by npm - phantomas.runScript('node_modules/.bin/' + binary, ['--url', entry.url, '--json'], function(err, results) { - runningScripts --; - - if (err !== null) { - phantomas.log('analyzeCss: sub-process failed!'); - - // report failed CSS parsing (issue #494( - var offender = entry.url; - if (err.indexOf('CSS parsing failed') > 0) { - offender += ' (' + err.trim() + ')'; - } - - phantomas.incrMetric('cssParsingErrors'); - phantomas.addOffender('cssParsingErrors', offender); - return; - } - - phantomas.log('analyzeCss: using ' + results.generator); - - var metrics = results.metrics || {}, - offenders = results.offenders || {}; - - Object.keys(metrics).forEach(function(metric) { - var metricPrefixed = 'css' + ucfirst(metric); - - // increase metrics - phantomas.incrMetric(metricPrefixed, metrics[metric]); - - // and add offenders - if (typeof offenders[metric] !== 'undefined') { - offenders[metric].forEach(function(msg) { - phantomas.addOffender(metricPrefixed, msg); - }); - } - }); - }); - - runningScripts ++; - } - }); - - phantomas.reportQueuePush(function(done) { - phantomas.on('loadFinished', function() { - var intervalId, - pollFn; - - phantomas.log('analyze-css: making sure all processes are finished'); - - pollFn = function() { - if (runningScripts === 0) { - clearInterval(intervalId); - done(); - } else { - phantomas.log('analyze-css: waiting for %d processes to finish', runningScripts); - } - }; - - intervalId = setInterval(pollFn, 200); - pollFn(); - }); - }); -}; diff --git a/phantomas_custom/modules/domComplexYLT/domComplexYLT.js b/phantomas_custom/modules/domComplexYLT/domComplexYLT.js deleted file mode 100644 index a64f3c6..0000000 --- a/phantomas_custom/modules/domComplexYLT/domComplexYLT.js +++ /dev/null @@ -1,135 +0,0 @@ -/** - * Analyzes DOM complexity - */ -/* global document: true, Node: true, window: true */ - -exports.version = '1.0.a'; - -exports.module = function(phantomas) { - 'use strict'; - - // total length of HTML comments (including brackets) - phantomas.setMetric('commentsSize'); // @desc the size of HTML comments on the page @offenders - - // total length of text nodes with whitespaces only (i.e. pretty formatting of HTML) - phantomas.setMetric('whiteSpacesSize'); // @desc the size of text nodes with whitespaces only - - // count all tags - phantomas.setMetric('DOMelementsCount'); // @desc total number of HTML element nodes - phantomas.setMetric('DOMelementMaxDepth'); // @desc maximum level on nesting of HTML element node - - // nodes with inlines CSS (style attribute) - phantomas.setMetric('nodesWithInlineCSS'); // @desc number of nodes with inline CSS styling (with style attribute) @offenders - - // images - phantomas.setMetric('imagesScaledDown'); // @desc number of nodes that have images scaled down in HTML @offenders - phantomas.setMetric('imagesWithoutDimensions'); // @desc number of nodes without both width and height attribute @offenders - - // duplicated ID (issue #392) - phantomas.setMetric('DOMidDuplicated'); // @desc number of duplicated IDs found in DOM - - var Collection = require('../../../node_modules/phantomas/lib/collection'), - DOMids = new Collection(); - - phantomas.on('domId', function(id) { - DOMids.push(id); - }); - - // HTML size - phantomas.on('report', function() { - phantomas.setMetricEvaluate('bodyHTMLSize', function() { // @desc the size of body tag content (document.body.innerHTML.length) - return document.body && document.body.innerHTML.length || 0; - }); - - phantomas.evaluate(function() { - (function(phantomas) { - var runner = new phantomas.nodeRunner(), - whitespacesRegExp = /^\s+$/, - DOMelementMaxDepth = 0, - DOMelementMaxDepthElts = [], - size = 0; - - runner.walk(document.body, function(node, depth) { - switch (node.nodeType) { - case Node.COMMENT_NODE: - size = node.textContent.length + 7; // ''.length - phantomas.incrMetric('commentsSize', size); - - // log HTML comments bigger than 64 characters - if (size > 64) { - phantomas.addOffender('commentsSize', phantomas.getDOMPath(node) + ' (' + size + ' characters)'); - } - break; - - case Node.ELEMENT_NODE: - phantomas.incrMetric('DOMelementsCount'); - - if (depth > DOMelementMaxDepth) { - DOMelementMaxDepth = depth; - DOMelementMaxDepthElts = [phantomas.getDOMPath(node)]; - } else if (depth === DOMelementMaxDepth) { - DOMelementMaxDepthElts.push(phantomas.getDOMPath(node)); - } - - // report duplicated ID (issue #392) - if (node.id) { - phantomas.emit('domId', node.id); - } - - // ignore inline