├── .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 |
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 |
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 |

handleImageClick()}/>
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 |
--------------------------------------------------------------------------------