├── .gitignore ├── .babelrc ├── client ├── index.js ├── index.html ├── Container.jsx ├── scss │ └── styles.scss ├── App.jsx └── PageCreator.jsx ├── server ├── models │ └── tachyonModel.js ├── server.js ├── routes │ └── api.js └── controllers │ └── tachyonController.js ├── .eslintrc.json ├── README.md ├── package.json └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | package-lock.json 4 | lighthouse -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/preset-react"] 3 | } -------------------------------------------------------------------------------- /client/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import App from './App.jsx'; 4 | import styles from './scss/styles.scss'; 5 | 6 | const root = ReactDOM.createRoot(document.getElementById('root')); 7 | root.render(); -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Tachyon - Concurrent Website Metric Reports 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /client/Container.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PageCreator from './PageCreator.jsx'; 3 | 4 | // This component is responsible for rendering all of the page nodes that have been created 5 | const Container = ({ data }) => { 6 | const PageArray = []; 7 | data.forEach((element) => PageArray.push()); 8 | 9 | return ( 10 |
11 | {PageArray} 12 |
13 | ); 14 | }; 15 | 16 | export default Container; 17 | -------------------------------------------------------------------------------- /server/models/tachyonModel.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | // Enter a database link here 4 | const MONGO_URI = ''; 5 | 6 | mongoose.connect(MONGO_URI, { 7 | useNewUrlParser: true, 8 | useUnifiedTopology: true, 9 | dbName: 'Tachyon' 10 | }) 11 | .then(() => console.log('Connected to Mongo DB.')) 12 | .catch(err => console.log(err)); 13 | 14 | 15 | const pageSchema = new mongoose.Schema({ 16 | url: String, 17 | title: String, 18 | isMobile: Boolean 19 | }); 20 | 21 | const Page = mongoose.model('page', pageSchema); 22 | 23 | module.exports = Page; 24 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "ignorePatterns": ["**/test", "**/__tests__"], 4 | "env": { 5 | "node": true, 6 | "browser": true, 7 | "es2021": true 8 | }, 9 | "plugins": ["react"], 10 | "extends": ["eslint:recommended", "plugin:react/recommended"], 11 | "parserOptions": { 12 | "sourceType": "module", 13 | "ecmaFeatures": { 14 | "jsx": true 15 | } 16 | }, 17 | "rules": { 18 | "indent": ["warn", 2], 19 | "no-unused-vars": ["off", { "vars": "local" }], 20 | "no-case-declarations": "off", 21 | "prefer-const": "warn", 22 | "quotes": ["warn", "single"], 23 | "react/prop-types": "off", 24 | "semi": ["warn", "always"], 25 | "space-infix-ops": "warn" 26 | }, 27 | "settings": { 28 | "react": { "version": "detect"} 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /server/server.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const express = require('express'); 3 | const app = express(); 4 | 5 | const apiRouter = require('./routes/api'); 6 | 7 | const PORT = 3000; 8 | 9 | app.use(express.json()); 10 | app.use(express.urlencoded({ extended: true })); 11 | app.use(express.static(path.resolve(__dirname, '../dist'))); 12 | 13 | // route handlers 14 | app.use('/api', apiRouter); 15 | 16 | // catch-all route handler for any requests to an unknown route 17 | app.use((req, res) => res.status(400).send('Unknown route requested... 404')); 18 | 19 | // global error handler 20 | app.use((err, req, res, next) => { 21 | const defaultErr = { 22 | log: 'Express error handler caught unknown middleware error', 23 | status: 500, 24 | message: { err: 'An error occurred' }, 25 | }; 26 | const errorObj = {...defaultErr, ...err}; 27 | console.log(errorObj.log); 28 | return res.status(errorObj.status).json(errorObj.message); 29 | }); 30 | 31 | // start server 32 | app.listen(PORT, () => { 33 | console.log(`Server listening on port: ${PORT}...`); 34 | }); 35 | 36 | module.exports = app; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tachyon 2 | Displays performance/accessibility scores for specified urls and allows you to compare them side by side. Click on the generated numbers for useful tips. Works for mobile webpages too! 3 | ___ 4 | How to download: 5 | 1. Clone this repository onto your machine. 6 | 2. Run 'npm install' in the terminal to download all dependencies. 7 | 3. Navigate to server/models/tachyonModel.js 8 | 4. Input a mongoDB link to your database in tachyonModel.js at line 4 in place of the empty string. 9 | 5. Run 'npm run build' to bundle the app into one folder. 10 | 5. Run 'npm start' and navigate towards 'localhost:3000/' in your browser. 11 | ___ 12 | How to use Tachyon: 13 | 1. Input a URL into the text field. If you want to see the metrics for the mobile version of the page, click on the checkbox. 14 | 2. Click the add button. 15 | 3. Wait for your image to load and then click on it to load the metrics below. (click on the title if you wish to navigate to the given URL) 16 | 4. Click on the loaded numbers to open up a full report in another tab if desired. 17 | 5. Wait for the previous query's numbers to render before starting another. 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "start": "node ./server/server.js", 4 | "dev": "concurrently \"cross-env NODE_ENV=development webpack-dev-server --open --hot --progress --color \" \"nodemon ./server/server.js\"", 5 | "build": "cross-env NODE_ENV=production webpack --progress --color" 6 | }, 7 | "dependencies": { 8 | "lighthouse": "^9.6.8", 9 | "puppeteer": "^19.7.2", 10 | "express": "^4.18.2", 11 | "mongoose": "^7.0.0", 12 | "react": "^18.2.0", 13 | "react-dom": "^18.2.0", 14 | "sass": "^1.58.3" 15 | }, 16 | "devDependencies": { 17 | "@babel/core": "^7.21.0", 18 | "@babel/preset-env": "^7.20.2", 19 | "@babel/preset-react": "^7.18.6", 20 | "babel-loader": "^9.1.2", 21 | "concurrently": "^7.6.0", 22 | "sass-loader": "^13.2.0", 23 | "cross-env": "^7.0.3", 24 | "css-loader": "^6.7.3", 25 | "eslint": "^8.35.0", 26 | "eslint-plugin-react": "^7.32.2", 27 | "file-loader": "^6.2.0", 28 | "html-webpack-plugin": "^5.5.0", 29 | "nodemon": "^2.0.20", 30 | "style-loader": "^3.3.1", 31 | "webpack": "^5.75.0", 32 | "webpack-cli": "^5.0.1", 33 | "webpack-dev-server": "^4.11.1", 34 | "webpack-hot-middleware": "^2.25.3", 35 | "mini-css-extract-plugin": "^2.7.2", 36 | "url-loader": "^4.1.1" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 4 | 5 | module.exports = { 6 | entry: './client/index.js', 7 | output: { 8 | filename: 'bundle.js', 9 | path: path.resolve(__dirname, 'dist') 10 | }, 11 | devServer: { 12 | static: { 13 | publicPath: '/dist', 14 | directory: path.join(__dirname, 'dist'), 15 | }, 16 | port: 8080, 17 | proxy: { 18 | '/api' : 'http://localhost:3000' 19 | } 20 | }, 21 | mode: process.env.NODE_ENV, 22 | module: { 23 | rules: [ 24 | { 25 | test: /\.jsx?/, 26 | exclude: /node_modules/, 27 | use: { 28 | loader: 'babel-loader', 29 | options: { 30 | presets: [ 31 | '@babel/preset-env', 32 | '@babel/preset-react' 33 | ] 34 | } 35 | } 36 | }, 37 | { 38 | test: /\.s[ac]ss$/i, 39 | use: [ 40 | MiniCssExtractPlugin.loader, 41 | // Creates `style` nodes from JS strings 42 | // Translates CSS into CommonJS 43 | 'css-loader', 44 | // Compiles Sass to CSS 45 | 'sass-loader', 46 | ], 47 | }, 48 | { 49 | test: /\.css$/i, 50 | use: [MiniCssExtractPlugin.loader, 'css-loader'], 51 | }, 52 | { 53 | test: /\.(png|jpg|gif|svg|css|eot|ttf)$/, 54 | loader: 'url-loader', 55 | } 56 | ], 57 | }, 58 | plugins: [new HtmlWebpackPlugin({ 59 | template: './client/index.html' 60 | }), new MiniCssExtractPlugin()], 61 | }; 62 | -------------------------------------------------------------------------------- /server/routes/api.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const tachyonController = require('../controllers/tachyonController'); 3 | const router = express.Router(); 4 | const fs = require('fs'); 5 | const path = require('path'); 6 | 7 | // Responds with all data from the database 8 | router.get('/display', 9 | tachyonController.display, 10 | (req, res) => res.status(200).json(res.locals.display) 11 | ); 12 | 13 | // Responds with the analytical data from lighthouse 14 | router.get('/metrics/:id', 15 | tachyonController.metrics, 16 | (req, res) => res.status(200).json(res.locals) 17 | ); 18 | 19 | router.get('/m/metrics/:id', 20 | tachyonController.mobileMetrics, 21 | (req, res) => res.status(200).json(res.locals) 22 | ); 23 | 24 | // Responds with the screenshot of the page 25 | router.get('/screenshot/:id', 26 | tachyonController.screenshot, 27 | (req, res) => res.status(200).json(res.locals) 28 | ); 29 | 30 | router.get('/m/screenshot/:id', 31 | tachyonController.mobileScreenshot, 32 | (req, res) => res.status(200).json(res.locals) 33 | ); 34 | 35 | // Responds with the html report of the lighthouse analysis 36 | router.get('/report/:title', (req, res) => { 37 | res.status(200).sendFile(path.join(__dirname, `../../lighthouse/desktop/${req.params.title}.html`)); 38 | }); 39 | 40 | router.get('/m/report/:title', (req, res) => { 41 | res.status(200).sendFile(path.join(__dirname, `../../lighthouse/mobile/${req.params.title}.html`)); 42 | }); 43 | 44 | // Clear all lighthouse reports in desktop and mobile folders 45 | // - if the folders do not exist, create them 46 | router.get('/clear', async (req, res) => { 47 | if (!fs.existsSync('lighthouse')) { 48 | fs.mkdirSync('lighthouse'); 49 | fs.mkdirSync('lighthouse/desktop'); 50 | fs.mkdirSync('lighthouse/mobile'); 51 | res.status(200).send('Created all folders'); 52 | } else { 53 | for (const file of fs.readdirSync('lighthouse/desktop')) { 54 | fs.unlinkSync(`lighthouse/desktop/${file}`); 55 | } 56 | for (const file of fs.readdirSync('lighthouse/mobile')) { 57 | fs.unlinkSync(`lighthouse/mobile/${file}`); 58 | } 59 | res.end(); 60 | } 61 | }); 62 | 63 | // Add data to the database from the client 64 | router.post('/addURL', 65 | tachyonController.addURL, 66 | (req, res) => res.status(200).json(res.locals.output) 67 | ); 68 | 69 | router.post('/m/addURL', 70 | tachyonController.addMobileURL, 71 | (req, res) => res.status(200).json(res.locals.output) 72 | ); 73 | 74 | // Delete data from the database 75 | router.delete('/delete/:id', 76 | tachyonController.deleteURL, 77 | (req, res) => res.status(200).send('Deleted') 78 | ); 79 | 80 | module.exports = router; -------------------------------------------------------------------------------- /client/scss/styles.scss: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100;0,200;0,300;0,400;1,100;1,200;1,300;1,400&display=swap'); 2 | @import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond&display=swap'); 3 | 4 | body { 5 | background-color: #050505; 6 | margin : 0; 7 | } 8 | 9 | .Deleted { 10 | display : none; 11 | } 12 | 13 | #loading { 14 | font-family : 'Cormorant Garamond', serif; 15 | font-size: 2rem; 16 | color: #F5F1E3; 17 | text-align: center; 18 | } 19 | 20 | h1 { 21 | font-family : 'Cormorant Garamond', serif; 22 | font-size: 3.5rem; 23 | color: #F5F1E3; 24 | text-align: center; 25 | margin : 2rem 0; 26 | margin-bottom: 0; 27 | 28 | } 29 | 30 | form { 31 | display : flex; 32 | flex-direction : column; 33 | align-items : center; 34 | justify-content : space-between; 35 | gap : 1rem; 36 | padding : 1rem; 37 | 38 | #preventEnterKey { 39 | display: none; 40 | } 41 | 42 | #isMobile { 43 | cursor: pointer; 44 | } 45 | 46 | #input { 47 | background-color: #F5F1E3; 48 | color: #050505; 49 | border-radius: 5px; 50 | width: 18.7rem; 51 | border: none; 52 | padding: 0.5rem; 53 | font-size: large; 54 | font-family: 'Montserrat', sans-serif; 55 | position: relative; 56 | } 57 | 58 | label { 59 | position: relative; 60 | top: 1.75px; 61 | color: #FFFFFF; 62 | font-family: 'Montserrat', sans-serif; 63 | font-size: large; 64 | } 65 | 66 | button { 67 | background-color: #F5F1E3; 68 | color: #050505; 69 | border-radius: 5px; 70 | border: none; 71 | padding: 0.5rem; 72 | font-size: large; 73 | font-family: 'Montserrat', sans-serif; 74 | position: relative; 75 | cursor: pointer; 76 | padding-left: 1rem; 77 | padding-right: 1rem; 78 | } 79 | } 80 | 81 | .Container { 82 | display : grid; 83 | grid-template-columns : 1fr 1fr; 84 | grid-gap : 1rem; 85 | margin : 1rem; 86 | } 87 | 88 | 89 | .Page { 90 | display : flex; 91 | flex-direction : column; 92 | align-items : center; 93 | justify-content : space-between; 94 | gap : 1rem; 95 | padding : 1rem; 96 | font-family: 'Montserrat', sans-serif; 97 | font-size: large; 98 | color: #FFFFFF; 99 | border: 1px solid #F5F1E3; 100 | border-radius: 10px; 101 | 102 | .Title { 103 | display : flex; 104 | flex-direction : column; 105 | align-items : center; 106 | justify-content : space-between; 107 | gap : 0.5rem; 108 | 109 | b { 110 | cursor: pointer; 111 | } 112 | 113 | div { 114 | color : grey; 115 | } 116 | } 117 | 118 | .Img { 119 | display: block; 120 | max-width:800px; 121 | max-height:450px; 122 | width: auto; 123 | height: auto; 124 | } 125 | 126 | .Stats { 127 | display : flex; 128 | flex-direction : column; 129 | align-items : center; 130 | justify-content : space-between; 131 | gap : 1rem; 132 | 133 | button { 134 | background-color: #F5F1E3; 135 | color: #050505; 136 | border-radius: 5px; 137 | border: none; 138 | padding: 0.5rem; 139 | font-size: large; 140 | font-family: 'Montserrat', sans-serif; 141 | cursor: pointer; 142 | padding-left: 1rem; 143 | padding-right: 1rem; 144 | } 145 | } 146 | } 147 | 148 | @media screen and (max-width: 1700px) { 149 | .Container { 150 | grid-template-columns : 1fr; 151 | } 152 | .Img { 153 | max-width: 1000px; 154 | max-height: 800px; 155 | } 156 | } 157 | 158 | @media screen and (max-width: 1068px) { 159 | .Container .Img { 160 | max-width: 600px; 161 | max-height: 400px; 162 | } 163 | } 164 | 165 | @media screen and (max-width: 664px) { 166 | .Container .Img { 167 | max-width: 400px; 168 | max-height: 300px; 169 | } 170 | } 171 | 172 | @media screen and (max-width: 500px) { 173 | .Container .Img { 174 | max-width: 250px; 175 | max-height: 250px; 176 | } 177 | } -------------------------------------------------------------------------------- /client/App.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Container from './Container.jsx'; 3 | 4 | const App = () => { 5 | const [data, setData] = React.useState([]); 6 | const [input, setURL] = React.useState(''); 7 | const [isMobile, setIsMobile] = React.useState(false); 8 | const [pending, setPending] = React.useState([]); 9 | const [error, setError] = React.useState(false); 10 | 11 | // Gather URL from input field and make a post request to '/addURL' with the URL as a JSON object 12 | const addURL = () => { 13 | let url = input; 14 | document.getElementById('input').value = ''; 15 | if (!/^https?:\/\//i.test(input)) { 16 | url = 'http://' + input; 17 | } 18 | const pendingPromises = [...pending]; 19 | pendingPromises.push(url); 20 | setPending(pendingPromises); 21 | setURL(''); 22 | setError(false); 23 | // if the isMobile checkbox is checked, make a post request to '/m/addURL' with the URL as a JSON object 24 | if (isMobile) { 25 | setIsMobile(false); 26 | document.getElementById('isMobile').checked = false; 27 | fetch('/api/m/addURL', { 28 | method: 'POST', 29 | headers: { 30 | 'Content-Type': 'application/json' 31 | }, 32 | body: JSON.stringify({url}) 33 | }) 34 | .then((res) => res.json()) 35 | .then((res) => { 36 | if (res.err) { 37 | setError(true); 38 | const pendingPromises = [...pending]; 39 | pendingPromises.splice(pendingPromises.indexOf(url), 1); 40 | setPending(pendingPromises); 41 | return; 42 | } 43 | setData([...data, res]); 44 | const pendingPromises = [...pending]; 45 | pendingPromises.splice(pendingPromises.indexOf(url), 1); 46 | setPending(pendingPromises); 47 | return res; 48 | }); 49 | } else { 50 | fetch('/api/addURL', { 51 | method: 'POST', 52 | headers: { 53 | 'Content-Type': 'application/json' 54 | }, 55 | body: JSON.stringify({url}) 56 | }) 57 | .then((res) => res.json()) 58 | .then((res) => { 59 | if (res.err) { 60 | setError(true); 61 | const pendingPromises = [...pending]; 62 | pendingPromises.splice(pendingPromises.indexOf(url), 1); 63 | setPending(pendingPromises); 64 | return; 65 | } 66 | setData([...data, res]); 67 | const pendingPromises = [...pending]; 68 | pendingPromises.splice(pendingPromises.indexOf(url), 1); 69 | setPending(pendingPromises); 70 | }); 71 | } 72 | return; 73 | }; 74 | 75 | // when the page loads, make a get request to '/' which clears all stored images and html files 76 | React.useEffect(() => { 77 | fetch('/api/clear', { method : 'GET' }); 78 | return; 79 | }, []); 80 | 81 | // fetch all page data from the server and set it to the data state 82 | React.useEffect(() => { 83 | fetch('/api/display') 84 | .then((res) => res.json()) 85 | .then((data) => setData(data)); 86 | return; 87 | }, [data]); 88 | 89 | React.useEffect(() => { 90 | const loading = document.getElementById('loading'); 91 | if (error) { 92 | loading.innerHTML = '   URL could not be found . . .'; 93 | loading.style.color = 'rgb(255, 51, 51)'; 94 | } else if (pending.length > 0) { 95 | loading.style.color = '#F5F1E3'; 96 | loading.innerHTML = '   Loading . . .'; 97 | } else { 98 | loading.innerHTML = ''; 99 | } 100 | }, [pending, error]); 101 | 102 | return ( 103 |
104 |
105 |

Tachyon

106 |
107 | setURL(e.target.value)}/> 108 | 109 |
110 | setIsMobile(true)}/> 111 | 112 |
113 | 114 | 115 |
116 |
117 | 118 |
119 | ); 120 | }; 121 | 122 | export default App; -------------------------------------------------------------------------------- /server/controllers/tachyonController.js: -------------------------------------------------------------------------------- 1 | const Page = require('../models/tachyonModel'); 2 | const puppeteer = require('puppeteer'); 3 | const fs = require('fs'); 4 | const lighthouse = require('lighthouse'); 5 | const {KnownDevices} = require('puppeteer'); 6 | 7 | const tachyonController = {}; 8 | 9 | //Find all pages in the database and send them to the client 10 | tachyonController.display = async (req, res, next) => { 11 | try { 12 | const display = await Page.find(); 13 | res.locals.display = display; 14 | next(); 15 | } catch (err) { 16 | next({ 17 | log: `Error in tachyonController.display: ${err}`, 18 | message: { err: '500: Could not display' }, 19 | }); 20 | } 21 | }; 22 | 23 | // Find the page in the database and send the url to lighthouse 24 | // - lighthouse will analyze the page and send the results back to the client 25 | // - the results will be saved as a html file in the lighthouse folder 26 | tachyonController.metrics = async (req, res, next) => { 27 | try { 28 | const { url } = await Page.findOne({ _id: req.params.id }); 29 | const browser = await puppeteer.launch(); 30 | const page = await browser.newPage(); 31 | await page.goto(url, { waitUntil: 'load' }); 32 | const title = await page.title(); 33 | const result = await lighthouse(url, { 34 | port: (new URL(browser.wsEndpoint())).port, 35 | output: 'html', 36 | onlyCategories: ['performance', 'accessibility'], 37 | }, 38 | { 39 | extends: 'lighthouse:default', 40 | settings: { 41 | formFactor: 'desktop', 42 | throttling: { 43 | rttMs: 40, 44 | throughputKbps: 10240, 45 | cpuSlowdownMultiplier: 1, 46 | requestLatencyMs: 0, 47 | downloadThroughputKbps: 0, 48 | uploadThroughputKbps: 0 49 | }, 50 | screenEmulation: { 51 | mobile: false, 52 | width: 1920, 53 | height: 1080, 54 | deviceScaleFactor: 2, 55 | disabled: false 56 | }, 57 | emulatedUserAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36' 58 | } 59 | } 60 | ); 61 | await browser.close(); 62 | fs.writeFileSync(`./lighthouse/desktop/${title}.html`, result.report); 63 | res.locals.performance = result.lhr.categories.performance.score * 100; 64 | res.locals.accessibility = result.lhr.categories.accessibility.score * 100; 65 | next(); 66 | } catch (err) { 67 | res.locals.performance = 'Error, try again'; 68 | res.locals.accessibility = 'please click one image at a time'; 69 | next(); 70 | } 71 | }; 72 | 73 | tachyonController.mobileMetrics = async (req, res, next) => { 74 | try { 75 | const { url } = await Page.findOne({ _id: req.params.id }); 76 | const browser = await puppeteer.launch(); 77 | const page = await browser.newPage(); 78 | const samsung = KnownDevices['Galaxy S9+']; 79 | await page.emulate(samsung); 80 | await page.goto(url, { waitUntil: 'load' }); 81 | const title = await page.title(); 82 | const result = await lighthouse(url, { 83 | port: (new URL(browser.wsEndpoint())).port, 84 | output: 'html', 85 | onlyCategories: ['performance', 'accessibility'], 86 | disableDeviceEmulation: false, 87 | }, { 88 | extends: 'lighthouse:default', 89 | settings: { 90 | formFactor: 'mobile', 91 | throttling: { 92 | rttMs: 40, 93 | throughputKbps: 10240, 94 | cpuSlowdownMultiplier: 1, 95 | requestLatencyMs: 0, 96 | downloadThroughputKbps: 0, 97 | uploadThroughputKbps: 0 98 | }, 99 | screenEmulation: { 100 | mobile: true, 101 | width: 412, 102 | height: 846, 103 | deviceScaleFactor: 2, 104 | disabled: false 105 | }}}); 106 | await browser.close(); 107 | fs.writeFileSync(`./lighthouse/mobile/${title}.html`, result.report); 108 | res.locals.performance = result.lhr.categories.performance.score * 100; 109 | res.locals.accessibility = result.lhr.categories.accessibility.score * 100; 110 | next(); 111 | } catch (err) { 112 | res.locals.performance = 'Error, try again'; 113 | res.locals.accessibility = 'please click one image at a time'; 114 | next(); 115 | } 116 | }; 117 | 118 | // Find the page in the database and send the url to puppeteer to take a screenshot 119 | // - store the screenshot and send the image to the client 120 | tachyonController.screenshot = async (req, res, next) => { 121 | try { 122 | const { url } = await Page.findOne({ _id: req.params.id }); 123 | const browser = await puppeteer.launch(); 124 | const page = await browser.newPage(); 125 | await page.goto(url, { waitUntil: 'networkidle2' }); 126 | const title = await page.title(); 127 | page.setViewport({ width: 1920, height: 1080, deviceScaleFactor: 2, isMobile: false}); 128 | const image = await page.screenshot({ type : 'jpeg', quality: 100}); 129 | await browser.close(); 130 | res.locals.src = image.toString('base64'); 131 | next(); 132 | } catch (err) { 133 | res.locals.src = 'Error'; 134 | next(); 135 | } 136 | }; 137 | 138 | tachyonController.mobileScreenshot = async (req, res, next) => { 139 | try { 140 | const { url } = await Page.findOne({ _id: req.params.id }); 141 | const browser = await puppeteer.launch(); 142 | const page = await browser.newPage(); 143 | const samsung = KnownDevices['Galaxy S9+']; 144 | await page.emulate(samsung); 145 | await page.goto(url, { waitUntil: 'networkidle2' }); 146 | const title = await page.title(); 147 | page.viewport({ width: 412, height: 846, deviceScaleFactor: 2, isMobile: true}); 148 | const image = await page.screenshot({ type : 'jpeg', quality: 100}); 149 | await browser.close(); 150 | res.locals.src = image.toString('base64'); 151 | next(); 152 | } catch (err) { 153 | res.locals.src = 'Error'; 154 | next(); 155 | } 156 | }; 157 | 158 | // Add a new page to the database after sending the url to puppeteer to scrape the title 159 | tachyonController.addURL = async (req, res, next) => { 160 | try { 161 | const { url } = req.body; 162 | const browser = await puppeteer.launch(); 163 | const page = await browser.newPage(); 164 | await page.goto(url, { waitUntil: 'load' }); 165 | const title = await page.title(); 166 | await browser.close(); 167 | const newPage = await Page.create({ title, url: url, isMobile: false }); 168 | res.locals.output = newPage; 169 | next(); 170 | } catch (err) { 171 | next({ 172 | status: 400, 173 | log: `Error in tachyonController.addURL: ${err}`, 174 | message: { err: 'Error adding URL' }, 175 | }); 176 | } 177 | }; 178 | 179 | tachyonController.addMobileURL = async (req, res, next) => { 180 | try { 181 | const { url } = req.body; 182 | const browser = await puppeteer.launch(); 183 | const page = await browser.newPage(); 184 | const samsung = KnownDevices['Galaxy S9+']; 185 | await page.emulate(samsung); 186 | await page.goto(url, { waitUntil: 'load' }); 187 | const title = await page.title(); 188 | await browser.close(); 189 | const newPage = await Page.create({ title, url: url, isMobile: true }); 190 | res.locals.output = newPage; 191 | next(); 192 | } catch (err) { 193 | next({ 194 | status: 400, 195 | log: `Error in tachyonController.addMobileURL: ${err}`, 196 | message: { err: 'Error adding URL' }, 197 | }); 198 | } 199 | }; 200 | 201 | // Delete a page from the database 202 | tachyonController.deleteURL = async (req, res, next) => { 203 | try { 204 | await Page.findByIdAndDelete(req.params.id); 205 | next(); 206 | } catch (err) { 207 | next({ 208 | log: `Error in tachyonController.deleteURL: ${err}`, 209 | message: { err: '505: Could not find/delete from database' }, 210 | }); 211 | } 212 | }; 213 | 214 | module.exports = tachyonController; 215 | -------------------------------------------------------------------------------- /client/PageCreator.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const PageCreator = ({ element }) => { 4 | const [values, setValues] = React.useState({ 5 | src : 'https://upload.wikimedia.org/wikipedia/commons/b/b9/Youtube_loading_symbol_1_(wobbly).gif', 6 | performance : 'Pending load...', 7 | accessibility : 'Pending load...', 8 | class : 'Page', 9 | isClicked : true, 10 | hasLoaded : false 11 | }); 12 | 13 | let isMobile; 14 | if (element.isMobile) { 15 | isMobile = 'Mobile'; 16 | } else { 17 | isMobile = 'Desktop'; 18 | } 19 | 20 | // Opens a new tab with the report for the page 21 | const handleClick = () => { 22 | if (values.hasLoaded) { 23 | if (element.isMobile) { 24 | window.open(`http://localhost:3000/api/m/report/${element.title}`, '_blank'); 25 | } else { 26 | window.open(`http://localhost:3000/api/report/${element.title}`, '_blank'); 27 | } 28 | } 29 | }; 30 | 31 | // Opens a new tab with the url for the page 32 | const handleOpenLink = () => { 33 | window.open(element.url, '_blank'); 34 | }; 35 | 36 | 37 | // fetches the screenshot for the page and sets the src state to the image 38 | React.useEffect(() => { 39 | if (element.isMobile) { 40 | fetch(`/api/m/screenshot/${element._id}`) 41 | .then((res) => res.json()) 42 | .then((res) => { 43 | const image = `data:image/png;base64,${res.src}`; 44 | document.getElementById(`img:${element._id}`).style.cursor = 'pointer'; 45 | setValues({ 46 | ...values, 47 | src: image, 48 | isClicked : false, 49 | performance : 'Click image...', 50 | accessibility : 'Click image...' 51 | }); 52 | }); 53 | } else { 54 | fetch(`/api/screenshot/${element._id}`) 55 | .then((res) => res.json()) 56 | .then((res) => { 57 | const image = `data:image/png;base64,${res.src}`; 58 | document.getElementById(`img:${element._id}`).style.cursor = 'pointer'; 59 | setValues({ 60 | ...values, 61 | src: image, 62 | isClicked: false, 63 | performance : 'Click image...', 64 | accessibility : 'Click image...' 65 | }); 66 | }); 67 | } 68 | }, []); 69 | 70 | // fetches the performance and accessibility metrics for the page and sets the performance and accessibility states to the metrics 71 | const handleImageClick = () => { 72 | if (values.class !== 'Deleted' && !values.isClicked) { 73 | document.getElementById(`img:${element._id}`).style.cursor = 'default'; 74 | let performance = document.getElementById(`performance:${element._id}`); 75 | let accessibility = document.getElementById(`accessibility:${element._id}`); 76 | performance.style.color = 'white'; 77 | accessibility.style.color = 'white'; 78 | performance.style.cursor = 'default'; 79 | accessibility.style.cursor = 'default'; 80 | setValues({ 81 | ...values, 82 | performance : 'Loading...', 83 | accessibility : 'Loading...', 84 | hasLoaded : false, 85 | isClicked : true 86 | }); 87 | // fetches the metrics for the page if it is mobile 88 | if (element.isMobile) { 89 | fetch(`/api/m/metrics/${element._id}`) 90 | .then((res) => res.json()) 91 | .then((res) => { 92 | // if the metrics are strings, then there was an error 93 | if (typeof res.performance === 'string' || typeof res.accessibility === 'string') { 94 | performance.style.color = 'rgb(255, 51, 51)'; 95 | accessibility.style.color = 'rgb(255, 51, 51)'; 96 | performance.style.fontWeight = 'bold'; 97 | accessibility.style.fontWeight = 'bold'; 98 | performance.style.cursor = 'default'; 99 | accessibility.style.cursor = 'default'; 100 | setValues({ 101 | ...values, 102 | performance: res.performance, 103 | accessibility: res.accessibility, 104 | hasLoaded: false 105 | }); 106 | } else { 107 | // changes the color of the text based on the score 108 | if (res.performance <= 49) { 109 | performance.style.color = 'rgb(255, 51, 51)'; 110 | } else if (res.performance <= 89) { 111 | performance.style.color = 'rgb(255, 170, 51)'; 112 | } else { 113 | performance.style.color = 'rgb(0, 204, 102)'; 114 | } 115 | if (res.accessibility <= 49) { 116 | accessibility.style.color = 'rgb(255, 51, 51)'; 117 | } else if (res.accessibility <= 89) { 118 | accessibility.style.color = 'rgb(255, 170, 51)'; 119 | } else { 120 | accessibility.style.color = 'rgb(0, 204, 102)'; 121 | } 122 | performance.style.fontWeight = 'bold'; 123 | performance.style.cursor = 'pointer'; 124 | accessibility.style.fontWeight = 'bold'; 125 | accessibility.style.cursor = 'pointer'; 126 | performance = `${res.performance}%`; 127 | accessibility = `${res.accessibility}%`; 128 | setValues({ 129 | ...values, 130 | performance, 131 | accessibility, 132 | hasLoaded: true 133 | }); 134 | } 135 | document.getElementById(`img:${element._id}`).style.cursor = 'pointer'; 136 | }); 137 | } else { 138 | // fetches the metrics for the page if it is desktop 139 | fetch(`/api/metrics/${element._id}`) 140 | .then((res) => res.json()) 141 | .then((res) => { 142 | if (typeof res.performance === 'string' || typeof res.accessibility === 'string') { 143 | performance.style.color = 'rgb(255, 51, 51)'; 144 | accessibility.style.color = 'rgb(255, 51, 51)'; 145 | performance.style.fontWeight = 'bold'; 146 | accessibility.style.fontWeight = 'bold'; 147 | performance.style.cursor = 'default'; 148 | accessibility.style.cursor = 'default'; 149 | setValues({ 150 | ...values, 151 | performance: res.performance, 152 | accessibility: res.accessibility, 153 | hasLoaded: false 154 | }); 155 | } else { 156 | if (res.performance <= 49) { 157 | performance.style.color = 'rgb(255, 51, 51)'; 158 | } else if (res.performance <= 89) { 159 | performance.style.color = 'rgb(255, 170, 51)'; 160 | } else { 161 | performance.style.color = 'rgb(0, 204, 102)'; 162 | } 163 | if (res.accessibility <= 49) { 164 | accessibility.style.color = 'rgb(255, 51, 51)'; 165 | } else if (res.accessibility <= 89) { 166 | accessibility.style.color = 'rgb(255, 170, 51)'; 167 | } else { 168 | accessibility.style.color = 'rgb(0, 204, 102)'; 169 | } 170 | performance.style.fontWeight = 'bold'; 171 | performance.style.cursor = 'pointer'; 172 | accessibility.style.fontWeight = 'bold'; 173 | accessibility.style.cursor = 'pointer'; 174 | performance = `${res.performance}%`; 175 | accessibility = `${res.accessibility}%`; 176 | setValues({ 177 | ...values, 178 | performance, 179 | accessibility, 180 | hasLoaded: true 181 | }); 182 | } 183 | document.getElementById(`img:${element._id}`).style.cursor = 'pointer'; 184 | }); 185 | } 186 | } 187 | return; 188 | }; 189 | 190 | // deletes the page from the database and sets the class state to 'Deleted' to hide the page 191 | const deletePage = () => { 192 | const id = element._id; 193 | fetch(`/api/delete/${id}`, { 194 | method: 'DELETE' 195 | }); 196 | return setValues({ 197 | ...values, 198 | class : 'Deleted' 199 | }); 200 | }; 201 | 202 | return ( 203 |
204 |
205 | handleOpenLink()}>{element.title} 206 |
{isMobile}
207 |
208 |
Failed to loadhandleImageClick()}/>
209 |
210 |
211 | Performance: 212 | handleClick()}>{values.performance} 213 |
214 |
215 | Accessibility: 216 | handleClick()}>{values.accessibility} 217 |
218 | 219 |
220 |
221 | ); 222 | }; 223 | 224 | export default PageCreator; 225 | 226 | --------------------------------------------------------------------------------