├── .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 | #
2 |
3 | # [VANTAGE]("https://www.vantagenext.com")
4 |
5 | [](https://github.com/oslabs-beta/Vantage/)
6 | [](https://www.npmjs.com/package/vantage-next)
7 | [](https://github.com/oslabs-beta/Vantage/issues)
8 | [](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 | 
34 |
35 | ### Compare Commit Results:
36 |
37 |
38 |
39 | ### Choose Endpoints:
40 |
41 |
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 |
29 | ));
30 |
31 | return (
32 |
33 |
46 |
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 |
99 |
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 |
130 |
131 | {performanceMetricsArr.length === 1 ? (
132 |
133 |
139 |
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 |
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 |
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 |
--------------------------------------------------------------------------------