├── .bowerrc ├── .eslintrc ├── .github └── CODEOWNERS ├── .gitignore ├── Procfile ├── README.md ├── bower.json ├── circle.yml ├── client └── src │ ├── images │ └── popup.png │ ├── js │ └── main.js │ └── scss │ └── .gitkeep ├── database ├── 1__drop_drop_all_tables_procedure.sql ├── 1_add_drop_all_tables_procedure.sql ├── 1_call_drop_all_tables_procedure.sql ├── a_pagetype.sql ├── a_pagetype_insert.sql ├── b_page.sql ├── b_page_insert.sql ├── c_properties.sql ├── c_properties_insert.sql ├── d_current_values.sql ├── e_value_history.sql └── f_current_values_for_domain.sql ├── extension ├── dialog.html ├── icon.png ├── images │ ├── check.png │ ├── cross.png │ ├── issue.png │ ├── refresh.png │ └── unsure.png ├── large_tile.png ├── manifest.json ├── marquee.png ├── scripts │ ├── background.js │ ├── lib │ │ ├── color.js │ │ └── widgetstyle.js │ ├── main.js │ └── popup.js └── small_tile.png ├── gulpfile.js ├── package.json ├── server ├── app.js ├── bin │ └── www ├── healthcheck.js ├── lib │ ├── .gitkeep │ ├── addInsights.js │ ├── betterThanCompetitors.js │ ├── betterThanFT.js │ ├── createPage.js │ ├── dataFor.js │ ├── database.js │ ├── detectPageType.js │ ├── domainDataFor.js │ ├── gatherDomainInsights.js │ ├── gatherPageInsights.js │ ├── getLatestValuesFor.js │ ├── hasInDateInsights.js │ ├── inFlight.js │ ├── insightProviders │ │ ├── domainInsightProviders │ │ │ ├── index.js │ │ │ └── sslLabs.js │ │ ├── index.js │ │ └── pageInsightProviders │ │ │ └── googlePageSpeedInsights.js │ ├── insightsExist.js │ ├── insightsOutOfDate.js │ ├── isConcerningValue.js │ ├── isFT.js │ ├── pageDataFor.js │ ├── pageExists.js │ ├── report.js │ └── updateCompetitorInsights.js ├── routes │ ├── api │ │ ├── dataFor.js │ │ └── index.js │ ├── contrast.js │ ├── helper │ │ └── jsonResponse.js │ ├── home.js │ ├── index.js │ ├── insights.js │ ├── staticFiles.js │ └── visualise.js ├── runbook.json └── views │ ├── main.hbs │ └── partials │ ├── contrast.hbs │ ├── header.hbs │ ├── home.hbs │ ├── insights.hbs │ ├── sentry.handlebars │ └── visualise.hbs └── tests ├── integration └── api │ └── api.spec.js └── server └── lib ├── .gitkeep ├── getLatestValuesFor.spec.js ├── insightsExist.spec.js └── pageExists.spec.js /.bowerrc: -------------------------------------------------------------------------------- 1 | { 2 | "registry": { 3 | "search": [ 4 | "http://registry.origami.ft.com", 5 | "https://bower.herokuapp.com" 6 | ] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "mocha": true, 6 | "node": true 7 | }, 8 | "ecmaFeatures": { 9 | "modules": true 10 | }, 11 | "rules": { 12 | "no-unused-vars": 2, 13 | "no-undef": 2, 14 | "eqeqeq": 2, 15 | "no-underscore-dangle": 0, 16 | "guard-for-in": 2, 17 | "no-extend-native": 2, 18 | "wrap-iife": 2, 19 | "new-cap": 2, 20 | "no-caller": 2, 21 | "quotes": [1, "single"], 22 | "no-loop-func": 2, 23 | "no-irregular-whitespace": 1, 24 | "no-multi-spaces": 2, 25 | "one-var": [2, "never"], 26 | "no-var": 1, 27 | "space-before-function-paren": [1, "always"], 28 | "strict": [1, "global"], 29 | "no-console": 1 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Guessed from commit history 2 | * @Financial-Times/newproducts 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | .env 3 | .DS_Store 4 | npm-debug.log 5 | /client/dist 6 | /coverage/ 7 | /extension-dist/ 8 | bower_components/ 9 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: npm start 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Perf Widget 2 | 3 | ### Development 4 | 5 | #### Prerequisites 6 | - [NodeJS](https://nodejs.org/en/) -- The runtime the application requires 7 | - [Heroku Toolbelt](https://toolbelt.heroku.com/) -- _Used for interacting with the production/testing instances_ 8 | - [Java Development Kit](http://www.oracle.com/technetwork/java/javase/downloads/index.html) -- Used for testing. 9 | - [MySQL] -- The database used to store the performance criteria 10 | 11 | #### Setting up development environment 12 | - Clone the repository -- `git clone git@github.com:ftlabs/perf-widget.git` 13 | - Change in repository directory -- `cd perf-widget` 14 | - Install the dependencies -- `npm install` 15 | - Build the files used by the web client -- `npm run build` 16 | - Spin up the web server -- `npm start` 17 | - Open the website in your browser of choice -- `open "localhost:3000"` -- it will default to port 3000 18 | 19 | ### Day-to-Day Development 20 | When developing you may want to have the server restart and client files rebuilt on any code changes. This can be done with the `develop` npm script -- `npm run develop`. 21 | 22 | #### Tasks 23 | - Build the application -- `npm run build` 24 | - Lint the files -- `npm run lint` 25 | - Start the application -- `npm run start` 26 | - Start the application and build the application whenever a file changes -- `npm run develop` 27 | - Test the client code -- `npm run test:client` 28 | - Test the server code -- `npm run test:server` 29 | - Test the integration of the application -- `npm run test:integration` 30 | - Run client, server and integration tests -- `npm run test` 31 | - Run all the tests whenever a file changes -- `npm run tdd` 32 | 33 | ### Testing 34 | Selenium is used for integration testing. In order to get Selenium running you will need to install the [Java Development Kit](http://www.oracle.com/technetwork/java/javase/downloads/index.html). 35 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "perf-widget", 3 | "version": "1.0.0", 4 | "homepage": "https://ftlabs-perf-widget.herokuapp.com", 5 | "authors": [ 6 | "FTLabs " 7 | ], 8 | "license": "MIT", 9 | "ignore": [ 10 | "**/.*", 11 | "node_modules", 12 | "bower_components", 13 | "test", 14 | "tests" 15 | ], 16 | "dependencies": { 17 | "o-tracking": "^1.1.6" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | --- 2 | machine: 3 | node: 4 | version: "5.6.0" 5 | post: 6 | - npm install -g npm@3 7 | - gem install scss-lint 8 | test: 9 | pre: 10 | - "npm run build" 11 | -------------------------------------------------------------------------------- /client/src/images/popup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ftlabs/perf-widget/7284cdd2197cd3b47a25bee37ae7fc972cc7e194/client/src/images/popup.png -------------------------------------------------------------------------------- /client/src/js/main.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const Chart = require('chart.js'); 3 | 4 | // Add the accessibility chart 5 | const chartWrapper = document.getElementById('contrastChartWrapper'); 6 | const url = decodeURIComponent(location.search.split('url=')[1].split('&')[0]); 7 | const chartData = location.search.split('data=')[1].split(',').map(Number).map(n => n.toFixed(2)).map(Number); 8 | const canvas = document.createElement('canvas'); 9 | const width = 480; 10 | canvas.width = width; 11 | canvas.height = width / 1.5; 12 | const title = document.createElement('h3'); 13 | chartWrapper.appendChild(title); 14 | title.innerHTML = `Contrast Ratio for: ${url}`; 15 | const yLegend = document.createElement('div'); 16 | chartWrapper.appendChild(yLegend); 17 | yLegend.innerHTML = 'Proportion of Characters'; 18 | const ctx = canvas.getContext('2d'); 19 | chartWrapper.appendChild(canvas); 20 | const xLegend = document.createElement('div'); 21 | chartWrapper.appendChild(xLegend); 22 | xLegend.innerHTML = 'Contrast Ratio (Higher is better)'; 23 | xLegend.style.textAlign = 'right'; 24 | 25 | const grd = ctx.createLinearGradient(0.000, 150.000, width, 150.000); 26 | 27 | // Add colors 28 | grd.addColorStop(0.000, 'rgba(255, 0, 0, 1.000)'); 29 | grd.addColorStop(0.470, 'rgba(219, 178, 30, 1.000)'); 30 | grd.addColorStop(1.000, 'rgba(95, 191, 0, 1.000)'); 31 | 32 | const line = new Chart(ctx).Line({ 33 | labels: [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,'15+'], 34 | datasets: [{ 35 | label: 'Contrast', 36 | fillColor: grd, 37 | strokeColor: 'rgba(220,220,220,1)', 38 | pointColor: 'rgba(220,220,220,1)', 39 | pointStrokeColor: '#fff', 40 | pointHighlightFill: '#fff', 41 | pointHighlightStroke: 'rgba(220,220,220,1)', 42 | data: chartData 43 | }] 44 | }, { 45 | pointDot: false, 46 | pointHitDetectionRadius : 0, 47 | scaleOverride: true, 48 | scaleSteps: 5, 49 | scaleStepWidth: 0.2, 50 | scaleStartValue: 0 51 | }); 52 | -------------------------------------------------------------------------------- /client/src/scss/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ftlabs/perf-widget/7284cdd2197cd3b47a25bee37ae7fc972cc7e194/client/src/scss/.gitkeep -------------------------------------------------------------------------------- /database/1__drop_drop_all_tables_procedure.sql: -------------------------------------------------------------------------------- 1 | DROP PROCEDURE IF EXISTS `drop_all_tables`; 2 | -------------------------------------------------------------------------------- /database/1_add_drop_all_tables_procedure.sql: -------------------------------------------------------------------------------- 1 | CREATE PROCEDURE `drop_all_tables`() 2 | BEGIN 3 | DECLARE _done INT DEFAULT FALSE; 4 | DECLARE _tableName VARCHAR(255); 5 | DECLARE _cursor CURSOR FOR 6 | SELECT table_name 7 | FROM information_schema.TABLES 8 | WHERE table_schema = SCHEMA(); 9 | DECLARE CONTINUE HANDLER FOR NOT FOUND SET _done = TRUE; 10 | 11 | SET FOREIGN_KEY_CHECKS = 0; 12 | 13 | OPEN _cursor; 14 | 15 | REPEAT FETCH _cursor INTO _tableName; 16 | 17 | IF NOT _done THEN 18 | SET @stmt_sql = CONCAT('DROP TABLE ', _tableName); 19 | PREPARE stmt1 FROM @stmt_sql; 20 | EXECUTE stmt1; 21 | DEALLOCATE PREPARE stmt1; 22 | END IF; 23 | 24 | UNTIL _done END REPEAT; 25 | 26 | CLOSE _cursor; 27 | SET FOREIGN_KEY_CHECKS = 1; 28 | END 29 | -------------------------------------------------------------------------------- /database/1_call_drop_all_tables_procedure.sql: -------------------------------------------------------------------------------- 1 | call drop_all_tables(); 2 | -------------------------------------------------------------------------------- /database/a_pagetype.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS `pagetype` ( 2 | `type` varchar(20) NOT NULL DEFAULT '', 3 | `pattern` varchar(2000) NOT NULL DEFAULT '', 4 | PRIMARY KEY (`type`) 5 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 6 | -------------------------------------------------------------------------------- /database/a_pagetype_insert.sql: -------------------------------------------------------------------------------- 1 | INSERT IGNORE INTO `pagetype` (`type`, `pattern`) 2 | VALUES 3 | ('article','(^https?:\\/\\/next.ft.com\\/content\\/.*)|(^https?:\\/\\/app.ft.com\\/(cms|content)\\/.*)'), 4 | ('homepage','(^https?:\\/\\/next.ft.com\\/(international|uk))|(^https?:\\/\\/app.ft.com\\/(index_page\\/home|home))'), 5 | ('stream','(^https?:\\/\\/next.ft.com\\/stream\\/.*)|(^https?:\\/\\/app.ft.com\\/(stream|topic)\\/.*)'), 6 | ('video','(^https?:\\/\\/video.ft.com\\/?.*)'); 7 | -------------------------------------------------------------------------------- /database/b_page.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS `page` ( 2 | `id` int(11) unsigned NOT NULL AUTO_INCREMENT, 3 | `url` varchar(2000) NOT NULL DEFAULT '', 4 | `domain` varchar(2000) NOT NULL DEFAULT '', 5 | `type` varchar(20), 6 | `friendly_name` varchar(1000) NOT NULL DEFAULT '', 7 | PRIMARY KEY (`id`), 8 | KEY `pagetype_page` (`type`), 9 | CONSTRAINT `pagetype_page` FOREIGN KEY (`type`) REFERENCES `pagetype` (`type`) 10 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 11 | -------------------------------------------------------------------------------- /database/b_page_insert.sql: -------------------------------------------------------------------------------- 1 | INSERT IGNORE INTO `page` (`id`, `url`, `type`, `domain`, `friendly_name`) 2 | VALUES 3 | (1, 'http://www.theguardian.com/uk', 'homepage', 'www.theguardian.com', 'The Guardian'), 4 | (2, 'http://international.nytimes.com/', 'homepage', 'international.nytimes.com', 'The NY Times'), 5 | (3, 'http://www.wsj.com/europe', 'homepage', 'www.wsj.com', 'The Wall Street Journal'), 6 | (4, 'http://www.theguardian.com/politics/2016/jan/17/britain-stronger-in-europe-eu-campaign-leaflet-uk', 'article', 'www.theguardian.com', 'The Guardian article page'), 7 | (5, 'http://www.nytimes.com/2016/01/18/us/politics/14-testy-months-behind-us-prisoner-swap-with-iran.html', 'article', 'www.nytimes.com', 'The NY Times article page'), 8 | (6, 'http://www.wsj.com/articles/oil-extends-slide-below-30-as-market-braces-for-iran-oil-influx-1453088057', 'article', 'www.wsj.com', 'The Wall Street Journal article page'), 9 | (7, 'http://www.theguardian.com/uk-news', 'stream', 'www.theguardian.com', 'The Guardian stream page'), 10 | (8, 'http://www.nytimes.com/pages/politics/index.html', 'stream', 'www.nytimes.com', 'The NY Times stream page'), 11 | (9, 'http://www.wsj.com/news/politics', 'stream', 'www.wsj.com', 'The Wall Street Journal stream page'), 12 | (10, 'http://www.theguardian.com/commentisfree/video/2016/jan/13/marlon-james-are-you-racist-video', 'video', 'www.theguardian.com', 'The Guardian video page'), 13 | (11, 'http://www.nytimes.com/video/technology/personaltech/100000004142268/fresh-from-ces.html?playlistId=1194811622182', 'video', 'www.nytimes.com', 'The NY Times video page'), 14 | (12, 'http://www.wsj.com/video/democratic-debate-in-two-minutes/31043401-0168-4AAD-ABB3-08F6E888F07E.html', 'video', 'www.wsj.com', 'The Wall Street Journal video page'); 15 | -------------------------------------------------------------------------------- /database/c_properties.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS `properties` ( 2 | `id` int(11) unsigned NOT NULL AUTO_INCREMENT, 3 | `name` varchar(100) NOT NULL DEFAULT '', 4 | `concerning_text` varchar(1000) NOT NULL, 5 | `reassuring_text` varchar(1000) NOT NULL, 6 | `minimum` int(11) DEFAULT NULL, 7 | `maximum` int(11) DEFAULT NULL, 8 | `category` varchar(1000) NOT NULL, 9 | `provider` varchar(1000) NOT NULL, 10 | `better_than_competitor` varchar(1000) NOT NULL, 11 | `worse_than_competitor` varchar(1000) NOT NULL, 12 | `better_than_ft` varchar(1000) NOT NULL, 13 | `worse_than_ft` varchar(1000) NOT NULL, 14 | `better` varchar(10) NOT NULL, 15 | PRIMARY KEY (`id`) 16 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 17 | -------------------------------------------------------------------------------- /database/c_properties_insert.sql: -------------------------------------------------------------------------------- 1 | INSERT IGNORE INTO `properties` (`name`, `concerning_text`, `reassuring_text`, `minimum`, `maximum`, `category`, `provider`, `better_than_ft`, `worse_than_ft`, `better_than_competitor`, `worse_than_competitor`, `better`) 2 | VALUES 3 | ('PageSpeedInsightsScore', 'This page is slow', 'This page is fast', 60, 100, 'Performance', 'Google Pagespeed', 'Fast for the FT', 'Slow for the FT', 'Faster than', 'Slower than', 'increasing'), 4 | ('NumberOfHosts', 'This page uses too many third-parties', 'This page uses no third-parties', 0, 0, 'Performance', 'Google Pagespeed', 'Fewer third-party requests than other FT products', 'More third-party requests than other FT products', 'Fewer third-party requests than', 'More third-party requests than', 'decreasing'), 5 | ('NumberOfResources', 'This page makes too many requests', 'This page makes few requests', null, 20, 'Performance', 'Google Pagespeed', 'Fewer requests than the FT', 'More requests than the FT', 'Fewer requests than', 'More requests than', 'decreasing'), 6 | ('WeightOfResources', 'This page is big', 'This page is small', null, 10000, 'Performance', 'Google Pagespeed', 'Small for the FT', 'Big for the FT', 'Smaller than', 'Bigger than', 'decreasing'), 7 | ('SSLLabsGrade', 'This connection is vulnverable to attack', 'This connection is secure', null, 2, 'Security', 'SSL Labs', null, null, null, null, null); 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /database/d_current_values.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS `current_values` ( 2 | `property_id` int(11) unsigned NOT NULL, 3 | `page_id` int(11) unsigned NOT NULL, 4 | `date` bigint(20) NOT NULL, 5 | `value` int(100), 6 | `link` varchar(1000), 7 | KEY `page_current_values` (`page_id`), 8 | KEY `property_current_values` (`property_id`), 9 | CONSTRAINT `page_current_values` FOREIGN KEY (`page_id`) REFERENCES `page` (`id`), 10 | CONSTRAINT `property_current_values` FOREIGN KEY (`property_id`) REFERENCES `properties` (`id`) 11 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 12 | -------------------------------------------------------------------------------- /database/e_value_history.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS `value_history` ( 2 | `property_id` int(11) unsigned NOT NULL, 3 | `page_id` int(11) unsigned NOT NULL, 4 | `date` int(20) NOT NULL, 5 | `value` int(100), 6 | `link` varchar(1000), 7 | KEY `property_value_history` (`property_id`), 8 | KEY `page_value_history` (`page_id`), 9 | CONSTRAINT `page_value_history` FOREIGN KEY (`page_id`) REFERENCES `page` (`id`) ON DELETE NO ACTION ON UPDATE NO ACTION, 10 | CONSTRAINT `property_value_history` FOREIGN KEY (`property_id`) REFERENCES `properties` (`id`) ON DELETE NO ACTION ON UPDATE NO ACTION 11 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 12 | -------------------------------------------------------------------------------- /database/f_current_values_for_domain.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS `current_values_for_domain` ( 2 | `property_id` int(11) unsigned NOT NULL, 3 | `domain` varchar(1000) NOT NULL, 4 | `value` int(100), 5 | KEY `property_current_values_for_domain` (`property_id`), 6 | CONSTRAINT `property_current_values_for_domain` FOREIGN KEY (`property_id`) REFERENCES `properties` (`id`) 7 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8; -------------------------------------------------------------------------------- /extension/dialog.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 23 | 24 | 25 | 26 |
27 |
28 |
29 | 30 | 31 |
32 |
33 | 34 | 35 |
36 |
37 | 38 |
39 |
40 |
41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /extension/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ftlabs/perf-widget/7284cdd2197cd3b47a25bee37ae7fc972cc7e194/extension/icon.png -------------------------------------------------------------------------------- /extension/images/check.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ftlabs/perf-widget/7284cdd2197cd3b47a25bee37ae7fc972cc7e194/extension/images/check.png -------------------------------------------------------------------------------- /extension/images/cross.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ftlabs/perf-widget/7284cdd2197cd3b47a25bee37ae7fc972cc7e194/extension/images/cross.png -------------------------------------------------------------------------------- /extension/images/issue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ftlabs/perf-widget/7284cdd2197cd3b47a25bee37ae7fc972cc7e194/extension/images/issue.png -------------------------------------------------------------------------------- /extension/images/refresh.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ftlabs/perf-widget/7284cdd2197cd3b47a25bee37ae7fc972cc7e194/extension/images/refresh.png -------------------------------------------------------------------------------- /extension/images/unsure.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ftlabs/perf-widget/7284cdd2197cd3b47a25bee37ae7fc972cc7e194/extension/images/unsure.png -------------------------------------------------------------------------------- /extension/large_tile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ftlabs/perf-widget/7284cdd2197cd3b47a25bee37ae7fc972cc7e194/extension/large_tile.png -------------------------------------------------------------------------------- /extension/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | 4 | "name": "FTLabs Perf Widget", 5 | "description": "A Chrome Extension that automagically adds a performance analysis widget.", 6 | "version": "2.3.0", 7 | "permissions": [ 8 | "tabs", 9 | "activeTab", 10 | "identity", 11 | "identity.email", 12 | "/*" 13 | ], 14 | "web_accessible_resources": [ 15 | "images/*.png" 16 | ], 17 | "browser_action": { 18 | "default_icon": "./icon.png", 19 | "default_popup": "dialog.html" 20 | }, 21 | "content_scripts": [ 22 | { 23 | "matches": [""], 24 | "js": ["scripts/main.js"] 25 | } 26 | ], 27 | "background": { 28 | "scripts": ["scripts/background.js"], 29 | "persistent": true 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /extension/marquee.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ftlabs/perf-widget/7284cdd2197cd3b47a25bee37ae7fc972cc7e194/extension/marquee.png -------------------------------------------------------------------------------- /extension/scripts/background.js: -------------------------------------------------------------------------------- 1 | 'use strict'; // eslint-disable-line strict 2 | /*global chrome, localStorage*/ 3 | 4 | const co = require('co'); 5 | const apiEndpoint = '/* @echo serviceURL */'; 6 | 7 | let enabled; 8 | 9 | if (localStorage.getItem('enabled') === null) { 10 | enabled = true; 11 | } else { 12 | enabled = localStorage.getItem('enabled') === 'true'; 13 | } 14 | 15 | fetch(`${apiEndpoint}/destruct`) 16 | .then(res => { return res.json(); }) 17 | .then(response => { 18 | 19 | if(response){ 20 | if(response.selfDestruct){ 21 | if (response.selfDestruct === true){ 22 | chrome.management.uninstallSelf(); 23 | } 24 | } 25 | } 26 | 27 | }) 28 | ; 29 | 30 | const enabledHosts = new Set(JSON.parse(localStorage.getItem('enabledHosts') || '[]')); 31 | 32 | // Runs automatically for the FT so be able to disable it for some domains 33 | const disabledFTHosts = new Set(JSON.parse(localStorage.getItem('disabledFTHosts') || '[]')); 34 | 35 | function emitMessage (method, data, url){ 36 | chrome.tabs.query({}, function (tabs){ 37 | tabs.forEach(function (tab) { 38 | chrome.tabs.sendMessage(tab.id, { 39 | method: method, 40 | data: data, 41 | url: url, 42 | apiEndpoint: apiEndpoint 43 | }); 44 | }); 45 | }); 46 | } 47 | 48 | function* getData (url, freshInsights) { 49 | let lastStatus = 202; 50 | let data = null; 51 | freshInsights = freshInsights === true; 52 | const apiUrl = `${apiEndpoint}/api/data-for?url=${encodeURIComponent(url)}`; 53 | 54 | const makeAPICall = function () { 55 | return fetch(apiUrl + `&fresh=${freshInsights}`, {cache: 'no-cache'}) 56 | .then(response => { 57 | freshInsights = false; 58 | lastStatus = response.status; 59 | return response.json(); 60 | }) 61 | .then(dataIn => { 62 | data = dataIn; 63 | return dataIn; 64 | }); 65 | } 66 | 67 | const waitThen = function (fn, timeout) { 68 | return new Promise(resolve => setTimeout(resolve, timeout || 10)).then(fn); 69 | } 70 | 71 | yield makeAPICall(); 72 | 73 | while (lastStatus === 202) { 74 | yield waitThen(makeAPICall, 3000); 75 | } 76 | 77 | if (lastStatus === 200) { 78 | return data.data; 79 | } else { 80 | throw Error(data.error); 81 | } 82 | } 83 | 84 | function isHostEnabled (host) { 85 | if (host.match(/ft.com$/)) { 86 | return !disabledFTHosts.has(host); 87 | } else { 88 | return enabledHosts.has(host); 89 | } 90 | } 91 | 92 | function setHostEnabled (host, enabled) { 93 | if (host.match(/ft.com$/)) { 94 | if (enabled) { 95 | disabledFTHosts.delete(host); 96 | } else { 97 | disabledFTHosts.add(host); 98 | } 99 | localStorage.setItem('disabledFTHosts', JSON.stringify(Array.from(disabledFTHosts))); 100 | } else { 101 | if (enabled) { 102 | enabledHosts.add(host); 103 | } else { 104 | enabledHosts.delete(host); 105 | } 106 | localStorage.setItem('enabledHosts', JSON.stringify(Array.from(enabledHosts))); 107 | } 108 | } 109 | 110 | chrome.runtime.onMessage.addListener(function (request, sender, sendResponse) { 111 | 112 | if (request.method === 'isEnabled') { 113 | sendResponse({ 114 | enabled: enabled 115 | }); 116 | } 117 | 118 | if (request.method === 'isEnabledForThisHost') { 119 | sendResponse({enabled: enabled && isHostEnabled(request.host)}); 120 | } 121 | 122 | if (request.method === 'trackUiInteraction') { 123 | new Promise( 124 | resolve => chrome.identity.getProfileUserInfo(resolve) 125 | ).then(identity => { 126 | chrome.tabs.query({ 127 | active: true, 128 | lastFocusedWindow: true 129 | }, function (tabs){ 130 | tabs.forEach(function (tab) { 131 | chrome.tabs.sendMessage(tab.id, { 132 | method: 'makeTrackingRequest', 133 | data: { 134 | identity: identity, 135 | details: request.details 136 | } 137 | }); 138 | }); 139 | }); 140 | }); 141 | } 142 | 143 | if (request.method === 'setEnabledForThisHost') { 144 | setHostEnabled(request.host, request.enabled); 145 | } 146 | 147 | if (request.method === 'setEnabled') { 148 | enabled = request.enabled; 149 | localStorage.setItem('enabled', String(request.enabled)); 150 | } 151 | 152 | if (request.method === 'showWidget') { 153 | chrome.tabs.query({ 154 | active: true, 155 | lastFocusedWindow: true 156 | }, function (tabs){ 157 | tabs.forEach(function (tab) { 158 | chrome.tabs.sendMessage(tab.id, {method: 'showWidget'}); 159 | }); 160 | }); 161 | } 162 | 163 | if (request.method === 'getData') { 164 | co(() => getData(request.url, request.freshInsights)) 165 | .then(data => { 166 | emitMessage('updateData', data, request.url); 167 | }, () => { 168 | emitMessage('updateError', {errorMessage: 'Could not return results, if this persists contact labs@ft.com'}, request.url); 169 | }); 170 | } 171 | }); 172 | -------------------------------------------------------------------------------- /extension/scripts/lib/color.js: -------------------------------------------------------------------------------- 1 | 'use strict'; //eslint-disable-line strict 2 | // From lea Verous Color Contrast tool. 3 | // https://github.com/LeaVerou/contrast-ratio/blob/gh-pages/color.js 4 | // 2016-02-08 5 | 6 | const self = module.exports; 7 | 8 | // Extend Math.round to allow for precision 9 | Math.round = (function(){ 10 | const round = Math.round; 11 | 12 | return function (number, decimals) { 13 | decimals = +decimals || 0; 14 | 15 | const multiplier = Math.pow(10, decimals); 16 | 17 | return round(number * multiplier) / multiplier; 18 | }; 19 | }()); 20 | 21 | // Simple class for handling sRGB colors 22 | (function(){ 23 | 24 | const _ = self.Color = function(rgba) { 25 | if (rgba === 'transparent') { 26 | rgba = [0,0,0,0]; 27 | } 28 | else if (typeof rgba === 'string') { 29 | const rgbaString = rgba; 30 | rgba = rgbaString.match(/rgba?\(([\d.]+), ([\d.]+), ([\d.]+)(?:, ([\d.]+))?\)/); 31 | 32 | if (rgba) { 33 | rgba.shift(); 34 | } 35 | else { 36 | throw new Error('Invalid string: ' + rgbaString); 37 | } 38 | } 39 | 40 | if (rgba[3] === undefined) { 41 | rgba[3] = 1; 42 | } 43 | 44 | rgba = rgba.map(function (a) { return Math.round(a, 3) }); 45 | 46 | this.rgba = rgba; 47 | } 48 | 49 | _.prototype = { 50 | get rgb () { 51 | return this.rgba.slice(0,3); 52 | }, 53 | 54 | get alpha () { 55 | return this.rgba[3]; 56 | }, 57 | 58 | set alpha (alpha) { 59 | this.rgba[3] = alpha; 60 | }, 61 | 62 | get luminance () { 63 | // Formula: http://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef 64 | const rgba = this.rgba.slice(); 65 | 66 | for(let i=0; i<3; i++) { 67 | let rgb = rgba[i]; 68 | 69 | rgb /= 255; 70 | 71 | rgb = rgb < .03928 ? rgb / 12.92 : Math.pow((rgb + .055) / 1.055, 2.4); 72 | 73 | rgba[i] = rgb; 74 | } 75 | 76 | return .2126 * rgba[0] + .7152 * rgba[1] + 0.0722 * rgba[2]; 77 | }, 78 | 79 | get inverse () { 80 | return new _([ 81 | 255 - this.rgba[0], 82 | 255 - this.rgba[1], 83 | 255 - this.rgba[2], 84 | this.alpha 85 | ]); 86 | }, 87 | 88 | toString: function() { 89 | return 'rgb' + (this.alpha < 1? 'a' : '') + '(' + this.rgba.slice(0, this.alpha >= 1? 3 : 4).join(', ') + ')'; 90 | }, 91 | 92 | clone: function() { 93 | return new _(this.rgba); 94 | }, 95 | 96 | // Overlay a color over another 97 | overlayOn: function (color) { 98 | const overlaid = this.clone(); 99 | 100 | const alpha = this.alpha; 101 | 102 | if (alpha >= 1) { 103 | return overlaid; 104 | } 105 | 106 | for(let i=0; i<3; i++) { 107 | overlaid.rgba[i] = overlaid.rgba[i] * alpha + color.rgba[i] * color.rgba[3] * (1 - alpha); 108 | } 109 | 110 | overlaid.rgba[3] = alpha + color.rgba[3] * (1 - alpha) 111 | 112 | return overlaid; 113 | }, 114 | 115 | contrast: function (color) { 116 | // Formula: http://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef 117 | const alpha = this.alpha; 118 | 119 | if (alpha >= 1) { 120 | if (color.alpha < 1) { 121 | color = color.overlayOn(this); 122 | } 123 | 124 | const l1 = this.luminance + .05; 125 | const l2 = color.luminance + .05; 126 | let ratio = l1/l2; 127 | 128 | if (l2 > l1) { 129 | ratio = 1 / ratio; 130 | } 131 | 132 | ratio = Math.round(ratio, 1); 133 | 134 | return { 135 | ratio: ratio, 136 | error: 0, 137 | min: ratio, 138 | max: ratio 139 | } 140 | } 141 | 142 | // If we’re here, it means we have a semi-transparent background 143 | // The text color may or may not be semi-transparent, but that doesn't matter 144 | 145 | const onBlack = this.overlayOn(_.BLACK).contrast(color).ratio; 146 | const onWhite = this.overlayOn(_.WHITE).contrast(color).ratio; 147 | 148 | const max = Math.max(onBlack, onWhite); 149 | 150 | let closest = this.rgb.map(function(c, i) { 151 | return Math.min(Math.max(0, (color.rgb[i] - c * alpha)/(1-alpha)), 255); 152 | }); 153 | 154 | closest = new _(closest); 155 | 156 | const min = this.overlayOn(closest).contrast(color).ratio; 157 | 158 | return { 159 | ratio: Math.round((min + max) / 2, 2), 160 | error: Math.round((max - min) / 2, 2), 161 | min: min, 162 | max: max, 163 | closest: closest, 164 | farthest: onWhite === max ? _.WHITE : _.BLACK 165 | }; 166 | } 167 | } 168 | 169 | _.BLACK = new _([0,0,0]); 170 | _.GRAY = new _([127.5, 127.5, 127.5]); 171 | _.WHITE = new _([255,255,255]); 172 | 173 | }()); 174 | -------------------------------------------------------------------------------- /extension/scripts/lib/widgetstyle.js: -------------------------------------------------------------------------------- 1 | /* global chrome*/ 2 | const style = document.createElement('style'); 3 | 4 | style.textContent = ` 5 | 6 | #perf-widget-holder { 7 | all: initial; 8 | } 9 | 10 | #perf-widget-holder * { 11 | all: unset; 12 | } 13 | 14 | #perf-widget-holder > * { 15 | font-weight: 100; 16 | } 17 | 18 | #perf-widget-holder { 19 | position: fixed; 20 | bottom : 20px; 21 | right : 20px; 22 | width : 250px; 23 | max-height : 300px; 24 | overflow-y: auto; 25 | background-color : #333; 26 | z-index : 2147483647; 27 | border-radius: 5px; 28 | box-shadow: 0 0 5px black; 29 | color: white; 30 | box-sizing: border-box; 31 | padding: 10px; 32 | font-size: 14px; 33 | padding-bottom: 20px; 34 | font-family: Helvetica, Arial, sans-serif; 35 | } 36 | 37 | #perf-widget-holder .close { 38 | position: absolute; 39 | right: 5px; 40 | top: 5px; 41 | color: rgba(255,255,255,.5); 42 | border: 1px solid white; 43 | width: 1em; 44 | height: 1em; 45 | text-align: center; 46 | border-radius: 100%; 47 | cursor: pointer; 48 | background-size: 60%; 49 | background-position: 50%; 50 | background-repeat: no-repeat; 51 | background-image: url('${chrome.extension.getURL('/images/cross.png')}'); 52 | } 53 | 54 | #perf-widget-holder .refresh { 55 | position: absolute; 56 | right: 30px; 57 | top: 5px; 58 | color: rgba(255,255,255,.5); 59 | width: 1em; 60 | height: 1em; 61 | text-align: center; 62 | border-radius: 100%; 63 | cursor: pointer; 64 | background-size: 80%; 65 | background-position: 50%; 66 | background-repeat: no-repeat; 67 | background-image: url('${chrome.extension.getURL('/images/refresh.png')}'); 68 | } 69 | 70 | #perf-widget-holder h3:first-of-type { 71 | margin-top:0; 72 | } 73 | 74 | #perf-widget-holder h3 { 75 | margin-top: 10px; 76 | display: block; 77 | font-weight: bold; 78 | } 79 | 80 | #perf-widget-holder .insights { 81 | display: block; 82 | margin-top: 5px; 83 | } 84 | 85 | #perf-widget-holder .insights h4 { 86 | font-size: 0.8em; 87 | margin-top: 0; 88 | margin-bottom : 0.3em; 89 | } 90 | 91 | #perf-widget-holder .insights li { 92 | list-style-type: none; 93 | padding: 0; 94 | margin: 0; 95 | font-size: 0.8em; 96 | width: 90%; 97 | margin-left: 10%; 98 | display: inline-block; 99 | line-height: 1.2em; 100 | margin-bottom: 0.3em; 101 | } 102 | 103 | #perf-widget-holder .insights li::before { 104 | width: 1em; 105 | height: 1em; 106 | display: inline-block; 107 | content: ""; 108 | margin-right: 0.5em; 109 | background-size: 100%; 110 | padding-top: 2px; 111 | background-position: 50%; 112 | background-repeat: no-repeat; 113 | background-image: url('${chrome.extension.getURL('/images/unsure.png')}'); 114 | position: absolute; 115 | margin-left: -18px; 116 | } 117 | 118 | #perf-widget-holder .insights li.ok-true::before { 119 | background-image: url('${chrome.extension.getURL('/images/check.png')}'); 120 | } 121 | 122 | #perf-widget-holder .insights li.ok-false::before { 123 | background-image: url('${chrome.extension.getURL('/images/issue.png')}'); 124 | } 125 | 126 | #perf-widget-holder .insights li a { 127 | color: rgba(255,255,255,.8); 128 | border: 0 solid black; 129 | text-decoration:none; 130 | display: inline-block; 131 | } 132 | 133 | #perf-widget-holder .footer { 134 | display: block; 135 | text-align: right; 136 | text-decoration: underline; 137 | font-size: 0.8em; 138 | margin-top: 1em; 139 | } 140 | 141 | `; 142 | 143 | document.head.appendChild(style); 144 | -------------------------------------------------------------------------------- /extension/scripts/main.js: -------------------------------------------------------------------------------- 1 | 'use strict'; // eslint-disable-line strict 2 | /*global chrome*/ 3 | 4 | const Color = require('./lib/color').Color; 5 | const oTracking = require('o-tracking'); 6 | 7 | const noColorCalculatedStyle = (function () { 8 | const temp = document.createElement('div'); 9 | document.body.appendChild(temp); 10 | const styleAttr = window.getComputedStyle(temp).backgroundColor; 11 | document.body.removeChild(temp); 12 | return styleAttr; 13 | }()); 14 | 15 | function nodesWithTextNodesUnder (el) { 16 | const elementsWithTextMap = new Map(); 17 | const walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null, false); 18 | let textNode; 19 | while(textNode = walk.nextNode()) { 20 | 21 | if (textNode.parentNode === undefined) { 22 | continue; 23 | } 24 | 25 | // ignore just whitespace nodes 26 | if (textNode.data.trim().length > 0) { 27 | if (elementsWithTextMap.has(textNode.parentNode)) { 28 | elementsWithTextMap.get(textNode.parentNode).push(textNode); 29 | } else { 30 | elementsWithTextMap.set(textNode.parentNode, [textNode]); 31 | } 32 | } 33 | } 34 | return Array.from(elementsWithTextMap); 35 | } 36 | 37 | function getBackgroundColorForEl (el) { 38 | 39 | const bgc = window.getComputedStyle(el).backgroundColor; 40 | if (bgc !== noColorCalculatedStyle && bgc !== '') { 41 | return bgc; 42 | } else if (el.parentNode) { 43 | if (el.parentNode !== document) { 44 | return getBackgroundColorForEl(el.parentNode); 45 | } 46 | } 47 | return null; 48 | } 49 | 50 | 51 | function getContrastForEl (el) { 52 | const style = window.getComputedStyle(el); 53 | const color = style.color; 54 | const backgroundColor = getBackgroundColorForEl(el) || 'rgba(255, 255, 255, 1)'; 55 | const bColor = new Color(backgroundColor); 56 | const fColor = new Color(color); 57 | return bColor.contrast(fColor); 58 | } 59 | 60 | function generateContrastData () { 61 | 62 | const textNodes = nodesWithTextNodesUnder(document.body); 63 | let goodChars = 0; 64 | let badChars = 0; 65 | let chartData = [ 66 | 0, 0, 0, 0, 0, 67 | 0, 0, 0, 0, 0, 68 | 0, 0, 0, 0, 0, 69 | 0 70 | ]; // buckets representing 0-(15+) 71 | textNodes.forEach(inNode => { 72 | const n = inNode[0]; 73 | const ratio = getContrastForEl(n).ratio; 74 | const noCharacters = inNode[1].map(t => t.length).reduce((a,b) => a + b, 0); 75 | const bucket = Math.min(15, Math.round(ratio)); 76 | chartData[bucket] += noCharacters; 77 | if (ratio < 4.5) { 78 | badChars += noCharacters; 79 | } else { 80 | goodChars += noCharacters; 81 | } 82 | }); 83 | 84 | return { 85 | proportionBadContrast: badChars / (goodChars + badChars), 86 | chartData: chartData.map(i => (i/(badChars + goodChars))) // average the data to keep numbers small 87 | } 88 | } 89 | 90 | function logInteraction (e) { 91 | const details = {}; 92 | const context = e.target.dataset.trackingAction; 93 | if (e.target.tagName === 'A') { 94 | details.action = 'widget-link-click'; 95 | details.destination = e.target.href; 96 | if (context) details.context = context; 97 | } else if (context) { 98 | details.action = 'click'; 99 | details.context = context; 100 | } 101 | 102 | if (details.action) { 103 | chrome.runtime.sendMessage({ 104 | method: 'trackUiInteraction', 105 | details: details 106 | }); 107 | } 108 | } 109 | 110 | // The background script picks the active tab in the active window to make 111 | // this request since it cannot be made from the background script 112 | function makeTrackingRequest (details, identity) { 113 | 114 | const trackingReq = details; 115 | trackingReq.category = 'ftlabs-performance-widget'; 116 | trackingReq.id = identity.id; 117 | trackingReq.email = identity.email; 118 | 119 | // may not work without a DOM, may have to redirect to active tab. 120 | oTracking.init({ 121 | server: 'https://spoor-api.ft.com/px.gif', 122 | context: { 123 | product: 'ftlabs-perf-widget' 124 | } 125 | }); 126 | 127 | oTracking.event({ 128 | detail: trackingReq 129 | }); 130 | } 131 | 132 | function loadWidget () { 133 | 134 | // add the widget stylesheet 135 | require('./lib/widgetstyle'); 136 | 137 | const header = document.createElement('div'); 138 | const holder = document.createElement('div'); 139 | const close = document.createElement('span'); 140 | const refresh = document.createElement('span'); 141 | const textTarget = document.createElement('div'); 142 | const footer = document.createElement('div'); 143 | const myUrl = window.location.href; 144 | const apiEndpoint = '/* @echo serviceURL */'; 145 | 146 | function removeSelf (){ 147 | widgetControls = null; 148 | holder.parentNode.removeChild(holder); 149 | chrome.runtime.onMessage.removeListener(recieveData); 150 | } 151 | 152 | function getData (url, freshInsights) { 153 | chrome.runtime.sendMessage({ 154 | method: 'getData', 155 | url: url, 156 | freshInsights : freshInsights 157 | }); 158 | } 159 | 160 | function recieveData (request) { 161 | 162 | if (request.method === 'updateError') { 163 | textTarget.innerHTML = request.data.errorMessage; 164 | } 165 | 166 | if (open && request.method === 'updateData' && request.url === myUrl) { 167 | const data = request.data; 168 | 169 | try { 170 | const contrastData = generateContrastData(); 171 | 172 | data.push({ 173 | category: 'Accessibility', 174 | provider: 'Local Page Contrast', 175 | comparisons: [{ 176 | ok: contrastData.proportionBadContrast < 0.2, 177 | text: `${Math.round((1-contrastData.proportionBadContrast)*100)}% of the text has good contrast.
` 178 | }], 179 | link: `${apiEndpoint}/contrastbreakdown/?url=${encodeURIComponent(location.toString())}&data=${contrastData.chartData.join(',')}` 180 | }); 181 | } catch (e) { 182 | 183 | // in the event of weird DOM causing the above to break don't break the rest of the data display. 184 | console.error(e.message, e.stack); 185 | } 186 | 187 | let output = ''; 188 | 189 | // Produce data structure combining categories and providers 190 | const reducedData = []; 191 | (function () { 192 | const data2 = new Map(); 193 | data.forEach(datum => { 194 | if (!data2.has(datum.category)) { 195 | data2.set(datum.category, new Map()); 196 | } 197 | if (!data2.get(datum.category).has(datum.provider)) { 198 | data2.get(datum.category).set(datum.provider, datum); 199 | reducedData.push(datum); 200 | } else { 201 | data2.get(datum.category).get(datum.provider).comparisons = data2.get(datum.category).get(datum.provider).comparisons.concat(datum.comparisons); 202 | } 203 | }); 204 | }()); 205 | 206 | reducedData.forEach(datum => { 207 | output += `

${datum.category}

`; 208 | datum.comparisons.forEach(comparison => { 209 | output += `
  • ${comparison.text}
  • `; 210 | }); 211 | output += '
    '; 212 | }); 213 | 214 | textTarget.innerHTML = output; 215 | } 216 | } 217 | 218 | function refreshFn () { 219 | textTarget.innerHTML = waitingText; 220 | getData(myUrl, true); 221 | } 222 | 223 | // prepare to recieve data. 224 | chrome.runtime.onMessage.addListener(recieveData); 225 | 226 | // ask for the data to be updated 227 | getData(myUrl); 228 | 229 | const waitingText = 'Loading Analysis...'; 230 | textTarget.innerHTML = waitingText; 231 | holder.appendChild(textTarget); 232 | holder.appendChild(header); 233 | header.appendChild(close); 234 | header.appendChild(refresh); 235 | holder.appendChild(footer); 236 | holder.addEventListener('click', logInteraction); 237 | 238 | footer.innerHTML = `

    Why am I seeing this?

    `; 239 | footer.classList.add('footer'); 240 | 241 | close.classList.add('close'); 242 | close.dataset.trackingAction = 'close'; 243 | close.addEventListener('click', removeSelf, false); 244 | 245 | refresh.classList.add('refresh'); 246 | refresh.dataset.trackingAction = 'refresh'; 247 | refresh.addEventListener('click', refreshFn, false); 248 | 249 | holder.setAttribute('id', 'perf-widget-holder'); 250 | document.body.appendChild(holder); 251 | 252 | return { 253 | close: removeSelf, 254 | refresh: refreshFn 255 | } 256 | } 257 | 258 | let widgetControls; 259 | 260 | chrome.runtime.sendMessage({ 261 | method: 'isEnabledForThisHost', 262 | host: location.host 263 | }, response => { 264 | if (response.enabled) widgetControls = loadWidget(); 265 | }); 266 | 267 | chrome.runtime.onMessage.addListener(function (request) { 268 | 269 | if (request.method === 'showWidget') { 270 | if (widgetControls) { 271 | widgetControls.refresh(); 272 | } else { 273 | widgetControls = loadWidget(); 274 | } 275 | } 276 | 277 | if (request.method === 'makeTrackingRequest') { 278 | makeTrackingRequest(request.data.details, request.data.identity); 279 | } 280 | }); 281 | -------------------------------------------------------------------------------- /extension/scripts/popup.js: -------------------------------------------------------------------------------- 1 | 'use strict'; // eslint-disable-line strict 2 | /*global window, chrome, document*/ 3 | 4 | chrome.runtime.sendMessage({method: 'isEnabled'}, function (response) { 5 | document.forms[0].enabled.checked = response.enabled; 6 | }); 7 | 8 | chrome.runtime.sendMessage({ 9 | method: 'trackUiInteraction', 10 | details: { 11 | action: 'open-extension-pop-up', 12 | } 13 | }); 14 | 15 | chrome.tabs.query({ 16 | active: true, 17 | lastFocusedWindow: true 18 | }, function (tabs){ 19 | tabs.forEach(function (tab) { 20 | const host = (new URL(tab.url)).host; 21 | document.getElementById('domaingoeshere').textContent = host; 22 | chrome.runtime.sendMessage({ 23 | method: 'isEnabledForThisHost', 24 | host: host 25 | }, response => { 26 | document.forms[0].enabledforthishost.checked = response.enabled; 27 | }); 28 | 29 | document.forms[0].enabledforthishost.addEventListener('click', function () { 30 | 31 | const enabled = document.forms[0].enabledforthishost.checked; 32 | 33 | chrome.runtime.sendMessage({ 34 | method: 'setEnabledForThisHost', 35 | host: host, 36 | enabled: enabled 37 | }); 38 | 39 | chrome.runtime.sendMessage({ 40 | method: 'trackUiInteraction', 41 | details: { 42 | action: enabled ? 'enable-for-host' : 'disable-for-host', 43 | host: host 44 | } 45 | }); 46 | }); 47 | }); 48 | }); 49 | 50 | document.forms[0].enabled.addEventListener('click', function () { 51 | 52 | const enabled = document.forms[0].enabled.checked; 53 | 54 | chrome.runtime.sendMessage({ 55 | method: 'setEnabled', 56 | enabled: enabled 57 | }); 58 | 59 | chrome.runtime.sendMessage({ 60 | method: 'trackUiInteraction', 61 | details: { 62 | action: enabled ? 'enable-globally' : 'disable-globally', 63 | } 64 | }); 65 | }); 66 | 67 | document.forms[0].showwidget.addEventListener('click', function (e) { 68 | 69 | chrome.runtime.sendMessage({ 70 | method: 'trackUiInteraction', 71 | details: { 72 | action: 'press-show-widget' 73 | } 74 | }); 75 | 76 | chrome.runtime.sendMessage({ 77 | method: 'showWidget' 78 | }); 79 | 80 | e.preventDefault(); 81 | }); 82 | -------------------------------------------------------------------------------- /extension/small_tile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ftlabs/perf-widget/7284cdd2197cd3b47a25bee37ae7fc972cc7e194/extension/small_tile.png -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | const gulp = require('gulp'); 3 | const preprocess = require('gulp-preprocess'); 4 | const uglify = require('gulp-uglify'); 5 | const insert = require('gulp-insert'); 6 | const env = require('dotenv').config(); 7 | const argv = require('yargs').argv; 8 | const serverDomain = process.env.SERVER_DOMAIN || 'https://ftlabs-perf-widget-test.herokuapp.com'; 9 | const obt = require('origami-build-tools'); 10 | 11 | function getDestination () { 12 | 13 | const locations = { 14 | LIVE : 'https://ftlabs-perf-widget.herokuapp.com', 15 | TEST : 'https://ftlabs-perf-widget-test.herokuapp.com' 16 | }; 17 | 18 | if(argv.destination !== undefined){ 19 | if (locations[argv.destination] !== undefined){ 20 | return locations[argv.destination]; 21 | } 22 | } 23 | 24 | return process.env.NODE_ENV === "development" ? 'http://localhost:3000' : serverDomain; 25 | 26 | } 27 | 28 | gulp.task('client', ['copy-client-images'], function () { 29 | 30 | return obt.build(gulp, { 31 | js: './client/src/js/main.js', 32 | buildJs: 'contrast-bundle.js', 33 | buildFolder: './client/dist/' 34 | }); 35 | 36 | }); 37 | 38 | gulp.task('copy-client-images', function () { 39 | 40 | return gulp.src([ 41 | 'client/src/images/*' 42 | ]) 43 | .pipe(gulp.dest('./client/dist/images/')); 44 | 45 | }); 46 | 47 | 48 | gulp.task('set-service-url', function () { 49 | 50 | return gulp.src('./client/dist/*.js') 51 | .pipe(preprocess( { context : { serviceURL : getDestination() } } ) ) 52 | .pipe(gulp.dest('./client/dist/')) 53 | ; 54 | 55 | }); 56 | 57 | gulp.task('build-extension-main', ['copy-extension-files'], function () { 58 | 59 | return obt.build(gulp, { 60 | js: './extension/scripts/main.js', 61 | buildJs: 'main.js', 62 | buildFolder: './extension-dist/scripts/' 63 | }); 64 | }); 65 | 66 | gulp.task('build-extension-background', ['copy-extension-files'], function () { 67 | 68 | return obt.build(gulp, { 69 | js: './extension/scripts/background.js', 70 | buildJs: 'background.js', 71 | buildFolder: './extension-dist/scripts/' 72 | }); 73 | }); 74 | 75 | gulp.task('build-extension-popup', ['copy-extension-files'], function () { 76 | 77 | return obt.build(gulp, { 78 | js: './extension/scripts/popup.js', 79 | buildJs: 'popup.js', 80 | buildFolder: './extension-dist/scripts/' 81 | }); 82 | }); 83 | 84 | gulp.task('build-extension-manifest', ['copy-extension-files'], function () { 85 | gulp.src('./extension/manifest.json') 86 | .pipe(preprocess( {context : { serviceURL : getDestination() } } ) ) 87 | .pipe(gulp.dest('./extension-dist/')); 88 | }); 89 | 90 | gulp.task('build-extension', ['build-extension-main', 'build-extension-background', 'build-extension-popup', 'build-extension-manifest'], function () { 91 | 92 | return gulp.src('./extension-dist/scripts/*.js') 93 | .pipe(preprocess( { context : { serviceURL : getDestination() } } ) ) 94 | .pipe(gulp.dest('./extension-dist/scripts/')) 95 | ; 96 | 97 | }); 98 | 99 | gulp.task('copy-extension-files', ['copy-extension-images'], function () { 100 | 101 | return gulp.src([ 102 | 'extension/*' 103 | ]) 104 | .pipe(gulp.dest('./extension-dist/')); 105 | 106 | }); 107 | 108 | gulp.task('copy-extension-images', function () { 109 | 110 | return gulp.src([ 111 | 'extension/images/*' 112 | ]) 113 | .pipe(gulp.dest('./extension-dist/images/')); 114 | 115 | }); 116 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "perf-widget", 3 | "version": "1.0.0", 4 | "repository": { 5 | "type": "git", 6 | "url": "https://github.com/ftlabs/perf-widget.git" 7 | }, 8 | "author": "FT Labs", 9 | "private": true, 10 | "license": "", 11 | "description": "Perf Widget front-end and back-end", 12 | "engines": { 13 | "node": "5.6.0" 14 | }, 15 | "main": "server/bin/www", 16 | "dependencies": { 17 | "array-includes": "^3.0.1", 18 | "babel-core": "^6.3.21", 19 | "babel-loader": "^6.2.0", 20 | "babel-preset-es2015": "^6.3.13", 21 | "bluebird": "^3.1.5", 22 | "body-parser": "^1.14.1", 23 | "bower": "^1.7.7", 24 | "chart.js": "^1.0.2", 25 | "debug": "^2.2.0", 26 | "denodeify": "^1.2.1", 27 | "dotenv": "^1.2.0", 28 | "express": "^4.13.3", 29 | "express-enforces-ssl": "^1.1.0", 30 | "express-ftwebservice": "^2.0.0", 31 | "express-hbs": "^0.8.4", 32 | "graphite": "0.0.6", 33 | "gulp": "^3.9.0", 34 | "gulp-env": "^0.4.0", 35 | "gulp-insert": "^0.5.0", 36 | "gulp-preprocess": "^2.0.0", 37 | "gulp-uglify": "^1.5.1", 38 | "gulp-util": "^3.0.7", 39 | "hsts": "^1.0.0", 40 | "is-url-superb": "^2.0.0", 41 | "lodash": "^4.0.0", 42 | "lru-cache": "^4.0.0", 43 | "morgan": "^1.6.1", 44 | "mysql": "^2.10.2", 45 | "node-fetch": "^1.3.3", 46 | "node-ssllabs": "^0.4.3", 47 | "npm-run-all": "^1.4.0", 48 | "origami-build-tools": "^4.5.2", 49 | "psi": "^2.0.2", 50 | "raven": "^0.9.0", 51 | "s3o-middleware": "^1.2.1", 52 | "yargs": "^3.32.0" 53 | }, 54 | "devDependencies": { 55 | "chai": "^3.4.1", 56 | "chai-as-promised": "^5.1.0", 57 | "co": "^4.6.0", 58 | "istanbul": "^0.4.1", 59 | "mocha": "^2.3.4", 60 | "mockery": "^1.4.0", 61 | "nodemon": "^1.8.1", 62 | "sinon": "^1.17.2", 63 | "supertest": "^1.1.0" 64 | }, 65 | "scripts": { 66 | "bower": "bower install", 67 | "build": "npm-run-all clean bower mkdir build:app build:extension build:client", 68 | "build:app": "gulp set-service-url", 69 | "build:client": "gulp client", 70 | "build:extension": "gulp build-extension", 71 | "build:extension:test": "gulp build-extension --destination TEST", 72 | "build:extension:live": "gulp build-extension --destination LIVE", 73 | "clean": "rimraf client/dist/ extension-dist/", 74 | "mkdir": "mkdir -p client/dist", 75 | "develop": "npm run watch -- --exec 'npm-run-all -p start build'", 76 | "lint": "obt verify --excludeFiles=\\!client/src/js/**/*.js", 77 | "postinstall": "npm run build", 78 | "start": "DEBUG=perf-widget:* node $npm_package_main", 79 | "tdd": "npm run watch -- --exec 'npm-run-all test'", 80 | "test": "DEBUG=perf-widget:* npm-run-all lint test:*", 81 | "test:integration": "npm-run-all test:integration:*", 82 | "test:integration:api": "_mocha ./tests/integration/api/*.spec.js", 83 | "test:server": "istanbul cover _mocha -- -R spec ./tests/server/**/*.spec.js", 84 | "watch": "nodemon --ignore client/dist --ignore coverage -e js,scss,json" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /server/app.js: -------------------------------------------------------------------------------- 1 | require('dotenv').load({silent: true}); 2 | const express = require('express'); 3 | const path = require('path'); 4 | const app = express(); 5 | const db = require('./lib/database'); 6 | const debug = require('debug')('perf-widget:app'); 7 | const hsts = require('hsts'); 8 | const enforceSSL = require('express-enforces-ssl'); 9 | 10 | app.enable('trust proxy'); 11 | 12 | if(process.env.NODE_ENV !== 'development'){ 13 | app.use(enforceSSL()); 14 | app.use( hsts({ 15 | maxAge : 604800000, 16 | includeSubdomains : true, 17 | force : true 18 | })); 19 | } 20 | 21 | // FT Web App configuration 22 | const ftwebservice = require('express-ftwebservice'); 23 | ftwebservice(app, { 24 | manifestPath: path.join(__dirname, '../package.json'), 25 | about: require('./runbook.json'), 26 | healthCheck: require('./healthcheck'), 27 | goodToGoTest: () => Promise.resolve(true) // TODO 28 | }); 29 | 30 | // Error Reporting 31 | const SENTRY_DSN = process.env.SENTRY_DSN; 32 | const raven = require('raven'); 33 | const client = new raven.Client(SENTRY_DSN); 34 | app.use(raven.middleware.express.requestHandler(SENTRY_DSN)); 35 | app.use(raven.middleware.express.errorHandler(SENTRY_DSN)); 36 | client.patchGlobal(); 37 | const logger = require('morgan'); 38 | app.use(logger('dev')); 39 | 40 | // View Engine 41 | const hbs = require('express-hbs'); 42 | app.engine('hbs', hbs.express4({ 43 | partialsDir: path.join(__dirname, '/views/partials') 44 | })); 45 | app.set('views', path.join(__dirname, 'views')); //TODO: remove this 46 | app.set('view engine', 'hbs'); 47 | 48 | // Decode JSON sent in request bodies 49 | app.use(require('body-parser').json()); 50 | 51 | module.exports = app; 52 | 53 | module.exports.ready = db.createTables().then(function () { 54 | debug('tables created') 55 | const updateCompetitorInsights = require('./lib/updateCompetitorInsights'); 56 | updateCompetitorInsights(); 57 | 58 | // Assign routes 59 | app.use('/', require('./routes')); 60 | }).catch(err => { 61 | 62 | debug(`An error occurred while we were trying to create the tables for the application.\n${err}`); 63 | db.abort() 64 | .then(function (){ 65 | process.exit(1); 66 | }). 67 | catch(err => { 68 | debug(`An error occurred when we tried to gracefully end the connections in our SQL pool.\n${err}`); 69 | process.exit(1); 70 | }) 71 | ; 72 | 73 | }); 74 | 75 | process.on('SIGTERM', db.abort); -------------------------------------------------------------------------------- /server/bin/www: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require('dotenv').load({silent: true}); 3 | const debug = require('debug')('perf-widget:bin:www'); 4 | const database = require('../lib/database'); 5 | 6 | // Kill application if a rejected promise is not handled 7 | process.on('unhandledRejection', function(error) { 8 | debug(error); 9 | database.abort().then(function() { 10 | process.abort(); 11 | }); 12 | }); 13 | 14 | const start = Date.now(); 15 | const app = require('../app'); 16 | app.ready.then(function () { 17 | const end = Date.now(); 18 | debug('start:', start, 'end:', end, 'duration:', end - start); 19 | const http = require('http'); 20 | 21 | /** 22 | * Normalize a port into a number, string, or false. 23 | */ 24 | const normalizePort = val => { 25 | const port = parseInt(val, 10); 26 | 27 | if (isNaN(port)) { 28 | // named pipe 29 | return val; 30 | } else if (port >= 0) { 31 | // port number 32 | return port; 33 | } else { 34 | return false; 35 | } 36 | } 37 | 38 | /** 39 | * Get port from environment and store in Express. 40 | */ 41 | const port = normalizePort(process.env.PORT || '3000'); 42 | process.env.PORT = port; 43 | 44 | /** 45 | * Create HTTP server. 46 | */ 47 | const server = http.createServer(app); 48 | 49 | /** 50 | * Listen on provided port, on all network interfaces. 51 | */ 52 | server.listen(port); 53 | server.on('error', function onError(error) { 54 | if (error.syscall !== 'listen') { 55 | throw error; 56 | } 57 | 58 | const bind = typeof port === 'string' 59 | ? 'Pipe ' + port 60 | : 'Port ' + port; 61 | 62 | // handle specific listen errors with friendly messages 63 | switch (error.code) { 64 | case 'EACCES': 65 | console.error(bind + ' requires elevated privileges'); 66 | process.exit(1); 67 | break; 68 | case 'EADDRINUSE': 69 | console.error(bind + ' is already in use'); 70 | process.exit(1); 71 | break; 72 | default: 73 | throw error; 74 | } 75 | }); 76 | 77 | server.on('listening', function onListening() { 78 | const addr = server.address(); 79 | const bind = typeof addr === 'string' 80 | ? 'pipe ' + addr 81 | : 'port ' + addr.port; 82 | debug('Listening on ' + bind); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /server/healthcheck.js: -------------------------------------------------------------------------------- 1 | 2 | // Return a promise that resolves to a set of healthchecks 3 | module.exports = function() { 4 | 5 | // You might have several async checks that you need to perform or 6 | // collect the results from, this is a really simplistic example 7 | return new Promise(function(resolve) { 8 | resolve([ 9 | { 10 | name: 'TODO - create some healthchecks', 11 | ok: true, 12 | severity: 2, 13 | businessImpact: 'TODO', 14 | technicalSummary: 'TODO', 15 | panicGuide: 'TODO', 16 | checkOutput: 'TODO', 17 | lastUpdated: new Date().toISOString() 18 | } 19 | ]); 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /server/lib/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ftlabs/perf-widget/7284cdd2197cd3b47a25bee37ae7fc972cc7e194/server/lib/.gitkeep -------------------------------------------------------------------------------- /server/lib/addInsights.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('perf-widget:lib:addInsights'); // eslint-disable-line no-unused-vars 2 | const bluebird = require('bluebird'); 3 | const query = require('./database').query; 4 | const escape = require('mysql').escape; 5 | const graphite = require('graphite'); 6 | const GRAPHITE_ENDPOINT = process.env.GRAPHITE_ENDPOINT; 7 | const client = GRAPHITE_ENDPOINT ? graphite.createClient(GRAPHITE_ENDPOINT) : undefined; 8 | 9 | module.exports = bluebird.coroutine(function* addInsights(propertyName, url, value, date, link) { 10 | const insertIntoValueHistory = `INSERT INTO value_history (property_id, page_id, value, date, link) VALUES ((SELECT id from properties WHERE name=${escape(propertyName)}), (SELECT id from page WHERE url=${escape(url)}), ${escape(value)}, ${escape(date)}, ${escape(link)});`; 11 | const deleteFromCurrentValues = `DELETE FROM current_values WHERE current_values.property_id = (SELECT id from properties WHERE properties.name=${escape(propertyName)}) AND current_values.page_id = (SELECT id from page WHERE url=${escape(url)});`; 12 | const insertIntoCurrentValues = `INSERT INTO current_values (property_id, page_id, value, date, link) VALUES ((SELECT id from properties WHERE name=${escape(propertyName)}), (SELECT id from page WHERE url=${escape(url)}), ${escape(value)}, ${escape(date)}, ${escape(link)});`; 13 | const deleteFromCurrentValuesForDomain = `DELETE FROM current_values_for_domain WHERE current_values_for_domain.property_id = (SELECT id from properties WHERE properties.name=${escape(propertyName)}) AND current_values_for_domain.domain = (SELECT domain from page WHERE url=${escape(url)});`; 14 | const insertIntoCurrentValuesForDomain = `INSERT INTO current_values_for_domain (property_id, domain, value) VALUES ((SELECT id from properties WHERE name=${escape(propertyName)}), (SELECT domain from page WHERE url=${escape(url)}), ${escape(value)});`; 15 | 16 | yield query(insertIntoValueHistory) 17 | yield query(deleteFromCurrentValues); 18 | yield query(insertIntoCurrentValues); 19 | yield query(deleteFromCurrentValuesForDomain); 20 | yield query(insertIntoCurrentValuesForDomain); 21 | 22 | if (client) { 23 | const metrics = {[`labs.perf_widget.${url.replace(/\./g,'_')}.${propertyName}`]: value}; 24 | 25 | client.write(metrics, date * 1000, function(err) { 26 | if (err) { 27 | debug(err); 28 | } else { 29 | debug(metrics); 30 | } 31 | }); 32 | } 33 | }); 34 | -------------------------------------------------------------------------------- /server/lib/betterThanCompetitors.js: -------------------------------------------------------------------------------- 1 | const bluebird = require('bluebird'); 2 | const query = require('./database').query; 3 | const escape = require('mysql').escape; 4 | const debug = require('debug')('perf-widget:lib:betterThanCompetitors'); // eslint-disable-line no-unused-vars 5 | const partition = require("lodash").partition; 6 | 7 | module.exports = bluebird.coroutine(function* betterThanCompetitors(name, value, type) { 8 | if (type === null || type === undefined) { 9 | return undefined; 10 | } 11 | 12 | const command = `SELECT page.domain, current_values_for_domain.value, page.friendly_name FROM current_values_for_domain JOIN page ON page.domain = current_values_for_domain.domain JOIN properties ON properties.id = current_values_for_domain.property_id WHERE current_values_for_domain.domain IN (SELECT domain FROM page WHERE domain='www.theguardian.com' OR domain='international.nytimes.com' OR domain='www.nytimes.com' OR domain='www.wsj.com') AND properties.name = ${escape(name)} AND page.type = ${escape(type)};`; 13 | 14 | const results = yield query(command); 15 | 16 | const temp = partition(results, function(o) { return o.value < value; }); 17 | const betterThan = temp[0].map(o => o.friendly_name); 18 | const worseThan = temp[1].map(o => o.friendly_name); 19 | 20 | return { 21 | 'true': betterThan, 22 | 'false': worseThan 23 | }; 24 | }); 25 | -------------------------------------------------------------------------------- /server/lib/betterThanFT.js: -------------------------------------------------------------------------------- 1 | const query = require('./database').query; 2 | const escape = require('mysql').escape; 3 | const debug = require('debug')('perf-widget:lib:betterThanFT'); // eslint-disable-line no-unused-vars 4 | 5 | module.exports = function betterThanFT(name, value) { 6 | 7 | const count = `SELECT COUNT(DISTINCT current_values_for_domain.domain) as amount FROM current_values_for_domain JOIN page ON page.domain = current_values_for_domain.domain JOIN properties ON properties.id = current_values_for_domain.property_id WHERE current_values_for_domain.domain NOT IN (SELECT domain FROM page WHERE domain='www.theguardian.com' OR domain='international.nytimes.com' OR domain='www.nytimes.com' OR domain='www.wsj.com') AND properties.name = ${escape(name)};`; 8 | const average = `SELECT better, a.average FROM properties JOIN (SELECT AVG(current_values.value) as average FROM current_values JOIN page ON page.id = current_values.page_id JOIN properties ON properties.id = current_values.property_id WHERE current_values.page_id NOT IN (SELECT id FROM page WHERE domain='www.theguardian.com' OR domain='international.nytimes.com' OR domain='www.nytimes.com' OR domain='www.wsj.com') AND properties.name = ${escape(name)}) as a WHERE name = ${escape(name)};`; 9 | 10 | return Promise.all([query(count), query(average)]).then(function(results) { 11 | const count = results[0][0].amount; 12 | const averageForFT = results[1][0].average; 13 | const better = results[1][0].better; 14 | 15 | var result; // eslint-disable-line no-var 16 | if (count > 1) { 17 | if (better === 'increasing') { 18 | result = value >= averageForFT; 19 | } else if (better === 'decreasing') { 20 | result = value <= averageForFT; 21 | } 22 | } 23 | 24 | return result; 25 | }); 26 | }; 27 | -------------------------------------------------------------------------------- /server/lib/createPage.js: -------------------------------------------------------------------------------- 1 | 'use strict'; // eslint-disable-line strict 2 | const query = require('./database').query; 3 | const escape = require('mysql').escape; 4 | const detectPageType = require('./detectPageType'); 5 | const parse = require('url').parse; 6 | const debug = require('debug')('perf-widget:lib:createPage'); // eslint-disable-line no-unused-vars 7 | 8 | module.exports = function createPage(url) { 9 | const domain = parse(url).host || url; 10 | 11 | return detectPageType(url).then(function pageType(type) { 12 | let addPageCommand; 13 | 14 | if (type) { 15 | addPageCommand = `INSERT INTO page (url, type, domain) VALUES (${escape(url)}, ${escape(type)}, ${escape(domain)});`; 16 | } else { 17 | addPageCommand = `INSERT INTO page (url, domain) VALUES (${escape(url)}, ${escape(domain)});`; 18 | } 19 | 20 | return query(addPageCommand).then(function () { 21 | return true; 22 | }); 23 | }); 24 | }; 25 | -------------------------------------------------------------------------------- /server/lib/dataFor.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('perf-widget:lib:dataFor'); 2 | const detectUrl = require('is-url-superb'); 3 | const parseUrl = require('url').parse; 4 | const flattenDeep = require('lodash').flattenDeep; 5 | const bluebird = require('bluebird'); 6 | const pageDataFor = require('./pageDataFor'); 7 | const domainDataFor = require('./domainDataFor'); 8 | const fetch = require('node-fetch'); 9 | const inFlight = require('./inFlight'); 10 | 11 | const hasInDateInsights = require('./hasInDateInsights'); 12 | const getLatestValuesFor = require('./getLatestValuesFor'); 13 | 14 | const isUp = function (url){ 15 | 16 | return fetch( url, {cache: 'no-cache', mode: 'head', timeout : 2500} ) 17 | .then(res => { 18 | const up = res.status < 500; 19 | 20 | debug('Is site accessible from the server\'s location?', url, up); 21 | 22 | return up; 23 | }) 24 | .catch(() => { 25 | return false; 26 | }) 27 | ; 28 | } 29 | 30 | const isRedirect = function (url){ 31 | 32 | return fetch(url, {cache: 'no-cache', mode: 'head', redirect: 'error', timeout : 2500, follow: 0}) 33 | .then(() => { 34 | return false; 35 | }).catch(err => { 36 | debug('err', err, true); 37 | return true; 38 | }) 39 | ; 40 | } 41 | 42 | const getPageInsights = function(url, freshInsights){ 43 | return pageDataFor(url, freshInsights).catch(function (err) { 44 | debug(`Promise was rejected, deleting ${url} from insightsCache. ${err} `); 45 | }); 46 | } 47 | 48 | const getDomainInsights = function(url, freshInsights){ 49 | const host = parseUrl(url).host; 50 | return domainDataFor(host, freshInsights).catch(function (err) { 51 | debug(`Promise was rejected, ${url} . ${err} `); 52 | }); 53 | } 54 | 55 | function getLatestValuesForPageAndDomain (url) { 56 | const host = parseUrl(url).host; 57 | 58 | return bluebird.all([getLatestValuesFor(url), getLatestValuesFor(host)]).then(flattenDeep); 59 | } 60 | 61 | function hasPageOrDomainInsights (url) { 62 | const host = parseUrl(url).host; 63 | 64 | return bluebird.all([hasInDateInsights(url), hasInDateInsights(host)]).then(results => { 65 | return results[0] || results[1]; 66 | }); 67 | } 68 | 69 | module.exports = bluebird.coroutine(function* (url, freshInsights) { 70 | if (!url) { 71 | return { 72 | error: 'Missing url parameter.' 73 | }; 74 | } 75 | 76 | const isUrl = typeof url === 'string' ? detectUrl(url) : false; 77 | 78 | if (!isUrl) { 79 | return { 80 | error: 'URL parameter needs to be a valid URL.' 81 | }; 82 | } 83 | 84 | try { 85 | const inDateInsights = yield hasPageOrDomainInsights(url); 86 | 87 | if (inDateInsights && !freshInsights) { 88 | debug('has in date insights, returning insights.'); 89 | return getLatestValuesForPageAndDomain(url); 90 | } 91 | 92 | if (inFlight.has(url)) { 93 | return { 94 | reason: 'Gathering results' 95 | }; 96 | } 97 | 98 | const up = yield isUp(url) 99 | 100 | if (up) { 101 | debug('adding to in flight table.'); 102 | 103 | inFlight.add(url); 104 | 105 | const redirect = yield isRedirect(url) 106 | 107 | if (redirect) { 108 | getDomainInsights(url, freshInsights).then(() => { 109 | debug('insights gathered, removing from in flight table.'); 110 | inFlight.remove(url); 111 | }); 112 | } else { 113 | Promise.all([getPageInsights(url, freshInsights), getDomainInsights(url, freshInsights)]).then(() => { 114 | debug('insights gathered, removing from in flight table.'); 115 | inFlight.remove(url); 116 | }); 117 | } 118 | 119 | return { 120 | reason: 'Gathering results' 121 | }; 122 | 123 | } 124 | 125 | return { 126 | error: 'Unable to access this URL to perform insights' 127 | } 128 | } catch (e) { 129 | debug(e) 130 | 131 | return { 132 | error : `An error occurred when we tried to check this URL.` 133 | } 134 | } 135 | }); 136 | -------------------------------------------------------------------------------- /server/lib/database.js: -------------------------------------------------------------------------------- 1 | const mysql = require('mysql'); 2 | const denodeify = require('denodeify'); 3 | const debug = require('debug')('perf-widget:lib:database'); // eslint-disable-line no-unused-vars 4 | const fs = require('fs'); 5 | const path = require('path'); 6 | 7 | const pool = mysql.createPool({ 8 | connectionLimit : process.env.MYSQL_CONNECTION_LIMIT, 9 | host : process.env.MYSQL_HOST, 10 | user : process.env.MYSQL_USER, 11 | password : process.env.MYSQL_PASSWORD, 12 | database : process.env.MYSQL_DATABASE, 13 | debug : process.env.MYSQL_DEBUG === 'false' || process.env.MYSQL_DEBUG === undefined ? false : true 14 | }); 15 | 16 | const getConnection = denodeify(pool.getConnection.bind(pool)); 17 | 18 | function readSQLFiles () { 19 | return fs.readdirSync(path.join(__dirname, '../../database')).map(function(item) { 20 | if (item.endsWith('.sql')) { 21 | return fs.readFileSync(path.join(__dirname, '../../database', item), 'utf8'); 22 | } 23 | }); 24 | } 25 | 26 | module.exports.createTables = function createTables () { 27 | 28 | const connection = getConnection(); 29 | 30 | function createDB (conn){ 31 | 32 | const query = denodeify(conn.query.bind(conn)); 33 | 34 | return readSQLFiles().map(function (sql) { 35 | return query(sql); 36 | }); 37 | 38 | } 39 | 40 | if (process.env.NODE_ENV !== 'production'){ 41 | 42 | debug('Not in production. Rebuild DB'); 43 | return connection.then(function (connection){ 44 | return Promise.all( createDB(connection) ) 45 | }); 46 | 47 | } else { 48 | debug('In production. Check state of DB. Build if required.'); 49 | return connection.then(function (connection){ 50 | 51 | const query = denodeify(connection.query.bind(connection)); 52 | 53 | return query(`SELECT table_name FROM information_schema.tables WHERE table_schema='perf_widget'`) 54 | .then(res => { 55 | if(res.length < 1){ 56 | // There are no tables, let's add them 57 | return Promise.all( createDB(connection) ); 58 | } else { 59 | connection.release(); 60 | return 61 | } 62 | }) 63 | ; 64 | 65 | }); 66 | 67 | } 68 | }; 69 | 70 | module.exports.query = function query (command) { 71 | const connection = getConnection(); 72 | 73 | return connection.then(function (connection) { 74 | 75 | const query = denodeify(connection.query.bind(connection)); 76 | return query(command).then(function (results) { 77 | connection.release(); 78 | return results; 79 | }).catch(err => { 80 | debug('An error occured when running the query.'); 81 | debug('>>>>>>> SQL ERROR <<<<<<<\n\n', command, '>>>\n\n', err, '\n\n>>>>>>> |||||||| <<<<<<<'); 82 | connection.release(); 83 | }); 84 | }) 85 | .catch(err => { 86 | debug('An error occured when retrieving a database connection'); 87 | debug(err); 88 | }); 89 | }; 90 | 91 | module.exports.abort = function (){ 92 | return new Promise(function (resolve, reject){ 93 | 94 | pool.end(function (err){ 95 | if(err){ 96 | reject(err); 97 | } else { 98 | resolve(); 99 | } 100 | }); 101 | 102 | }); 103 | } 104 | -------------------------------------------------------------------------------- /server/lib/detectPageType.js: -------------------------------------------------------------------------------- 1 | const query = require('./database').query; 2 | 3 | module.exports = function detectPageType(url) { 4 | const command = `SELECT * FROM pagetype`; 5 | 6 | const queryResult = query(command); 7 | 8 | return queryResult.then(function(results) { 9 | 10 | const patternType = {}; 11 | const patterns = []; 12 | 13 | results.forEach(function(result) { 14 | const patternRegex = new RegExp(result.pattern); 15 | patternType[patternRegex] = result.type; 16 | patterns.push(patternRegex); 17 | }); 18 | 19 | const matchedPattern = patterns.find(function(pattern) { 20 | return pattern.test(url); 21 | }); 22 | 23 | if (matchedPattern !== undefined) { 24 | return patternType[matchedPattern]; 25 | } else { 26 | return undefined; 27 | } 28 | }); 29 | }; 30 | -------------------------------------------------------------------------------- /server/lib/domainDataFor.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('perf-widget:lib:domainDataFor'); 2 | const bluebird = require('bluebird'); 3 | const insightsExist = require('./insightsExist'); 4 | const pageExists = require('./pageExists'); 5 | const getLatestValuesFor = require('./getLatestValuesFor'); 6 | const createPage = require('./createPage'); 7 | const insightsOutOfDate = require('./insightsOutOfDate'); 8 | const addInsights = require('./addInsights'); 9 | const gatherDomainInsights = require('./gatherDomainInsights'); 10 | 11 | module.exports = bluebird.coroutine(function* domainDataFor(domain, freshInsights) { 12 | const page = yield pageExists(domain); 13 | debug('pageExists', page, domain) 14 | 15 | if (!page) { 16 | yield createPage(domain); 17 | yield gatherAndAddDomainInsights(domain); 18 | } 19 | 20 | if (freshInsights) { 21 | yield gatherAndAddDomainInsights(domain); 22 | } 23 | 24 | const insights = yield insightsExist(domain); 25 | 26 | if (!insights) { 27 | yield gatherAndAddDomainInsights(domain) 28 | } 29 | 30 | const outOfDate = yield insightsOutOfDate(domain); 31 | 32 | if (outOfDate) { 33 | yield gatherAndAddDomainInsights(domain) 34 | } 35 | 36 | yield getLatestValuesFor(domain) 37 | }); 38 | 39 | const gatherAndAddDomainInsights = bluebird.coroutine(function* (domain) { 40 | const results = yield gatherDomainInsights(domain); 41 | 42 | // Use same timestamp for all results 43 | const date = Date.now() / 1000; 44 | 45 | // Add results to the database 46 | const insightsAdded = results.map(function (insight) { 47 | debug('addInsights', insight.name, domain, insight.value, date, insight.link) 48 | return addInsights(insight.name, domain, insight.value, date, insight.link); 49 | }); 50 | 51 | return Promise.all(insightsAdded); 52 | }); 53 | -------------------------------------------------------------------------------- /server/lib/gatherDomainInsights.js: -------------------------------------------------------------------------------- 1 | const insights = require('./insightProviders/domainInsightProviders'); 2 | const debug = require('debug')('perf-widget:lib:gatherDomainInsights'); 3 | const flattenDeep = require('lodash').flattenDeep; 4 | 5 | module.exports = function gatherDomainInsights(domain) { 6 | 7 | debug('Gathering domain insights for', domain); 8 | 9 | return Promise.all(insights(domain)).then(flattenDeep); 10 | }; 11 | -------------------------------------------------------------------------------- /server/lib/gatherPageInsights.js: -------------------------------------------------------------------------------- 1 | const insights = require('./insightProviders'); 2 | const debug = require('debug')('perf-widget:lib:gatherPageInsights'); 3 | const flattenDeep = require('lodash').flattenDeep; 4 | 5 | module.exports = function gatherPageInsights(url) { 6 | 7 | debug('Gathering insights for', url); 8 | 9 | return Promise.all(insights(url)).then(flattenDeep); 10 | }; 11 | -------------------------------------------------------------------------------- /server/lib/getLatestValuesFor.js: -------------------------------------------------------------------------------- 1 | const query = require('./database').query; 2 | const escape = require('mysql').escape; 3 | const debug = require('debug')('perf-widget:lib:getLatestValuesFor'); // eslint-disable-line no-unused-vars 4 | const isConcerningValue = require('./isConcerningValue'); 5 | const betterThanFT = require('./betterThanFT'); 6 | const isFT = require('./isFT'); 7 | const betterThanCompetitors = require('./betterThanCompetitors'); 8 | 9 | module.exports = function getLatestValuesFor(url) { 10 | const command = `SELECT page.type, properties.name, properties.category, properties.provider, properties.better_than_competitor, properties.worse_than_competitor, properties.better_than_ft, properties.worse_than_ft, properties.concerning_text, properties.reassuring_text, current_values.link, current_values.value FROM current_values JOIN properties ON properties.id = current_values.property_id JOIN page ON page.id = current_values.page_id AND page.url = ${escape(url)};`; 11 | 12 | const queryResult = query(command); 13 | 14 | return queryResult.then(function(rows) { 15 | const ft = isFT(url); 16 | const result = rows.map(function (row) { 17 | 18 | return Promise.all([ 19 | isConcerningValue(row.name, row.value), 20 | ft ? betterThanFT(row.name, row.value) : undefined, 21 | ft ? betterThanCompetitors(row.name, row.value, row.type) : undefined 22 | ]).then(function (values) { 23 | const concerning = values[0]; 24 | const betterThanOtherFTProducts = values[1]; 25 | const betterThanCompetitorProducts = values[2]; 26 | 27 | const results = []; 28 | 29 | if (concerning) { 30 | results.push({ 31 | ok: false, 32 | text: row.concerning_text 33 | }); 34 | } else { 35 | results.push({ 36 | ok: true, 37 | text: row.reassuring_text 38 | }); 39 | } 40 | 41 | if (betterThanOtherFTProducts !== undefined) { 42 | if (betterThanOtherFTProducts) { 43 | results.push({ 44 | ok: true, 45 | text: row.better_than_ft 46 | }); 47 | } else { 48 | results.push({ 49 | ok: false, 50 | text: row.worse_than_ft 51 | }); 52 | } 53 | } 54 | 55 | if (betterThanCompetitorProducts !== undefined) { 56 | betterThanCompetitorProducts['false'].forEach(function(competitor) { 57 | results.push({ 58 | ok: false, 59 | text: `${row.worse_than_competitor} ${competitor}` 60 | }); 61 | }); 62 | betterThanCompetitorProducts['true'].forEach(function(competitor) { 63 | results.push({ 64 | ok: true, 65 | text: `${row.better_than_competitor} ${competitor}` 66 | }); 67 | }); 68 | } 69 | 70 | return { 71 | category: row.category, 72 | provider: row.provider, 73 | comparisons: results, 74 | link: row.link 75 | }; 76 | }); 77 | }); 78 | 79 | return Promise.all(result); 80 | }); 81 | }; 82 | -------------------------------------------------------------------------------- /server/lib/hasInDateInsights.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('perf-widget:lib:hasInDateInsights'); 2 | const bluebird = require('bluebird'); 3 | const insightsExist = require('./insightsExist'); 4 | const pageExists = require('./pageExists'); 5 | const insightsOutOfDate = require('./insightsOutOfDate'); 6 | 7 | module.exports = bluebird.coroutine(function* hasInDateInsights (url) { 8 | const page = yield pageExists(url); 9 | debug('pageExists', page, url); 10 | 11 | if (!page) { 12 | return false; 13 | } 14 | 15 | const insights = yield insightsExist(url); 16 | 17 | if (!insights) { 18 | return false; 19 | } 20 | 21 | const outOfDate = yield insightsOutOfDate(url); 22 | 23 | if (outOfDate) { 24 | return false; 25 | } 26 | 27 | return true; 28 | }); 29 | -------------------------------------------------------------------------------- /server/lib/inFlight.js: -------------------------------------------------------------------------------- 1 | const inFlight = new Map(); 2 | 3 | module.exports = { 4 | has: function (url) { 5 | return inFlight.has(url); 6 | }, 7 | add: function (url) { 8 | return inFlight.set(url); 9 | }, 10 | remove: function(url) { 11 | return inFlight.delete(url); 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /server/lib/insightProviders/domainInsightProviders/index.js: -------------------------------------------------------------------------------- 1 | const sslLabs = require('./sslLabs'); 2 | 3 | module.exports = function(domain) { 4 | return [ 5 | sslLabs(domain) 6 | ]; 7 | }; 8 | -------------------------------------------------------------------------------- /server/lib/insightProviders/domainInsightProviders/sslLabs.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('perf-widget:lib:insightsProviders:domainInsightProviders:sslLabs'); // eslint-disable-line no-unused-vars 2 | const ssllabs = require("node-ssllabs"); 3 | const denodeify = require('denodeify'); 4 | const scan = denodeify(ssllabs.scan.bind(ssllabs)); 5 | 6 | const g = { 7 | A : 1, 8 | B : 2, 9 | C : 3, 10 | D : 4, 11 | E : 5, 12 | F : 6, 13 | T : 7 14 | }; 15 | 16 | module.exports = function (url) { 17 | console.time('SSL-Labs'); 18 | return scan(url).then(function(results) { 19 | debug(console.timeEnd('SSL-Labs')); 20 | 21 | const grade = results.endpoints[0].grade === undefined ? null : g[results.endpoints[0].grade[0]]; 22 | 23 | return [{ 24 | name: 'SSLLabsGrade', 25 | value: grade, 26 | link: `https://www.ssllabs.com/ssltest/analyze.html?d=${url}` 27 | }]; 28 | }); 29 | }; 30 | -------------------------------------------------------------------------------- /server/lib/insightProviders/index.js: -------------------------------------------------------------------------------- 1 | const pagespeed = require('./pageInsightProviders/googlePageSpeedInsights'); 2 | 3 | module.exports = function(url) { 4 | return [ 5 | pagespeed(url) 6 | ]; 7 | }; 8 | -------------------------------------------------------------------------------- /server/lib/insightProviders/pageInsightProviders/googlePageSpeedInsights.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('perf-widget:lib:getLatestValuesFor'); // eslint-disable-line no-unused-vars 2 | 3 | const psi = require('psi'); 4 | 5 | module.exports = function googlePageSpeedInsights(url) { 6 | 7 | console.time('PSI'); 8 | 9 | return psi(url).then(function(results) { 10 | debug(console.timeEnd('PSI')); 11 | 12 | return [{ 13 | name: 'PageSpeedInsightsScore', 14 | value: parseInt(results.ruleGroups.SPEED.score, 10), 15 | link: `https://developers.google.com/speed/pagespeed/insights/?url=${url}&tab=mobile` 16 | },{ 17 | name: 'NumberOfHosts', 18 | value: parseInt(results.pageStats.numberHosts, 10), 19 | link: `https://developers.google.com/speed/pagespeed/insights/?url=${url}&tab=mobile` 20 | },{ 21 | name: 'NumberOfResources', 22 | value: parseInt(results.pageStats.numberResources, 10), 23 | link: `https://developers.google.com/speed/pagespeed/insights/?url=${url}&tab=mobile` 24 | },{ 25 | name: 'WeightOfResources', 26 | value: parseInt(results.pageStats.htmlResponseBytes, 10) || 0 + 27 | parseInt(results.pageStats.cssResponseBytes, 10) || 0 + 28 | parseInt(results.pageStats.imageResponseBytes, 10) || 0 + 29 | parseInt(results.pageStats.javascriptResponseBytes, 10) || 0 + 30 | parseInt(results.pageStats.otherResponseBytes, 10) || 0, 31 | link: `https://developers.google.com/speed/pagespeed/insights/?url=${url}&tab=mobile` 32 | }]; 33 | }); 34 | }; 35 | -------------------------------------------------------------------------------- /server/lib/insightsExist.js: -------------------------------------------------------------------------------- 1 | const bluebird = require('bluebird'); 2 | const query = require('./database').query; 3 | const escape = require('mysql').escape; 4 | 5 | module.exports = bluebird.coroutine(function* insightsExist(url) { 6 | const command = `SELECT EXISTS (SELECT 1 FROM current_values JOIN page ON page.id = current_values.page_id and page.url = ${escape(url)})` 7 | 8 | const result = yield query(command); 9 | 10 | const key = Object.keys(result[0])[0]; 11 | 12 | const exists = result[0][key]; 13 | 14 | return exists === 1; 15 | }); 16 | -------------------------------------------------------------------------------- /server/lib/insightsOutOfDate.js: -------------------------------------------------------------------------------- 1 | const bluebird = require('bluebird'); 2 | const query = require('./database').query; 3 | const escape = require('mysql').escape; 4 | const debug = require('debug')('perf-widget:lib:insightsOutOfDate'); // eslint-disable-line no-unused-vars 5 | const DAYS_TO_STAY_IN_CACHE = process.env.DAYS_TO_STAY_IN_CACHE; 6 | 7 | function daysToSeconds(days) { 8 | return (60 * 60 * 24) * days; 9 | } 10 | 11 | module.exports = bluebird.coroutine(function* insightsOutOfDate(url) { 12 | const command = `SELECT date FROM current_values JOIN page ON page.id = current_values.page_id and page.url = ${escape(url)};`; 13 | 14 | const result = yield query(command); 15 | 16 | const key = Object.keys(result[0])[0]; 17 | 18 | const date = result[0][key]; 19 | 20 | return (date + daysToSeconds(DAYS_TO_STAY_IN_CACHE)) < (Date.now() / 1000); 21 | }); 22 | -------------------------------------------------------------------------------- /server/lib/isConcerningValue.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('perf-widget:lib:isConcerningValue'); // eslint-disable-line no-unused-vars 2 | 3 | const query = require('./database').query; 4 | 5 | const command = `SELECT name, minimum, maximum FROM properties`; 6 | 7 | const queryResult = query(command); 8 | 9 | const propertyThresholds = queryResult.then(function(rows) { 10 | 11 | return new Map(rows.map(function (row) { 12 | const minimum = row.minimum || -Infinity; 13 | const maximum = row.maximum || Infinity; 14 | 15 | return [row.name, function(value) { 16 | if(value === null){ 17 | return true; 18 | } 19 | if (value <= minimum) { 20 | return true; 21 | } else if (value >= maximum) { 22 | return true; 23 | } else { 24 | return false; 25 | } 26 | } 27 | ]; 28 | })); 29 | }); 30 | 31 | module.exports = function isConcerningValue(name, value) { 32 | return propertyThresholds.then(function(propertyThresholdsMap) { 33 | return propertyThresholdsMap.get(name)(value); 34 | }) 35 | }; 36 | -------------------------------------------------------------------------------- /server/lib/isFT.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('perf-widget:lib:isFT'); // eslint-disable-line no-unused-vars 2 | const parse = require('url').parse; 3 | 4 | module.exports = function isFT(url) { 5 | const domain = parse(url).host || url; 6 | const nonFTDomains = ['www.theguardian.com', 'international.nytimes.com', 'www.nytimes.com', 'www.wsj.com']; 7 | 8 | return nonFTDomains.indexOf(domain) === -1; 9 | }; 10 | -------------------------------------------------------------------------------- /server/lib/pageDataFor.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('perf-widget:lib:pageDataFor'); 2 | const bluebird = require('bluebird'); 3 | const insightsExist = require('./insightsExist'); 4 | const pageExists = require('./pageExists'); 5 | const getLatestValuesFor = require('./getLatestValuesFor'); 6 | const createPage = require('./createPage'); 7 | const insightsOutOfDate = require('./insightsOutOfDate'); 8 | const addInsights = require('./addInsights'); 9 | const gatherPageInsights = require('./gatherPageInsights'); 10 | 11 | module.exports = bluebird.coroutine(function* pageDataFor (url, freshInsights) { 12 | const page = yield pageExists(url); 13 | debug('pageExists', page, url) 14 | if (!page) { 15 | yield createPage(url); 16 | yield gatherAndAddPageInsights(url); 17 | yield getLatestValuesFor(url); 18 | } 19 | 20 | if (freshInsights) { 21 | yield gatherAndAddPageInsights(url); 22 | yield getLatestValuesFor(url); 23 | } 24 | 25 | const insights = yield insightsExist(url); 26 | 27 | if (!insights) { 28 | yield gatherAndAddPageInsights(url); 29 | yield getLatestValuesFor(url); 30 | } 31 | 32 | const outOfDate = yield insightsOutOfDate(url); 33 | 34 | if (outOfDate) { 35 | yield gatherAndAddPageInsights(url); 36 | yield getLatestValuesFor(url); 37 | } 38 | 39 | return getLatestValuesFor(url); 40 | }); 41 | 42 | const gatherAndAddPageInsights = bluebird.coroutine(function* (page) { 43 | const results = yield gatherPageInsights(page); 44 | 45 | // Use same timestamp for all results 46 | const date = Date.now() / 1000; 47 | 48 | // Add results to the database 49 | const insightsAdded = results.map(function (insight) { 50 | debug('addInsights', insight.name, page, insight.value, date, insight.link) 51 | return addInsights(insight.name, page, insight.value, date, insight.link); 52 | }); 53 | 54 | // After results are added to the database, repeat this process 55 | return Promise.all(insightsAdded) 56 | }); 57 | -------------------------------------------------------------------------------- /server/lib/pageExists.js: -------------------------------------------------------------------------------- 1 | const bluebird = require('bluebird'); 2 | const query = require('./database').query; 3 | const escape = require('mysql').escape; 4 | const debug = require('debug')('perf-widget:lib:pageExists'); // eslint-disable-line no-unused-vars 5 | 6 | const pageExists = bluebird.coroutine(function* pageExists(url) { 7 | const command = `SELECT EXISTS(SELECT 1 FROM page WHERE url=${escape(url)})` 8 | 9 | const result = yield query(command); 10 | 11 | // Database closed the connection early return that the page does not exist 12 | if (!result) { 13 | return false; 14 | } 15 | 16 | const exists = result[0][Object.keys(result[0])[0]] === 1; 17 | 18 | return exists; 19 | }); 20 | 21 | module.exports = pageExists; 22 | -------------------------------------------------------------------------------- /server/lib/report.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('perf-widget:lib:areWeGettingBetter'); // eslint-disable-line no-unused-vars 2 | const escape = require('mysql').escape; 3 | const query = require('./database').query; 4 | 5 | function getHistoricalInsightsForDomain (domain){ 6 | 7 | debug('\n\n\n', domain); 8 | 9 | const command = `SELECT page.url, value_history.value, value_history.date FROM page JOIN value_history ON page.id = value_history.page_id JOIN properties ON value_history.property_id=1 WHERE page.domain=${escape(domain)} GROUP BY value_history.date, page.url, value_history.value;`; 10 | 11 | return query(command) 12 | .then(result => { 13 | debug('\n\n\n', result); 14 | return { 15 | data : result, 16 | domain : domain, 17 | str : JSON.stringify(result) 18 | }; 19 | }) 20 | ; 21 | 22 | } 23 | 24 | function getAllValuesFromTheLastTwentyFourHours (){ 25 | const command = `SELECT current_values.value, current_values.link, current_values.date, page.url, page.domain, properties.provider, properties.name FROM current_values JOIN properties ON current_values.property_id = properties.id JOIN page ON current_values.page_id = page.id WHERE date > UNIX_TIMESTAMP() - 86400 ORDER BY page.domain;`; 26 | 27 | return query(command); 28 | } 29 | 30 | module.exports.insights = { 31 | allValues : getAllValuesFromTheLastTwentyFourHours, 32 | historicalDomain : getHistoricalInsightsForDomain 33 | } 34 | -------------------------------------------------------------------------------- /server/lib/updateCompetitorInsights.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('perf-widget:lib:updateCompetitorInsights'); // eslint-disable-line no-unused-vars 2 | const pageDataFor = require('./pageDataFor'); 3 | const bluebird = require('bluebird'); 4 | 5 | module.exports = function updateCompetitorInsights () { 6 | const DAY_IN_MILLISECONDS = 60 * 60 * 24 * 1000; 7 | 8 | const competitorUrls = [ 9 | 'http://www.theguardian.com/uk', 10 | 'http://international.nytimes.com/', 11 | 'http://www.wsj.com/europe', 12 | 'http://www.theguardian.com/politics/2016/jan/17/britain-stronger-in-europe-eu-campaign-leaflet-uk', 13 | 'http://www.nytimes.com/2016/01/18/us/politics/14-testy-months-behind-us-prisoner-swap-with-iran.html', 14 | 'http://www.wsj.com/articles/oil-extends-slide-below-30-as-market-braces-for-iran-oil-influx-1453088057', 15 | 'http://www.theguardian.com/uk-news', 16 | 'http://www.nytimes.com/pages/politics/index.html', 17 | 'http://www.wsj.com/news/politics', 18 | 'http://www.theguardian.com/commentisfree/video/2016/jan/13/marlon-james-are-you-racist-video', 19 | 'http://www.nytimes.com/video/technology/personaltech/100000004142268/fresh-from-ces.html?playlistId=1194811622182', 20 | 'http://www.wsj.com/video/democratic-debate-in-two-minutes/31043401-0168-4AAD-ABB3-08F6E888F07E.html' 21 | ]; 22 | 23 | return bluebird.map(competitorUrls, pageDataFor, {concurrency: 6}).then(function () { 24 | debug('Finished updating the competitor pages.'); 25 | }); 26 | 27 | setTimeout(function () { 28 | updateCompetitorInsights().catch(function () { 29 | debug('Errored updating the competitor pages.'); 30 | }); 31 | }, DAY_IN_MILLISECONDS); 32 | }; 33 | -------------------------------------------------------------------------------- /server/routes/api/dataFor.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('perf-widget:routes:api:dataFor'); // eslint-disable-line no-unused-vars 2 | const dataFor = require('../../lib/dataFor'); 3 | 4 | const response = require('../helper/jsonResponse'); 5 | const _ = require('lodash'); 6 | 7 | module.exports = function (req, res) { 8 | 9 | res.header('Cache-Control', 'private, no-cache, no-store, must-revalidate'); 10 | res.header('Expires', '-1'); 11 | res.header('Pragma', 'no-cache'); 12 | 13 | const freshInsights = req.query.fresh === "true"; 14 | 15 | dataFor(req.query.url, freshInsights) 16 | .then(function (data) { 17 | if (data.error) { 18 | 19 | response(res, 422, false, data); 20 | 21 | } else if (data.reason) { 22 | 23 | response(res, 202, true, data); 24 | } else { 25 | // we have data 26 | const results = data.map(function(datum) { 27 | const clone = Object.assign({}, datum); 28 | 29 | const oneRandomComparison = _.sample(clone.comparisons); 30 | 31 | clone.comparisons = [oneRandomComparison]; 32 | 33 | return clone; 34 | }); 35 | response(res, 200, true, results); 36 | } 37 | }); 38 | } 39 | -------------------------------------------------------------------------------- /server/routes/api/index.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router(); //eslint-disable-line new-cap 2 | 3 | router.get('/data-for', require('./dataFor')); 4 | 5 | module.exports = router; 6 | -------------------------------------------------------------------------------- /server/routes/contrast.js: -------------------------------------------------------------------------------- 1 | module.exports = function (req, res){ 2 | 3 | res.render('main', { 4 | partial : 'contrast' 5 | }); 6 | 7 | } 8 | -------------------------------------------------------------------------------- /server/routes/helper/jsonResponse.js: -------------------------------------------------------------------------------- 1 | const curry = require('lodash').curry; 2 | 3 | module.exports = curry(function (res, code, success, data) { 4 | return res.status(code).json({ success, data, code }) 5 | }); 6 | -------------------------------------------------------------------------------- /server/routes/home.js: -------------------------------------------------------------------------------- 1 | module.exports = function (req, res){ 2 | 3 | res.render('main', { 4 | partial : "home" 5 | }); 6 | 7 | } 8 | -------------------------------------------------------------------------------- /server/routes/index.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router(); //eslint-disable-line new-cap 2 | 3 | // Serve static assets from /static 4 | router.get('/insights', require('./insights')); 5 | router.get('/contrastbreakdown', require('./contrast')); 6 | router.use('/static', require('./staticFiles')); 7 | router.use('/api', require('./api')); 8 | router.use('/destruct', function(req, res){ 9 | 10 | res.json({ 11 | selfDestruct : process.env.EXTENSION_SELFDESTRUCT === "true" 12 | }); 13 | 14 | }); 15 | 16 | const authS3O = require('s3o-middleware'); 17 | 18 | router.use(authS3O); 19 | router.get('/', require('./home')); 20 | router.use('/visualise', require('./visualise')); 21 | 22 | // 404 handler 23 | router.use(function (req, res) { 24 | res.sendStatus(404); // TODO: Redirect to FT 404 Page? 25 | }); 26 | 27 | module.exports = router; 28 | -------------------------------------------------------------------------------- /server/routes/insights.js: -------------------------------------------------------------------------------- 1 | module.exports = function (req, res){ 2 | 3 | res.render('main', { 4 | partial : 'insights' 5 | }); 6 | 7 | } 8 | -------------------------------------------------------------------------------- /server/routes/staticFiles.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const path = require('path'); 3 | 4 | module.exports = express.static(path.join(__dirname, '../../client/dist')); 5 | -------------------------------------------------------------------------------- /server/routes/visualise.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('perf-widget:routes:visualise'); // eslint-disable-line no-unused-vars 2 | const whatDoesItAllMean = require('../lib/report'); 3 | 4 | module.exports = function (req, res){ 5 | 6 | const visRequests = [whatDoesItAllMean.insights.allValues(), whatDoesItAllMean.insights.historicalDomain('www.theguardian.com')]; 7 | 8 | Promise.all(visRequests) 9 | .then(results => { 10 | 11 | debug(`First row of response\n${results[0][0]}\n${results[1][0]}`); 12 | 13 | res.render('main', { 14 | partial : 'visualise', 15 | results : { 16 | dayResults : results[0], 17 | historical : results[1] 18 | } 19 | }); 20 | 21 | }) 22 | .catch(err => { 23 | 24 | res.render('main', { 25 | partial : 'visualise' 26 | }); 27 | 28 | debug(`An error occured when we tried to get data for the visualisations ${err}`); 29 | 30 | }); 31 | ; 32 | 33 | } -------------------------------------------------------------------------------- /server/runbook.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemaVersion": 1, 3 | "name": "perf-widget", 4 | "purpose": "Records and highlights properties of FT webpages.", 5 | "audience": "public", 6 | "serviceTier": "bronze", 7 | "dateCreated": "2016-01-18", 8 | "contacts": [ 9 | { "name":"FT Labs team", "email":"ftlabs@ft.com", "rel":"owner", "domain":"All support enquiries" } 10 | ], 11 | "links": [ 12 | { "url":"https://github.com/ftlabs/perf-widget.git", "category":"repo" } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /server/views/main.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | FT Labs Perf-Widget 7 | 8 | 9 | 10 | 25 | 26 | 27 | 28 | 29 | 30 | {{>header}} 31 | 32 |
    33 | {{>(lookup . 'partial') }} 34 |
    35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /server/views/partials/contrast.hbs: -------------------------------------------------------------------------------- 1 | 35 | 36 |
    37 |

    38 | FT Labs Perf-Widget Chrome Extension and Service 39 |

    40 |
    41 | 42 |
    43 |
    44 | 45 |
    46 |

    Contrast Analysis

    47 |
    48 | 49 |

    This insight provides information about the legibility of your site.

    50 |

    It compares the css text colour to the css background colour for block of text on the page.

    51 |

    It then works out the contrast ratio (a number between 0 and 21). It is based upon this tool by Lea Verou. Where 1 is no contrast (same colour) and 21 is comparing white to black

    52 |

    A rating of below 4 is poor contrast and should be considered illegible.

    53 |

    The tool measures that enough of the site is above a threshold of 4 and provides a small graph showing what proportion of the text on your page is legible.

    54 | 55 |
    56 | 57 |
    58 | 59 | -------------------------------------------------------------------------------- /server/views/partials/header.hbs: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    4 |
    5 | 8 |
    9 | 16 |
    17 |
    18 |
    19 | -------------------------------------------------------------------------------- /server/views/partials/home.hbs: -------------------------------------------------------------------------------- 1 |
    2 |

    3 | FT Labs Perf-Widget Chrome Extension and Service 4 |

    5 |
    6 | 7 |
    8 |
    9 | 10 |
    11 |

    What is this?

    12 |

    Perf-Widget (or Insights, or P-Widge) is a chrome extension and service that allows you to benchmark and compare FT sites with competitors

    13 | 14 |

    How do I use it?

    15 |

    First, you need to install the extension from the Chrome Web Store.

    16 | 17 |

    Next, browse to any webpage of your choosing that you would like to run performance checks for. ft.com sites will test automatically if the extension is enabled.

    18 | 19 |

    For other sites you will need to click on the 'Show Widget' button to trigger the analysis. 20 | 21 |

    22 | 23 |

    If tests have never been run for the page you've chosen, a small modal will appear in the bottom-right corner of the webpage informing you that the web page has been added to the queue. 24 | The modal will stay until the results appear but it is okay to close it and open it again later. 25 |

    26 | 27 |

    If tests for the page you've selected have been run recently, they will appear immediately.

    28 | 29 |

    If tests for the page you've selected have been run in the past, but not recently (longer than a week at present), they will be run again.

    30 | 31 |
    32 | 33 |
    34 | -------------------------------------------------------------------------------- /server/views/partials/insights.hbs: -------------------------------------------------------------------------------- 1 |
    2 |

    3 | FT Labs Perf-Widget Chrome Extension and Service 4 |

    5 |
    6 | 7 |
    8 |
    9 | 10 |
    11 |

    Insights

    12 |

    This page is description of the insights used to generate the information used in each section of the performance widget.

    13 | 14 |

    Performance

    15 |
    16 |

    These statistics come from Google PageSpeed Insights. The information this service surfaces relate to the loading speed for page.

    17 |

    This insight needs to load up the webpage so will be unavailable if the page URL returns a redirect, e.g. you have to log in.

    18 |
      19 |
    • Number of Connections to 3rd party sites, each connection requires a slow handshake which will slow down the site.
    • 20 |
    • The size of the content which needs to be downloaded, more content more needs to be downloaded. Consider reducing number of assets, minifying assets and optimising images.
    • 21 |
    • Amount of requests, for http 1.1 (most FT online products) making many requests can be slow, consider concatenating stylesheets and scripts to reduce number of required connections.
    • 22 |
    23 |
    24 | 25 | 26 |

    Security

    27 |
    28 |

    Qualys's SSL Labs provide this insight. It looks at the https encryption for the web page and determines if it is adequate.

    29 |

    To take the measurements the insight provider by queries the server. Insights may be unavailable if the server is inaccessible from outside a local network.

    30 |

    Https is important because it prevents malicious 3rd parties, i.e. public access points, injecting adverts or malicious scripts amongst other things.

    31 |

    There has also been discussion within browser vendors about marking unencrypted http insecure so it is important to ensure web pages are available available over https.

    32 |

    This insight will also test for whether the https implementation is out of date and susceptible to known attacks. This may require updating your server.

    33 |
    34 | 35 |

    Accessibility

    36 |
    37 |

    This insight is calculated by the extension so should work regardless, and is displayed if the server is accessible.

    38 |

    This insight provides information about the legibility of your site.

    39 |

    It compares the css text colour to the css background colour for block of text on the page.

    40 |

    It then works out the contrast ratio (a number between 0 and 21). It is based upon this tool by Lea Verou. Where 1 is no contrast (same colour) and 21 is comparing white to black

    41 |

    A rating of below 4 is poor contrast and should be considered illegible.

    42 |

    The tool measures that enough of the site is above a threshold of 4 and provides a small graph showing what proportion of the text on your page is legible.

    43 |
    44 | 45 |
    46 | 47 |
    48 | -------------------------------------------------------------------------------- /server/views/partials/sentry.handlebars: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /server/views/partials/visualise.hbs: -------------------------------------------------------------------------------- 1 |
    2 | 3 |
    4 | 5 |
    6 | 7 |

    All insights for the last 24 hours

    8 | 9 |
    10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | {{#each results.dayResults}} 18 | 19 | {{/each}} 20 | 21 | 22 |
    DomainURLTimeProviderMetricValue
    {{domain}}{{url}}{{date}}{{provider}}{{name}}{{value}}
    23 | 24 |
    25 | 26 |
    27 | 28 |
    29 | 30 | 31 | 113 | 114 | 149 | 150 |

    Page Speed values across all pages on {{ results.historical.domain }} across time

    151 | 152 |
    153 | 154 |
    155 | 156 | 222 | 223 |
    224 | 225 | -------------------------------------------------------------------------------- /tests/integration/api/api.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; //eslint-disable-line strict 2 | /*global describe, before, it, after*/ 3 | 4 | const request = require('supertest'); 5 | 6 | const app = require('../../../server/app'); 7 | 8 | describe('App', function () { 9 | before(done => { 10 | app.listen(9999, done); 11 | }); 12 | 13 | it('returns 422 for /api/data-for', function (done) { 14 | this.timeout(50000); 15 | app.ready.then(() => { 16 | request(app) 17 | .get('/api/data-for') 18 | .expect(422, done); 19 | }); 20 | }); 21 | 22 | it('returns JSON for /api/data-for', function (done) { 23 | this.timeout(50000); 24 | app.ready.then(() => { 25 | request(app) 26 | .get('/api/data-for') 27 | .expect('Content-Type', /json/, done); 28 | }); 29 | }); 30 | 31 | it('returns FT Labs API JSON structure for /api/data-for', function (done) { 32 | this.timeout(50000); 33 | app.ready.then(() => { 34 | request(app) 35 | .get('/api/data-for') 36 | .expect({ 37 | success: false, 38 | data: { 39 | error: 'Missing url parameter.' 40 | }, 41 | code: 422 42 | }, done); 43 | }); 44 | }); 45 | 46 | it('returns 202 for a never before seen url', function (done) { 47 | this.timeout(50000); 48 | app.ready.then(() => { 49 | request(app) 50 | .get('/api/data-for?url=https://next.ft.com/uk') 51 | .expect(202, done); 52 | }); 53 | }); 54 | 55 | it('returns 202 for a url it has seen before but has no data for', function (done) { 56 | this.timeout(50000); 57 | app.ready.then(() => { 58 | request(app) 59 | .get('/api/data-for?url=https://next.ft.com/uk') 60 | .expect(202, done); 61 | }); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /tests/server/lib/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ftlabs/perf-widget/7284cdd2197cd3b47a25bee37ae7fc972cc7e194/tests/server/lib/.gitkeep -------------------------------------------------------------------------------- /tests/server/lib/getLatestValuesFor.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; // eslint-disable-line strict 2 | 3 | /* global describe, it, before, beforeEach, afterEach, after */ 4 | 5 | const chai = require('chai'); 6 | const chaiAsPromised = require('chai-as-promised'); 7 | chai.use(chaiAsPromised); 8 | const expect = chai.expect; 9 | const mockery = require('mockery'); 10 | const sinon = require('sinon'); 11 | 12 | const databaseMock = { 13 | query: sinon.stub() 14 | }; 15 | mockery.registerMock('./database', databaseMock); 16 | 17 | const mysqlMock = { 18 | escape: sinon.stub() 19 | }; 20 | mockery.registerMock('mysql', mysqlMock); 21 | 22 | const isConcerningValueMock = sinon.stub().returns(true); 23 | mockery.registerMock('./isConcerningValue', isConcerningValueMock); 24 | 25 | const moduleUnderTest = '../../../server/lib/getLatestValuesFor'; 26 | 27 | mockery.enable({ 28 | useCleanCache: true, 29 | warnOnReplace: false, 30 | warnOnUnregistered: false 31 | }); 32 | 33 | mockery.registerAllowable(moduleUnderTest); 34 | 35 | const getLatestValuesFor = require(moduleUnderTest); 36 | 37 | describe('getLatestValuesFor', function () { 38 | afterEach(function () { 39 | databaseMock.query.reset(); 40 | mysqlMock.escape.reset(); 41 | }); 42 | 43 | after(mockery.disable); 44 | 45 | it('it returns a promise', function () { 46 | mysqlMock.escape.returnsArg(0); 47 | databaseMock.query.returns(Promise.resolve()); 48 | expect(getLatestValuesFor()).to.be.a('promise'); 49 | }); 50 | 51 | it('rejects if the queries promise is rejected', function () { 52 | mysqlMock.escape.returnsArg(0); 53 | databaseMock.query.returns(Promise.reject()); 54 | expect(getLatestValuesFor()).to.be.rejected; 55 | 56 | }); 57 | 58 | it('resolves if the queries promise is resolved', function () { 59 | mysqlMock.escape.returnsArg(0); 60 | databaseMock.query.returns(Promise.resolve()); 61 | expect(getLatestValuesFor()).to.be.resolved; 62 | }); 63 | 64 | it('resolves with an empty Map object if query returned no rows', function () { 65 | mysqlMock.escape.returnsArg(0); 66 | databaseMock.query.returns( 67 | Promise.resolve([]) 68 | ); 69 | expect(getLatestValuesFor('https://next.ft.com')).to.eventually.be.resolved; 70 | expect(getLatestValuesFor('https://next.ft.com')).to.eventually.be.a('map'); 71 | expect(getLatestValuesFor('https://next.ft.com')).to.eventually.have.property('size', 0); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /tests/server/lib/insightsExist.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; // eslint-disable-line strict 2 | 3 | /* global describe, it, before, beforeEach, afterEach, after */ 4 | 5 | const chai = require('chai'); 6 | const chaiAsPromised = require('chai-as-promised'); 7 | chai.use(chaiAsPromised); 8 | const expect = chai.expect; 9 | const mockery = require('mockery'); 10 | const sinon = require('sinon'); 11 | 12 | const databaseMock = { 13 | query: sinon.stub() 14 | }; 15 | mockery.registerMock('./database', databaseMock); 16 | 17 | const mysqlMock = { 18 | escape: sinon.stub() 19 | }; 20 | mockery.registerMock('mysql', mysqlMock); 21 | 22 | const moduleUnderTest = '../../../server/lib/insightsExist'; 23 | 24 | mockery.enable({ 25 | useCleanCache: true, 26 | warnOnReplace: false, 27 | warnOnUnregistered: false 28 | }); 29 | 30 | mockery.registerAllowable(moduleUnderTest); 31 | 32 | const insightsExist = require(moduleUnderTest); 33 | 34 | describe('insightsExist', function () { 35 | afterEach(function () { 36 | databaseMock.query.reset(); 37 | mysqlMock.escape.reset(); 38 | }); 39 | 40 | after(mockery.disable); 41 | 42 | it('rejects if the queries promise is rejected', function () { 43 | mysqlMock.escape.returnsArg(0); 44 | databaseMock.query.returns(Promise.reject()); 45 | expect(insightsExist('a')).to.be.rejected; 46 | }); 47 | 48 | it('resolves if the queries promise is resolved', function () { 49 | mysqlMock.escape.returnsArg(0); 50 | databaseMock.query.returns(Promise.resolve([{a:1}])); 51 | expect(insightsExist('a')).to.be.resolved; 52 | }); 53 | 54 | it('resolves with a boolean value', function () { 55 | mysqlMock.escape.returnsArg(0); 56 | databaseMock.query.returns(Promise.resolve([{a:1}])); 57 | expect(insightsExist('a')).to.eventually.be.resolved; 58 | expect(insightsExist('a')).to.eventually.be.a('boolean'); 59 | }); 60 | 61 | it('resolves with true if page exists', function () { 62 | mysqlMock.escape.returnsArg(0); 63 | databaseMock.query.returns(Promise.resolve([{a:1}])); 64 | expect(insightsExist('a')).to.eventually.be.true; 65 | }); 66 | 67 | it('resolves with false if page does not exist', function () { 68 | mysqlMock.escape.returnsArg(0); 69 | databaseMock.query.returns(Promise.resolve([{a:0}])); 70 | expect(insightsExist('a')).to.eventually.be.false; 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /tests/server/lib/pageExists.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; // eslint-disable-line strict 2 | 3 | /* global describe, it, before, beforeEach, afterEach, after */ 4 | 5 | const chai = require('chai'); 6 | const chaiAsPromised = require('chai-as-promised'); 7 | chai.use(chaiAsPromised); 8 | const expect = chai.expect; 9 | const mockery = require('mockery'); 10 | const sinon = require('sinon'); 11 | 12 | const databaseMock = { 13 | query: sinon.stub() 14 | }; 15 | mockery.registerMock('./database', databaseMock); 16 | 17 | const mysqlMock = { 18 | escape: sinon.stub() 19 | }; 20 | mockery.registerMock('mysql', mysqlMock); 21 | 22 | const moduleUnderTest = '../../../server/lib/pageExists'; 23 | 24 | mockery.enable({ 25 | useCleanCache: true, 26 | warnOnReplace: false, 27 | warnOnUnregistered: false 28 | }); 29 | 30 | mockery.registerAllowable(moduleUnderTest); 31 | 32 | const pageExists = require(moduleUnderTest); 33 | 34 | describe('pageExists', function () { 35 | afterEach(function () { 36 | databaseMock.query.reset(); 37 | mysqlMock.escape.reset(); 38 | }); 39 | 40 | after(mockery.disable); 41 | 42 | it('rejects if the queries promise is rejected', function () { 43 | mysqlMock.escape.returnsArg(0); 44 | databaseMock.query.returns(Promise.reject()); 45 | expect(pageExists('a')).to.be.rejected; 46 | }); 47 | 48 | it('resolves if the queries promise is resolved', function () { 49 | mysqlMock.escape.returnsArg(0); 50 | databaseMock.query.returns(Promise.resolve([{a:1}])); 51 | expect(pageExists('a')).to.be.resolved; 52 | }); 53 | 54 | it('resolves with a boolean value', function () { 55 | mysqlMock.escape.returnsArg(0); 56 | databaseMock.query.returns(Promise.resolve([{a:1}])); 57 | expect(pageExists(0, 1)).to.eventually.be.a('boolean'); 58 | }); 59 | 60 | it('resolves with true if page exists', function () { 61 | mysqlMock.escape.returnsArg(0); 62 | databaseMock.query.returns(Promise.resolve([{a:1}])); 63 | expect(pageExists('a')).to.eventually.be.true; 64 | }); 65 | 66 | it('resolves with false if page does not exist', function () { 67 | mysqlMock.escape.returnsArg(0); 68 | databaseMock.query.returns(Promise.resolve([{a:0}])); 69 | expect(pageExists('a')).to.eventually.be.false; 70 | }); 71 | }); 72 | --------------------------------------------------------------------------------