├── .babelrc ├── .eslintrc.json ├── .github ├── .gitkeep └── workflows │ ├── CI.yml │ └── npm-publish.yml ├── .gitignore ├── .npmignore ├── MIT-LICENSE.txt ├── README.md ├── __mocks__ ├── fileMock.js └── styleMock.js ├── __test_utils__ ├── dataSlice_test.js ├── sampleData.js └── store_test.js ├── __tests__ ├── app.js ├── currentViewSlice.js ├── metric.js ├── sample_lhr.json ├── snapshot.js └── titleContainer.js ├── client ├── App.jsx ├── assets │ ├── background.svg │ └── vantage-logo.svg ├── components │ ├── CustomMUISwitch.jsx │ ├── CustomMUITooltip.jsx │ ├── CustomTooltip.jsx │ ├── DropDownMenu.jsx │ ├── Metric.jsx │ ├── OverallMetricChart.jsx │ └── PerformanceMetricChart.jsx ├── containers │ ├── ChartContainer.jsx │ ├── DescriptionContainer.jsx │ ├── Footer.jsx │ ├── MainContainer.jsx │ ├── MetricContainer.jsx │ ├── PerformanceMetrics.jsx │ └── TitleContainer.jsx ├── index.html ├── index.js ├── store │ ├── currentViewSlice.js │ ├── dataSlice.js │ └── store.js └── styles.scss ├── jest.config.js ├── package-lock.json ├── package.json ├── package ├── git-hooks │ └── gitHookInstall.js ├── lighthouse │ ├── html-script.js │ └── lighthouse.js └── main │ └── vantage.js ├── vantage_dev └── sampleData.json └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/preset-react"] 3 | } -------------------------------------------------------------------------------- /.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 | "react/prop-types": "off", 23 | "semi": ["warn", "always"], 24 | "space-infix-ops": "warn" 25 | }, 26 | "settings": { 27 | "react": { "version": "detect"} 28 | } 29 | } -------------------------------------------------------------------------------- /.github/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/Vantage/66e0eaf51ebca2af4256bf01cef54c29981a7080/.github/.gitkeep -------------------------------------------------------------------------------- /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - dev 7 | - main 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: actions/setup-node@v3 15 | with: 16 | node-version: 16 17 | - run: npm ci --ignore-scripts 18 | - run: npm test 19 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will publish to npm when code is pushed successfully to the main branch 2 | 3 | name: Publish Package to npmjs 4 | on: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: actions/setup-node@v3 15 | with: 16 | node-version: 16 17 | registry-url: "https://registry.npmjs.org" 18 | - run: npm ci --ignore-scripts 19 | - run: npm publish 20 | env: 21 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | # package-lock.json 107 | .DS_Store 108 | test-results/ 109 | playwright-report/ 110 | 111 | vantage_dev/index.html -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | client 2 | node_modules/ 3 | .DS_Store 4 | package-lock.json 5 | vantage_dev/ -------------------------------------------------------------------------------- /MIT-LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022 Ari Shoham, Paul Perez, Michael Noah, and Eli Davis 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Vantage Logo 2 | 3 | # [VANTAGE]("https://www.vantagenext.com") 4 | 5 | [![Build](https://img.shields.io/github/workflow/status/oslabs-beta/Vantage/Node.js%20CI)](https://github.com/oslabs-beta/Vantage/) 6 | [![NPM Downloads](https://img.shields.io/npm/dm/vantage-next)](https://www.npmjs.com/package/vantage-next) 7 | [![Issues](https://img.shields.io/github/issues/oslabs-beta/Vantage)](https://github.com/oslabs-beta/Vantage/issues) 8 | [![License](https://img.shields.io/github/license/oslabs-beta/Vantage)](https://github.com/oslabs-beta/Vantage/) 9 | 10 | Vantage is a web optimization tool designed for NEXTjs apps. 11 | 12 | - Uses Google lighthouse under the hood to determine key web vital scores and improvement suggestions. 13 | - Evaluates your app in the background with every commit. 14 | - Automatically evaluates every page in the app by traversing the project's endpoints to capture data. 15 | - Allows you to compare snapshots to see exactly which recommendations changed, and how your updates have directly influenced specific metrics. 16 | 17 | ## Get Started 18 | 19 | Install as a dev dependency in your project to get started. 20 | 21 | ``` 22 | npm install vantage-next --save-dev 23 | ``` 24 | 25 | ## Documentation 26 | 27 | View further documentation, config setup, and troubleshooting guides at: 28 | 29 | https://www.vantagenext.com/docs 30 | 31 | ### Vantage Dashboard 32 | 33 | ![Vantage Dashboard](https://github.com/oslabs-beta/vantage-splash/blob/dev/public/splash/dashboard-view.png?raw=true) 34 | 35 | ### Compare Commit Results: 36 | 37 | Compare Commits 38 | 39 | ### Choose Endpoints: 40 | 41 | Choose Endpoints 42 | 43 | ## Technologies used 44 | 45 | - Google Lighthouse 46 | - Reactjs 47 | - Material UI 48 | - Redux Toolkit 49 | - Recharts 50 | - SASS 51 | - Webpack 52 | - Puppeteer 53 | - Node 54 | 55 | ## Contributors 56 | 57 | - Ari Shoham - [Github](https://github.com/arishoham) | [LinkedIn](https://www.linkedin.com/in/ari-shoham/) 58 | - Michael Noah - [Github](https://github.com/mnoah1) | [LinkedIn](https://www.linkedin.com/in/mnoah/) 59 | - Eli Davis - [Github](https://github.com/elidavis42) | [LinkedIn](https://www.linkedin.com/in/elidavis42/) 60 | - Paul Perez - [Github](https://github.com/perezp92) | [LinkedIn](https://www.linkedin.com/in/perezp92/) 61 | 62 | ## Want to Contribute? 63 | 64 | 1. Clone the repo and make a new branch: `$ git checkout https://github.com/oslabs-beta/Vantage.git -b [name_of_new_branch]`. 65 | 1. Add a feature, fix a bug, or refactor some code :) 66 | - Make sure to lint your code! 67 | 1. Write/update tests for the changes you made, if necessary. 68 | 1. Run unit & integration tests and make sure all tests pass: `npm test`. 69 | 1. Open a Pull Request with a comprehensive description of changes to the `dev` branch. 70 | 1. Open a Pull Request to the [docs](https://github.com/oslabs-beta/vantage-splash) and _Contributors_ if necessary. 71 | 72 | **Thank you!** 73 | -------------------------------------------------------------------------------- /__mocks__/fileMock.js: -------------------------------------------------------------------------------- 1 | module.exports = 'test-file-stub'; -------------------------------------------------------------------------------- /__mocks__/styleMock.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; -------------------------------------------------------------------------------- /__test_utils__/dataSlice_test.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from "@reduxjs/toolkit"; 2 | import sampleData from './sampleData'; 3 | 4 | export const dataSlice = createSlice({ 5 | name: "data", 6 | initialState: sampleData, 7 | }); 8 | 9 | export const selectWebVitals = (state) => state.data["web-vitals"]; 10 | export const selectRunList = (state) => state.data["run-list"]; 11 | export const selectEndpoints = (state) => state.data.endpoints; 12 | export const selectCommits = (state) => state.data.commits; 13 | 14 | export const selectOverallScoreByEndpoint = (state, endpoint) => 15 | state.data["overall-scores"][endpoint]; 16 | 17 | export const selectMostRecentWebVital = (state, webVital, endpoint) => { 18 | const runList = state.data["run-list"]; 19 | const score = 20 | state.data["web-vitals"][webVital].results[endpoint][ 21 | runList[runList.length - 1] 22 | ].score; 23 | const title = state.data["web-vitals"][webVital].title; 24 | const description = state.data["web-vitals"][webVital].description; 25 | return { score, title, description }; 26 | }; 27 | 28 | export const selectWebVitalData = (state, webVital, endpoint) => 29 | state.data["web-vitals"][webVital].results[endpoint]; 30 | 31 | export default dataSlice.reducer; 32 | -------------------------------------------------------------------------------- /__test_utils__/store_test.js: -------------------------------------------------------------------------------- 1 | import { configureStore } from "@reduxjs/toolkit"; 2 | import currentViewReducer from "../client/store/currentViewSlice"; 3 | 4 | // Test version of data Slice 5 | import dataReducer from "./dataSlice_test"; 6 | 7 | export default configureStore({ 8 | reducer: { 9 | data: dataReducer, 10 | currentView: currentViewReducer, 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /__tests__/app.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import React from "react"; 6 | import { Provider } from "react-redux"; 7 | import { render, fireEvent } from "@testing-library/react"; 8 | import App from "../client/App.jsx"; 9 | import "@testing-library/jest-dom"; 10 | import regeneratorRuntime from "regenerator-runtime"; 11 | 12 | // Test store 13 | import store from '../__test_utils__/store_test' 14 | 15 | describe("React-Redux integration tests", () => { 16 | describe("Render app before each test", () => { 17 | let app; 18 | beforeEach(async () => { 19 | app = await render( 20 | 21 | 22 | 23 | ); 24 | }); 25 | 26 | test("App is rendering", () => { 27 | expect(app.getByText("Vantage")).toBeInTheDocument(); 28 | expect(app.getAllByText("Performance")[0]).toBeInTheDocument(); 29 | expect(app.getAllByText("SEO")[0]).toBeInTheDocument(); 30 | expect(app.getAllByText("Best Practices")[0]).toBeInTheDocument(); 31 | expect(app.getAllByText("Accessibility")[0]).toBeInTheDocument(); 32 | }); 33 | 34 | describe("Clicking on each metric shows suggestions", () => { 35 | beforeEach(() => { 36 | //Close suggestions 37 | const curEndpoint = app.getByText("Current Endpoint:"); 38 | fireEvent.click(curEndpoint); 39 | }); 40 | 41 | test("Performance", () => { 42 | let descContainer = app.container.querySelector( 43 | "#description-container" 44 | ); 45 | expect(descContainer.firstChild).toBeNull(); 46 | const metric = app.getAllByText("Performance")[0]; 47 | fireEvent.click(metric); 48 | expect(descContainer.firstChild).not.toBe(null); 49 | }); 50 | 51 | test("SEO", () => { 52 | let descContainer = app.container.querySelector( 53 | "#description-container" 54 | ); 55 | expect(descContainer.firstChild).toBeNull(); 56 | const metric = app.getAllByText("SEO")[0]; 57 | fireEvent.click(metric); 58 | expect(descContainer.firstChild).not.toBe(null); 59 | }); 60 | 61 | test("Best Practices", () => { 62 | let descContainer = app.container.querySelector( 63 | "#description-container" 64 | ); 65 | expect(descContainer.firstChild).toBeNull(); 66 | const metric = app.getAllByText("Best Practices")[0]; 67 | fireEvent.click(metric); 68 | expect(descContainer.firstChild).not.toBe(null); 69 | }); 70 | 71 | test("Accessibility", () => { 72 | let descContainer = app.container.querySelector( 73 | "#description-container" 74 | ); 75 | expect(descContainer.firstChild).toBeNull(); 76 | const metric = app.getAllByText("Accessibility")[0]; 77 | fireEvent.click(metric); 78 | expect(descContainer.firstChild).not.toBe(null); 79 | }); 80 | }); 81 | 82 | describe("Performance web vitals", () => { 83 | beforeEach(() => { 84 | //Close suggestions 85 | const curEndpoint = app.getByText("Current Endpoint:"); 86 | fireEvent.click(curEndpoint); 87 | }); 88 | 89 | test("Clicking performance Metric shows web vitals", () => { 90 | const perf = app.getAllByText("Performance")[0]; 91 | fireEvent.click(perf); 92 | const webVitalArr = ["FCP", "SI", "LCP", "TTI", "TBT", "CLS"]; 93 | webVitalArr.forEach((cur) => { 94 | expect(app.getByText(cur)).toBeInTheDocument(); 95 | }); 96 | }); 97 | 98 | test("Clicking on each web vital shows it in the legend", () => { 99 | const perf = app.getAllByText("Performance")[0]; 100 | fireEvent.click(perf); 101 | const webVitalArr = ["FCP", "SI", "LCP", "TTI", "TBT", "CLS"]; 102 | webVitalArr.forEach((cur) => { 103 | const webVitalMetric = app.getByText(cur); 104 | fireEvent.click(webVitalMetric); 105 | expect(app.getAllByText(cur)[0]).toBeInTheDocument(); 106 | fireEvent.click(webVitalMetric); 107 | }); 108 | }); 109 | 110 | test("Clicking on each web vital shows a line for it individually", () => { 111 | const perf = app.getAllByText("Performance")[0]; 112 | fireEvent.click(perf); 113 | const webVitalArr = ["FCP", "SI", "LCP", "TTI", "TBT", "CLS"]; 114 | webVitalArr.forEach((cur) => { 115 | const webVitalMetric = app.getByText(cur); 116 | fireEvent.click(webVitalMetric); 117 | const webVitalLine = app.container.querySelectorAll(".recharts-line"); 118 | expect(webVitalLine.length).toBe(1); 119 | fireEvent.click(webVitalMetric); 120 | }); 121 | }); 122 | 123 | test("Clicking on each web vital shows it's unit on the graph", () => { 124 | const perf = app.getAllByText("Performance")[0]; 125 | fireEvent.click(perf); 126 | const webVitalArr = ["FCP", "SI", "LCP", "TTI", "TBT"]; 127 | webVitalArr.forEach((cur, i) => { 128 | const webVitalMetric = app.getByText(cur); 129 | fireEvent.click(webVitalMetric); 130 | const unit = app 131 | .getAllByText("ms") 132 | .filter(({ nodeName }) => nodeName === "tspan"); 133 | expect(unit[0]).toBeInTheDocument(); 134 | fireEvent.click(webVitalMetric); 135 | }); 136 | }); 137 | 138 | test("Clicking on each web vital shows a line for it together", () => { 139 | const perf = app.getAllByText("Performance")[0]; 140 | fireEvent.click(perf); 141 | const webVitalArr = ["FCP", "SI", "LCP", "TTI", "TBT", "CLS"]; 142 | webVitalArr.forEach((cur, i) => { 143 | const webVitalMetric = app.getByText(cur); 144 | fireEvent.click(webVitalMetric); 145 | const webVitalLine = app.container.querySelectorAll(".recharts-line"); 146 | expect(webVitalLine.length).toBe(i + 1); 147 | }); 148 | }); 149 | }); 150 | 151 | describe("Chart range switch", () => { 152 | let switchContainer; 153 | beforeEach(() => { 154 | const metric = app.getAllByText("Performance")[0]; 155 | fireEvent.click(metric); 156 | switchContainer = app.container.querySelector("#range-switch"); 157 | }); 158 | 159 | test("Switch is in document", () => { 160 | expect(switchContainer).toBeInTheDocument(); 161 | }); 162 | 163 | test("selectorSwitch in store starts as false", () => { 164 | expect(store.getState().currentView.selectorSwitch).toBe(false); 165 | }); 166 | 167 | test("selectorSwitch in to be true after range switch is clicked", () => { 168 | fireEvent.click(app.container.querySelector("#range-switch-click")); 169 | expect(store.getState().currentView.selectorSwitch).toBe(true); 170 | }); 171 | }); 172 | 173 | describe("Suggestions", () => { 174 | const metricArr = []; 175 | beforeEach(() => { 176 | metricArr[0] = app.getAllByText("Performance")[0]; 177 | metricArr[1] = app.getAllByText("Accessibility")[0]; 178 | metricArr[2] = app.getAllByText("Best Practices")[0]; 179 | metricArr[3] = app.getAllByText("SEO")[0]; 180 | }); 181 | 182 | test("Each Suggestion has two paragraphs and a button", () => { 183 | metricArr.forEach((metric) => { 184 | fireEvent.click(metric); 185 | const suggestions = app.container.querySelectorAll(".suggestion"); 186 | suggestions.forEach((cur) => { 187 | expect(cur).toBeInTheDocument(); 188 | expect(cur.children[0].nodeName).toBe("P"); 189 | expect(cur.children[1].nodeName).toBe("P"); 190 | expect(cur.lastChild.nodeName).toBe("BUTTON"); 191 | }); 192 | }); 193 | }); 194 | 195 | test("Each suggestion description has text", () => { 196 | metricArr.forEach((metric) => { 197 | fireEvent.click(metric); 198 | const suggestions = app.container.querySelectorAll(".suggestion"); 199 | suggestions.forEach((cur) => { 200 | expect(cur.children[0].innerHTML).toBeTruthy(); 201 | }); 202 | }); 203 | }); 204 | 205 | test("Each suggestion has a valid value", () => { 206 | metricArr.forEach((metric) => { 207 | fireEvent.click(metric); 208 | const suggestions = app.container.querySelectorAll(".suggestion"); 209 | suggestions.forEach((cur) => { 210 | const num = Number(cur.children[1].innerHTML); 211 | if (isNaN(num)) { 212 | expect(isNaN(cur.children[1].firstChild.nodeValue)).toBe(false); 213 | expect(cur.children[1].children[0].innerHTML).toMatch( 214 | /ms|B|elements|s|KiB|Kelements/ 215 | ); 216 | } else { 217 | expect(num).toBeLessThanOrEqual(100); 218 | expect(num).toBeGreaterThanOrEqual(0); 219 | } 220 | }); 221 | }); 222 | }); 223 | }); 224 | }); 225 | }); 226 | -------------------------------------------------------------------------------- /__tests__/currentViewSlice.js: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | import currentViewSlice, { 3 | changeMetric, 4 | changeEndpoint, 5 | changePerformanceMetrics, 6 | } from '../client/store/currentViewSlice'; 7 | import store from '../__test_utils__/store_test' 8 | 9 | 10 | describe('CurrentViewSlice', () => { 11 | let initialState; 12 | const { currentView } = store.getState(); 13 | 14 | beforeEach(() => { 15 | initialState = currentView; 16 | }); 17 | 18 | it('should provide a default state when given an undefined input', () => { 19 | expect(currentViewSlice(undefined, { type: undefined})).toEqual(initialState); 20 | }); 21 | 22 | describe('Unrecognized action types', () => { 23 | it('should not affect state', () => { 24 | const action = { type: 'FAKE_TYPE'}; 25 | expect(currentViewSlice(initialState, action)).toBe(initialState); 26 | }) 27 | }); 28 | 29 | describe('changeMetric', () => { 30 | it('should change currentMetric to action payload', () => { 31 | const { currentMetric } = store.getState().currentView; 32 | expect(currentMetric).toEqual('default') 33 | const metricArr = ['Performance', 'Accessibility', 'Best Practices', 'SEO', 'default'] 34 | for(const metric of metricArr){ 35 | store.dispatch(changeMetric(metric)) 36 | const newMetric = store.getState().currentView.currentMetric; 37 | expect(newMetric).toEqual(metric); 38 | }; 39 | }); 40 | }) 41 | 42 | describe('changeEndpoint', () => { 43 | it('should change Endpoint to action payload', () => { 44 | const action = store.dispatch(changeEndpoint('Documentation')) 45 | const endpoint = action.payload; 46 | const { currentEndpoint } = store.getState().currentView; 47 | expect(currentEndpoint).toEqual(endpoint); 48 | }); 49 | }) 50 | describe('changePerformanceMetrics', () => { 51 | it('If a non valid performance metric is dispatched, nothing should change', () => { 52 | store.dispatch(changePerformanceMetrics('FCP')) 53 | expect(store.getState().currentView.performanceMetricsArr).toEqual(['FCP']); 54 | store.dispatch(changePerformanceMetrics('YES')) 55 | store.dispatch(changePerformanceMetrics('NO')) 56 | store.dispatch(changePerformanceMetrics('MAYBE')) 57 | expect(store.getState().currentView.performanceMetricsArr).toEqual(['FCP']); 58 | store.dispatch(changePerformanceMetrics('FCP')) 59 | }); 60 | 61 | it('should add and remove performance metrics from performanceMetricsArr', () => { 62 | store.dispatch(changePerformanceMetrics('FCP')) 63 | expect(store.getState().currentView.performanceMetricsArr).toEqual(['FCP']); 64 | store.dispatch(changePerformanceMetrics('TTI')) 65 | expect(store.getState().currentView.performanceMetricsArr).toEqual(['FCP','TTI']); 66 | store.dispatch(changePerformanceMetrics('TTI')) 67 | expect(store.getState().currentView.performanceMetricsArr).toEqual(['FCP']); 68 | store.dispatch(changePerformanceMetrics('FCP')) 69 | expect(store.getState().currentView.performanceMetricsArr).toEqual([]); 70 | }); 71 | }) 72 | }); -------------------------------------------------------------------------------- /__tests__/metric.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import React from "react"; 6 | import userEvent from "@testing-library/user-event"; 7 | import { render, screen, waitFor, fireEvent } from "@testing-library/react"; 8 | import "@testing-library/jest-dom"; 9 | // import regeneratorRuntime from 'regenerator-runtime'; 10 | 11 | // import App from '../client/App'; 12 | import Metric from "../client/components/Metric.jsx"; 13 | 14 | describe("Unit testing React components", () => { 15 | describe("Metric basic", () => { 16 | let metric; 17 | let handleClick; 18 | 19 | beforeEach(() => { 20 | handleClick = jest.fn() 21 | const props = { name: "name", value: 80, handleClick}; 22 | metric = render(); 23 | // console.log(text); 24 | }); 25 | 26 | test("Metric name and value appear on screen", () => { 27 | expect(metric.getByText('name')).toBeInTheDocument() 28 | expect(metric.getByText('80')).toBeInTheDocument() 29 | }); 30 | 31 | test("Click on metric", () => { 32 | expect(handleClick.mock.calls.length).toBe(0) 33 | fireEvent.click(metric.getByText('name')) 34 | expect(handleClick.mock.calls.length).toBe(1) 35 | }); 36 | }); 37 | 38 | describe("Metric advanced", () => { 39 | test("Circle svg appear on screen", () => { 40 | const handleClick = jest.fn() 41 | const props = { name: "name", value: 95, handleClick}; 42 | const metric = render(); 43 | const circle = metric.container.querySelector('circle') 44 | expect(circle).toBeInTheDocument() 45 | // expect(circle).toHaveStyle('color: rgb(71, 255, 130)'); 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /__tests__/snapshot.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = 'testing'; 2 | 3 | const SAMPLE_DATA_FILE = './__tests__/sample_lhr.json'; 4 | const DATA_STORE_FILE = './__tests__/data_store.json'; 5 | const snapshot = require("../package/lighthouse/lighthouse"); 6 | const fs = require('fs'); 7 | 8 | describe("data_store.json tests", () => { 9 | let data_store; 10 | let json; 11 | 12 | describe("Export a single run", () => { 13 | beforeEach(async () => { 14 | const sample_lhr = JSON.parse(fs.readFileSync(SAMPLE_DATA_FILE)); 15 | await snapshot.generateUpdatedDataStore(sample_lhr, '2021-01-01 00:00:00', '/myEndpoint', 'Sample Commit', true, DATA_STORE_FILE); 16 | data_store = fs.readFileSync(DATA_STORE_FILE); 17 | json = JSON.parse(data_store); 18 | }); 19 | 20 | test("Run list is and has correct value", () => { 21 | expect(json['run-list'].length).toEqual(1); 22 | expect(json['run-list'][0]).toEqual('2021-01-01 00:00:00'); 23 | }); 24 | 25 | test("Endpoints list is present and has correct value", () => { 26 | expect(json['endpoints'].length).toEqual(1); 27 | expect(json['endpoints'][0]).toEqual('/myEndpoint'); 28 | }); 29 | 30 | test("Commit list is present and has correct value", () => { 31 | expect(json['commits']['2021-01-01 00:00:00']).toBeDefined(); 32 | expect(json['commits']['2021-01-01 00:00:00']).toEqual('Sample Commit'); 33 | }); 34 | 35 | test("Overall scores are present", () => { 36 | expect(json['overall-scores']['/myEndpoint']['2021-01-01 00:00:00']['performance']).toBeDefined(); 37 | expect(json['overall-scores']['/myEndpoint']['2021-01-01 00:00:00']['accessibility']).toBeDefined(); 38 | expect(json['overall-scores']['/myEndpoint']['2021-01-01 00:00:00']['best-practices']).toBeDefined(); 39 | expect(json['overall-scores']['/myEndpoint']['2021-01-01 00:00:00']['seo']).toBeDefined(); 40 | expect(json['overall-scores']['/myEndpoint']['2021-01-01 00:00:00']['pwa']).toBeDefined(); 41 | }); 42 | 43 | test("Overall scores are all numbers", () => { 44 | expect(typeof json['overall-scores']['/myEndpoint']['2021-01-01 00:00:00']['performance']).toBe('number'); 45 | expect(typeof json['overall-scores']['/myEndpoint']['2021-01-01 00:00:00']['accessibility']).toBe('number'); 46 | expect(typeof json['overall-scores']['/myEndpoint']['2021-01-01 00:00:00']['best-practices']).toBe('number'); 47 | expect(typeof json['overall-scores']['/myEndpoint']['2021-01-01 00:00:00']['seo']).toBe('number'); 48 | expect(typeof json['overall-scores']['/myEndpoint']['2021-01-01 00:00:00']['pwa']).toBe('number'); 49 | }); 50 | 51 | test("Web Vitals scores are all present", () => { 52 | expect(json['web-vitals']['first-contentful-paint']['results']['/myEndpoint']['2021-01-01 00:00:00']).toBeDefined(); 53 | expect(json['web-vitals']['interactive']['results']['/myEndpoint']['2021-01-01 00:00:00']).toBeDefined(); 54 | expect(json['web-vitals']['speed-index']['results']['/myEndpoint']['2021-01-01 00:00:00']).toBeDefined(); 55 | expect(json['web-vitals']['total-blocking-time']['results']['/myEndpoint']['2021-01-01 00:00:00']).toBeDefined(); 56 | expect(json['web-vitals']['largest-contentful-paint']['results']['/myEndpoint']['2021-01-01 00:00:00']).toBeDefined(); 57 | expect(json['web-vitals']['cumulative-layout-shift']['results']['/myEndpoint']['2021-01-01 00:00:00']).toBeDefined(); 58 | }); 59 | 60 | test("Web Vitals scores are all numbers", () => { 61 | expect(typeof json['web-vitals']['first-contentful-paint']['results']['/myEndpoint']['2021-01-01 00:00:00']['numericValue']).toBe('number'); 62 | expect(typeof json['web-vitals']['interactive']['results']['/myEndpoint']['2021-01-01 00:00:00']['numericValue']).toBe('number'); 63 | expect(typeof json['web-vitals']['speed-index']['results']['/myEndpoint']['2021-01-01 00:00:00']['numericValue']).toBe('number'); 64 | expect(typeof json['web-vitals']['total-blocking-time']['results']['/myEndpoint']['2021-01-01 00:00:00']['numericValue']).toBe('number'); 65 | expect(typeof json['web-vitals']['largest-contentful-paint']['results']['/myEndpoint']['2021-01-01 00:00:00']['numericValue']).toBe('number'); 66 | expect(typeof json['web-vitals']['cumulative-layout-shift']['results']['/myEndpoint']['2021-01-01 00:00:00']['numericValue']).toBe('number'); 67 | }); 68 | 69 | // Additional Tests: Remainder of audit categories 70 | 71 | afterEach(() => { 72 | // Delete the data_store file 73 | fs.unlinkSync(DATA_STORE_FILE); 74 | }); 75 | }); 76 | 77 | 78 | const customCountTest = function(count) { 79 | describe(`Export multiple runs: ${count} runs`, () => { 80 | let commits, commitMsgs, endpoints; 81 | beforeEach(async () => { 82 | 83 | commits = ['2021-01-01 00:00:00', '2021-01-02 00:00:00', '2021-01-03 00:00:00', '2021-01-04 00:00:00', '2021-01-05 00:00:00', '2021-01-06 00:00:00', '2021-01-07 00:00:00', '2021-01-08 00:00:00', '2021-01-09 00:00:00', '2021-01-10 00:00:00', '2021-01-11 00:00:00', '2021-01-12 00:00:00', '2021-01-13 00:00:00', '2021-01-14 00:00:00', '2021-01-15 00:00:00']; 84 | commitMsgs = ['Commit 1', 'Commit 2', 'Commit 3', 'Commit 4', 'Commit 5', 'Commit 6', 'Commit 7', 'Commit 8', 'Commit 9', 'Commit 10', 'Commit 11', 'Commit 12', 'Commit 13', 'Commit 14', 'Commit 15']; 85 | endpoints = ['/', '/myPage', '/myPage/blog']; 86 | 87 | for (let i = 0; i < count; i++) { 88 | for (let j = 0; j < endpoints.length; j++) { 89 | const sample_lhr = await JSON.parse(fs.readFileSync(SAMPLE_DATA_FILE)); 90 | await snapshot.generateUpdatedDataStore(sample_lhr, commits[i], endpoints[j], commitMsgs[i], true, DATA_STORE_FILE); 91 | } 92 | } 93 | data_store = fs.readFileSync(DATA_STORE_FILE); 94 | json = JSON.parse(data_store); 95 | }); 96 | 97 | test("Run list exists and has correct value", () => { 98 | expect(json['run-list'].length).toEqual(count > 10 ? 10 : count); 99 | for (let i = count - 1; i >= 0 && i >= count - 10; i--) { 100 | expect(json['run-list']).toContain(commits[i]); 101 | } 102 | }); 103 | 104 | test("Endpoints list is present and each has correct value", () => { 105 | expect(json['endpoints'].length).toEqual(endpoints.length); 106 | for (let i = 0; i < endpoints.length; i++) { 107 | expect(json['endpoints']).toContain(endpoints[i]); 108 | } 109 | }); 110 | 111 | test("Commit list is present and each key has correct value", () => { 112 | expect(Object.keys(json['commits']).length).toEqual(count > 10 ? 10 : count); 113 | 114 | for (let i = count - 1; i >= 0 && i >= count - 10; i--) { 115 | expect(json['commits'][commits[i]]).toBeDefined(); 116 | expect(json['commits'][commits[i]]).toEqual(commitMsgs[i]); 117 | } 118 | 119 | }); 120 | 121 | test("Overall scores are present for each commit", () => { 122 | 123 | for (const endpoint of endpoints) { 124 | for (let i = count - 1; i >= 0 && i >= count - 10; i--) { 125 | expect(json['overall-scores'][endpoint][commits[i]]['performance']).toBeDefined(); 126 | expect(json['overall-scores'][endpoint][commits[i]]['accessibility']).toBeDefined(); 127 | expect(json['overall-scores'][endpoint][commits[i]]['best-practices']).toBeDefined(); 128 | expect(json['overall-scores'][endpoint][commits[i]]['seo']).toBeDefined(); 129 | expect(json['overall-scores'][endpoint][commits[i]]['pwa']).toBeDefined(); 130 | } 131 | } 132 | }); 133 | 134 | test("Overall scores are all numbers", () => { 135 | for (const endpoint of endpoints) { 136 | for (let i = count - 1; i >= 0 && i >= count - 10; i--) { 137 | expect(typeof json['overall-scores'][endpoint][commits[i]]['performance']).toBe('number'); 138 | expect(typeof json['overall-scores'][endpoint][commits[i]]['accessibility']).toBe('number'); 139 | expect(typeof json['overall-scores'][endpoint][commits[i]]['best-practices']).toBe('number'); 140 | expect(typeof json['overall-scores'][endpoint][commits[i]]['seo']).toBe('number'); 141 | expect(typeof json['overall-scores'][endpoint][commits[i]]['pwa']).toBe('number'); 142 | } 143 | } 144 | }); 145 | 146 | test("Web Vitals scores are all present", () => { 147 | for (const endpoint of endpoints) { 148 | for (let i = count - 1; i >= 0 && i >= count - 10; i--) { 149 | expect(json['web-vitals']['first-contentful-paint']['results'][endpoint][commits[i]]).toBeDefined(); 150 | expect(json['web-vitals']['interactive']['results'][endpoint][commits[i]]).toBeDefined(); 151 | expect(json['web-vitals']['speed-index']['results'][endpoint][commits[i]]).toBeDefined(); 152 | expect(json['web-vitals']['total-blocking-time']['results'][endpoint][commits[i]]).toBeDefined(); 153 | expect(json['web-vitals']['largest-contentful-paint']['results'][endpoint][commits[i]]).toBeDefined(); 154 | expect(json['web-vitals']['cumulative-layout-shift']['results'][endpoint][commits[i]]).toBeDefined(); 155 | } 156 | } 157 | }); 158 | 159 | test("Web Vitals scores are all numbers", () => { 160 | for (const endpoint of endpoints) { 161 | for (let i = count - 1; i >= 0 && i >= count - 10; i--) { 162 | expect(typeof json['web-vitals']['first-contentful-paint']['results'][endpoint][commits[i]]['numericValue']).toBe('number'); 163 | expect(typeof json['web-vitals']['interactive']['results'][endpoint][commits[i]]['numericValue']).toBe('number'); 164 | expect(typeof json['web-vitals']['speed-index']['results'][endpoint][commits[i]]['numericValue']).toBe('number'); 165 | expect(typeof json['web-vitals']['total-blocking-time']['results'][endpoint][commits[i]]['numericValue']).toBe('number'); 166 | expect(typeof json['web-vitals']['largest-contentful-paint']['results'][endpoint][commits[i]]['numericValue']).toBe('number'); 167 | expect(typeof json['web-vitals']['cumulative-layout-shift']['results'][endpoint][commits[i]]['numericValue']).toBe('number'); 168 | } 169 | } 170 | 171 | }); 172 | 173 | // Additional future tests: Testing of each audit category for numbers and correct data type 174 | 175 | afterEach(() => { 176 | // Delete the data_store file 177 | fs.unlinkSync(DATA_STORE_FILE); 178 | }); 179 | }); 180 | } 181 | customCountTest(9); 182 | customCountTest(14); 183 | customCountTest(15); 184 | 185 | }); 186 | -------------------------------------------------------------------------------- /__tests__/titleContainer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import React from "react"; 6 | import { render, fireEvent } from "@testing-library/react"; 7 | import "@testing-library/jest-dom"; 8 | import { Provider } from "react-redux"; 9 | import TitleContainer from "../client/containers/TitleContainer.jsx"; 10 | import DropDownMenu from "../client/components/DropDownMenu.jsx"; 11 | import regeneratorRuntime from "regenerator-runtime"; 12 | 13 | //Test store 14 | import store from '../__test_utils__/store_test' 15 | 16 | describe("testing Title Container", () => { 17 | let menu; 18 | let titleContainer; 19 | const endpoints = store.getState().data.endpoints; 20 | 21 | beforeEach(async () => { 22 | process.env.NODE_ENV = "development"; 23 | titleContainer = await render( 24 | 25 | 26 | 27 | ); 28 | menu = await render( 29 | 30 | 31 | 32 | ); 33 | }); 34 | 35 | test("Title Container contains title", () => { 36 | expect(titleContainer.getByText("Vantage")).toBeTruthy(); 37 | }); 38 | 39 | test("DropDownMenu lists Endpoints from store", () => { 40 | let menuIcon = titleContainer.container.querySelector("#dropDownMenu"); 41 | fireEvent.click(menuIcon); 42 | endpoints.forEach((el) => { 43 | expect(menu.getAllByText(el)).toBeTruthy(); 44 | }); 45 | }); 46 | 47 | test("Clicking endpoint changes view slice", () => { 48 | let menuIcon = titleContainer.container.querySelector("#dropDownMenu"); 49 | const endpointText = endpoints[0]; 50 | expect(titleContainer.queryAllByText(endpointText)).toHaveLength(0); 51 | fireEvent.click(menuIcon); 52 | const menuItem = menu.getByText(endpointText); 53 | fireEvent.click(menuItem); 54 | expect(titleContainer.getAllByText(endpointText)).toBeTruthy(); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /client/App.jsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from "react"; 2 | import MainContainer from "./containers/MainContainer"; 3 | import CssBaseline from "@mui/material/CssBaseline"; 4 | import { createTheme, ThemeProvider } from "@mui/material/styles"; 5 | import "./styles.scss"; 6 | import Waves from "./assets/background.svg"; 7 | 8 | const App = () => { 9 | const theme = useMemo(() => 10 | createTheme({ 11 | palette: { 12 | primary: { 13 | main: "#881dff", 14 | light: "#d9b6ff", 15 | }, 16 | secondary: { 17 | main: "#46b7ff", 18 | light: "#55fffe", 19 | }, 20 | background: { 21 | default: "#131219", 22 | paper: "#222233", 23 | }, 24 | text: { 25 | primary: "#efecfd", 26 | }, 27 | success: { 28 | main: "#47ff82", 29 | }, 30 | warning: { 31 | main: "#e9f835", 32 | }, 33 | error: { 34 | main: "#ff4b4b", 35 | }, 36 | }, 37 | }) 38 | ); 39 | 40 | return ( 41 | 42 | 43 | 44 | 45 | 46 | ); 47 | }; 48 | 49 | export default App; 50 | -------------------------------------------------------------------------------- /client/assets/background.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/assets/vantage-logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/components/CustomMUISwitch.jsx: -------------------------------------------------------------------------------- 1 | import { styled } from "@mui/material/styles"; 2 | import { Switch } from "@mui/material"; 3 | 4 | const CustomMUISwitch = styled(Switch)(({ theme }) => ({ 5 | width: 28, 6 | height: 16, 7 | padding: 0, 8 | display: "flex", 9 | "&:active": { 10 | "& .MuiSwitch-thumb": { 11 | width: 15, 12 | }, 13 | "& .MuiSwitch-switchBase.Mui-checked": { 14 | transform: "translateX(9px)", 15 | }, 16 | }, 17 | "& .MuiSwitch-switchBase": { 18 | padding: 2, 19 | "&.Mui-checked": { 20 | transform: "translateX(12px)", 21 | color: "#fff", 22 | "& + .MuiSwitch-track": { 23 | opacity: 1, 24 | // backgroundColor: theme.palette.mode === "dark" ? "#177ddc" : "#1890ff", 25 | backgroundColor: theme.palette.primary.main, 26 | }, 27 | }, 28 | }, 29 | "& .MuiSwitch-thumb": { 30 | boxShadow: "0 2px 4px 0 rgb(0 35 11 / 20%)", 31 | width: 12, 32 | height: 12, 33 | borderRadius: 6, 34 | transition: theme.transitions.create(["width"], { 35 | duration: 200, 36 | }), 37 | }, 38 | "& .MuiSwitch-track": { 39 | borderRadius: 16 / 2, 40 | opacity: 1, 41 | backgroundColor: 42 | theme.palette.mode === "dark" 43 | ? "rgba(255,255,255,.35)" 44 | : "rgba(0,0,0,.25)", 45 | boxSizing: "border-box", 46 | }, 47 | })); 48 | 49 | export default CustomMUISwitch; 50 | -------------------------------------------------------------------------------- /client/components/CustomMUITooltip.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { styled } from '@mui/material/styles'; 3 | import Tooltip, { tooltipClasses } from '@mui/material/Tooltip'; 4 | 5 | 6 | const CustomMUITooltip = styled(({ className, ...props }) => ( 7 | 8 | ))(({ theme }) => ({ 9 | [`& .${tooltipClasses.tooltip}`]: { 10 | backgroundColor: theme.palette.background.paper, 11 | color: theme.palette.text.primary, 12 | boxShadow: theme.shadows[1], 13 | fontSize: 11, 14 | }, 15 | [`& .${tooltipClasses.arrow}`]: { 16 | color: theme.palette.background.paper, 17 | }, 18 | })); 19 | 20 | export default CustomMUITooltip; -------------------------------------------------------------------------------- /client/components/CustomTooltip.jsx: -------------------------------------------------------------------------------- 1 | import { Card } from "@mui/material"; 2 | import React from "react"; 3 | 4 | const CustomTooltip = ({ active, payload, commits, unit }) => { 5 | if (active && payload && payload.length) { 6 | const activeTime = payload[0].payload.name; 7 | const dateFormatted = new Date(activeTime).toLocaleString(); 8 | 9 | const payloadComponents = payload.map((cur, i) => { 10 | return ( 11 |

{`${cur.name} : ${Math.round(cur.value)} ${unit}`}

15 | ); 16 | }); 17 | 18 | return ( 19 | 20 |

{dateFormatted}

21 |

{commits[activeTime]}

22 | {payloadComponents} 23 |
24 | ); 25 | } 26 | 27 | return null; 28 | }; 29 | 30 | CustomTooltip.defaultProps = { 31 | unit: "" 32 | }; 33 | 34 | export default CustomTooltip; 35 | -------------------------------------------------------------------------------- /client/components/DropDownMenu.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Menu, MenuItem, IconButton, Box } from "@mui/material"; 3 | import ArticleIcon from "@mui/icons-material/Article"; 4 | import { useDispatch, useSelector } from "react-redux"; 5 | import { changeEndpoint } from "../store/currentViewSlice"; 6 | import { selectEndpoints } from "../store/dataSlice"; 7 | 8 | export default function DropDownMenu() { 9 | const endpoints = useSelector(selectEndpoints); 10 | const [anchorEl, setAnchorEl] = React.useState(null); 11 | const dispatch = useDispatch(); 12 | 13 | const open = !!anchorEl; 14 | 15 | const handleClick = (e) => { 16 | setAnchorEl(e.currentTarget); 17 | }; 18 | const handleClose = () => { 19 | setAnchorEl(null); 20 | }; 21 | const selectEndpoint = (endpointName) => { 22 | dispatch(changeEndpoint(endpointName)); 23 | setAnchorEl(null); 24 | }; 25 | const endpointList = endpoints.map((el, i) => ( 26 | selectEndpoint(el)}> 27 | {el} 28 | 29 | )); 30 | 31 | return ( 32 | 33 | 44 | 45 | 46 | 55 | {endpointList} 56 | 57 | 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /client/components/Metric.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { CircularProgress, Typography, Box } from "@mui/material"; 3 | import CustomMUITooltip from "./CustomMUITooltip"; 4 | 5 | const Metric = ({ name, value, handleClick, size, isActive, description }) => { 6 | const color = value > 90 ? "success" : value > 70 ? "warning" : "error"; 7 | 8 | const activeClass = isActive ? "active-metric" : ""; 9 | 10 | return ( 11 | 17 | handleClick(name)} 20 | sx={{ 21 | position: "relative", 22 | display: "inline-flex", 23 | flexDirection: "column", 24 | alignItems: "center", 25 | justifyContent: "center", 26 | cursor: "pointer", 27 | }} 28 | > 29 | 43 | 50 | 65 | {`${Math.round(value)}`} 66 | 67 | 68 | 75 | 76 | {name} 77 | 78 | 79 | 80 | 81 | ); 82 | }; 83 | 84 | Metric.defaultProps = { 85 | size: 70, 86 | isActive: false, 87 | description: "", 88 | }; 89 | 90 | export default Metric; 91 | -------------------------------------------------------------------------------- /client/components/OverallMetricChart.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | LineChart, 4 | Line, 5 | XAxis, 6 | YAxis, 7 | CartesianGrid, 8 | Tooltip, 9 | Legend, 10 | Label, 11 | ReferenceArea, 12 | ReferenceLine, 13 | } from "recharts"; 14 | import { 15 | selectOverallScoreByEndpoint, 16 | selectCommits, 17 | selectRunList, 18 | } from "../store/dataSlice.js"; 19 | import { 20 | getCurrentEndpoint, 21 | getCurrentMetric, 22 | addRunValue, 23 | } from "../store/currentViewSlice.js"; 24 | import { useSelector, useDispatch } from "react-redux"; 25 | import CustomTooltip from "./CustomTooltip.jsx"; 26 | import { useTheme } from "@mui/material/styles"; 27 | 28 | const OverallMetricChart = () => { 29 | const theme = useTheme(); 30 | 31 | const [runA, runB] = useSelector( 32 | (state) => state.currentView.runValueArrSort 33 | ); 34 | const dispatch = useDispatch(); 35 | const currentEndpoint = useSelector(getCurrentEndpoint); 36 | const commits = useSelector(selectCommits); 37 | const overallScore = useSelector((state) => 38 | selectOverallScoreByEndpoint(state, currentEndpoint) 39 | ); 40 | const runList = useSelector(selectRunList); 41 | const currentMetric = useSelector(getCurrentMetric); 42 | 43 | // Data for chart 44 | const data = runList.map((cur) => { 45 | const curData = overallScore[cur]; 46 | return { 47 | name: cur, 48 | SEO: curData.seo * 100, 49 | "Best Practices": curData["best-practices"] * 100, 50 | Performance: curData.performance * 100, 51 | Accessibility: curData.accessibility * 100, 52 | }; 53 | }); 54 | 55 | const sw = 2; //stroke width 56 | 57 | const handleClick = (data) => { 58 | if (data) { 59 | dispatch(addRunValue(data.activePayload[0].payload.name)); 60 | } 61 | }; 62 | 63 | const lineComponents = [ 64 | { name: "Performance", color: theme.palette.primary.main }, 65 | { name: "SEO", color: theme.palette.primary.light }, 66 | { name: "Best Practices", color: theme.palette.secondary.main }, 67 | { name: "Accessibility", color: theme.palette.secondary.light }, 68 | ].map(({ name, color }) => ( 69 | 70 | {(currentMetric === "default" || currentMetric === name) && ( 71 | 78 | )} 79 | 80 | )); 81 | 82 | return ( 83 | 96 | 97 | 98 | 100 | 101 | } /> 102 | 103 | {lineComponents} 104 | {runB && ( 105 | 111 | )} 112 | 117 | 122 | 123 | ); 124 | }; 125 | 126 | export default OverallMetricChart; 127 | -------------------------------------------------------------------------------- /client/components/PerformanceMetricChart.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | LineChart, 4 | Line, 5 | XAxis, 6 | YAxis, 7 | CartesianGrid, 8 | Tooltip, 9 | Legend, 10 | Label, 11 | ReferenceArea, 12 | ReferenceLine, 13 | } from "recharts"; 14 | import CustomTooltip from "./CustomTooltip"; 15 | import { 16 | selectCommits, 17 | selectRunList, 18 | selectWebVitalData, 19 | } from "../store/dataSlice.js"; 20 | import { getCurrentEndpoint, addRunValue } from "../store/currentViewSlice.js"; 21 | import { useSelector, useDispatch } from "react-redux"; 22 | import { useTheme } from "@mui/material/styles"; 23 | 24 | const PerformanceMetricChart = () => { 25 | const theme = useTheme(); 26 | const [runA, runB] = useSelector( 27 | (state) => state.currentView.runValueArrSort 28 | ); 29 | const dispatch = useDispatch(); 30 | const currentEndpoint = useSelector(getCurrentEndpoint); 31 | const commits = useSelector(selectCommits); 32 | const runList = useSelector(selectRunList); 33 | const performanceMetricsArr = useSelector( 34 | (state) => state.currentView.performanceMetricsArr 35 | ); 36 | 37 | const fcpData = useSelector((state) => 38 | selectWebVitalData(state, "first-contentful-paint", currentEndpoint) 39 | ); 40 | const ttiData = useSelector((state) => 41 | selectWebVitalData(state, "interactive", currentEndpoint) 42 | ); 43 | const siData = useSelector((state) => 44 | selectWebVitalData(state, "speed-index", currentEndpoint) 45 | ); 46 | const tbtData = useSelector((state) => 47 | selectWebVitalData(state, "total-blocking-time", currentEndpoint) 48 | ); 49 | const lcpData = useSelector((state) => 50 | selectWebVitalData(state, "largest-contentful-paint", currentEndpoint) 51 | ); 52 | const clsData = useSelector((state) => 53 | selectWebVitalData(state, "cumulative-layout-shift", currentEndpoint) 54 | ); 55 | 56 | const valueType = performanceMetricsArr.length > 1 ? "score" : "numericValue"; 57 | 58 | // Score/metric for each metric 59 | const data = runList.map((cur, i) => { 60 | const multiple = valueType === "score" ? 100 : 1; 61 | return { 62 | name: cur, 63 | FCP: Math.round(fcpData[cur][valueType] * multiple), 64 | TTI: Math.round(ttiData[cur][valueType] * multiple), 65 | SI: Math.round(siData[cur][valueType] * multiple), 66 | TBT: Math.round(tbtData[cur][valueType] * multiple), 67 | LCP: Math.round(lcpData[cur][valueType] * multiple), 68 | CLS: Math.round(clsData[cur][valueType] * multiple), 69 | }; 70 | }); 71 | 72 | const webVitalUnits = { 73 | FCP: "ms", 74 | TTI: "ms", 75 | SI: "ms", 76 | TBT: "ms", 77 | LCP: "ms", 78 | CLS: "", 79 | }; 80 | 81 | //Only add unit if 1 web vital is selected 82 | const unit = 83 | performanceMetricsArr.length > 1 84 | ? "" 85 | : webVitalUnits[performanceMetricsArr[0]]; 86 | 87 | const handleClick = (data) => { 88 | if (data) { 89 | dispatch(addRunValue(data.activePayload[0].payload.name)); 90 | } 91 | }; 92 | 93 | const lineColorObj = { 94 | FCP: theme.palette.primary.light, 95 | SI: theme.palette.primary.main, 96 | LCP: theme.palette.primary.dark, 97 | TTI: theme.palette.secondary.light, 98 | TBT: theme.palette.secondary.main, 99 | CLS: theme.palette.secondary.dark, 100 | }; 101 | 102 | const lineComponents = performanceMetricsArr.map((curr, i) => ( 103 | 111 | )); 112 | 113 | return ( 114 | 127 | 128 | 129 | 131 | {performanceMetricsArr.length === 1 ? ( 132 | 133 | 140 | ) : ( 141 | 142 | )} 143 | 146 | } 147 | /> 148 | 149 | {lineComponents} 150 | {runB && ( 151 | 157 | )} 158 | 163 | 168 | 169 | ); 170 | }; 171 | 172 | export default PerformanceMetricChart; 173 | -------------------------------------------------------------------------------- /client/containers/ChartContainer.jsx: -------------------------------------------------------------------------------- 1 | import { Paper, Stack } from "@mui/material"; 2 | import React from "react"; 3 | import { useDispatch, useSelector } from "react-redux"; 4 | import PerformanceMetricChart from "../components/PerformanceMetricChart"; 5 | import { 6 | getCurrentMetric, 7 | changeSelectorSwitch, 8 | } from "../store/currentViewSlice"; 9 | import CompareArrowsRoundedIcon from "@mui/icons-material/CompareArrowsRounded"; 10 | import CommitRoundedIcon from "@mui/icons-material/CommitRounded"; 11 | 12 | import PerformanceMetrics from "./PerformanceMetrics"; 13 | import OverallMetricChart from "../components/OverallMetricChart"; 14 | import CustomMUITooltip from "../components/CustomMUITooltip"; 15 | import CustomMUISwitch from "../components/CustomMUISwitch"; 16 | 17 | const ChartContainer = () => { 18 | const dispatch = useDispatch(); 19 | const currentMetric = useSelector(getCurrentMetric); 20 | const selectorSwitch = useSelector( 21 | (state) => state.currentView.selectorSwitch 22 | ); 23 | 24 | const performanceMetricsArr = useSelector( 25 | (state) => state.currentView.performanceMetricsArr 26 | ); 27 | const isPerfMetricSelected = performanceMetricsArr.every((v) => v === false); 28 | 29 | return ( 30 | 31 | {currentMetric === "Performance" && } 32 | {(isPerfMetricSelected || currentMetric !== "Performance") && ( 33 | 34 | )} 35 | {!isPerfMetricSelected && currentMetric === "Performance" && ( 36 | 37 | )} 38 | {currentMetric !== "default" && ( 39 | 45 | 46 | 50 | dispatch(changeSelectorSwitch())} 53 | checked={selectorSwitch} 54 | /> 55 | 56 | 57 | 58 | )} 59 | 60 | ); 61 | }; 62 | 63 | export default ChartContainer; 64 | -------------------------------------------------------------------------------- /client/containers/DescriptionContainer.jsx: -------------------------------------------------------------------------------- 1 | import { Card, Typography, IconButton } from "@mui/material"; 2 | import { Box } from "@mui/system"; 3 | import React from "react"; 4 | import { useSelector } from "react-redux"; 5 | import { 6 | getCurrentEndpoint, 7 | getCurrentMetric, 8 | } from "../store/currentViewSlice"; 9 | import { useTheme } from "@mui/material/styles"; 10 | import ArrowCircleRightRoundedIcon from "@mui/icons-material/ArrowCircleRightRounded"; 11 | import CustomMUITooltip from "../components/CustomMUITooltip"; 12 | 13 | const DescriptionContainer = () => { 14 | const theme = useTheme(); 15 | const currentMetric = useSelector(getCurrentMetric); 16 | const currentEndpoint = useSelector(getCurrentEndpoint); 17 | const metricMap = { 18 | SEO: "seo", 19 | "Best Practices": "best-practices", 20 | Performance: "performance", 21 | Accessibility: "accessibility", 22 | }; 23 | const data = useSelector((state) => state.data[metricMap[currentMetric]]); 24 | const runValueArrSort = useSelector( 25 | (state) => state.currentView.runValueArrSort 26 | ); 27 | 28 | const dataArray = []; 29 | if (data && runValueArrSort.length) { 30 | for (const key in data) { 31 | const title = data[key].title; 32 | const description = data[key].description; 33 | const url = data[key].url; 34 | const numericUnit = data[key]?.numericUnit; 35 | 36 | //Get numeric difference 37 | const earlyRun = runValueArrSort[0]; 38 | 39 | let lateRun, scoreColor, numericDiff, scoreDiff; 40 | if (runValueArrSort[1]) { 41 | lateRun = runValueArrSort[1]; 42 | 43 | numericDiff = numericUnit 44 | ? Math.round( 45 | data[key].results[currentEndpoint][lateRun].numericValue - 46 | data[key].results[currentEndpoint][earlyRun].numericValue 47 | ) 48 | : ""; 49 | 50 | scoreDiff = Math.round( 51 | (data[key].results[currentEndpoint][lateRun].score - 52 | data[key].results[currentEndpoint][earlyRun].score) * 53 | 100 54 | ); 55 | 56 | scoreColor = 57 | scoreDiff > 0 58 | ? theme.palette.success.dark 59 | : scoreDiff < 0 60 | ? theme.palette.error.main 61 | : null; 62 | 63 | //Only viewing one run 64 | } else { 65 | numericDiff = numericUnit 66 | ? Math.round( 67 | data[key].results[currentEndpoint][earlyRun].numericValue 68 | ) 69 | : ""; 70 | scoreDiff = Math.round( 71 | data[key].results[currentEndpoint][earlyRun].score * 100 72 | ); 73 | 74 | scoreColor = 75 | scoreDiff > 90 76 | ? theme.palette.success.dark 77 | : scoreDiff > 70 78 | ? theme.palette.warning.dark 79 | : theme.palette.error.dark; 80 | } 81 | 82 | const unitMap = { 83 | millisecond: "ms", 84 | byte: "B", 85 | element: "elements", 86 | }; 87 | 88 | let newNumericUnit = unitMap?.[numericUnit]; 89 | 90 | if (numericDiff > 1000) { 91 | numericDiff = Math.round(numericDiff / 1000); 92 | if (newNumericUnit === "ms") newNumericUnit = "s"; 93 | if (newNumericUnit === "B") newNumericUnit = "KiB"; 94 | if (newNumericUnit === "elements") newNumericUnit = "Kelements"; 95 | } 96 | 97 | if ( 98 | data[key].results[currentEndpoint][earlyRun].scoreDisplay !== 99 | "notApplicable" && data[key].results[currentEndpoint][earlyRun].score !== undefined 100 | ) { 101 | dataArray.push({ 102 | description, 103 | title, 104 | scoreColor, 105 | numericDiff, 106 | scoreDiff, 107 | url, 108 | newNumericUnit, 109 | }); 110 | } 111 | } 112 | } 113 | 114 | // console.log(dataArray); 115 | dataArray.sort((a, b) => { 116 | if (a.scoreDiff < b.scoreDiff) return -1; 117 | if (a.scoreDiff > b.scoreDiff) return 1; 118 | return 0; 119 | }); 120 | 121 | const dataComponents = dataArray.map( 122 | ({ 123 | description, 124 | title, 125 | scoreColor, 126 | numericDiff, 127 | scoreDiff, 128 | url, 129 | newNumericUnit, 130 | }) => { 131 | return ( 132 | 138 | 139 | {title} 140 | 141 | {newNumericUnit ? ( 142 | <> 143 | {numericDiff} {newNumericUnit} 144 | 145 | ) : ( 146 | <>{scoreDiff} 147 | )} 148 | 149 | window.open(url)} 151 | disabled={url === null} 152 | > 153 | 154 | 155 | 156 | 157 | ); 158 | } 159 | ); 160 | 161 | return {dataComponents}; 162 | }; 163 | 164 | export default DescriptionContainer; 165 | -------------------------------------------------------------------------------- /client/containers/Footer.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useTheme } from '@mui/material/styles'; 3 | import { AppBar, Link, Typography } from '@mui/material'; 4 | 5 | 6 | 7 | const Footer = () => { 8 | const theme = useTheme(); 9 | return ( 10 | 11 | 12 | 13 | Powered by Lighthouse 14 | 15 | 16 | 17 | ); 18 | }; 19 | 20 | export default Footer; -------------------------------------------------------------------------------- /client/containers/MainContainer.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import TitleContainer from "./TitleContainer"; 3 | import MetricContainer from "./MetricContainer"; 4 | import ChartContainer from "./ChartContainer"; 5 | import Box from "@mui/material/Box"; 6 | import DescriptionContainer from "./DescriptionContainer"; 7 | import Footer from "./Footer"; 8 | 9 | const MainContainer = () => { 10 | return ( 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 |
22 | ); 23 | }; 24 | 25 | export default MainContainer; 26 | -------------------------------------------------------------------------------- /client/containers/MetricContainer.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Box, Paper } from "@mui/material"; 3 | import Metric from "../components/Metric"; 4 | import { useDispatch, useSelector } from "react-redux"; 5 | import { 6 | getCurrentEndpoint, 7 | changeMetric, 8 | getCurrentMetric, 9 | } from "../store/currentViewSlice"; 10 | import { 11 | selectOverallScoreByEndpoint, 12 | selectRunList, 13 | } from "../store/dataSlice.js"; 14 | 15 | const MetricContainer = () => { 16 | const dispatch = useDispatch(); 17 | const currentMetric = useSelector(getCurrentMetric); 18 | const currentEndpoint = useSelector(getCurrentEndpoint); 19 | const overallScore = useSelector((state) => 20 | selectOverallScoreByEndpoint(state, currentEndpoint) 21 | ); 22 | const runList = useSelector(selectRunList); 23 | const mostRecentRun = runList[runList.length - 1]; 24 | const mostRecentOverallScore = overallScore[mostRecentRun]; 25 | 26 | const handleClick = (metric) => { 27 | dispatch(changeMetric(metric)); 28 | }; 29 | 30 | return ( 31 |
32 | 33 |
34 | 35 | 41 | 47 | 53 | 59 | 60 |
61 |
62 |
63 | ); 64 | }; 65 | 66 | export default MetricContainer; 67 | -------------------------------------------------------------------------------- /client/containers/PerformanceMetrics.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Metric from "../components/Metric"; 3 | import { useDispatch, useSelector } from "react-redux"; 4 | import { 5 | getCurrentEndpoint, 6 | changePerformanceMetrics, 7 | } from "../store/currentViewSlice"; 8 | import { selectMostRecentWebVital } from "../store/dataSlice.js"; 9 | import { Box } from "@mui/material"; 10 | 11 | const PerformanceMetrics = () => { 12 | const dispatch = useDispatch(); 13 | const performanceMetricsArr = useSelector( 14 | (state) => state.currentView.performanceMetricsArr 15 | ); 16 | 17 | const currentEndpoint = useSelector(getCurrentEndpoint); 18 | const fcp = useSelector((state) => 19 | selectMostRecentWebVital(state, "first-contentful-paint", currentEndpoint) 20 | ); 21 | const tti = useSelector((state) => 22 | selectMostRecentWebVital(state, "interactive", currentEndpoint) 23 | ); 24 | const si = useSelector((state) => 25 | selectMostRecentWebVital(state, "speed-index", currentEndpoint) 26 | ); 27 | const tbt = useSelector((state) => 28 | selectMostRecentWebVital(state, "total-blocking-time", currentEndpoint) 29 | ); 30 | const lcp = useSelector((state) => 31 | selectMostRecentWebVital(state, "largest-contentful-paint", currentEndpoint) 32 | ); 33 | const cls = useSelector((state) => 34 | selectMostRecentWebVital(state, "cumulative-layout-shift", currentEndpoint) 35 | ); 36 | 37 | const handleClick = (metric) => { 38 | dispatch(changePerformanceMetrics(metric)); 39 | }; 40 | 41 | const data = [ 42 | { name: "FCP", value: fcp.score * 100, description: fcp.title }, 43 | { name: "TTI", value: tti.score * 100, description: tti.title }, 44 | { name: "SI", value: si.score * 100, description: si.title }, 45 | { name: "TBT", value: tbt.score * 100, description: tbt.title }, 46 | { name: "LCP", value: lcp.score * 100, description: lcp.title }, 47 | { name: "CLS", value: cls.score * 100, description: cls.title }, 48 | ]; 49 | 50 | const metricsArr = data.map((cur) => ( 51 | 60 | )); 61 | 62 | return {metricsArr}; 63 | }; 64 | 65 | export default PerformanceMetrics; 66 | -------------------------------------------------------------------------------- /client/containers/TitleContainer.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import DropDownMenu from "../components/DropDownMenu"; 3 | import { AppBar, Box, Toolbar, Typography } from "@mui/material/"; 4 | import { useDispatch, useSelector } from "react-redux"; 5 | import { 6 | getCurrentEndpoint, 7 | changeMetric, 8 | resetRunValue, 9 | } from "../store/currentViewSlice"; 10 | import { selectRunList } from "../store/dataSlice"; 11 | import Logo from "../assets/vantage-logo.svg"; 12 | 13 | const TitleContainer = () => { 14 | const dispatch = useDispatch(); 15 | const currentEndpoint = useSelector(getCurrentEndpoint); 16 | const runList = useSelector(selectRunList); 17 | 18 | const handleClick = () => { 19 | dispatch(changeMetric("default")); 20 | dispatch(resetRunValue(runList[runList.length - 1])); 21 | }; 22 | 23 | // Set the selected run to the latest initially 24 | useEffect(() => { 25 | dispatch(resetRunValue(runList[runList.length - 1])); 26 | }, []); 27 | 28 | return ( 29 |
30 | 31 | 32 | 33 | 41 | 42 | 49 | Vantage 50 | 51 | 52 | 56 | 62 | Current Endpoint: 63 | 64 | 65 | 66 | {currentEndpoint} 67 | 68 | 69 | 70 | 71 | 72 | 73 |
74 | ); 75 | }; 76 | 77 | export default TitleContainer; 78 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vantage 8 | 11 | 12 | 13 |
14 | 15 | -------------------------------------------------------------------------------- /client/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render } from "react-dom"; 3 | import App from "./App.jsx"; 4 | import { Provider } from "react-redux"; 5 | import store from "./store/store"; 6 | 7 | render( 8 | 9 | 10 | , 11 | document.getElementById("root") 12 | ); 13 | -------------------------------------------------------------------------------- /client/store/currentViewSlice.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from "@reduxjs/toolkit"; 2 | 3 | //manages what the user is viewing 4 | export const currentViewSlice = createSlice({ 5 | name: "currentView", 6 | initialState: { 7 | currentMetric: "default", 8 | currentEndpoint: "/", 9 | //initialize performance metrics as not selected 10 | performanceMetricsArr: [], 11 | //initialize selected commit points as empty 12 | runValueArr: [], 13 | runValueArrSort: [], 14 | selectorSwitch: false, 15 | }, 16 | reducers: { 17 | //changes which metric the user is viewing 18 | changeMetric: (state, action) => { 19 | const regex = /Performance|Accessibility|Best Practices|SEO|default/; 20 | if (regex.test(action.payload)) state.currentMetric = action.payload; 21 | else throw "changeMetric payload incorrect"; 22 | }, 23 | //changes which endpoint the user is viewing 24 | changeEndpoint: (state, action) => { 25 | state.currentEndpoint = action.payload; 26 | }, 27 | //changes which metric the user is viewing 28 | changePerformanceMetrics: (state, action) => { 29 | const regex = /FCP|TTI|SI|TBT|LCP|CLS/; 30 | if (regex.test(action.payload)) { 31 | const index = state.performanceMetricsArr.indexOf(action.payload); 32 | index === -1 33 | ? state.performanceMetricsArr.push(action.payload) 34 | : state.performanceMetricsArr.splice(index, 1); 35 | } 36 | }, 37 | //adds a selected commit to the run value array 38 | addRunValue: (state, action) => { 39 | if (state.currentMetric !== "default") { 40 | //check if range mode is selected 41 | if (state.selectorSwitch && state.runValueArr[1] !== action.payload) { 42 | const run = action.payload; 43 | if (state.runValueArr.length >= 2) state.runValueArr.shift(); 44 | if (run !== state.runValueArr[0]) state.runValueArr.push(run); 45 | //sort the selected commits to keep track of which was first selected 46 | state.runValueArrSort = state.runValueArr.slice().sort(); 47 | } else if (!state.selectorSwitch) { 48 | state.runValueArr = state.runValueArrSort = [action.payload]; 49 | } 50 | } 51 | }, 52 | //clears run value array 53 | resetRunValue: (state, action) => { 54 | state.runValueArr = state.runValueArrSort = [action.payload]; 55 | }, 56 | //toggle between single and range view 57 | changeSelectorSwitch: (state) => { 58 | state.selectorSwitch = !state.selectorSwitch; 59 | if (state.runValueArr.length >= 2 && !state.selectorSwitch) { 60 | state.runValueArr.shift(); 61 | state.runValueArrSort = state.runValueArr; 62 | } 63 | }, 64 | }, 65 | }); 66 | 67 | export const { 68 | changeTheme, 69 | changeMetric, 70 | changeEndpoint, 71 | changePerformanceMetrics, 72 | addRunValue, 73 | resetRunValue, 74 | changeSelectorSwitch, 75 | } = currentViewSlice.actions; 76 | 77 | export const getCurrentMetric = (state) => state.currentView.currentMetric; 78 | export const getCurrentEndpoint = (state) => state.currentView.currentEndpoint; 79 | 80 | export default currentViewSlice.reducer; 81 | -------------------------------------------------------------------------------- /client/store/dataSlice.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from "@reduxjs/toolkit"; 2 | 3 | //manages the data from the report 4 | //the front end does not change the data so no reducers are necessary 5 | export const dataSlice = createSlice({ 6 | name: "data", 7 | initialState: { ...window.__VANTAGE_JSON__ }, 8 | }); 9 | 10 | export const selectWebVitals = (state) => state.data["web-vitals"]; 11 | export const selectRunList = (state) => state.data["run-list"]; 12 | export const selectEndpoints = (state) => state.data.endpoints; 13 | export const selectCommits = (state) => state.data.commits; 14 | 15 | export const selectOverallScoreByEndpoint = (state, endpoint) => 16 | state.data["overall-scores"][endpoint]; 17 | 18 | //displays data from the most recent commit 19 | export const selectMostRecentWebVital = (state, webVital, endpoint) => { 20 | const runList = state.data["run-list"]; 21 | const score = 22 | state.data["web-vitals"][webVital].results[endpoint][ 23 | runList[runList.length - 1] 24 | ].score; 25 | const title = state.data["web-vitals"][webVital].title; 26 | const description = state.data["web-vitals"][webVital].description; 27 | return { score, title, description }; 28 | }; 29 | //displays data a different commit 30 | export const selectWebVitalData = (state, webVital, endpoint) => 31 | state.data["web-vitals"][webVital].results[endpoint]; 32 | 33 | export default dataSlice.reducer; 34 | -------------------------------------------------------------------------------- /client/store/store.js: -------------------------------------------------------------------------------- 1 | import { configureStore } from "@reduxjs/toolkit"; 2 | import dataReducer from "./dataSlice"; 3 | import currentViewReducer from "./currentViewSlice"; 4 | 5 | export default configureStore({ 6 | reducer: { 7 | data: dataReducer, 8 | currentView: currentViewReducer, 9 | }, 10 | devTools: process.env.NODE_ENV === "development", 11 | }); 12 | -------------------------------------------------------------------------------- /client/styles.scss: -------------------------------------------------------------------------------- 1 | body { 2 | margin-bottom: 0; 3 | background-attachment: fixed; 4 | background-repeat: no-repeat; 5 | background-size: 100% auto; 6 | overflow: hidden; 7 | } 8 | #mainBox { 9 | display: flex; 10 | flex-direction: column; 11 | align-items: center; 12 | margin-top: 15px; 13 | position: relative; 14 | } 15 | 16 | #waves { 17 | position: absolute; 18 | top: 0; 19 | z-index: -1; 20 | } 21 | 22 | #contentContainer { 23 | display: flex; 24 | flex-direction: column; 25 | align-items: center; 26 | } 27 | 28 | #performance-metrics { 29 | display: grid; 30 | grid-template-columns: repeat(2, 1fr); 31 | column-gap: 1rem; 32 | animation: fadeIn 0.5s; 33 | align-content: center; 34 | row-gap: 10px; 35 | } 36 | 37 | .active-metric { 38 | border-radius: 50%; 39 | box-shadow: 0 0 10px 2px#55fffc6b; 40 | } 41 | 42 | #chart-container { 43 | position: relative; 44 | justify-self: center; 45 | margin: 1rem; 46 | display: flex; 47 | justify-content: center; 48 | width: 100%; 49 | padding-bottom: 10px; 50 | } 51 | 52 | .all-charts { 53 | margin-top: 30px; 54 | } 55 | 56 | #metric-container { 57 | display: flex; 58 | flex-direction: column; 59 | align-items: center; 60 | } 61 | 62 | #description-container { 63 | display: flex; 64 | flex-direction: column; 65 | gap: 0.3rem; 66 | margin-bottom: 10px; 67 | padding-left: 5px; 68 | padding-right: 5px; 69 | height: calc(100vh - 605px); 70 | width: 73ch; 71 | overflow: auto; 72 | } 73 | 74 | .suggestion { 75 | flex: 1; 76 | display: flex; 77 | flex-direction: row; 78 | justify-content: space-between; 79 | align-items: center; 80 | gap: 2rem; 81 | padding: 0.5em; 82 | min-height: 4em; 83 | p { 84 | font-weight: 800; 85 | font-size: 1.5rem; 86 | span { 87 | font-size: 1rem; 88 | } 89 | } 90 | p:first-child { 91 | width: 400px; 92 | font-size: 0.9rem; 93 | } 94 | } 95 | 96 | #suggestion-title { 97 | width: 400px; 98 | } 99 | 100 | .metric-container-inner { 101 | padding: 10px; 102 | min-width: 530px; 103 | } 104 | 105 | .metric { 106 | transition: all 0.2s ease-in-out; 107 | } 108 | 109 | .metric:hover { 110 | transform: scale(1.05); 111 | } 112 | 113 | #footer { 114 | position: fixed; 115 | top: auto; 116 | bottom: 0; 117 | height: 30px; 118 | padding-left: 20px; 119 | } 120 | #metricCircle { 121 | border-radius: 50%; 122 | } 123 | 124 | /* Scrollbar styles */ 125 | ::-webkit-scrollbar { 126 | width: 10px; 127 | } 128 | 129 | ::-webkit-scrollbar-track { 130 | border: 1px solid black; 131 | border-radius: 15px; 132 | } 133 | 134 | ::-webkit-scrollbar-thumb { 135 | background: rgb(91, 91, 91); 136 | border-radius: 15px; 137 | } 138 | 139 | ::-webkit-scrollbar-thumb:hover { 140 | background: rgb(123, 123, 123); 141 | } 142 | 143 | #nav-bar { 144 | background: linear-gradient( 145 | 0deg, 146 | rgba(25, 25, 34, 1) 0%, 147 | rgba(46, 46, 68, 1) 52%, 148 | rgba(66, 66, 97, 1) 100% 149 | ); 150 | justify-content: space-between; 151 | padding-left: 150px; 152 | padding-right: 150px; 153 | } 154 | 155 | @media only screen and (max-width: 1050px) { 156 | #nav-bar { 157 | padding-left: 20px; 158 | padding-right: 20px; 159 | } 160 | } 161 | 162 | #range-switch { 163 | position: absolute; 164 | bottom: 13px; 165 | right: 10px; 166 | animation: fadeIn 0.5s; 167 | } 168 | 169 | @media only screen and (max-height: 840px) { 170 | body { 171 | overflow-y: auto; 172 | } 173 | #description-container { 174 | height: auto; 175 | } 176 | } 177 | 178 | @keyframes fadeIn { 179 | 0% { 180 | opacity: 0; 181 | } 182 | 100% { 183 | opacity: 1; 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // Sync object 2 | /** @type {import('@jest/types').Config.InitialOptions} */ 3 | const config = { 4 | verbose: true, 5 | transform: { 6 | "\\.[jt]sx?$": "babel-jest", 7 | "\\.svg$": "svg-jest", 8 | }, 9 | moduleNameMapper: { 10 | "\\.(css|less|sass|scss)$": "/__mocks__/styleMock.js", 11 | "\\.(gif|ttf|eot|svg)$": "/__mocks__/fileMock.js", 12 | }, 13 | }; 14 | 15 | module.exports = config; 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vantage-next", 3 | "version": "1.0.6", 4 | "description": "Next.js SEO optimization and monitoring tool", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "jest --verbose", 8 | "postinstall": "./package/git-hooks/gitHookInstall.js", 9 | "dev": "cross-env NODE_ENV=development webpack-dev-server --open", 10 | "build": "cross-env NODE_ENV=production webpack", 11 | "prepublishOnly": "cross-env NODE_ENV=production webpack" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/oslabs-beta/Vantage.git" 16 | }, 17 | "keywords": [ 18 | "nextjs", 19 | "seo", 20 | "lighthouse", 21 | "web vitals" 22 | ], 23 | "author": "", 24 | "license": "ISC", 25 | "bugs": { 26 | "url": "https://github.com/oslabs-beta/Vantage/issues" 27 | }, 28 | "homepage": "https://github.com/oslabs-beta/Vantage#readme", 29 | "bin": { 30 | "snapshot": "./package/lighthouse/lighthouse.js", 31 | "vantage": "./package/main/vantage.js" 32 | }, 33 | "dependencies": { 34 | "kill-port": "^1.6.1", 35 | "lighthouse": "^9.4.0", 36 | "node-inject-html": "^0.0.5", 37 | "open-cli": "^7.0.1", 38 | "puppeteer": "^13.5.1" 39 | }, 40 | "devDependencies": { 41 | "@babel/core": "^7.17.5", 42 | "@babel/preset-env": "^7.16.11", 43 | "@babel/preset-react": "^7.16.7", 44 | "@emotion/react": "^11.8.1", 45 | "@emotion/styled": "^11.8.1", 46 | "@mui/icons-material": "^5.5.0", 47 | "@mui/material": "^5.5.0", 48 | "@reduxjs/toolkit": "^1.8.0", 49 | "@svgr/webpack": "^6.2.1", 50 | "@testing-library/jest-dom": "^5.16.2", 51 | "@testing-library/react": "^12.1.4", 52 | "@testing-library/user-event": "^13.5.0", 53 | "babel-loader": "^8.2.3", 54 | "clean-webpack-plugin": "^4.0.0", 55 | "cross-env": "^7.0.3", 56 | "css-loader": "^6.7.0", 57 | "eslint": "^8.10.0", 58 | "eslint-plugin-react": "^7.29.3", 59 | "file-loader": "^6.2.0", 60 | "html-inline-script-webpack-plugin": "^3.0.0", 61 | "html-webpack-plugin": "^5.5.0", 62 | "jest": "^27.5.1", 63 | "jest-environment-jsdom": "^27.5.1", 64 | "react": "^17.0.2", 65 | "react-dom": "^17.0.2", 66 | "react-redux": "^7.2.6", 67 | "recharts": "^2.1.9", 68 | "sass": "^1.49.9", 69 | "sass-loader": "^12.6.0", 70 | "style-loader": "^3.3.1", 71 | "svg-inline-loader": "^0.8.2", 72 | "svg-jest": "^1.0.1", 73 | "typescript": "^4.6.2", 74 | "webpack": "^5.70.0", 75 | "webpack-cli": "^4.9.2", 76 | "webpack-dev-server": "^4.7.4" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /package/git-hooks/gitHookInstall.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fs = require("fs"); 4 | const { resolve } = require("path"); 5 | 6 | const hook = "post-commit"; 7 | //Get files of hook template 8 | //const hookFileContents = fs.readFileSync(`./${hook}`).toString(); 9 | 10 | function installHooks() { 11 | //Make sure there is a .git folder 12 | const gitRoot = resolve(process.env.INIT_CWD + "/.git"); 13 | if (fs.existsSync(gitRoot)) { 14 | const hooksDir = resolve(gitRoot, "hooks"); 15 | ensureDirExists(hooksDir); //Add hooks folder if it doesn't exist 16 | const hookFile = resolve(hooksDir, hook); 17 | if (fs.existsSync(hookFile)) { 18 | if (hookFile.toString().match(/npx snapshot/)) { 19 | console.warn("Correct git hook already in place"); 20 | } else { 21 | console.warn( 22 | "Post-commit git hook already exists.\nPlease add `npx snapshot` to your existing post-commit hook to enable Vantage." 23 | ); 24 | } 25 | return; 26 | } 27 | fs.writeFileSync( 28 | hookFile, 29 | `#!/bin/sh 30 | npx snapshot >&- 2>&- &` 31 | ); //create hook file 32 | fs.chmodSync(hookFile, "755"); //make hook file executable 33 | console.log("Vantage git integration was successful!"); 34 | } else { 35 | console.warn("This does not seem to be a git project."); 36 | } 37 | } 38 | 39 | function ensureDirExists(dir) { 40 | fs.existsSync(dir) || fs.mkdirSync(dir); 41 | } 42 | 43 | function addToGitignore() { 44 | const gitignoreFilePath = resolve(process.env.INIT_CWD + "/.gitignore"); 45 | fs.readFile(gitignoreFilePath, (err, data) => { 46 | if (err) throw err; 47 | if (!/vantage\//.test(data.toString())) { 48 | fs.appendFile(gitignoreFilePath, "\nvantage/", function (err) { 49 | if (err) throw err; 50 | console.log("Added to gitignore"); 51 | }); 52 | } 53 | }); 54 | } 55 | 56 | addToGitignore(); 57 | installHooks(); 58 | -------------------------------------------------------------------------------- /package/lighthouse/html-script.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var path = require('path'); 4 | var fs = require('fs'); 5 | var injectHTML = require('node-inject-html').injectHTML; 6 | 7 | // Inject JSON into the blank bundled HTML report 8 | function htmlFileOutput() { 9 | const htmlTest = fs.readFileSync('./node_modules/vantage-next/dist/index.html').toString(); 10 | const VANTAGE_JSON = fs.readFileSync('./vantage/data_store.json').toString(); 11 | const htmlInject = ``; 12 | const newHtml = injectHTML(htmlTest, {headStart: htmlInject}); 13 | fs.writeFileSync(path.resolve('./vantage/vantage_report.html'), newHtml); 14 | } 15 | 16 | module.exports = htmlFileOutput; -------------------------------------------------------------------------------- /package/lighthouse/lighthouse.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // Import required dependencies 4 | const lighthouse = require('lighthouse'); 5 | const fs = require('fs'); 6 | const puppeteer = require('puppeteer'); 7 | const { exec, execSync } = require('child_process'); 8 | const { resolve } = require('path'); 9 | const kill = require('kill-port'); 10 | const htmlOutput = require('./html-script'); 11 | const regeneratorRuntime = require('regenerator-runtime'); 12 | 13 | let nextConfig; 14 | try { nextConfig = require(resolve(process.env.INIT_CWD + '/next.config.js')); } catch { nextConfig = {}} 15 | 16 | // Define constants to be used throughout various functions 17 | let SERVER_COMMAND, BUILD_COMMAND, PORT, ENDPOINTS, CONFIG, EXTENSIONS, DATA_STORE; 18 | const log = (message) => { 19 | fs.appendFileSync('./vantage/run_history.log', `\n${message}`); 20 | }; 21 | 22 | 23 | // Initialize all constants based on provided values in the ./vantage/vantage_config.json file. 24 | function initialize() { 25 | !fs.existsSync('./vantage/') && fs.mkdirSync('./vantage/'); 26 | let configData; 27 | try { 28 | const currentData = fs.readFileSync('./vantage/vantage_config.json'); 29 | configData = JSON.parse(currentData); 30 | } catch { 31 | configData = {nextAppSettings : {}}; 32 | log(`The config file was not found or the format is incorrect. Proceeding with default values.`); 33 | } 34 | 35 | 36 | PORT = configData.nextAppSettings.port ?? 3500; 37 | BUILD_COMMAND = configData.nextAppSettings.buildCommand ?? 'npx next build'; 38 | SERVER_COMMAND = configData.nextAppSettings.serverCommand ?? `npx next start -p ${PORT}`; 39 | ENDPOINTS = configData.nextAppSettings.endpoints ?? []; 40 | SRC_DIRECTORY = configData.nextAppSettings.srcDirectory ?? false; 41 | EXTENSIONS = nextConfig.pageExtensions ?? ['mdx', 'md', 'jsx', 'js', 'tsx', 'ts']; 42 | DATA_STORE = nextConfig.dataStore ?? './vantage/data_store.json'; 43 | 44 | log(`Parameters for this run: SERVER_COMMAND: ${SERVER_COMMAND}, BUILD_COMMAND: ${BUILD_COMMAND}, PORT: ${PORT}, ENDPOINTS: ${ENDPOINTS.toString()}`); 45 | 46 | } 47 | 48 | // Build the Next.js project and then start server 49 | async function startServer() { 50 | await kill(PORT); 51 | const stdOut = execSync(BUILD_COMMAND, { encoding: 'utf-8' }); 52 | exec(SERVER_COMMAND, (err, stdOut, stdErr) => { 53 | if (err) throw Error(`Error starting project's server.\nAdditional error details: ${err}`); 54 | }); 55 | } 56 | 57 | // Traverse the 'pages' folder in project directory and capture list of endpoints to check 58 | function getRoutes(subfolders = '') { 59 | let commands = `cd ${SRC_DIRECTORY ? `src/` : ''}pages`; 60 | if (subfolders !== '') commands += ` && cd ${subfolders}`; 61 | try { 62 | const stdOut = execSync(`${commands} && ls`, { encoding: 'utf-8' }); 63 | if (stdOut.includes("Not a directory")) throw Error(`Not a directory`); 64 | if (stdOut.includes("Failed to compile.")) throw Error(`Project failed to compile.`); 65 | const files = stdOut.split('\n'); 66 | if (!Array.isArray(ENDPOINTS)) ENDPOINTS = []; 67 | files.map((file) => { 68 | addFileToList(file, subfolders); 69 | }); 70 | ENDPOINTS.sort(); 71 | } catch (err) { 72 | if (!err.message.includes('Not a directory')) throw Error(`Error capturing structure of pages folder. Please ensure your project follows the required structure for the NEXT.js pages folder.`); 73 | } 74 | } 75 | 76 | // Helper function to recursively traverse the pages folder and capture all endpoints 77 | function addFileToList(file, subfolders) { 78 | const prefix = subfolders !== '' ? '/' + subfolders + '/' : '/'; 79 | const checkExtensions = (file) => { 80 | for (const extension of EXTENSIONS) if (file.endsWith('.' + extension)) return ('.' + extension); 81 | return false; 82 | }; 83 | 84 | const fileType = checkExtensions(file); 85 | if (fileType && !file.startsWith('_') && file !== ('index' + fileType)) { 86 | const endpointName = prefix + file.split(fileType)[0]; 87 | if (!ENDPOINTS.includes(endpointName)) { 88 | ENDPOINTS.push(endpointName); 89 | } 90 | } else if (file === ('index' + fileType)) { 91 | const endpointName = prefix + '/'; 92 | if (!ENDPOINTS.includes(prefix)) { 93 | ENDPOINTS.push(prefix); 94 | } 95 | } else if (!fileType && file !== 'api' && file !== '' && !file.endsWith('.json')) { 96 | getRoutes(subfolders === '' ? file : subfolders + '/' + file); 97 | } 98 | } 99 | 100 | // Initiate a headless Chrome session and check performance of the specified endpoint 101 | async function getLighthouseResultsPuppeteer(url, gitMessage) { 102 | 103 | const chrome = await puppeteer.launch({args: ['--remote-debugging-port=9222'],}); 104 | const options = { 105 | logLevel: 'silent', 106 | output: 'html', 107 | maxWaitForLoad: 10000, 108 | port: 9222 109 | }; 110 | const runnerResult = await lighthouse(url, options, CONFIG); 111 | await chrome.close(); 112 | return runnerResult.lhr; 113 | } 114 | 115 | // Process the returned lighthouse object and update JSON file with new data 116 | async function generateUpdatedDataStore(lhr, snapshotTimestamp, endpoint, commitMessage, lastResult, dataStore) { 117 | // Load existing JSON file or create new one if not yet present 118 | let data; 119 | 120 | try { 121 | const currentData = await fs.readFileSync(dataStore); 122 | data = JSON.parse(currentData); 123 | } catch { 124 | data = {"run-list": [], "endpoints":[], "commits":{}, "overall-scores": {}, "web-vitals": {}}; 125 | } 126 | 127 | // If more than 10 runs are present, store the oldest timestamp for use during delete lines below 128 | let oldestRun; 129 | if (data["run-list"].length > 10) { 130 | if (!lastResult) oldestRun = data["run-list"][0]; 131 | else oldestRun = data["run-list"].shift(); 132 | } 133 | 134 | // Parse through lhr and handle its current contents 135 | data["run-list"].push(snapshotTimestamp); 136 | data["run-list"] = Array.from(new Set(data["run-list"])); 137 | data["endpoints"].push(endpoint); 138 | data["endpoints"] = Array.from(new Set(data["endpoints"])).sort(); 139 | 140 | if (oldestRun !== undefined) delete data["commits"][oldestRun]; 141 | data["commits"][snapshotTimestamp] = !lastResult ? ['PROCESSING IN PROGRESS, PLEASE WAIT', commitMessage] : commitMessage; 142 | if (data["overall-scores"][endpoint] === undefined) data["overall-scores"][endpoint] = {}; 143 | if (oldestRun !== undefined) delete data["overall-scores"][endpoint][oldestRun]; 144 | data["overall-scores"][endpoint][snapshotTimestamp] = { 145 | "performance": lhr['categories']['performance']['score'], 146 | "accessibility": lhr['categories']['accessibility']['score'], 147 | "best-practices": lhr['categories']['best-practices']['score'], 148 | "seo": lhr['categories']['seo']['score'], 149 | "pwa": lhr['categories']['pwa']['score'] 150 | }; 151 | 152 | 153 | // Update audit results within the object 154 | const webVitals = new Set(['first-contentful-paint', 'speed-index', 'largest-contentful-paint', 'interactive', 'total-blocking-time', 'cumulative-layout-shift']); 155 | 156 | for (const category of Object.keys(lhr['categories'])) { 157 | const refs = lhr['categories'][category]['auditRefs']; 158 | const keys = []; 159 | refs.map((ref) => keys.push(ref.id)); 160 | for (const item of keys) { 161 | const thisItem = {}; 162 | Object.assign(thisItem, lhr['audits'][item]); 163 | const currentResults = {'scoreDisplayMode' : thisItem['scoreDisplayMode'], 'score' : thisItem['score'], 'numericValue' : thisItem['numericValue'], 'displayValue' : thisItem['displayValue']}; 164 | const resultType = webVitals.has(item) ? 'web-vitals' : category; 165 | 166 | if (data[resultType] === undefined) data[resultType] = {}; 167 | 168 | if (data[resultType][item] === undefined) { 169 | data[resultType][item] = lhr['audits'][item]; 170 | delete data[resultType][item]['id']; 171 | delete data[resultType][item]['score']; 172 | delete data[resultType][item]['numericValue']; 173 | delete data[resultType][item]['displayValue']; 174 | delete data[resultType][item]['details']; 175 | delete data[resultType][item]['scoreDisplayMode']; 176 | 177 | // Pull Learn More URL out of description 178 | try { 179 | data[resultType][item]['url'] = data[resultType][item]['description'].match(/\[Learn [Mm]ore\]\((.+)\)/)[1]; 180 | data[resultType][item]['description'] = data[resultType][item]['description'].match(/(.*) \[Learn [Mm]ore\]/)[1]; 181 | } catch { 182 | data[resultType][item]['url'] = null; 183 | } 184 | 185 | // Pull excess URLs out of all descriptions 186 | data[resultType][item]['description'] = data[resultType][item]['description'].replace(/\(http.*\)/, ''); 187 | data[resultType][item]['description'] = data[resultType][item]['description'].replace(/\[/, ''); 188 | data[resultType][item]['description'] = data[resultType][item]['description'].replace(/\]/, ''); 189 | data[resultType][item]['results'] = { [endpoint] : {[snapshotTimestamp]: {...currentResults}}}; 190 | 191 | 192 | } else if (data[resultType][item]['results'][endpoint] === undefined) { 193 | // score, numeric value, display value 194 | data[resultType][item]['results'][endpoint] = {[snapshotTimestamp] : currentResults}; 195 | } else { 196 | data[resultType][item]['results'][endpoint][snapshotTimestamp] = currentResults; 197 | } 198 | 199 | if (oldestRun !== undefined) delete data[resultType][item]['results'][endpoint][oldestRun]; 200 | 201 | } 202 | } 203 | 204 | // Save output to JSON 205 | fs.writeFileSync(dataStore, JSON.stringify(data)); 206 | } 207 | 208 | // Primary function to step through all parts of refresh and data collection process 209 | async function initiateRefresh() { 210 | 211 | try { 212 | const snapshotTimestamp = new Date().toISOString(); 213 | const commitMsg = execSync("git log -1 --pretty=%B").toString().trim(); 214 | 215 | initialize(); 216 | log(`>>> New run for commit '${commitMsg}' at ${snapshotTimestamp}`); 217 | 218 | getRoutes(); 219 | await startServer(); 220 | log('Endpoints tested: ' + ENDPOINTS); 221 | for (const endpoint of ENDPOINTS) { 222 | const lhr = await getLighthouseResultsPuppeteer(`http://localhost:${PORT}${endpoint}`); 223 | await generateUpdatedDataStore(lhr, snapshotTimestamp, endpoint, commitMsg, endpoint === ENDPOINTS[ENDPOINTS.length - 1], DATA_STORE); 224 | } 225 | htmlOutput(); 226 | log('Tests completed'); 227 | } catch(err) { 228 | log('Vantage was unable to complete for this commit'); 229 | log(err.stack); 230 | } 231 | await kill(PORT); 232 | log('>>> PROCESS EXITING'); 233 | process.exit(0); 234 | } 235 | 236 | if (process.env.NODE_ENV !== 'testing') initiateRefresh(); 237 | 238 | module.exports = {generateUpdatedDataStore, initiateRefresh}; -------------------------------------------------------------------------------- /package/main/vantage.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var cp = require('child_process'); 4 | cp.execSync("npx open-cli ./vantage/vantage_report.html"); -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | const webpack = require("webpack"); 3 | const path = require("path"); 4 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 5 | const HtmlInlineScriptPlugin = require("html-inline-script-webpack-plugin"); 6 | const { CleanWebpackPlugin } = require("clean-webpack-plugin"); 7 | var fs = require("fs"); 8 | var injectHTML = require("node-inject-html").injectHTML; 9 | 10 | //Inject sample data into index.html 11 | function htmlFileOutput() { 12 | const htmlTest = fs.readFileSync("./client/index.html").toString(); 13 | const VANTAGE_JSON = fs 14 | .readFileSync("./vantage_dev/sampleData.json") 15 | .toString(); 16 | const htmlInject = ``; 17 | const newHtml = injectHTML(htmlTest, { headStart: htmlInject }); 18 | fs.writeFileSync(path.resolve("./vantage_dev/index.html"), newHtml); 19 | } 20 | 21 | //Only use HtmlInlineScriptPlugin for production 22 | const pluginsArr = [new CleanWebpackPlugin()]; 23 | if (process.env.NODE_ENV === "production") { 24 | pluginsArr.push( 25 | new HtmlWebpackPlugin({ 26 | inject: "body", 27 | template: "./client/index.html", 28 | }) 29 | ); 30 | pluginsArr.push(new HtmlInlineScriptPlugin()); 31 | //Inject sample data into index.html in development mode 32 | } else if (process.env.NODE_ENV === "development") { 33 | htmlFileOutput(); 34 | pluginsArr.push( 35 | new HtmlWebpackPlugin({ 36 | inject: "body", 37 | template: "./vantage_dev/index.html", 38 | }) 39 | ); 40 | } 41 | 42 | module.exports = { 43 | entry: [ 44 | // entry point of our app 45 | "./client/index.js", 46 | ], 47 | output: { 48 | path: path.resolve(__dirname, "dist"), 49 | publicPath: "/", 50 | filename: "bundle.js", 51 | }, 52 | devtool: "eval-source-map", 53 | mode: process.env.NODE_ENV, 54 | devServer: { 55 | host: "localhost", 56 | port: 8080, 57 | // enable HMR on the devServer 58 | hot: true, 59 | // fallback to root for other urls 60 | historyApiFallback: true, 61 | static: { 62 | // match the output path 63 | directory: path.resolve(__dirname, "dist"), 64 | // match the output 'publicPath' 65 | publicPath: "/", 66 | }, 67 | 68 | headers: { "Access-Control-Allow-Origin": "*" }, 69 | }, 70 | module: { 71 | rules: [ 72 | { 73 | test: /.(js|jsx)$/, 74 | exclude: /node_modules/, 75 | use: { 76 | loader: "babel-loader", 77 | }, 78 | }, 79 | { 80 | test: /.(css|scss)$/, 81 | exclude: /node_modules/, 82 | use: ["style-loader", "css-loader", "sass-loader"], 83 | }, 84 | { 85 | test: /\.svg$/i, 86 | issuer: /\.[jt]sx?$/, 87 | use: ["@svgr/webpack"], 88 | }, 89 | ], 90 | }, 91 | plugins: pluginsArr, 92 | resolve: { 93 | // Enable importing JS / JSX files without specifying their extension 94 | extensions: [".js", ".jsx"], 95 | }, 96 | }; 97 | --------------------------------------------------------------------------------