├── .gitignore ├── routes ├── suite_config.js ├── info.js ├── run_tests.js └── tests.js ├── package.json ├── LICENSE ├── example_config.json ├── bin └── www ├── data_store ├── index.js ├── file.js └── db.js ├── CODE_OF_CONDUCT.md ├── app.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | public/results 4 | npm-debug.log 5 | .idea 6 | config.json -------------------------------------------------------------------------------- /routes/suite_config.js: -------------------------------------------------------------------------------- 1 | /* 2 | * A route to show the currently running config for the app. 3 | */ 4 | 5 | const express = require("express"); 6 | const router = express.Router(); 7 | const debug = require("debug")("wpt-api:suite_config"); 8 | const jf = require("jsonfile"); 9 | 10 | router.get("/", function(req, res, next) { 11 | const config = jf.readFileSync(process.env.SUITE_CONFIG); 12 | res.json(config); 13 | }); 14 | 15 | module.exports = router; 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wpt-api", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "node ./bin/www" 7 | }, 8 | "dependencies": { 9 | "any-db": "2.1.0", 10 | "any-db-postgres": "2.1.4", 11 | "async": "1.5.0", 12 | "camelcase-keys": "3.0.0", 13 | "cheerio": "0.20.0", 14 | "compression": "1.6.2", 15 | "debug": ">=2.6.9", 16 | "express": "4.13.3", 17 | "jsonfile": "2.3.1", 18 | "junk": "1.0.3", 19 | "lodash": ">=4.17.21", 20 | "memory-cache": "0.1.6", 21 | "mkdirp": "0.5.1", 22 | "moment": "2.19.3 ", 23 | "morgan": ">=1.9.1", 24 | "pg": ">=6.0.5", 25 | "request": "2.74.0", 26 | "webpagetest": "0.3.8" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /routes/info.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Return the available routes and what they do. 3 | */ 4 | 5 | const express = require("express"); 6 | const router = express.Router(); 7 | const debug = require("debug")("wpt-api:info"); 8 | 9 | router.get("/", function(req, res, next) { 10 | const data = { 11 | availableEndpoints: { 12 | "/run_tests/:suiteName": 13 | "Manually run the tests. Don't abuse, WPT is sensitive", 14 | "/suite_config": "See the test config that is being used", 15 | "/tests": "get a list of suites and their tests", 16 | "/tests/:suiteName/": "Get chartable data for each test in the suite.", 17 | "/tests/:suiteName/:testName": 18 | "Get aggregated data for a test within a suite, and links to the tests within. A lot of data.", 19 | "/tests/:suiteName/:testName/:testId": "Get a specific test" 20 | } 21 | }; 22 | debug("sending info: " + data.toString()); 23 | res.json(data); 24 | }); 25 | 26 | module.exports = router; 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Trulia, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /example_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "wptApiKey": "get one from http://www.webpagetest.org/getkey.php", 3 | "wptServer": "use this for a private wpt instance, otherwise delete it", 4 | "testSuites":[ 5 | { 6 | "suiteDisplayName": "GoogleVsBingVsDDG", 7 | "suiteId": "google_bing_dgg", 8 | "desc": "This suite runs on a Chrome in Dulles, VA", 9 | "runEvery": 600, 10 | "testHost": "http://google.com/", 11 | "location": "ec2-us-west-2:Chrome", 12 | "chartConfig" : [ 13 | { 14 | "type" : "SpeedIndex", 15 | "dataRange": [0, 8000], 16 | "dateCutoff": 30 17 | } 18 | ], 19 | 20 | "testPages": [ 21 | { 22 | "testDisplayName": "Google Homepage", 23 | "testId": "google_homepage", 24 | "path": "search?q=High+Performance+Browser+Networking" 25 | }, 26 | { 27 | "testDisplayName": "Bing Homepage", 28 | "testId": "bing_homepage", 29 | "path": "search?q=High+Performance+Browser+Networking", 30 | "testHost": "http://bing.com/" 31 | }, 32 | { 33 | "testDisplayName": "Duck Duck Go Homepage", 34 | "testId": "ddg_homepage", 35 | "path": "?q=High+Performance+Browser+Networking", 36 | "testHost": "http://duckduckgo.com/" 37 | } 38 | ] 39 | }, 40 | { 41 | "suiteDisplayName": "TopRedditvsTopHN", 42 | "suiteId": "reddit_hn_top", 43 | "desc": "This suite runs on Chrome in Dulles, VA", 44 | "runEvery": 1200, 45 | "testHost": "http://reddit.com/", 46 | "location": "ec2-us-west-2:Chrome", 47 | "chartConfig" : [ 48 | { 49 | "type" : "SpeedIndex", 50 | "dataRange": [0, 8000], 51 | "dateCutoff": 30 52 | } 53 | ], 54 | 55 | "testPages": [ 56 | { 57 | "testDisplayName": "Reddit Top Story", 58 | "testId": "reddit_top", 59 | "parentPath": "", 60 | "parentHrefSelector": ".sitetable .comments" 61 | }, 62 | { 63 | "testDisplayName": "Hacker News Top Story", 64 | "testId": "hn_top", 65 | "testHost": "http://news.ycombinator.com/", 66 | "parentPath": "", 67 | "parentHrefSelector": ".subtext a:last-of-type" 68 | } 69 | ] 70 | } 71 | ] 72 | } 73 | -------------------------------------------------------------------------------- /bin/www: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /* 4 | * This is pretty mich all boilerplate except for 5 | * SUITE_CONFIG at the top and app.startTests() at the bottom 6 | */ 7 | 8 | var fs = require('fs'); 9 | 10 | //a config file is required on startup 11 | if(!fs.existsSync(process.env.SUITE_CONFIG)) { 12 | throw new Error( 13 | 'Config file: ' + process.env.SUITE_CONFIG + ' not found! ' + 14 | 'You must supply a valid path to your ' + 15 | 'SUITE_CONFIG env value for the test config. E.g. ' + 16 | '`export SUITE_CONFIG=/var/wpt_configs/foo.json && npm start`' 17 | ); 18 | } 19 | 20 | var app = require('../app'); 21 | var debug = require('debug')('wpt-api:server'); 22 | var http = require('http'); 23 | 24 | 25 | var port = normalizePort(process.env.PORT || '3001'); 26 | app.set('port', port); 27 | var server = http.createServer(app); 28 | 29 | /* 30 | * Listen on provided port, on all network interfaces. 31 | */ 32 | server.listen(port); 33 | server.on('error', onError); 34 | server.on('listening', onListening); 35 | 36 | /* 37 | * Normalize a port into a number, string, or false. 38 | */ 39 | function normalizePort(val) { 40 | var port = parseInt(val, 10); 41 | 42 | if (isNaN(port)) { 43 | // named pipe 44 | return val; 45 | } 46 | 47 | if (port >= 0) { 48 | // port number 49 | return port; 50 | } 51 | 52 | return false; 53 | } 54 | 55 | /* 56 | * Event listener for HTTP server "error" event. 57 | */ 58 | function onError(error) { 59 | if (error.syscall !== 'listen') { 60 | throw error; 61 | } 62 | 63 | var bind = typeof port === 'string' 64 | ? 'Pipe ' + port 65 | : 'Port ' + port; 66 | 67 | // handle specific listen errors with friendly messages 68 | switch (error.code) { 69 | case 'EACCES': 70 | console.error(bind + ' requires elevated privileges'); 71 | process.exit(1); 72 | break; 73 | case 'EADDRINUSE': 74 | console.error(bind + ' is already in use'); 75 | process.exit(1); 76 | break; 77 | default: 78 | throw error; 79 | } 80 | } 81 | 82 | /* 83 | * Event listener for HTTP server "listening" event. 84 | */ 85 | function onListening() { 86 | var addr = server.address(); 87 | var bind = typeof addr === 'string' 88 | ? 'pipe ' + addr 89 | : 'port ' + addr.port; 90 | debug('Listening on ' + bind); 91 | //start running the tests. 92 | app.startTests(); 93 | } 94 | -------------------------------------------------------------------------------- /data_store/index.js: -------------------------------------------------------------------------------- 1 | //specify the dataStore that you want to use 2 | const dataInterface = require("./file"); 3 | //var dataInterface = require('./db'); 4 | const debug = require("debug")("wpt-api:data_store"); 5 | 6 | var apiInterface = { 7 | saveDatapoint: function saveDatapoint_interface(test, results) { 8 | //the internet is flaky sometimes 9 | if (!goodTestResults(results)) { 10 | console.error("Test Died on: " + results.data.testUrl); 11 | return; 12 | } 13 | 14 | try { 15 | delete results.data.average; 16 | delete results.data.median; 17 | delete results.data.standardDeviation; 18 | delete results.data.lighthouse; //huuuuuge 19 | } catch (e) { 20 | debug("ran into trouble deleting extra data."); 21 | } 22 | 23 | dataInterface.saveDatapoint(test, results); 24 | }, 25 | 26 | getDatapoint: dataInterface.getDatapoint, 27 | getSuite: dataInterface.getSuite, 28 | getSuiteTest: dataInterface.getSuiteTest 29 | }; 30 | 31 | module.exports = apiInterface; 32 | 33 | /* 34 | * An overly verbose debugged method for helping 35 | * with occasional network service inconsistencies 36 | */ 37 | function goodTestResults(results) { 38 | let msg = "goodTestResults suceeeded"; 39 | let res = true; 40 | 41 | if (!results.data.runs[1]) { 42 | msg = "no results.data.runs[1]"; 43 | res = false; 44 | } else if (!results.data.runs[1].firstView) { 45 | msg = "no results.data.runs[1].firstView"; 46 | res = false; 47 | } else if (!results.data.runs[1].firstView.images) { 48 | msg = "no results.data.runs[1].firstView.images"; 49 | res = false; 50 | } else if ( 51 | results.data.runs[1].repeatView && 52 | !results.data.runs[1].repeatView.images 53 | ) { 54 | msg = "no results.data.runs[1].repeatView.images"; 55 | res = false; 56 | } else if (!results.data.runs[1].firstView.SpeedIndex) { 57 | msg = "no results.data.runs[1].firstView.SpeedIndex"; 58 | res = false; 59 | } else if ( 60 | results.data.runs[1].repeatView && 61 | !results.data.runs[1].repeatView.SpeedIndex 62 | ) { 63 | msg = "no results.data.runs[1].repeatView.SpeedIndex"; 64 | res = false; 65 | } else if (!results.data.runs[1].firstView["lighthouse.Performance"]) { 66 | msg = "no results.data.runs[1].firstView.['lighthouse.Performance']"; 67 | res = false; 68 | } else if ( 69 | results.data.runs[1].repeatView && 70 | !results.data.runs[1].repeatView["lighthouse.Performance"] 71 | ) { 72 | msg = "no results.data.runs[1].repeatView.['lighthouse.Performance']"; 73 | res = false; 74 | } 75 | 76 | debug(msg); 77 | debug(results); 78 | return res; 79 | } 80 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | education, socio-economic status, nationality, personal appearance, race, 10 | religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at [INSERT EMAIL ADDRESS]. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Defines routes and the test running cron/setInterval 3 | */ 4 | 5 | const express = require("express"); 6 | const path = require("path"); 7 | const logger = require("morgan"); 8 | const compress = require("compression"); 9 | const jf = require("jsonfile"); 10 | const request = require("request"); 11 | const debug = require("debug")("wpt-api:app"); 12 | 13 | //route handlers 14 | const info = require("./routes/info"); 15 | const suiteConfig = require("./routes/suite_config"); 16 | const runTests = require("./routes/run_tests"); 17 | const tests = require("./routes/tests"); 18 | 19 | const app = express(); 20 | 21 | // if you want authentication, uncomment this section 22 | // and `npm install --save basic-auth` 23 | // note, that your ui can access the api using the login 24 | // in the url: http://a-username:a-password@example.com/ 25 | // var basicAuth = require('basic-auth'); 26 | // 27 | // checkAuth = function(username, password) { 28 | // return function(req, res, next) { 29 | // var user = basicAuth(req); 30 | // 31 | // if (!user || user.name !== username || user.pass !== password) { 32 | // res.set('WWW-Authenticate', 'Basic realm=Authorization Required'); 33 | // return res.send(401); 34 | // } 35 | // 36 | // next(); 37 | // }; 38 | // }; 39 | // 40 | // // change these... 41 | // app.use(checkAuth('a-username', 'a-password')); 42 | // end auth section 43 | 44 | app.use(logger("dev")); 45 | app.use(compress()); 46 | 47 | //used to serve saved assets saved to the fs from wpt 48 | app.use(express.static(path.join(__dirname, "public"))); 49 | 50 | //map the routes 51 | app.use("/", info); 52 | app.use("/suite_config", suiteConfig); 53 | app.use("/run_tests", runTests); 54 | app.use("/tests", tests); 55 | 56 | // catch 404 and forward to error handler 57 | app.use(function(req, res, next) { 58 | const err = new Error("Not Found"); 59 | err.status = 404; 60 | next(err); 61 | }); 62 | 63 | // development error handler 64 | // will print stacktrace 65 | if (app.get("env") === "development") { 66 | app.use(function(err, req, res, next) { 67 | res.status(err.status || 500); 68 | res.json({ 69 | error_message: err.message, 70 | error: err.stack 71 | }); 72 | }); 73 | } 74 | 75 | // production error handler 76 | // no stacktraces leaked to user 77 | app.use(function(err, req, res, next) { 78 | res.status(err.status || 500); 79 | res.json({ 80 | error_message: err.message, 81 | error: {} 82 | }); 83 | }); 84 | 85 | /* 86 | * A method to start the test cron rolling 87 | * called by the bin/www when the server starts 88 | * to listen. 89 | */ 90 | app.startTests = function() { 91 | // do this every minute, track the last time te test ran 92 | // via app.set and get 93 | setInterval(function() { 94 | //read in the config 95 | const testConfig = jf.readFileSync(process.env.SUITE_CONFIG); 96 | testConfig.testSuites.forEach(function(testSuite) { 97 | const url = 98 | "http://localhost:" + 99 | app.get("port") + 100 | "/run_tests/" + 101 | testSuite.suiteId; 102 | const interval = testSuite.runEvery * 60 * 1000; 103 | debug( 104 | "Checking test run for " + 105 | url + 106 | " every " + 107 | testSuite.runEvery + 108 | " minutes" 109 | ); 110 | let lastRunTs = parseInt(app.get("ranSuite" + testSuite.suiteId), 10); 111 | lastRunTs = isNaN(lastRunTs) ? 0 : lastRunTs; 112 | if (lastRunTs + interval < Date.now()) { 113 | debug("requesting " + url); 114 | app.set("ranSuite" + testSuite.suiteId, Date.now()); 115 | request(url); 116 | } else { 117 | debug(url + " still within interval"); 118 | } 119 | }); 120 | }, 60000); 121 | }; 122 | 123 | module.exports = app; 124 | -------------------------------------------------------------------------------- /data_store/file.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Given a test result data, save it. 3 | * Asked for a test result, retrieve it. 4 | */ 5 | 6 | const debug = require("debug")("wpt-api:data_store"); 7 | const moment = require("moment"); 8 | const request = require("request"); 9 | const mkdirp = require("mkdirp"); 10 | const fs = require("fs"); 11 | const path = require("path"); 12 | const jf = require("jsonfile"); 13 | const os = require("os"); 14 | const junk = require("junk"); 15 | const async = require("async"); 16 | 17 | //this should probably come from config 18 | const resultsPath = "public" + path.sep + "results" + path.sep; 19 | 20 | dataStore = { 21 | /* 22 | * Given a test and some results, save to the file system: 23 | * the json 24 | * filmstrip image 25 | * waterfall image 26 | * for the intial view and the refresh view. 27 | */ 28 | saveDatapoint: function saveDatapoint(test, results) { 29 | let response = results.data; 30 | let datePath = moment().format("YYYY-MM-DD-HH-mm-ss"); 31 | let datapointPath = 32 | test.suiteId + path.sep + test.testId + path.sep + datePath; 33 | let datapointDir = resultsPath + datapointPath; 34 | 35 | //make the new dir structure 36 | mkdirp.sync(datapointDir); 37 | 38 | //save the json test data to a file 39 | jf.writeFile(datapointDir + path.sep + "results.json", results, function( 40 | err 41 | ) { 42 | if (err) console.error(err); 43 | }); 44 | 45 | debug("Saved results for " + response.testUrl); 46 | }, 47 | 48 | getDatapoint: function getDatapoint(suiteId, testId, datapointId, callback) { 49 | fs.readdir(resultsPath + suiteId + path.sep + testId, function(err, tests) { 50 | if (err || !tests) { 51 | debug( 52 | "no tests found for datapoint: " + 53 | suiteId + 54 | " - " + 55 | testId + 56 | " - " + 57 | datapointId 58 | ); 59 | callback({}); 60 | return; 61 | } 62 | tests = tests.filter(junk.not); 63 | let testIndex = tests.indexOf(datapointId); 64 | let testDir = 65 | resultsPath + 66 | suiteId + 67 | path.sep + 68 | testId + 69 | path.sep + 70 | tests[testIndex] + 71 | path.sep; 72 | let data = {}; 73 | let resourceBase = 74 | "/results/" + suiteId + "/" + testId + "/" + datapointId + "/"; 75 | 76 | jf.readFile(testDir + "results.json", function(err, jsonResults) { 77 | data = { 78 | datapointId: datapointId, 79 | suiteId: suiteId, 80 | testId: testId, 81 | jsonLink: resourceBase + "results.json", 82 | testResults: jsonResults, 83 | testDate: tests[testIndex], 84 | nextTest: 85 | testIndex < tests.length - 1 86 | ? { 87 | suiteId: suiteId, 88 | testId: testId, 89 | datapointId: tests[testIndex + 1] 90 | } 91 | : null, 92 | prevTest: 93 | testIndex > 0 94 | ? { 95 | suiteId: suiteId, 96 | testId: testId, 97 | datapointId: tests[testIndex - 1] 98 | } 99 | : null 100 | }; 101 | callback(data); 102 | }); 103 | }); 104 | }, 105 | 106 | /* 107 | * Return the data for a suite of tests 108 | */ 109 | getSuite: function getSuite(suiteId, callback) { 110 | debug("getting suite: " + suiteId); 111 | 112 | let suiteDir = resultsPath + suiteId; 113 | fs.readdir(suiteDir, function(err, testDirsRaw) { 114 | if (err) { 115 | console.error(err); 116 | } 117 | const testDirs = testDirsRaw.filter(junk.not); 118 | 119 | suite = { 120 | suiteId: suiteId, 121 | tests: testDirs 122 | }; 123 | 124 | callback(suite); 125 | }); 126 | }, 127 | 128 | getSuiteTest: function getSuiteTest(suiteName, testName, callback) { 129 | debug("getting suite test: " + suiteName + " - " + testName); 130 | 131 | let suiteTests = { 132 | suite: suiteName, 133 | testName: testName, 134 | datapoints: [] 135 | }; 136 | 137 | let testDirBase = resultsPath + suiteName + path.sep + testName; 138 | 139 | fs.readdir(testDirBase, function(err, testDirs) { 140 | testDirs = testDirs.filter(junk.not); 141 | async.map( 142 | testDirs, 143 | function(testDir, asyncCallback) { 144 | jf.readFile( 145 | testDirBase + path.sep + testDir + path.sep + "results.json", 146 | function(err, jsonData) { 147 | const datapoint = { 148 | datapointId: testDir, 149 | data: jsonData.data 150 | }; 151 | suiteTests.datapoints.push(datapoint); 152 | asyncCallback(); 153 | } 154 | ); 155 | }, 156 | function() { 157 | callback(suiteTests); 158 | } 159 | ); 160 | }); 161 | } 162 | }; 163 | 164 | module.exports = dataStore; 165 | -------------------------------------------------------------------------------- /data_store/db.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Given a test result test_results, save it. 3 | * Asked for a test result, retrieve it. 4 | */ 5 | 6 | const debug = require("debug")("wpt-api:data_store"); 7 | const moment = require("moment"); 8 | const async = require("async"); 9 | const camelCaseKeys = require("camelcase-keys"); 10 | const db = require("any-db"); 11 | 12 | //update this string with your db type, login, server, and db name 13 | const config = { 14 | conString: "need-to-specify://user:pass@server/database-name" 15 | }; 16 | 17 | dataStore = { 18 | /* 19 | * Given a test and some results, save to the db: 20 | * the json 21 | * for the intial view and the refresh view. 22 | */ 23 | saveDatapoint: function saveDatapoint(test, results) { 24 | var response = results.data, 25 | testId = test.testId, 26 | suiteId = test.suiteId, 27 | conString = config.conString, 28 | conn = new db.createConnection(conString), 29 | insertQuery = 30 | "\ 31 | INSERT INTO \ 32 | webpagetestcharts \ 33 | (test_results, \ 34 | date, \ 35 | test_id, \ 36 | suite_id) \ 37 | VALUES \ 38 | ('" + 39 | JSON.stringify(results) + 40 | "', \ 41 | 'now()', \ 42 | '" + 43 | testId + 44 | "', \ 45 | '" + 46 | suiteId + 47 | "')"; 48 | 49 | conn.query(insertQuery, function(err, result) { 50 | if (err) { 51 | return console.error("error running query:" + insertQuery, err); 52 | } 53 | debug("Saved results for " + response.testUrl); 54 | }); 55 | }, 56 | 57 | getDatapoint: function getDatapoint(suiteId, testId, datapointId, callback) { 58 | var data = {}, 59 | pageString, 60 | conString = config.conString, 61 | conn = new db.createConnection(conString), 62 | testQuery = 63 | " \ 64 | SELECT * \ 65 | FROM webpagetestcharts \ 66 | WHERE datapoint_id = '" + 67 | datapointId + 68 | "' \ 69 | AND suite_id = '" + 70 | suiteId + 71 | "' \ 72 | AND test_id = '" + 73 | testId + 74 | "' \ 75 | UNION ALL \ 76 | (SELECT * \ 77 | FROM webpagetestcharts \ 78 | WHERE datapoint_id < '" + 79 | datapointId + 80 | "' \ 81 | AND suite_id = '" + 82 | suiteId + 83 | "' \ 84 | AND test_id = '" + 85 | testId + 86 | "' \ 87 | ORDER BY datapoint_id \ 88 | DESC limit 1) \ 89 | UNION ALL \ 90 | (SELECT * \ 91 | FROM webpagetestcharts \ 92 | WHERE datapoint_id > '" + 93 | datapointId + 94 | "' \ 95 | AND suite_id = '" + 96 | suiteId + 97 | "' \ 98 | AND test_id = '" + 99 | testId + 100 | "' \ 101 | ORDER BY datapoint_id \ 102 | ASC limit 1) "; 103 | 104 | conn.query(testQuery, function(err, result) { 105 | if (err) { 106 | return console.error("error running query: " + testQuery, err); 107 | } 108 | debug("fetched results for " + datapointId); 109 | 110 | result.rows[0].test_results = JSON.parse(result.rows[0].test_results); 111 | data = camelCaseKeys(result.rows[0]); 112 | 113 | //next / prev result? 114 | [1, 2].forEach(function(j) { 115 | if (result.rows[j]) { 116 | pageString = "nextTest"; 117 | if (result.rows[j].datapoint_id < result.rows[0].datapoint_id) { 118 | pageString = "prevTest"; 119 | } 120 | delete result.rows[j].test_results; 121 | data[pageString] = camelCaseKeys(result.rows[j]); 122 | } 123 | }); 124 | 125 | callback(data); 126 | }); 127 | }, 128 | 129 | /* 130 | * Return the data for a suite of tests 131 | */ 132 | getSuite: function getSuite(suiteId, callback) { 133 | debug("getting suite: " + suiteId); 134 | 135 | var data, 136 | conString = config.conString, 137 | conn = new db.createConnection(conString), 138 | testQuery = 139 | " \ 140 | SELECT \ 141 | DISTINCT test_id as test \ 142 | FROM \ 143 | webpagetestcharts \ 144 | WHERE \ 145 | suite_id = '" + 146 | suiteId + 147 | "'"; 148 | 149 | var query = conn.query(testQuery, function(err, result) { 150 | if (err) { 151 | return console.error("error running query", err); 152 | } 153 | debug("fetched results for " + suiteId); 154 | 155 | data = { 156 | suiteId: suiteId, 157 | tests: result.rows.map(function(r) { 158 | return r.test; 159 | }) 160 | }; 161 | callback(data); 162 | }); 163 | }, 164 | 165 | getSuiteTest: function getSuiteTest(suiteId, testId, callback) { 166 | var data = { 167 | suite: suiteId, 168 | testName: testId, 169 | datapoints: [] 170 | }, 171 | conString = config.conString, 172 | conn = new db.createConnection(conString), 173 | testsQuery = 174 | " \ 175 | SELECT \ 176 | test_results, \ 177 | date, \ 178 | datapoint_id, \ 179 | test_id, \ 180 | suite_id \ 181 | FROM \ 182 | webpagetestcharts \ 183 | WHERE \ 184 | suite_id = '" + 185 | suiteId + 186 | "' \ 187 | AND \ 188 | test_id = '" + 189 | testId + 190 | "' \ 191 | ORDER BY \ 192 | date ASC"; 193 | 194 | var query = conn.query(testsQuery, function(err, result) { 195 | if (err) { 196 | return console.error("error running query", err); 197 | } 198 | debug("fetched results for " + suiteId + " - " + testId); 199 | 200 | result.rows.forEach(function(row) { 201 | datapoint = { 202 | datapointId: row.datapoint_id, 203 | data: JSON.parse(row.test_results).data 204 | }; 205 | data.datapoints.push(datapoint); 206 | }); 207 | 208 | callback(data); 209 | }); 210 | } 211 | }; 212 | 213 | module.exports = dataStore; 214 | -------------------------------------------------------------------------------- /routes/run_tests.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Deals with running the tests and processing the responses. 3 | * The saving of the data is handled by the dataStore. 4 | */ 5 | 6 | const express = require("express"); 7 | const router = express.Router(); 8 | const debug = require("debug")("wpt-api:run_tests"); 9 | const jf = require("jsonfile"); 10 | const _ = require("lodash"); 11 | 12 | const WebPageTest = require("webpagetest"); 13 | const cheerio = require("cheerio"); 14 | const request = require("request"); 15 | const url = require("url"); 16 | 17 | const querystring = require("querystring"); 18 | const async = require("async"); 19 | const events = require("events"); 20 | 21 | const dataStore = require("../data_store"); 22 | 23 | let testConfig = jf.readFileSync(process.env.SUITE_CONFIG); 24 | let eventEmitter = new events.EventEmitter(); 25 | 26 | // Used to space out the test runs to prevent 27 | // accidentally overwhleming WPT 28 | let nextTestRun = Date.now(); 29 | let testInterval = 1000 * 10; 30 | 31 | /* 32 | * Run the tests for the given suite 33 | */ 34 | router.get("/:testSuite", function(req, res, next) { 35 | const testSuite = _.find(testConfig.testSuites, { 36 | suiteId: req.params.testSuite 37 | }); 38 | 39 | eventEmitter.emit("startTests", testSuite); 40 | res.json({ message: "tests have started for " + req.params.testSuite }); 41 | }); 42 | 43 | module.exports = router; 44 | 45 | /* 46 | * Start the test process, start parsing the data 47 | * into a format that WPT will like, gathering urls 48 | * if necessary 49 | */ 50 | eventEmitter.on("startTests", function startTests(testSuite) { 51 | debug("starting tests on " + testSuite.suiteId); 52 | 53 | //blend the suite settings into each test. 54 | testSuite.testPages.forEach(function(el, index, arr) { 55 | _.defaults(testSuite.testPages[index], { 56 | testHost: testSuite.testHost, 57 | queryStringData: testSuite.queryStringData, 58 | parentRequestUserAgent: testSuite.parentRequestUserAgent, 59 | SpeedIndexChartRange: testSuite.SpeedIndexChartRange, 60 | location: testSuite.location, 61 | firstViewOnly: testSuite.firstViewOnly 62 | }); 63 | 64 | testSuite.testPages[index].suiteId = testSuite.suiteId; 65 | 66 | if (testSuite.preTestScript) { 67 | testSuite.testPages[index].preTestScript = testSuite.testPages[index] 68 | .preTestScript 69 | ? testSuite.testPages[index].preTestScript 70 | : []; 71 | testSuite.testPages[index].preTestScript = testSuite.preTestScript.concat( 72 | testSuite.testPages[index].preTestScript 73 | ); 74 | } 75 | 76 | if (testSuite.parentRequestUserAgent) { 77 | testSuite.testPages[index].headers = { 78 | "User-Agent": testSuite.parentRequestUserAgent 79 | }; 80 | } 81 | }); 82 | 83 | convertToWPTRequests(testSuite.testPages); 84 | }); 85 | 86 | /* 87 | * Convert the test data structure into a WPT data structure 88 | * Some test building requires making web requests, so this 89 | * is wrapped in async. 90 | */ 91 | function convertToWPTRequests(testPages) { 92 | debug("converting tests"); 93 | debug(testPages); 94 | async.map(testPages, prepareTest, function(err, tests) { 95 | debug("prepared"); 96 | debug(tests); 97 | eventEmitter.emit("runTests", tests); 98 | }); 99 | } 100 | 101 | function buildTestScript(item) { 102 | //wrap the pre script so that it's ignored inthe process 103 | var script = []; 104 | var testUrl; 105 | if (item.fullTestScript) { 106 | script = item.fullTestScript; 107 | } else { 108 | if (item.preTestScript) { 109 | script = item.preTestScript.slice(0); 110 | script.push({ logdata: 1 }); 111 | script.unshift({ logdata: 0 }); 112 | } 113 | testUrl = makeTestUrl(item.testHost, item.path, item.queryStringData); 114 | script.push({ navigate: testUrl }); 115 | } 116 | return script; 117 | } 118 | 119 | /* 120 | * Take a test item and set it up to be processed by WPT 121 | * asyncCallback() is a noop used by async to know when 122 | * the `map` call in `convertToWPTRequests` is done 123 | */ 124 | function prepareTest(item, asyncCallback) { 125 | debug("preparing..."); 126 | debug(item); 127 | var hrefUrl; 128 | var testUrl; 129 | 130 | //parentPage tests are twofold. Visit a page, get a url from that page, then test that url 131 | if (isParentPage(item)) { 132 | item.url = item.testHost + item.parentPath; 133 | request(item, function prepareEm(err, response, body) { 134 | if (err) { 135 | console.error(err); 136 | } 137 | item.path = url.parse(getHrefFromElement(body, item.parentHrefSelector)); 138 | item.script = buildTestScript(item); 139 | asyncCallback(null, item); 140 | }); 141 | } else { 142 | item.script = buildTestScript(item); 143 | asyncCallback(null, item); 144 | } 145 | } 146 | 147 | /* 148 | * Is the page given in the test a page that 149 | * needs to be parsed for the actual link to test 150 | */ 151 | function isParentPage(page) { 152 | return _.has(page, "parentHrefSelector") && _.has(page, "parentPath"); 153 | } 154 | 155 | /* 156 | * Build URL from host path and querystring. Never been done before. 157 | */ 158 | function makeTestUrl(host, path, qs) { 159 | let base = host + path; 160 | let qsJoin = base.strPos ? "&" : "?"; 161 | let url = 162 | host + path + (!_.isEmpty(qs) ? qsJoin + querystring.stringify(qs) : ""); 163 | 164 | debug("test url:" + url); 165 | return url; 166 | } 167 | 168 | /* 169 | * Given a bit of html get the first matching link in it. 170 | */ 171 | function getHrefFromElement(body, selector) { 172 | var href, 173 | $ = cheerio.load(body); 174 | 175 | if ($(selector)[0]) { 176 | href = $(selector)[0].attribs.href; 177 | } 178 | 179 | debug("using " + selector + " found a href of" + href); 180 | return href; 181 | } 182 | 183 | /* 184 | * Run each test 185 | */ 186 | eventEmitter.on("runTests", function runTests(tests) { 187 | tests.forEach(function(test) { 188 | setTimeout(function() { 189 | runTest(test); 190 | }, getTestRunTimeout()); 191 | }); 192 | }); 193 | 194 | /* 195 | * Spaces out the tests every `testInterval` ms to prevent overloading WPT 196 | */ 197 | function getTestRunTimeout() { 198 | var difference = Date.now() - nextTestRun, 199 | pad = difference > 0 ? 0 : testInterval + Math.abs(difference); 200 | 201 | nextTestRun = Date.now() + pad; 202 | return pad; 203 | } 204 | 205 | /* 206 | * Run the webpage test and save the results 207 | * The specified options could be in config. 208 | */ 209 | function runTest(test) { 210 | debug("parsing test"); 211 | var testConfig = jf.readFileSync(process.env.SUITE_CONFIG); 212 | var wptLoc = testConfig.wptServer 213 | ? testConfig.wptServer 214 | : "https://www.webpagetest.org"; 215 | 216 | var wpt = new WebPageTest(wptLoc, testConfig.wptApiKey), 217 | options = { 218 | pollResults: 5, //poll every 5 seconds 219 | timeout: 600, //wait for 10 minutes 220 | video: true, //this enables the filmstrip 221 | location: test.location, 222 | firstViewOnly: test.firstViewOnly, //refresh view? 223 | requests: false, //do not capture the details of every request 224 | lighthouse: true 225 | }; 226 | 227 | wptScript = wpt.scriptToString(test.script); 228 | 229 | debug( 230 | "starting test on script " + wptScript + " in location " + test.location 231 | ); 232 | 233 | wpt.runTest(wptScript, options, function(err, results) { 234 | if (err) { 235 | return console.error([err, { url: test.url, options: options }]); 236 | } 237 | dataStore.saveDatapoint(test, results); 238 | }); 239 | } 240 | -------------------------------------------------------------------------------- /routes/tests.js: -------------------------------------------------------------------------------- 1 | /* 2 | * the endpoints for fetching test result data 3 | * makes some assumptions about what is wanted (eg: charts) 4 | */ 5 | 6 | const express = require("express"); 7 | const router = express.Router(); 8 | const debug = require("debug")("wpt-api:tests"); 9 | const _ = require("lodash"); 10 | const async = require("async"); 11 | const jf = require("jsonfile"); 12 | const moment = require("moment"); 13 | const cache = require("memory-cache"); 14 | 15 | //change this to the data store you want to use 16 | const dataStore = require("../data_store"); 17 | 18 | /* 19 | * Settings for tests 20 | */ 21 | const masterConfig = jf.readFileSync(process.env.SUITE_CONFIG); 22 | const availableChartTypes = [ 23 | "SpeedIndex", 24 | "loadTime", 25 | "fullyLoaded", 26 | "TTFB", 27 | "visualComplete", 28 | "lighthouse.Performance", 29 | "lighthouse.Performance.speed-index-metric", 30 | "lighthouse.BestPractices", 31 | "lighthouse.Performance.estimated-input-latency", 32 | "lighthouse.Performance.first-interactive", 33 | "lighthouse.Accessibility", 34 | "lighthouse.Performance.consistently-interactive", 35 | "lighthouse.SEO", 36 | "lighthouse.ProgressiveWebApp", 37 | "lighthouse.Performance.first-meaningful-paint", 38 | "lighthouse.Performance" 39 | ]; 40 | const defaultChartConfig = { 41 | type: "SpeedIndex", 42 | dataRange: [0, Infinity], 43 | dateCutoff: 30 44 | }; 45 | 46 | const defaultHeaders = { 47 | "Cache-Control": "public, max-age: 3600" 48 | }; 49 | 50 | /** 51 | * Get all the tests for a suite and data points 52 | * within those tests. 53 | */ 54 | router.get("/:suiteId", function(req, res) { 55 | let data = getCache(req); 56 | 57 | if (data) { 58 | encloseRenderSuite(req, res)(data); 59 | } else { 60 | data = dataStore.getSuite(req.params.suiteId, encloseRenderSuite(req, res)); 61 | } 62 | }); 63 | 64 | /** 65 | * Get a specific datapoint 66 | */ 67 | router.get("/:suiteId/:testId/:datapointId", function(req, res) { 68 | let data = getCache(req); 69 | 70 | if (data) { 71 | encloseRenderDatapoint(req, res)(data); 72 | } else { 73 | dataStore.getDatapoint( 74 | req.params.suiteId, 75 | req.params.testId, 76 | req.params.datapointId, 77 | encloseRenderDatapoint(req, res) 78 | ); 79 | } 80 | }); 81 | 82 | module.exports = router; 83 | 84 | function buildChartConfig(req, defaultConfig) { 85 | let type = makeType(req.query.chartType); 86 | let typeConfig = _.find(defaultConfig, { type: type }); 87 | let dateCutoff = makeDateCutoff(req.query.dateCutoff, typeConfig); 88 | let dataRange = makeDataRange(req.query.dataRange, typeConfig); 89 | 90 | return { 91 | type: type, 92 | dateCutoff: dateCutoff, 93 | dataRange: dataRange 94 | }; 95 | } 96 | 97 | function makeType(type) { 98 | return _.indexOf(availableChartTypes, type) != "-1" 99 | ? type 100 | : defaultChartConfig.type; 101 | } 102 | 103 | function makeDateCutoff(cutoff, suiteConfig) { 104 | const custom = parseInt(cutoff, 10); 105 | const defaultVal = 106 | suiteConfig && suiteConfig.dateCutoff 107 | ? suiteConfig.dateCutoff 108 | : defaultChartConfig.dateCutoff; 109 | 110 | return custom || defaultVal; 111 | } 112 | 113 | function makeDataRange(range, suiteConfig) { 114 | range = range ? range : "0,0"; 115 | const defaultVal = 116 | suiteConfig && suiteConfig.dataRange 117 | ? suiteConfig.dataRange 118 | : defaultChartConfig.dataRange; 119 | 120 | const dataRange = range.split(",").map(function(val) { 121 | var parsed = parseInt(val, 10); 122 | if (isNaN(parsed)) { 123 | parsed = Infinity; 124 | } 125 | return parsed; 126 | }); 127 | 128 | //valid range, or default for suite, or default for anything 129 | var validRange = dataRange[0] < dataRange[1] ? dataRange : defaultVal; 130 | return validRange; 131 | } 132 | 133 | function chartFromDatapoints(suiteId, testConfig, datapoints, chartConfig) { 134 | let chart = { 135 | suiteId: suiteId, 136 | testId: testConfig.testId, 137 | testDisplayName: testConfig.testDisplayName, 138 | fvValues: [], 139 | rvValues: [], 140 | datapoints: [] 141 | }; 142 | let dateCutoff = moment().subtract(chartConfig.dateCutoff || 30, "days"); 143 | 144 | datapoints.forEach(function(dp) { 145 | let dataDate = new Date(dp.data.completed * 1000); 146 | //if older ignore. 147 | if (dataDate < dateCutoff) { 148 | return; 149 | } 150 | 151 | fvPointValue = parseFloat(dp.data.runs[1].firstView[chartConfig.type]); 152 | 153 | //if test requests a repeat firstView 154 | if (!testConfig.firstViewOnly) { 155 | rvPointValue = parseFloat(dp.data.runs[1].repeatView[chartConfig.type]); 156 | } 157 | 158 | //this filtering should be moved to the data_store 159 | if ( 160 | (inRange(fvPointValue, chartConfig.dataRange) && 161 | testConfig.firstViewOnly) || 162 | (inRange(fvPointValue, chartConfig.dataRange) && 163 | inRange(rvPointValue, chartConfig.dataRange)) 164 | ) { 165 | chart.fvValues.push([dataDate.getTime(), fvPointValue]); 166 | if (!testConfig.firstViewOnly) { 167 | chart.rvValues.push([dataDate.getTime(), rvPointValue]); 168 | } 169 | chart.datapoints.push(dp.datapointId); 170 | } 171 | }); 172 | 173 | return chart; 174 | } 175 | 176 | function inRange(value, range) { 177 | debug("inRange comparing " + value + " to " + range.toString()); 178 | return value > range[0] && value < range[1]; 179 | } 180 | 181 | function cacheKey(req) { 182 | let paramsKey = 183 | "suite" + 184 | req.params.suiteId + 185 | "test" + 186 | req.params.testId + 187 | "dp" + 188 | req.params.datapointId; 189 | let queryKey = 190 | "ct" + 191 | req.query.chartType + 192 | "dr" + 193 | req.query.dataRange + 194 | "dc" + 195 | req.query.dateCutoff; 196 | 197 | return paramsKey + queryKey; 198 | } 199 | 200 | function getCache(req) { 201 | const key = cacheKey(req); 202 | const data = cache.get(key); 203 | 204 | debug("getting cache key: " + key); 205 | debug(data); 206 | 207 | return data; 208 | } 209 | 210 | function setCache(req, data) { 211 | const key = cacheKey(req); 212 | 213 | debug("setting cache key: " + key); 214 | debug(data); 215 | 216 | return cache.put(key, data, 1000 * (60 * 60)); 217 | } 218 | 219 | function encloseRenderSuite(req, res) { 220 | return function renderSuite(data) { 221 | const suiteConfig = _.find(masterConfig.testSuites, { 222 | suiteId: data.suiteId 223 | }); 224 | 225 | data.charts = []; 226 | data.chartConfig = buildChartConfig(req, suiteConfig.chartConfig); 227 | data.availableChartTypes = availableChartTypes; 228 | data.suiteConfig = suiteConfig; 229 | 230 | async.map( 231 | data.tests, 232 | function(testName, asyncCallback) { 233 | dataStore.getSuiteTest(data.suiteId, testName, function(testData) { 234 | data.charts.push( 235 | chartFromDatapoints( 236 | data.suiteId, 237 | _.find(suiteConfig.testPages, { testId: testName }), 238 | testData.datapoints, 239 | data.chartConfig 240 | ) 241 | ); 242 | asyncCallback(); 243 | }); 244 | }, 245 | function(err, results) { 246 | setCache(req, data); 247 | res.set(defaultHeaders); 248 | res.json(data); 249 | } 250 | ); 251 | }; 252 | } 253 | 254 | function encloseRenderDatapoint(req, res) { 255 | return function renderDatapoint(data) { 256 | const suiteConfig = _.find(masterConfig.testSuites, { 257 | suiteId: data.suiteId 258 | }); 259 | let testData; 260 | 261 | dataStore.getSuiteTest(data.suiteId, data.testId, function(testData) { 262 | data.testConfig = _.find(suiteConfig.testPages, { testId: data.testId }); 263 | data.chartConfig = buildChartConfig(req, suiteConfig.chartConfig); 264 | data.chart = chartFromDatapoints( 265 | data.suiteId, 266 | _.find(suiteConfig.testPages, { testId: data.testId }), 267 | testData.datapoints, 268 | data.chartConfig 269 | ); 270 | setCache(req, data); 271 | 272 | res.set(defaultHeaders); 273 | res.json(data); 274 | }); 275 | }; 276 | } 277 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WebPagetest Charts API 2 | [WebPagetest](http://www.webpagetest.org/) Rules. There are tools that are easier to use, but nothing that lets you 3 | really see deeply into the browser side of things. But there's no easy way to compare results over time. 4 | So this is a small express application that runs tests, stores them, and offers endpoints to access the 5 | data. It assumes that you'll want to look at a variety of charts for your data, so the 6 | following datapoints are available: 7 | 8 | - SpeedIndex: Google's special score. It's an excellent 9 | summtion of how Google will see your site, and a great 10 | numerical indicator for how fast your site feels to 11 | visitors. Read the [SpeedIndex docs](https://sites.google.com/a/webpagetest.org/docs/using-webpagetest/metrics/speed-index) for more info 12 | - loadTime: How long (ms) to load the critical page content 13 | - fullyLoaded: How long (ms) to fully load all the page content 14 | - ~~requests: how many requests are made when loading the page~~ (needs update for new API response) 15 | - TTFB: time to the first byte recieved, rumored to be the most important for SEO 16 | - visualComplete: Time (ms) until the page is done changing visually 17 | - Lighthouse Suite: [Lighthouse](https://developers.google.com/web/tools/lighthouse/) tracks a lot of metrics, the ones WepageTest expsoses are: 18 | - Performance 19 | - Performance.speed-index-metric 20 | - Performance.first-meaningful-paint 21 | - Performance.estimated-input-latency 22 | - Performance.first-interactive 23 | - Performance.consistently-interactive 24 | - BestPractices 25 | - Accessibility 26 | - SEO 27 | - ProgressiveWebApp 28 | 29 | 30 | It also keeps links to 31 | the full WebPagetest results for deeper introspection. You can build a UI on 32 | this API. A working example is https://github.com/trulia/webpagetest-charts-ui. Visit that repo to see screenshots of what it can display. 33 | 34 | And none of this would have happened without [marcelduran](https://github.com/marcelduran) and his 35 | [webpagetest-api](https://github.com/marcelduran/webpagetest-api) 36 | module which made the data side of this very easy to prototype quickly. 37 | 38 | ## How It Works 39 | In this repo, there's no database: just the file system. (The data storage logic is its own 40 | module, so it could be replaced with something else, mongo, etc. PRs welcome!) The app saves results into 41 | `public/results///