From 06228b71e95418f13bf1616db68e21196bf28498 Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Mon, 6 Apr 2020 12:07:25 -0400 Subject: [PATCH] feat: implement Lighthouse plugin using lighthouse-audit-service --- packages/app/package.json | 5 +- packages/app/src/apis.ts | 8 + packages/app/src/plugins.ts | 4 +- .../cli/src/commands/plugin/rollup.config.ts | 2 +- packages/core/package.json | 2 +- packages/core/src/api/apis/index.ts | 1 + packages/core/src/index.ts | 1 + packages/core/src/layout/Content/Content.tsx | 20 +- packages/core/src/layout/Page/Page.tsx | 2 +- packages/test-utils/package.json | 2 +- plugins/home-page/package.json | 4 +- plugins/lighthouse/README.md | 50 ++ plugins/lighthouse/package.json | 50 ++ .../__fixtures__/create-audit-response.json | 6 + .../__fixtures__/website-list-response.json | 579 ++++++++++++++++++ .../src/__fixtures__/website-response.json | 118 ++++ plugins/lighthouse/src/api.ts | 143 +++++ .../AuditList/AuditListTable.test.tsx | 118 ++++ .../components/AuditList/AuditListTable.tsx | 153 +++++ .../src/components/AuditList/index.test.tsx | 185 ++++++ .../src/components/AuditList/index.tsx | 130 ++++ .../src/components/AuditStatusIcon/index.tsx | 28 + .../src/components/AuditView/index.test.tsx | 226 +++++++ .../src/components/AuditView/index.tsx | 191 ++++++ .../CategoryTrendline/index.test.tsx | 78 +++ .../components/CategoryTrendline/index.tsx | 39 ++ .../src/components/CreateAudit/index.test.tsx | 178 ++++++ .../src/components/CreateAudit/index.tsx | 174 ++++++ .../src/components/Intro/index.test.tsx | 63 ++ .../lighthouse/src/components/Intro/index.tsx | 173 ++++++ .../src/components/SupportButton/index.tsx | 25 + plugins/lighthouse/src/index.ts | 18 + plugins/lighthouse/src/plugin.test.ts | 23 + plugins/lighthouse/src/plugin.ts | 29 + plugins/lighthouse/src/setupTests.ts | 18 + plugins/lighthouse/src/utils.ts | 30 + plugins/lighthouse/tsconfig.json | 7 + plugins/welcome/package.json | 4 +- yarn.lock | 284 ++++++++- 39 files changed, 3138 insertions(+), 33 deletions(-) create mode 100644 plugins/lighthouse/README.md create mode 100644 plugins/lighthouse/package.json create mode 100644 plugins/lighthouse/src/__fixtures__/create-audit-response.json create mode 100644 plugins/lighthouse/src/__fixtures__/website-list-response.json create mode 100644 plugins/lighthouse/src/__fixtures__/website-response.json create mode 100644 plugins/lighthouse/src/api.ts create mode 100644 plugins/lighthouse/src/components/AuditList/AuditListTable.test.tsx create mode 100644 plugins/lighthouse/src/components/AuditList/AuditListTable.tsx create mode 100644 plugins/lighthouse/src/components/AuditList/index.test.tsx create mode 100644 plugins/lighthouse/src/components/AuditList/index.tsx create mode 100644 plugins/lighthouse/src/components/AuditStatusIcon/index.tsx create mode 100644 plugins/lighthouse/src/components/AuditView/index.test.tsx create mode 100644 plugins/lighthouse/src/components/AuditView/index.tsx create mode 100644 plugins/lighthouse/src/components/CategoryTrendline/index.test.tsx create mode 100644 plugins/lighthouse/src/components/CategoryTrendline/index.tsx create mode 100644 plugins/lighthouse/src/components/CreateAudit/index.test.tsx create mode 100644 plugins/lighthouse/src/components/CreateAudit/index.tsx create mode 100644 plugins/lighthouse/src/components/Intro/index.test.tsx create mode 100644 plugins/lighthouse/src/components/Intro/index.tsx create mode 100644 plugins/lighthouse/src/components/SupportButton/index.tsx create mode 100644 plugins/lighthouse/src/index.ts create mode 100644 plugins/lighthouse/src/plugin.test.ts create mode 100644 plugins/lighthouse/src/plugin.ts create mode 100644 plugins/lighthouse/src/setupTests.ts create mode 100644 plugins/lighthouse/src/utils.ts create mode 100644 plugins/lighthouse/tsconfig.json diff --git a/packages/app/package.json b/packages/app/package.json index 0d7d29e480..232a00416d 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -5,9 +5,10 @@ "dependencies": { "@backstage/cli": "^0.1.1-alpha.3", "@backstage/core": "^0.1.1-alpha.3", - "@backstage/plugin-home-page": "^0.1.1-alpha.3", - "@backstage/plugin-welcome": "^0.1.1-alpha.3", "@backstage/theme": "^0.1.1-alpha.3", + "@backstage/plugin-home-page": "^0.1.1-alpha.3", + "@backstage/plugin-lighthouse": "^0.1.1-alpha.3", + "@backstage/plugin-welcome": "^0.1.1-alpha.3", "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts index 494b469312..88b75c1392 100644 --- a/packages/app/src/apis.ts +++ b/packages/app/src/apis.ts @@ -21,13 +21,21 @@ import { featureFlagsApiRef, FeatureFlags, } from '@backstage/core'; +import { + lighthouseApiRef, + LighthouseRestApi, +} from '@backstage/plugin-lighthouse'; + import { ErrorDisplayForwarder } from './components/ErrorDisplay/ErrorDisplay'; const builder = ApiRegistry.builder(); export const errorDialogForwarder = new ErrorDisplayForwarder(); + builder.add(errorApiRef, errorDialogForwarder); builder.add(featureFlagsApiRef, new FeatureFlags()); +builder.add(lighthouseApiRef, new LighthouseRestApi('http://localhost:3003')); + export default builder.build() as ApiHolder; diff --git a/packages/app/src/plugins.ts b/packages/app/src/plugins.ts index c81c403ef7..9f72242c80 100644 --- a/packages/app/src/plugins.ts +++ b/packages/app/src/plugins.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - import { default as HomePagePlugin } from '@backstage/plugin-home-page'; import { default as WelcomePlugin } from '@backstage/plugin-welcome'; -export { HomePagePlugin, WelcomePlugin }; +import { default as LighthousePlugin } from '@backstage/plugin-lighthouse'; +export { HomePagePlugin, WelcomePlugin, LighthousePlugin }; diff --git a/packages/cli/src/commands/plugin/rollup.config.ts b/packages/cli/src/commands/plugin/rollup.config.ts index bc73a789ff..fe2675e0e0 100644 --- a/packages/cli/src/commands/plugin/rollup.config.ts +++ b/packages/cli/src/commands/plugin/rollup.config.ts @@ -39,7 +39,7 @@ export default { }), commonjs({ include: ['node_modules/**', '../../node_modules/**'], - exclude: ['**/*.stories.js'], + exclude: ['**/*.stories.*', '**/*.test.*'], }), postcss(), imageFiles(), diff --git a/packages/core/package.json b/packages/core/package.json index cc30222cf4..3dc8150f83 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -50,6 +50,6 @@ "react-router": "^5.1.2" }, "peerDependencies": { - "@backstage/theme": "^0.1.1-alpha.2" + "@backstage/theme": "^0.1.1-alpha.3" } } diff --git a/packages/core/src/api/apis/index.ts b/packages/core/src/api/apis/index.ts index 650948edc0..9214414f5c 100644 --- a/packages/core/src/api/apis/index.ts +++ b/packages/core/src/api/apis/index.ts @@ -17,5 +17,6 @@ export { default as ApiProvider, useApi } from './ApiProvider'; export { default as ApiRegistry } from './ApiRegistry'; export { default as ApiTestRegistry } from './ApiTestRegistry'; +export { default as ApiRef } from './ApiRef'; export * from './types'; export * from './definitions'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4472f57d40..740a4cc803 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -32,3 +32,4 @@ export { AlphaLabel, BetaLabel } from './components/Lifecycle'; export { default as SupportButton } from './components/SupportButton'; export { default as SortableTable } from './components/SortableTable'; export { FeatureCalloutCircular } from './components/FeatureDiscovery/FeatureCalloutCircular'; +export * from './components/Status'; diff --git a/packages/core/src/layout/Content/Content.tsx b/packages/core/src/layout/Content/Content.tsx index 83cfea5538..f3e1ef55ac 100644 --- a/packages/core/src/layout/Content/Content.tsx +++ b/packages/core/src/layout/Content/Content.tsx @@ -15,6 +15,7 @@ */ import React, { FC } from 'react'; +import classNames from 'classnames'; import { Theme, makeStyles } from '@material-ui/core'; const useStyles = makeStyles((theme: Theme) => ({ @@ -25,12 +26,27 @@ const useStyles = makeStyles((theme: Theme) => ({ paddingBottom: theme.spacing(3), ...theme.mixins.gutters({}), }, + stretch: { + display: 'flex', + flexDirection: 'column', + flexGrow: 1, + }, })); -const Content: FC<{}> = ({ children, ...props }) => { +const Content: FC<{ stretch?: boolean; className?: string }> = ({ + children, + className, + stretch, + ...props +}) => { const classes = useStyles(); return ( -
+
{children}
); diff --git a/packages/core/src/layout/Page/Page.tsx b/packages/core/src/layout/Page/Page.tsx index b4ff827d45..23c2a14dbe 100644 --- a/packages/core/src/layout/Page/Page.tsx +++ b/packages/core/src/layout/Page/Page.tsx @@ -27,7 +27,7 @@ const useStyles = makeStyles(() => ({ "'pageHeader pageHeader pageHeader' 'pageSubheader pageSubheader pageSubheader' 'pageNav pageContent pageSidebar'", gridTemplateRows: 'auto auto 1fr', gridTemplateColumns: 'auto 1fr auto', - minHeight: '100%', + minHeight: '100vh', }, })); diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 98b2dcd749..55b7436aae 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -38,7 +38,7 @@ "react-router-dom": "^5.1.2" }, "peerDependencies": { - "@backstage/theme": "^0.1.1-alpha.2", + "@backstage/theme": "^0.1.1-alpha.3", "@material-ui/core": "^4.9.1", "@testing-library/jest-dom": "^4.2.4", "@testing-library/react": "^9.3.2", diff --git a/plugins/home-page/package.json b/plugins/home-page/package.json index e5d90de209..7f78f1bf63 100644 --- a/plugins/home-page/package.json +++ b/plugins/home-page/package.json @@ -26,8 +26,8 @@ "react-dom": "^16.12.0" }, "peerDependencies": { - "@backstage/core": "^0.1.1-alpha.2", - "@backstage/theme": "^0.1.1-alpha.2", + "@backstage/core": "^0.1.1-alpha.3", + "@backstage/theme": "^0.1.1-alpha.3", "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", diff --git a/plugins/lighthouse/README.md b/plugins/lighthouse/README.md new file mode 100644 index 0000000000..57a8f316ad --- /dev/null +++ b/plugins/lighthouse/README.md @@ -0,0 +1,50 @@ +# @backstage/plugin-lighthouse + +A frontend for [lighthouse-audit-service](https://github.com/spotify/lighthouse-audit-service), this plugin allows you to trigger Lighthouse audits on websites and track them over time. + +## Getting Started + +### Use cases + +Google's [Lighthouse](https://developers.google.com/web/tools/lighthouse) auditing tool for websites +is a great open-source resource forbenchmarking and improving the accessibility, performance, SEO, and best practices of your site. +At Spotify, we keep track of Lighthouse audit scores over time to look at trends and overall areas for investment. + +This plugin allows you to generate on-demand Lighthouse audits for websites, and to track the trends for the +top-level categories of Lighthouse at a glance. + +In the future, we hope to add support for scheduling audits (which we do internally), as well as allowing +custom runs of Lighthouse to be ingested (for auditing sites that require authentication or some session state). + +### Installation + +To get started, you will need a running instance of [lighthouse-audit-service](https://github.com/spotify/lighthouse-audit-service). +_It's likely you will need to enable CORS when running lighthouse-audit-service. Initialize the app +with the environment variable `LAS_CORS` set to `true`._ + +When you have an instance running that Backstage can hook into, make sure to export the plugin in +your app's [`plugins.ts`](https://github.com/spotify/backstage/blob/master/packages/app/src/plugins.ts) +to enable the plugin: + +```js +export { default as LighthousePlugin } from '@backstage/plugin-lighthouse'; +``` + +Then, you need to use the `lighthouseApiRef` exported from the plugin to initialize the Rest API in +your [`apis.ts`](https://github.com/spotify/backstage/blob/master/packages/app/src/apis.ts). + +```js +import { ApiHolder, ApiRegistry } from '@backstage/core'; +import { + lighthouseApiRef, + LighthouseRestApi, +} from '@backstage/lighthouse-audits'; + +const builder = ApiRegistry.builder(); + +export const lighthouseApi = + new LighthouseRestApi(/* your service url here! */); +builder.add(lighthouseApiRef, lighthouseApi); + +export default builder.build() as ApiHolder; +``` diff --git a/plugins/lighthouse/package.json b/plugins/lighthouse/package.json new file mode 100644 index 0000000000..fd9d40ffc7 --- /dev/null +++ b/plugins/lighthouse/package.json @@ -0,0 +1,50 @@ +{ + "name": "@backstage/plugin-lighthouse", + "version": "0.1.1-alpha.3", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts", + "license": "Apache-2.0", + "private": true, + "scripts": { + "build:watch": "backstage-cli plugin:build --watch", + "build": "backstage-cli build-cache -- backstage-cli plugin:build", + "lint": "backstage-cli lint", + "test": "backstage-cli test" + }, + "dependencies": { + "react-markdown": "^4.3.1", + "react-sparklines": "^1.7.0" + }, + "devDependencies": { + "@backstage/cli": "^0.1.1-alpha.3", + "@backstage/core": "^0.1.1-alpha.3", + "@backstage/test-utils": "^0.1.1-alpha.3", + "@backstage/theme": "^0.1.1-alpha.3", + "@material-ui/core": "^4.9.1", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.45", + "@testing-library/jest-dom": "^4.2.4", + "@testing-library/react": "^9.3.2", + "@testing-library/user-event": "^7.1.2", + "@types/jest": "^24.0.0", + "@types/node": "^12.0.0", + "@types/react-sparklines": "^1.7.0", + "@types/testing-library__jest-dom": "5.0.2", + "jest-fetch-mock": "^3.0.3", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "react-router-dom": "^5.1.2", + "react-use": "^13.24.0" + }, + "peerDependencies": { + "@backstage/core": "^0.1.1-alpha.3", + "@backstage/theme": "^0.1.1-alpha.3", + "@material-ui/core": "^4.9.1", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.45", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "react-router-dom": "^5.1.2", + "react-use": "^13.24.0" + } +} diff --git a/plugins/lighthouse/src/__fixtures__/create-audit-response.json b/plugins/lighthouse/src/__fixtures__/create-audit-response.json new file mode 100644 index 0000000000..d3b9323a06 --- /dev/null +++ b/plugins/lighthouse/src/__fixtures__/create-audit-response.json @@ -0,0 +1,6 @@ +{ + "id": "80d92fff-52ec-4ef5-848f-9cec8b383e41", + "url": "https://anchor.fm", + "timeCreated": "2020-03-31T15:34:02.199-04:00", + "status": "RUNNING" +} diff --git a/plugins/lighthouse/src/__fixtures__/website-list-response.json b/plugins/lighthouse/src/__fixtures__/website-list-response.json new file mode 100644 index 0000000000..e46f464c62 --- /dev/null +++ b/plugins/lighthouse/src/__fixtures__/website-list-response.json @@ -0,0 +1,579 @@ +{ + "items": [ + { + "url": "https://anchor.fm", + "audits": [ + { + "id": "80d92fff-52ec-4ef5-848f-9cec8b383e41", + "url": "https://anchor.fm", + "timeCreated": "2020-03-31T15:34:02.199-04:00", + "status": "RUNNING" + } + ], + "lastAudit": { + "id": "80d92fff-52ec-4ef5-848f-9cec8b383e41", + "url": "https://anchor.fm", + "timeCreated": "2020-03-31T15:34:02.199-04:00", + "status": "RUNNING" + } + }, + { + "url": "https://artists.spotify.com/", + "audits": [ + { + "id": "d6389155-6a3c-43d4-8032-f9ae98f663a4", + "url": "https://artists.spotify.com/", + "timeCreated": "2020-03-31T15:31:10.769-04:00", + "timeCompleted": "2020-03-31T15:31:17.311-04:00", + "status": "COMPLETED", + "categories": { + "pwa": { + "id": "pwa", + "title": "Progressive Web App", + "score": 0.56 + }, + "seo": { "id": "seo", "title": "SEO", "score": 0.91 }, + "performance": { + "id": "performance", + "title": "Performance", + "score": 0.62 + }, + "accessibility": { + "id": "accessibility", + "title": "Accessibility", + "score": 0.83 + }, + "best-practices": { + "id": "best-practices", + "title": "Best Practices", + "score": 0.93 + } + } + } + ], + "lastAudit": { + "id": "d6389155-6a3c-43d4-8032-f9ae98f663a4", + "url": "https://artists.spotify.com/", + "timeCreated": "2020-03-31T15:31:10.769-04:00", + "timeCompleted": "2020-03-31T15:31:17.311-04:00", + "status": "COMPLETED", + "categories": { + "pwa": { "id": "pwa", "title": "Progressive Web App", "score": 0.56 }, + "seo": { "id": "seo", "title": "SEO", "score": 0.91 }, + "performance": { + "id": "performance", + "title": "Performance", + "score": 0.62 + }, + "accessibility": { + "id": "accessibility", + "title": "Accessibility", + "score": 0.83 + }, + "best-practices": { + "id": "best-practices", + "title": "Best Practices", + "score": 0.93 + } + } + } + }, + { + "url": "https://spotify.com", + "audits": [ + { + "id": "f471916c-80bc-4a22-93de-f3347835e785", + "url": "https://spotify.com", + "timeCreated": "2020-03-31T15:29:25.68-04:00", + "timeCompleted": "2020-03-31T15:29:33.085-04:00", + "status": "COMPLETED", + "categories": { + "pwa": { + "id": "pwa", + "title": "Progressive Web App", + "score": 0.33 + }, + "seo": { "id": "seo", "title": "SEO", "score": 0.9 }, + "performance": { + "id": "performance", + "title": "Performance", + "score": 0.43 + }, + "accessibility": { + "id": "accessibility", + "title": "Accessibility", + "score": 0.82 + }, + "best-practices": { + "id": "best-practices", + "title": "Best Practices", + "score": 0.86 + } + } + }, + { + "id": "047f506b-15f8-4eb1-b177-d8e3d0377b85", + "url": "https://spotify.com", + "timeCreated": "2020-03-27T12:58:34.605-04:00", + "timeCompleted": "2020-03-27T12:58:43.842-04:00", + "status": "COMPLETED", + "categories": { + "pwa": { + "id": "pwa", + "title": "Progressive Web App", + "score": 0.33 + }, + "seo": { "id": "seo", "title": "SEO", "score": 0.92 }, + "performance": { + "id": "performance", + "title": "Performance", + "score": 0.44 + }, + "accessibility": { + "id": "accessibility", + "title": "Accessibility", + "score": 0.82 + }, + "best-practices": { + "id": "best-practices", + "title": "Best Practices", + "score": 0.86 + } + } + }, + { + "id": "2e7810fd-fe42-4620-8787-b047b7f6c57a", + "url": "https://spotify.com", + "timeCreated": "2020-03-26T14:09:59.146-04:00", + "timeCompleted": "2020-03-26T14:10:05.356-04:00", + "status": "COMPLETED", + "categories": { + "pwa": { + "id": "pwa", + "title": "Progressive Web App", + "score": 0.33 + }, + "seo": { "id": "seo", "title": "SEO", "score": 0.92 }, + "performance": { + "id": "performance", + "title": "Performance", + "score": 0.47 + }, + "accessibility": { + "id": "accessibility", + "title": "Accessibility", + "score": 0.82 + }, + "best-practices": { + "id": "best-practices", + "title": "Best Practices", + "score": 0.86 + } + } + }, + { + "id": "0f094aa2-12c6-479e-bb52-fad2b089d5c1", + "url": "https://spotify.com", + "timeCreated": "2020-03-26T14:02:03.023-04:00", + "timeCompleted": "2020-03-26T14:02:33.336-04:00", + "status": "FAILED" + }, + { + "id": "628d7eb6-e26a-4d1a-9690-adc3c6af1baa", + "url": "https://spotify.com", + "timeCreated": "2020-03-26T14:00:43.878-04:00", + "timeCompleted": "2020-03-26T14:01:14.203-04:00", + "status": "FAILED" + }, + { + "id": "71b8e012-88fc-49d8-9d88-3b03ef9ec34e", + "url": "https://spotify.com", + "timeCreated": "2020-03-26T13:59:51.243-04:00", + "timeCompleted": "2020-03-26T13:59:51.595-04:00", + "status": "FAILED" + } + ], + "lastAudit": { + "id": "f471916c-80bc-4a22-93de-f3347835e785", + "url": "https://spotify.com", + "timeCreated": "2020-03-31T15:29:25.68-04:00", + "timeCompleted": "2020-03-31T15:29:33.085-04:00", + "status": "COMPLETED", + "categories": { + "pwa": { "id": "pwa", "title": "Progressive Web App", "score": 0.33 }, + "seo": { "id": "seo", "title": "SEO", "score": 0.9 }, + "performance": { + "id": "performance", + "title": "Performance", + "score": 0.43 + }, + "accessibility": { + "id": "accessibility", + "title": "Accessibility", + "score": 0.82 + }, + "best-practices": { + "id": "best-practices", + "title": "Best Practices", + "score": 0.86 + } + } + } + }, + { + "url": "https://www.soundtrap.com/", + "audits": [ + { + "id": "3fba085f-651e-4e09-a368-70998d6594d4", + "url": "https://www.soundtrap.com/", + "timeCreated": "2020-03-31T15:19:48.711-04:00", + "timeCompleted": "2020-03-31T15:19:59.422-04:00", + "status": "FAILED" + } + ], + "lastAudit": { + "id": "3fba085f-651e-4e09-a368-70998d6594d4", + "url": "https://www.soundtrap.com/", + "timeCreated": "2020-03-31T15:19:48.711-04:00", + "timeCompleted": "2020-03-31T15:19:59.422-04:00", + "status": "FAILED" + } + }, + { + "url": "https://backstage.io", + "audits": [ + { + "id": "51211f04-848a-4c54-a891-ce747502958c", + "url": "https://backstage.io", + "timeCreated": "2020-03-30T18:23:24.734-04:00", + "timeCompleted": "2020-03-30T18:23:30.533-04:00", + "status": "COMPLETED", + "categories": { + "pwa": { + "id": "pwa", + "title": "Progressive Web App", + "score": 0.56 + }, + "seo": { "id": "seo", "title": "SEO", "score": 1 }, + "performance": { + "id": "performance", + "title": "Performance", + "score": 0.98 + }, + "accessibility": { + "id": "accessibility", + "title": "Accessibility", + "score": 0.81 + }, + "best-practices": { + "id": "best-practices", + "title": "Best Practices", + "score": 1 + } + } + }, + { + "id": "8a6b32f8-930e-4750-a699-7046a51e7b8e", + "url": "https://backstage.io", + "timeCreated": "2020-03-30T09:34:28.25-04:00", + "timeCompleted": "2020-03-30T09:34:33.275-04:00", + "status": "COMPLETED", + "categories": { + "pwa": { + "id": "pwa", + "title": "Progressive Web App", + "score": 0.54 + }, + "seo": { "id": "seo", "title": "SEO", "score": 1 }, + "performance": { + "id": "performance", + "title": "Performance", + "score": 0.99 + }, + "accessibility": { + "id": "accessibility", + "title": "Accessibility", + "score": 0.81 + }, + "best-practices": { + "id": "best-practices", + "title": "Best Practices", + "score": 1 + } + } + }, + { + "id": "259bf4c9-57ad-479d-8072-06da8956fdf4", + "url": "https://backstage.io", + "timeCreated": "2020-03-27T16:10:13.512-04:00", + "timeCompleted": "2020-03-27T16:10:17.998-04:00", + "status": "COMPLETED", + "categories": { + "pwa": { + "id": "pwa", + "title": "Progressive Web App", + "score": 0.54 + }, + "seo": { "id": "seo", "title": "SEO", "score": 1 }, + "performance": { + "id": "performance", + "title": "Performance", + "score": 0.99 + }, + "accessibility": { + "id": "accessibility", + "title": "Accessibility", + "score": 0.81 + }, + "best-practices": { + "id": "best-practices", + "title": "Best Practices", + "score": 1 + } + } + }, + { + "id": "0c2ce744-019a-42f2-8c61-9b49478a2ecd", + "url": "https://backstage.io", + "timeCreated": "2020-03-27T13:01:21.399-04:00", + "timeCompleted": "2020-03-27T13:01:25.761-04:00", + "status": "COMPLETED", + "categories": { + "pwa": { + "id": "pwa", + "title": "Progressive Web App", + "score": 0.56 + }, + "seo": { "id": "seo", "title": "SEO", "score": 1 }, + "performance": { + "id": "performance", + "title": "Performance", + "score": 0.99 + }, + "accessibility": { + "id": "accessibility", + "title": "Accessibility", + "score": 0.81 + }, + "best-practices": { + "id": "best-practices", + "title": "Best Practices", + "score": 1 + } + } + } + ], + "lastAudit": { + "id": "51211f04-848a-4c54-a891-ce747502958c", + "url": "https://backstage.io", + "timeCreated": "2020-03-30T18:23:24.734-04:00", + "timeCompleted": "2020-03-30T18:23:30.533-04:00", + "status": "COMPLETED", + "categories": { + "pwa": { "id": "pwa", "title": "Progressive Web App", "score": 0.56 }, + "seo": { "id": "seo", "title": "SEO", "score": 1 }, + "performance": { + "id": "performance", + "title": "Performance", + "score": 0.98 + }, + "accessibility": { + "id": "accessibility", + "title": "Accessibility", + "score": 0.81 + }, + "best-practices": { + "id": "best-practices", + "title": "Best Practices", + "score": 1 + } + } + } + }, + { + "url": "https://adstudio.spotify.com", + "audits": [ + { + "id": "5c89fad2-96ed-4728-856c-f9fbcfb8244e", + "url": "https://adstudio.spotify.com", + "timeCreated": "2020-03-27T15:29:00.859-04:00", + "timeCompleted": "2020-03-27T15:29:11.014-04:00", + "status": "COMPLETED", + "categories": { + "pwa": { + "id": "pwa", + "title": "Progressive Web App", + "score": 0.73 + }, + "seo": { "id": "seo", "title": "SEO", "score": 1 }, + "performance": { + "id": "performance", + "title": "Performance", + "score": 0.56 + }, + "accessibility": { + "id": "accessibility", + "title": "Accessibility", + "score": 0.84 + }, + "best-practices": { + "id": "best-practices", + "title": "Best Practices", + "score": 1 + } + } + }, + { + "id": "7b68a014-5e23-462c-952c-dae575d9e10c", + "url": "https://adstudio.spotify.com", + "timeCreated": "2020-03-27T15:28:37.319-04:00", + "timeCompleted": "2020-03-27T15:28:47.727-04:00", + "status": "COMPLETED", + "categories": { + "pwa": { + "id": "pwa", + "title": "Progressive Web App", + "score": 0.73 + }, + "seo": { "id": "seo", "title": "SEO", "score": 1 }, + "performance": { + "id": "performance", + "title": "Performance", + "score": 0.55 + }, + "accessibility": { + "id": "accessibility", + "title": "Accessibility", + "score": 0.84 + }, + "best-practices": { + "id": "best-practices", + "title": "Best Practices", + "score": 1 + } + } + }, + { + "id": "275ae2d4-4409-45ae-b1dd-91121478cc3c", + "url": "https://adstudio.spotify.com", + "timeCreated": "2020-03-27T12:24:19.422-04:00", + "timeCompleted": "2020-03-27T12:24:30.299-04:00", + "status": "COMPLETED", + "categories": { + "pwa": { + "id": "pwa", + "title": "Progressive Web App", + "score": 0.74 + }, + "seo": { "id": "seo", "title": "SEO", "score": 0.98 }, + "performance": { + "id": "performance", + "title": "Performance", + "score": 0.51 + }, + "accessibility": { + "id": "accessibility", + "title": "Accessibility", + "score": 0.84 + }, + "best-practices": { + "id": "best-practices", + "title": "Best Practices", + "score": 1 + } + } + } + ], + "lastAudit": { + "id": "5c89fad2-96ed-4728-856c-f9fbcfb8244e", + "url": "https://adstudio.spotify.com", + "timeCreated": "2020-03-27T15:29:00.859-04:00", + "timeCompleted": "2020-03-27T15:29:11.014-04:00", + "status": "COMPLETED", + "categories": { + "pwa": { "id": "pwa", "title": "Progressive Web App", "score": 0.73 }, + "seo": { "id": "seo", "title": "SEO", "score": 1 }, + "performance": { + "id": "performance", + "title": "Performance", + "score": 0.56 + }, + "accessibility": { + "id": "accessibility", + "title": "Accessibility", + "score": 0.84 + }, + "best-practices": { + "id": "best-practices", + "title": "Best Practices", + "score": 1 + } + } + } + }, + { + "url": "https://spotify.com/us", + "audits": [ + { + "id": "cac7f052-361f-49ff-9112-c780dd720416", + "url": "https://spotify.com/us", + "timeCreated": "2020-03-27T12:17:41.711-04:00", + "timeCompleted": "2020-03-27T12:17:48.85-04:00", + "status": "COMPLETED", + "categories": { + "pwa": { + "id": "pwa", + "title": "Progressive Web App", + "score": 0.33 + }, + "seo": { "id": "seo", "title": "SEO", "score": 0.92 }, + "performance": { + "id": "performance", + "title": "Performance", + "score": 0.46 + }, + "accessibility": { + "id": "accessibility", + "title": "Accessibility", + "score": 0.82 + }, + "best-practices": { + "id": "best-practices", + "title": "Best Practices", + "score": 0.86 + } + } + } + ], + "lastAudit": { + "id": "cac7f052-361f-49ff-9112-c780dd720416", + "url": "https://spotify.com/us", + "timeCreated": "2020-03-27T12:17:41.711-04:00", + "timeCompleted": "2020-03-27T12:17:48.85-04:00", + "status": "COMPLETED", + "categories": { + "pwa": { "id": "pwa", "title": "Progressive Web App", "score": 0.33 }, + "seo": { "id": "seo", "title": "SEO", "score": 0.92 }, + "performance": { + "id": "performance", + "title": "Performance", + "score": 0.46 + }, + "accessibility": { + "id": "accessibility", + "title": "Accessibility", + "score": 0.82 + }, + "best-practices": { + "id": "best-practices", + "title": "Best Practices", + "score": 0.86 + } + } + } + } + ], + "total": 7, + "limit": 10, + "offset": 0 +} diff --git a/plugins/lighthouse/src/__fixtures__/website-response.json b/plugins/lighthouse/src/__fixtures__/website-response.json new file mode 100644 index 0000000000..36c04236b3 --- /dev/null +++ b/plugins/lighthouse/src/__fixtures__/website-response.json @@ -0,0 +1,118 @@ +{ + "url": "https://spotify.com", + "audits": [ + { + "id": "f471916c-80bc-4a22-93de-f3347835e785", + "url": "https://spotify.com", + "timeCreated": "2020-03-31T15:29:25.68-04:00", + "status": "RUNNING" + }, + { + "id": "047f506b-15f8-4eb1-b177-d8e3d0377b85", + "url": "https://spotify.com", + "timeCreated": "2020-03-27T12:58:34.605-04:00", + "timeCompleted": "2020-03-27T12:58:43.842-04:00", + "status": "COMPLETED", + "categories": { + "pwa": { + "id": "pwa", + "title": "Progressive Web App", + "score": 0.33 + }, + "seo": { "id": "seo", "title": "SEO", "score": 0.92 }, + "performance": { + "id": "performance", + "title": "Performance", + "score": 0.44 + }, + "accessibility": { + "id": "accessibility", + "title": "Accessibility", + "score": 0.82 + }, + "best-practices": { + "id": "best-practices", + "title": "Best Practices", + "score": 0.86 + } + } + }, + { + "id": "2e7810fd-fe42-4620-8787-b047b7f6c57a", + "url": "https://spotify.com", + "timeCreated": "2020-03-26T14:09:59.146-04:00", + "timeCompleted": "2020-03-26T14:10:05.356-04:00", + "status": "COMPLETED", + "categories": { + "pwa": { + "id": "pwa", + "title": "Progressive Web App", + "score": 0.33 + }, + "seo": { "id": "seo", "title": "SEO", "score": 0.92 }, + "performance": { + "id": "performance", + "title": "Performance", + "score": 0.47 + }, + "accessibility": { + "id": "accessibility", + "title": "Accessibility", + "score": 0.82 + }, + "best-practices": { + "id": "best-practices", + "title": "Best Practices", + "score": 0.86 + } + } + }, + { + "id": "0f094aa2-12c6-479e-bb52-fad2b089d5c1", + "url": "https://spotify.com", + "timeCreated": "2020-03-26T14:02:03.023-04:00", + "timeCompleted": "2020-03-26T14:02:33.336-04:00", + "status": "FAILED" + }, + { + "id": "628d7eb6-e26a-4d1a-9690-adc3c6af1baa", + "url": "https://spotify.com", + "timeCreated": "2020-03-26T14:00:43.878-04:00", + "timeCompleted": "2020-03-26T14:01:14.203-04:00", + "status": "FAILED" + }, + { + "id": "71b8e012-88fc-49d8-9d88-3b03ef9ec34e", + "url": "https://spotify.com", + "timeCreated": "2020-03-26T13:59:51.243-04:00", + "timeCompleted": "2020-03-26T13:59:51.595-04:00", + "status": "FAILED" + } + ], + "lastAudit": { + "id": "f471916c-80bc-4a22-93de-f3347835e785", + "url": "https://spotify.com", + "timeCreated": "2020-03-31T15:29:25.68-04:00", + "timeCompleted": "2020-03-31T15:29:33.085-04:00", + "status": "COMPLETED", + "categories": { + "pwa": { "id": "pwa", "title": "Progressive Web App", "score": 0.33 }, + "seo": { "id": "seo", "title": "SEO", "score": 0.9 }, + "performance": { + "id": "performance", + "title": "Performance", + "score": 0.43 + }, + "accessibility": { + "id": "accessibility", + "title": "Accessibility", + "score": 0.82 + }, + "best-practices": { + "id": "best-practices", + "title": "Best Practices", + "score": 0.86 + } + } + } +} diff --git a/plugins/lighthouse/src/api.ts b/plugins/lighthouse/src/api.ts new file mode 100644 index 0000000000..07540cc06d --- /dev/null +++ b/plugins/lighthouse/src/api.ts @@ -0,0 +1,143 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ApiRef } from '@backstage/core'; + +export type LighthouseCategoryId = + | 'pwa' + | 'seo' + | 'performance' + | 'accessibility' + | 'best-practices'; + +export interface LighthouseCategoryAbbr { + id: LighthouseCategoryId; + score: number; + title: string; +} + +export interface LASListRequest { + offset?: number; + limit?: number; +} + +export interface LASListResponse { + items: Item[]; + total: number; + offset: number; + limit: number; +} + +interface AuditBase { + id: string; + url: string; + timeCreated: string; +} + +export interface AuditRunning extends AuditBase { + status: 'RUNNING'; +} + +export interface AuditFailed extends AuditBase { + status: 'FAILED'; + timeCompleted: string; +} + +export interface AuditCompleted extends AuditBase { + status: 'COMPLETED'; + timeCompleted: string; + report: Object; + categories: Record; +} + +export type Audit = AuditRunning | AuditFailed | AuditCompleted; + +export interface Website { + url: string; + audits: Audit[]; + lastAudit: Audit; +} + +export type WebsiteListResponse = LASListResponse; + +export interface TriggerAuditPayload { + url: string; + options: { + lighthouseConfig: { + settings: { + emulatedFormFactor: string; + }; + }; + }; +} + +export class FetchError extends Error { + get name(): string { + return this.constructor.name; + } + + static async forResponse(resp: Response): Promise { + return new FetchError( + `Request failed with status code ${ + resp.status + }.\nReason: ${await resp.text()}`, + ); + } +} + +export type LighthouseApi = { + url: string; + getWebsiteList: (listOptions: LASListRequest) => Promise; + getWebsiteForAuditId: (auditId: string) => Promise; + triggerAudit: (payload: TriggerAuditPayload) => Promise; +}; + +export const lighthouseApiRef = new ApiRef({ + id: 'plugin.lighthouse.service', + description: 'Used by the Lighthouse plugin to make requests', +}); + +export class LighthouseRestApi implements LighthouseApi { + constructor(public url: string) {} + + private async fetch(input: string, init?: RequestInit): Promise { + const resp = await fetch(`${this.url}${input}`, init); + if (!resp.ok) throw await FetchError.forResponse(resp); + return await resp.json(); + } + + async getWebsiteList({ limit, offset }: LASListRequest = {}): Promise< + WebsiteListResponse + > { + return await this.fetch( + `/v1/websites?limit=${limit}&offset=${offset}`, + ); + } + + async getWebsiteForAuditId(auditId: string): Promise { + return await this.fetch(`/v1/audits/${auditId}/website`); + } + + async triggerAudit(payload: TriggerAuditPayload): Promise { + return await this.fetch('/v1/audits', { + method: 'POST', + body: JSON.stringify(payload), + headers: { + 'Content-Type': 'application/json', + }, + }); + } +} diff --git a/plugins/lighthouse/src/components/AuditList/AuditListTable.test.tsx b/plugins/lighthouse/src/components/AuditList/AuditListTable.test.tsx new file mode 100644 index 0000000000..1e01b72ddf --- /dev/null +++ b/plugins/lighthouse/src/components/AuditList/AuditListTable.test.tsx @@ -0,0 +1,118 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import fs from 'fs'; +import path from 'path'; +import { render } from '@testing-library/react'; +import { wrapInThemedTestApp } from '@backstage/test-utils'; + +import AuditListTable from './AuditListTable'; +import { WebsiteListResponse } from '../../api'; +import { formatTime } from '../../utils'; + +const websiteListResponseJson = fs + .readFileSync( + path.join(__dirname, '../../__fixtures__/website-list-response.json'), + ) + .toString(); + +const websiteListResponse: WebsiteListResponse = JSON.parse( + websiteListResponseJson, +); + +describe('AuditListTable', () => { + it('renders the link to each website', () => { + const rendered = render( + wrapInThemedTestApp(), + ); + const link = rendered.queryByText('https://anchor.fm'); + const website = websiteListResponse.items.find( + w => w.url === 'https://anchor.fm', + ); + if (!website) + throw new Error('https://anchor.fm must be present in fixture'); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute( + 'href', + `/lighthouse/audit/${website.lastAudit.id}`, + ); + }); + + it('renders the dates that are available for a given row', () => { + const rendered = render( + wrapInThemedTestApp(), + ); + const website = websiteListResponse.items.find( + w => w.url === 'https://anchor.fm', + ); + if (!website) + throw new Error('https://anchor.fm must be present in fixture'); + expect( + rendered.queryByText(formatTime(website.lastAudit.timeCreated)), + ).toBeInTheDocument(); + }); + + it('renders the status for a given row', async () => { + const rendered = render( + wrapInThemedTestApp(), + ); + + const completed = await rendered.findAllByText('completed'); + expect(completed).toHaveLength( + websiteListResponse.items.filter(w => w.lastAudit.status === 'COMPLETED') + .length, + ); + + const failed = await rendered.findAllByText('failed'); + expect(failed).toHaveLength( + websiteListResponse.items.filter(w => w.lastAudit.status === 'FAILED') + .length, + ); + + const running = await rendered.findAllByText('failed'); + expect(running).toHaveLength( + websiteListResponse.items.filter(w => w.lastAudit.status === 'RUNNING') + .length, + ); + }); + + describe('sparklines', () => { + it('correctly maps the data from the website payload', () => { + const rendered = render( + wrapInThemedTestApp( + , + ), + ); + const backstageSEO = rendered.getByTitle( + 'trendline for SEO category of https://backstage.io', + ); + expect(backstageSEO).toBeInTheDocument(); + }); + + it('does not break when no data is available', () => { + const rendered = render( + wrapInThemedTestApp( + , + ), + ); + const anchorSEO = rendered.queryByTitle( + 'trendline for SEO category of https://anchor.fm', + ); + expect(anchorSEO).not.toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/lighthouse/src/components/AuditList/AuditListTable.tsx b/plugins/lighthouse/src/components/AuditList/AuditListTable.tsx new file mode 100644 index 0000000000..69994253e7 --- /dev/null +++ b/plugins/lighthouse/src/components/AuditList/AuditListTable.tsx @@ -0,0 +1,153 @@ +/* eslint-disable react/prop-types */ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { FC, useMemo } from 'react'; +import { + Link, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, +} from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; + +import { + Audit, + AuditCompleted, + LighthouseCategoryId, + Website, +} from '../../api'; +import { formatTime } from '../../utils'; +import CategoryTrendline from '../CategoryTrendline'; +import AuditStatusIcon from '../AuditStatusIcon'; + +export const CATEGORIES: LighthouseCategoryId[] = [ + 'accessibility', + 'performance', + 'seo', + 'best-practices', +]; + +export const CATEGORY_LABELS: Record = { + accessibility: 'Accessibility', + performance: 'Performance', + seo: 'SEO', + 'best-practices': 'Best Practices', + pwa: 'Progressive Web App', +}; + +const useStyles = makeStyles({ + table: { + minWidth: 650, + }, + status: { + textTransform: 'capitalize', + }, + link: { padding: '0.75rem 0', display: 'inline-block' }, + statusCell: { whiteSpace: 'nowrap' }, + sparklinesCell: { minWidth: 120 }, +}); + +type SparklinesDataByCategory = Record; +function buildSparklinesDataForItem(item: Website): SparklinesDataByCategory { + return item.audits + .filter( + (audit: Audit): audit is AuditCompleted => audit.status === 'COMPLETED', + ) + .reduce((scores, audit) => { + Object.values(audit.categories).forEach(category => { + scores[category.id] = scores[category.id] || []; + scores[category.id].unshift(category.score); + }); + + // edge case: if only one audit exists, force a "flat" sparkline + Object.values(scores).forEach(arr => { + if (arr.length === 1) arr.push(arr[0]); + }); + + return scores; + }, {} as SparklinesDataByCategory); +} + +export const AuditListTable: FC<{ items: Website[] }> = ({ items }) => { + const classes = useStyles(); + const categorySparklines: Record = useMemo( + () => + items.reduce( + (res, item) => ({ + ...res, + [item.url]: buildSparklinesDataForItem(item), + }), + {}, + ), + [items], + ); + + return ( + + + + + Website URL + {CATEGORIES.map(category => ( + + {CATEGORY_LABELS[category]} + + ))} + Last Report + Last Audit Triggered + + + + {items.map(website => ( + + + + {website.url} + + + {CATEGORIES.map(category => ( + + + + ))} + + {' '} + + {website.lastAudit.status.toLowerCase()} + + + {formatTime(website.lastAudit.timeCreated)} + + ))} + +
+
+ ); +}; + +export default AuditListTable; diff --git a/plugins/lighthouse/src/components/AuditList/index.test.tsx b/plugins/lighthouse/src/components/AuditList/index.test.tsx new file mode 100644 index 0000000000..5edf963377 --- /dev/null +++ b/plugins/lighthouse/src/components/AuditList/index.test.tsx @@ -0,0 +1,185 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +jest.mock('react-router-dom', () => { + const mocks = { + replace: jest.fn(), + push: jest.fn(), + }; + return { + ...jest.requireActual('react-router-dom'), + useHistory: jest.fn(() => mocks), + }; +}); + +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import fs from 'fs'; +import path from 'path'; +import mockFetch from 'jest-fetch-mock'; +import { render, fireEvent } from '@testing-library/react'; +import { ApiRegistry, ApiProvider } from '@backstage/core'; +import { wrapInThemedTestApp, wrapInTheme } from '@backstage/test-utils'; + +import { lighthouseApiRef, LighthouseRestApi } from '../../api'; +import AuditList from '.'; + +const { useHistory } = require.requireMock('react-router-dom'); + +const websiteListJson = fs + .readFileSync( + path.join(__dirname, '../../__fixtures__/website-list-response.json'), + ) + .toString(); + +describe('AuditList', () => { + let apis: ApiRegistry; + + beforeEach(() => { + apis = ApiRegistry.from([ + [lighthouseApiRef, new LighthouseRestApi('http://lighthouse')], + ]); + mockFetch.mockResponse(websiteListJson); + }); + + it('should render the table', async () => { + const rendered = render( + wrapInThemedTestApp( + + + , + ), + ); + const element = await rendered.findByText('https://anchor.fm'); + expect(element).toBeInTheDocument(); + }); + + it('renders a link to create a new audit', async () => { + const rendered = render( + wrapInThemedTestApp( + + + , + ), + ); + const element = await rendered.findByText('Create Audit'); + expect(element).toBeInTheDocument(); + expect(element.parentElement).toHaveAttribute( + 'href', + '/lighthouse/create-audit', + ); + }); + + describe('pagination', () => { + it('requests the correct limit and offset from the api based on the query', () => { + mockFetch.mockClear(); + render( + wrapInTheme( + + + + + , + ), + ); + expect(mockFetch).toHaveBeenLastCalledWith( + 'http://lighthouse/v1/websites?limit=10&offset=10', + undefined, + ); + }); + + describe('when only one page is needed', () => { + it('hides pagination elements', () => { + const rendered = render( + wrapInThemedTestApp( + + + , + ), + ); + expect(rendered.queryByLabelText(/Go to page/)).not.toBeInTheDocument(); + }); + }); + + describe('when multiple pages are needed', () => { + beforeEach(() => { + const response = JSON.parse(websiteListJson); + response.limit = 5; + response.offset = 5; + response.total = 7; + mockFetch.mockResponseOnce(JSON.stringify(response)); + }); + + it('shows pagination elements', async () => { + const rendered = render( + wrapInThemedTestApp( + + + , + ), + ); + expect( + await rendered.findByLabelText(/Go to page/), + ).toBeInTheDocument(); + }); + + it('changes the page on click', async () => { + const rendered = render( + wrapInTheme( + + + + + , + ), + ); + const element = await rendered.findByLabelText(/Go to page 1/); + fireEvent.click(element); + expect(useHistory().replace).toHaveBeenCalledWith(`/lighthouse?page=1`); + }); + }); + }); + + describe('when waiting on the request', () => { + it('should render the loader', async () => { + mockFetch.mockResponseOnce(() => new Promise(() => {})); + const rendered = render( + wrapInThemedTestApp( + + + , + ), + ); + const element = await rendered.findByTestId('progress'); + expect(element).toBeInTheDocument(); + }); + }); + + describe('when the audits fail', () => { + it('should render an error', async () => { + mockFetch.mockRejectOnce(new Error('failed to fetch')); + const rendered = render( + wrapInThemedTestApp( + + + , + ), + ); + const element = await rendered.findByTestId('error-message'); + expect(element).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/lighthouse/src/components/AuditList/index.tsx b/plugins/lighthouse/src/components/AuditList/index.tsx new file mode 100644 index 0000000000..234fdb3d5c --- /dev/null +++ b/plugins/lighthouse/src/components/AuditList/index.tsx @@ -0,0 +1,130 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState, useMemo, FC, ReactNode } from 'react'; +import { useLocalStorage, useAsync } from 'react-use'; +import { useHistory } from 'react-router-dom'; +import { Grid, Button } from '@material-ui/core'; +import Alert from '@material-ui/lab/Alert'; +import Pagination from '@material-ui/lab/Pagination'; +import { + InfoCard, + Header, + Page, + Content, + ContentHeader, + HeaderLabel, + Progress, + pageTheme, + useApi, +} from '@backstage/core'; + +import { lighthouseApiRef, WebsiteListResponse } from '../../api'; +import { useQuery } from '../../utils'; +import LighthouseSupportButton from '../SupportButton'; +import LighthouseIntro, { LIGHTHOUSE_INTRO_LOCAL_STORAGE } from '../Intro'; +import AuditListTable from './AuditListTable'; + +export const LIMIT = 10; + +const AuditList: FC<{}> = () => { + const [dismissedStored] = useLocalStorage(LIGHTHOUSE_INTRO_LOCAL_STORAGE); + const [dismissed, setDismissed] = useState(dismissedStored); + + const query = useQuery(); + const page = query.get('page') + ? parseInt(query.get('page') as string, 10) || 1 + : 1; + + const lighthouseApi = useApi(lighthouseApiRef); + const { value, loading, error } = useAsync( + async (): Promise => + lighthouseApi.getWebsiteList({ + limit: LIMIT, + offset: (page - 1) * LIMIT, + }), + [page], + ); + + const pageCount: number = useMemo(() => { + if (value?.total && value?.limit) + return Math.ceil(value?.total / value?.limit); + return 0; + }, [value?.total, value?.limit]); + + const history = useHistory(); + + let content: ReactNode = null; + if (value) { + content = ( + <> + + {pageCount > 1 && ( + { + history.replace(`/lighthouse?page=${newPage}`); + }} + /> + )} + + ); + } else if (loading) { + content = ; + } else if (error) { + content = ( + + {error.message} + + ); + } + + return ( + +
+ + +
+ + setDismissed(true)} /> + + + {dismissed && } + + + + {content} + + + +
+ ); +}; + +export default AuditList; diff --git a/plugins/lighthouse/src/components/AuditStatusIcon/index.tsx b/plugins/lighthouse/src/components/AuditStatusIcon/index.tsx new file mode 100644 index 0000000000..ccab763603 --- /dev/null +++ b/plugins/lighthouse/src/components/AuditStatusIcon/index.tsx @@ -0,0 +1,28 @@ +/* eslint-disable react/prop-types */ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { FC } from 'react'; +import { StatusPending, StatusError, StatusOK } from '@backstage/core'; + +import { Audit } from '../../api'; + +const AuditStatusIcon: FC<{ audit: Audit }> = ({ audit }) => { + if (audit.status === 'FAILED') return ; + if (audit.status === 'COMPLETED') return ; + return ; +}; + +export default AuditStatusIcon; diff --git a/plugins/lighthouse/src/components/AuditView/index.test.tsx b/plugins/lighthouse/src/components/AuditView/index.test.tsx new file mode 100644 index 0000000000..977e30d1fb --- /dev/null +++ b/plugins/lighthouse/src/components/AuditView/index.test.tsx @@ -0,0 +1,226 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* eslint-disable jest/no-disabled-tests */ + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: jest.fn(() => ({})), +})); + +import React from 'react'; +import fs from 'fs'; +import path from 'path'; +import mockFetch from 'jest-fetch-mock'; +import { render } from '@testing-library/react'; +import { wrapInThemedTestApp } from '@backstage/test-utils'; +import { ApiRegistry, ApiProvider } from '@backstage/core'; + +import AuditView from '.'; +import { lighthouseApiRef, LighthouseRestApi, Audit, Website } from '../../api'; +import { formatTime } from '../../utils'; + +const { useParams }: { useParams: jest.Mock } = require.requireMock( + 'react-router-dom', +); + +const websiteResponseJson = fs + .readFileSync( + path.join(__dirname, '../../__fixtures__/website-response.json'), + ) + .toString(); + +describe('AuditView', () => { + let apis: ApiRegistry; + let website: Website; + let id: string; + + beforeEach(() => { + mockFetch.mockResponse(websiteResponseJson); + apis = ApiRegistry.from([ + [lighthouseApiRef, new LighthouseRestApi('https://lighthouse')], + ]); + website = JSON.parse(websiteResponseJson) as Website; + id = website.audits.find(a => a.status === 'COMPLETED')?.id as string; + useParams.mockReturnValue({ id }); + }); + + it('renders the iframe for the selected audit', async () => { + const rendered = render( + wrapInThemedTestApp( + + + , + ), + ); + + const iframe = await rendered.findByTitle( + 'Lighthouse audit for https://spotify.com', + ); + expect(iframe).toBeInTheDocument(); + expect(iframe).toHaveAttribute('src', `https://lighthouse/v1/audits/${id}`); + }); + + it('renders a link to create a new audit for this website', async () => { + const rendered = render( + wrapInThemedTestApp( + + + , + ), + ); + + const button = await rendered.findByText('Create Audit'); + expect(button).toBeInTheDocument(); + expect(button.parentElement).toHaveAttribute( + 'href', + `/lighthouse/create-audit?url=${encodeURIComponent( + 'https://spotify.com', + )}`, + ); + }); + + describe('sidebar', () => { + it('renders a list of all audits for the website', async () => { + const rendered = render( + wrapInThemedTestApp( + + + , + ), + ); + + await rendered.findByTestId('audit-sidebar'); + + website.audits.forEach(a => { + expect( + rendered.queryByText(formatTime(a.timeCreated)), + ).toBeInTheDocument(); + }); + }); + + it('sets the current audit as active', async () => { + const rendered = render( + wrapInThemedTestApp( + + + , + ), + ); + + await rendered.findByTestId('audit-sidebar'); + + const audit = website.audits.find(a => a.id === id) as Audit; + const auditElement = rendered.getByText(formatTime(audit.timeCreated)); + expect(auditElement.parentElement?.parentElement?.className).toContain( + 'selected', + ); + + const notSelectedAudit = website.audits.find(a => a.id !== id) as Audit; + const notSelectedAuditElement = rendered.getByText( + formatTime(notSelectedAudit.timeCreated), + ); + expect( + notSelectedAuditElement.parentElement?.parentElement?.className, + ).not.toContain('selected'); + }); + + it('navigates to the next report when an audit is clicked', async () => { + const rendered = render( + wrapInThemedTestApp( + + + , + ), + ); + + await rendered.findByTestId('audit-sidebar'); + + website.audits.forEach(a => { + expect( + rendered.getByText(formatTime(a.timeCreated)).parentElement + ?.parentElement, + ).toHaveAttribute('href', `/lighthouse/audit/${a.id}`); + }); + }); + }); + + describe('when the request for the website by id is pending', () => { + it('it shows the loading', async () => { + mockFetch.mockImplementationOnce(() => new Promise(() => {})); + const rendered = render( + wrapInThemedTestApp( + + + , + ), + ); + expect(await rendered.findByTestId('progress')).toBeInTheDocument(); + }); + }); + + describe('when the request for the website by id fails', () => { + it('it shows an error', async () => { + mockFetch.mockRejectOnce(new Error('failed to fetch')); + const rendered = render( + wrapInThemedTestApp( + + + , + ), + ); + expect(await rendered.findByText('failed to fetch')).toBeInTheDocument(); + }); + }); + + describe.skip('when a loading audit is accessed', () => { + it('shows a loading view', async () => { + id = website.audits.find(a => a.status === 'RUNNING')?.id as string; + useParams.mockReturnValueOnce({ id }); + + const rendered = render( + wrapInThemedTestApp( + + + , + ), + ); + + await rendered.findByTestId('audit-sidebar'); + + expect(rendered.queryByTestId('progress')).toBeInTheDocument(); + }); + }); + + describe.skip('when a failed audit is accessed', () => { + it('shows an error message', async () => { + id = website.audits.find(a => a.status === 'FAILED')?.id as string; + useParams.mockReturnValueOnce({ id }); + + const rendered = render( + wrapInThemedTestApp( + + + , + ), + ); + + await rendered.findByTestId('audit-sidebar'); + + expect(rendered.queryByText(/This audit failed/)).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/lighthouse/src/components/AuditView/index.tsx b/plugins/lighthouse/src/components/AuditView/index.tsx new file mode 100644 index 0000000000..09f37c4242 --- /dev/null +++ b/plugins/lighthouse/src/components/AuditView/index.tsx @@ -0,0 +1,191 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { useState, useEffect, ReactNode, FC } from 'react'; +import { Link, useParams } from 'react-router-dom'; +import { useAsync } from 'react-use'; +import { + makeStyles, + Button, + Grid, + List, + ListItem, + ListItemIcon, + ListItemText, +} from '@material-ui/core'; +import Alert from '@material-ui/lab/Alert'; +import { + useApi, + pageTheme, + InfoCard, + Header, + Page, + Content, + ContentHeader, + HeaderLabel, + Progress, +} from '@backstage/core'; + +import { lighthouseApiRef, Website, Audit } from '../../api'; +import AuditStatusIcon from '../AuditStatusIcon'; +import LighthouseSupportButton from '../SupportButton'; +import { formatTime } from '../../utils'; + +const useStyles = makeStyles({ + contentGrid: { + height: '100%', + }, + iframe: { + border: '0', + width: '100%', + height: '100%', + }, + errorOutput: { whiteSpace: 'pre' }, +}); + +interface AuditLinkListProps { + audits?: Audit[]; + selectedId: string; +} +const AuditLinkList: FC = ({ + audits = [], + selectedId, +}: AuditLinkListProps) => ( + + {audits.map(audit => ( + + + + + + + ))} + +); + +const AuditView: FC<{ audit?: Audit }> = ({ audit }: { audit?: Audit }) => { + const classes = useStyles(); + const params = useParams<{ id: string }>(); + const { url: lighthouseUrl } = useApi(lighthouseApiRef); + + if (audit?.status === 'RUNNING') return ; + if (audit?.status === 'FAILED') + return ( + + This audit failed when attempting to run after several retries. Check + that your instance of lighthouse-audit-service can successfully connect + to your website and try again. + + ); + + return ( + +