feat: implement Lighthouse plugin using lighthouse-audit-service
This commit is contained in:
@@ -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;
|
||||
```
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Item> {
|
||||
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<LighthouseCategoryId, LighthouseCategoryAbbr>;
|
||||
}
|
||||
|
||||
export type Audit = AuditRunning | AuditFailed | AuditCompleted;
|
||||
|
||||
export interface Website {
|
||||
url: string;
|
||||
audits: Audit[];
|
||||
lastAudit: Audit;
|
||||
}
|
||||
|
||||
export type WebsiteListResponse = LASListResponse<Website>;
|
||||
|
||||
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<FetchError> {
|
||||
return new FetchError(
|
||||
`Request failed with status code ${
|
||||
resp.status
|
||||
}.\nReason: ${await resp.text()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export type LighthouseApi = {
|
||||
url: string;
|
||||
getWebsiteList: (listOptions: LASListRequest) => Promise<WebsiteListResponse>;
|
||||
getWebsiteForAuditId: (auditId: string) => Promise<Website>;
|
||||
triggerAudit: (payload: TriggerAuditPayload) => Promise<Audit>;
|
||||
};
|
||||
|
||||
export const lighthouseApiRef = new ApiRef<LighthouseApi>({
|
||||
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<T = any>(input: string, init?: RequestInit): Promise<T> {
|
||||
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<WebsiteListResponse>(
|
||||
`/v1/websites?limit=${limit}&offset=${offset}`,
|
||||
);
|
||||
}
|
||||
|
||||
async getWebsiteForAuditId(auditId: string): Promise<Website> {
|
||||
return await this.fetch<Website>(`/v1/audits/${auditId}/website`);
|
||||
}
|
||||
|
||||
async triggerAudit(payload: TriggerAuditPayload): Promise<Audit> {
|
||||
return await this.fetch<Audit>('/v1/audits', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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(<AuditListTable items={websiteListResponse.items} />),
|
||||
);
|
||||
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(<AuditListTable items={websiteListResponse.items} />),
|
||||
);
|
||||
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(<AuditListTable items={websiteListResponse.items} />),
|
||||
);
|
||||
|
||||
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(
|
||||
<AuditListTable items={websiteListResponse.items} />,
|
||||
),
|
||||
);
|
||||
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(
|
||||
<AuditListTable items={websiteListResponse.items} />,
|
||||
),
|
||||
);
|
||||
const anchorSEO = rendered.queryByTitle(
|
||||
'trendline for SEO category of https://anchor.fm',
|
||||
);
|
||||
expect(anchorSEO).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<LighthouseCategoryId, string> = {
|
||||
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<LighthouseCategoryId, number[]>;
|
||||
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<string, SparklinesDataByCategory> = useMemo(
|
||||
() =>
|
||||
items.reduce(
|
||||
(res, item) => ({
|
||||
...res,
|
||||
[item.url]: buildSparklinesDataForItem(item),
|
||||
}),
|
||||
{},
|
||||
),
|
||||
[items],
|
||||
);
|
||||
|
||||
return (
|
||||
<TableContainer>
|
||||
<Table className={classes.table} size="small" aria-label="a dense table">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Website URL</TableCell>
|
||||
{CATEGORIES.map(category => (
|
||||
<TableCell key={`${category}-label`}>
|
||||
{CATEGORY_LABELS[category]}
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell>Last Report</TableCell>
|
||||
<TableCell>Last Audit Triggered</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{items.map(website => (
|
||||
<TableRow key={website.url}>
|
||||
<TableCell>
|
||||
<Link
|
||||
className={classes.link}
|
||||
href={`/lighthouse/audit/${website.lastAudit.id}`}
|
||||
>
|
||||
{website.url}
|
||||
</Link>
|
||||
</TableCell>
|
||||
{CATEGORIES.map(category => (
|
||||
<TableCell
|
||||
key={`${website.url}|${category}`}
|
||||
className={classes.sparklinesCell}
|
||||
>
|
||||
<CategoryTrendline
|
||||
title={`trendline for ${CATEGORY_LABELS[category]} category of ${website.url}`}
|
||||
data={categorySparklines[website.url][category] || []}
|
||||
/>
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell className={classes.statusCell}>
|
||||
<AuditStatusIcon audit={website.lastAudit} />{' '}
|
||||
<span className={classes.status}>
|
||||
{website.lastAudit.status.toLowerCase()}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{formatTime(website.lastAudit.timeCreated)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuditListTable;
|
||||
@@ -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(
|
||||
<ApiProvider apis={apis}>
|
||||
<AuditList />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
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(
|
||||
<ApiProvider apis={apis}>
|
||||
<AuditList />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
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(
|
||||
<MemoryRouter initialEntries={['/lighthouse?page=2']}>
|
||||
<ApiProvider apis={apis}>
|
||||
<AuditList />
|
||||
</ApiProvider>
|
||||
</MemoryRouter>,
|
||||
),
|
||||
);
|
||||
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(
|
||||
<ApiProvider apis={apis}>
|
||||
<AuditList />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
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(
|
||||
<ApiProvider apis={apis}>
|
||||
<AuditList />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
await rendered.findByLabelText(/Go to page/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('changes the page on click', async () => {
|
||||
const rendered = render(
|
||||
wrapInTheme(
|
||||
<MemoryRouter initialEntries={['/lighthouse?page=2']}>
|
||||
<ApiProvider apis={apis}>
|
||||
<AuditList />
|
||||
</ApiProvider>
|
||||
</MemoryRouter>,
|
||||
),
|
||||
);
|
||||
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(
|
||||
<ApiProvider apis={apis}>
|
||||
<AuditList />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
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(
|
||||
<ApiProvider apis={apis}>
|
||||
<AuditList />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
const element = await rendered.findByTestId('error-message');
|
||||
expect(element).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<WebsiteListResponse> =>
|
||||
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 = (
|
||||
<>
|
||||
<AuditListTable items={value?.items || []} />
|
||||
{pageCount > 1 && (
|
||||
<Pagination
|
||||
page={page}
|
||||
count={pageCount}
|
||||
onChange={(_event: Event, newPage: number) => {
|
||||
history.replace(`/lighthouse?page=${newPage}`);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
} else if (loading) {
|
||||
content = <Progress />;
|
||||
} else if (error) {
|
||||
content = (
|
||||
<Alert severity="error" data-testid="error-message">
|
||||
{error.message}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Page theme={pageTheme.tool}>
|
||||
<Header
|
||||
title="Lighthouse"
|
||||
subtitle="Website audits powered by Lighthouse"
|
||||
>
|
||||
<HeaderLabel label="Owner" value="Spotify" />
|
||||
<HeaderLabel label="Lifecycle" value="Alpha" />
|
||||
</Header>
|
||||
<Content>
|
||||
<LighthouseIntro onDismiss={() => setDismissed(true)} />
|
||||
<ContentHeader
|
||||
title="Audits"
|
||||
description="View all audits run for your website through Backstage here. Track the trend of your most recent audits."
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
href="/lighthouse/create-audit"
|
||||
>
|
||||
Create Audit
|
||||
</Button>
|
||||
{dismissed && <LighthouseSupportButton />}
|
||||
</ContentHeader>
|
||||
<Grid container spacing={3} direction="column">
|
||||
<Grid item>
|
||||
<InfoCard>{content}</InfoCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Content>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuditList;
|
||||
@@ -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 <StatusError />;
|
||||
if (audit.status === 'COMPLETED') return <StatusOK />;
|
||||
return <StatusPending />;
|
||||
};
|
||||
|
||||
export default AuditStatusIcon;
|
||||
@@ -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(
|
||||
<ApiProvider apis={apis}>
|
||||
<AuditView />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
<ApiProvider apis={apis}>
|
||||
<AuditView />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
<ApiProvider apis={apis}>
|
||||
<AuditView />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
<ApiProvider apis={apis}>
|
||||
<AuditView />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
<ApiProvider apis={apis}>
|
||||
<AuditView />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
<ApiProvider apis={apis}>
|
||||
<AuditView />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
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(
|
||||
<ApiProvider apis={apis}>
|
||||
<AuditView />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
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(
|
||||
<ApiProvider apis={apis}>
|
||||
<AuditView />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
<ApiProvider apis={apis}>
|
||||
<AuditView />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
await rendered.findByTestId('audit-sidebar');
|
||||
|
||||
expect(rendered.queryByText(/This audit failed/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<AuditLinkListProps> = ({
|
||||
audits = [],
|
||||
selectedId,
|
||||
}: AuditLinkListProps) => (
|
||||
<List
|
||||
data-testid="audit-sidebar"
|
||||
component="nav"
|
||||
aria-label="lighthouse audit history"
|
||||
>
|
||||
{audits.map(audit => (
|
||||
<ListItem
|
||||
key={audit.id}
|
||||
selected={audit.id === selectedId}
|
||||
button
|
||||
component={Link}
|
||||
replace
|
||||
to={`/lighthouse/audit/${audit.id}`}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<AuditStatusIcon audit={audit} />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={formatTime(audit.timeCreated)} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
|
||||
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 <Progress />;
|
||||
if (audit?.status === 'FAILED')
|
||||
return (
|
||||
<Alert severity="error">
|
||||
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.
|
||||
</Alert>
|
||||
);
|
||||
|
||||
return (
|
||||
<InfoCard variant="fullHeight">
|
||||
<iframe
|
||||
className={classes.iframe}
|
||||
title={`Lighthouse audit${audit?.url ? ` for ${audit.url}` : ''}`}
|
||||
src={`${lighthouseUrl}/v1/audits/${params.id}`}
|
||||
/>
|
||||
</InfoCard>
|
||||
);
|
||||
};
|
||||
|
||||
const ConnectedAuditView: FC<{}> = () => {
|
||||
const lighthouseApi = useApi(lighthouseApiRef);
|
||||
const params = useParams<{ id: string }>();
|
||||
const classes = useStyles();
|
||||
|
||||
const { loading, error, value: nextValue } = useAsync<Website>(
|
||||
async () => lighthouseApi.getWebsiteForAuditId(params.id),
|
||||
[params.id],
|
||||
);
|
||||
const [value, setValue] = useState<Website>();
|
||||
useEffect(() => {
|
||||
if (!!nextValue && nextValue.url !== value?.url) {
|
||||
setValue(nextValue);
|
||||
}
|
||||
}, [value, nextValue, setValue]);
|
||||
|
||||
let content: ReactNode = null;
|
||||
if (value) {
|
||||
content = (
|
||||
<Grid container alignItems="stretch" className={classes.contentGrid}>
|
||||
<Grid item xs={3}>
|
||||
<AuditLinkList audits={value?.audits} selectedId={params.id} />
|
||||
</Grid>
|
||||
<Grid item xs={9}>
|
||||
<AuditView audit={value?.audits.find(a => a.id === params.id)} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
} else if (loading) {
|
||||
content = <Progress />;
|
||||
} else if (error) {
|
||||
content = (
|
||||
<Alert
|
||||
data-testid="error-message"
|
||||
severity="error"
|
||||
className={classes.errorOutput}
|
||||
>
|
||||
{error.message}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
let createAuditButtonUrl = '/lighthouse/create-audit';
|
||||
if (value?.url) {
|
||||
createAuditButtonUrl += `?url=${encodeURIComponent(value.url)}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<Page theme={pageTheme.tool}>
|
||||
<Header
|
||||
title="Lighthouse"
|
||||
subtitle="Website audits powered by Lighthouse"
|
||||
>
|
||||
<HeaderLabel label="Owner" value="Spotify" />
|
||||
<HeaderLabel label="Lifecycle" value="Alpha" />
|
||||
</Header>
|
||||
<Content stretch>
|
||||
<ContentHeader
|
||||
title={value?.url || 'Audit'}
|
||||
description="See a history of all Lighthouse audits for your website run through Backstage."
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
href={createAuditButtonUrl}
|
||||
>
|
||||
Create Audit
|
||||
</Button>
|
||||
<LighthouseSupportButton />
|
||||
</ContentHeader>
|
||||
{content}
|
||||
</Content>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectedAuditView;
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* 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 */
|
||||
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { wrapInThemedTestApp } from '@backstage/test-utils';
|
||||
|
||||
import CategoryTrendline from '.';
|
||||
|
||||
describe('CategoryTrendline', () => {
|
||||
describe('when no data is present', () => {
|
||||
it('renders null without throwing', () => {
|
||||
const rendered = render(
|
||||
wrapInThemedTestApp(<CategoryTrendline data={[]} title="sparkline" />),
|
||||
);
|
||||
expect(rendered.queryByTitle('sparkline')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when one datapoint is present', () => {
|
||||
it('renders as a straight line', () => {
|
||||
const rendered = render(
|
||||
wrapInThemedTestApp(
|
||||
<CategoryTrendline data={[0.5]} title="sparkline" />,
|
||||
),
|
||||
);
|
||||
expect(rendered.getByTitle('sparkline')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe.skip('when the data finishes above the success threshold', () => {
|
||||
it('renders with the correct color', () => {
|
||||
const rendered = render(
|
||||
wrapInThemedTestApp(
|
||||
<CategoryTrendline data={[0.5, 0.95]} title="sparkline" />,
|
||||
),
|
||||
);
|
||||
expect(rendered.getByTitle('sparkline')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe.skip('when the data finishes within the the warning threshold', () => {
|
||||
it('renders with the correct color', () => {
|
||||
const rendered = render(
|
||||
wrapInThemedTestApp(
|
||||
<CategoryTrendline data={[0.5, 0.65]} title="sparkline" />,
|
||||
),
|
||||
);
|
||||
expect(rendered.getByTitle('sparkline')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe.skip('when the data finishes within the the error threshold', () => {
|
||||
it('renders with the correct color', () => {
|
||||
const rendered = render(
|
||||
wrapInThemedTestApp(
|
||||
<CategoryTrendline data={[0.5, 0.4]} title="sparkline" />,
|
||||
),
|
||||
);
|
||||
expect(rendered.getByTitle('sparkline')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/* 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 { Sparklines, SparklinesLine, SparklinesProps } from 'react-sparklines';
|
||||
import { COLORS } from '@backstage/theme';
|
||||
|
||||
function color(data: number[]): string | undefined {
|
||||
const lastNum = data[data.length - 1];
|
||||
if (!lastNum) return undefined;
|
||||
if (lastNum >= 0.9) return COLORS.STATUS.OK;
|
||||
if (lastNum >= 0.5) return COLORS.STATUS.WARNING;
|
||||
return COLORS.STATUS.ERROR;
|
||||
}
|
||||
|
||||
const CategoryTrendline: FC<SparklinesProps & { title?: string }> = props => {
|
||||
if (!props.data) return null;
|
||||
return (
|
||||
<Sparklines width={120} height={30} min={0} max={1} {...props}>
|
||||
{props.title && <title>{props.title}</title>}
|
||||
<SparklinesLine color={color(props.data)} />
|
||||
</Sparklines>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoryTrendline;
|
||||
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
* 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 fs from 'fs';
|
||||
import path from 'path';
|
||||
import mockFetch from 'jest-fetch-mock';
|
||||
import { wait, render, fireEvent } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import {
|
||||
ApiRegistry,
|
||||
ApiProvider,
|
||||
ErrorApi,
|
||||
errorApiRef,
|
||||
} from '@backstage/core';
|
||||
import { wrapInThemedTestApp, wrapInTheme } from '@backstage/test-utils';
|
||||
|
||||
import { lighthouseApiRef, LighthouseRestApi } from '../../api';
|
||||
import CreateAudit from '.';
|
||||
|
||||
const { useHistory }: { useHistory: jest.Mock } = require.requireMock(
|
||||
'react-router-dom',
|
||||
);
|
||||
|
||||
const createAuditResponseJson = fs
|
||||
.readFileSync(
|
||||
path.join(__dirname, '../../__fixtures__/create-audit-response.json'),
|
||||
)
|
||||
.toString();
|
||||
|
||||
// TODO add act() to these tests without breaking them!
|
||||
describe('CreateAudit', () => {
|
||||
let apis: ApiRegistry;
|
||||
let errorApi: ErrorApi;
|
||||
|
||||
beforeEach(() => {
|
||||
errorApi = { post: jest.fn() };
|
||||
apis = ApiRegistry.from([
|
||||
[lighthouseApiRef, new LighthouseRestApi('http://lighthouse')],
|
||||
[errorApiRef, errorApi],
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders the form', () => {
|
||||
const rendered = render(
|
||||
wrapInThemedTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<CreateAudit />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
expect(rendered.getByLabelText(/URL/)).toBeEnabled();
|
||||
expect(rendered.getByLabelText(/URL/)).toHaveAttribute('value', '');
|
||||
expect(rendered.getByText(/Create Audit/)).toBeEnabled();
|
||||
});
|
||||
|
||||
describe('when the location contains a url', () => {
|
||||
it('prefills the url into the form', () => {
|
||||
const url = 'https://spotify.com';
|
||||
const rendered = render(
|
||||
wrapInTheme(
|
||||
<MemoryRouter
|
||||
initialEntries={[
|
||||
`/lighthouse/create-audit?url=${encodeURIComponent(url)}`,
|
||||
]}
|
||||
>
|
||||
<ApiProvider apis={apis}>
|
||||
<CreateAudit />
|
||||
</ApiProvider>
|
||||
</MemoryRouter>,
|
||||
),
|
||||
);
|
||||
expect(rendered.getByLabelText(/URL/)).toHaveAttribute('value', url);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when waiting on the request', () => {
|
||||
it('disables the form fields', () => {
|
||||
mockFetch.mockResponseOnce(() => new Promise(() => {}));
|
||||
|
||||
const rendered = render(
|
||||
wrapInThemedTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<CreateAudit />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.change(rendered.getByLabelText(/URL/), {
|
||||
target: { value: 'https://spotify.com' },
|
||||
});
|
||||
fireEvent.click(rendered.getByText(/Create Audit/));
|
||||
|
||||
expect(rendered.getByLabelText(/URL/)).toBeDisabled();
|
||||
expect(rendered.getByText(/Create Audit/)).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the audit is successfully created', () => {
|
||||
it('triggers a location change to the table', async () => {
|
||||
useHistory().push.mockClear();
|
||||
mockFetch.mockResponseOnce(createAuditResponseJson);
|
||||
|
||||
const rendered = render(
|
||||
wrapInThemedTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<CreateAudit />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.change(rendered.getByLabelText(/URL/), {
|
||||
target: { value: 'https://spotify.com' },
|
||||
});
|
||||
fireEvent.click(rendered.getByText(/Create Audit/));
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'http://lighthouse/v1/audits',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
}),
|
||||
);
|
||||
|
||||
await wait(() => expect(rendered.getByLabelText(/URL/)).toBeEnabled());
|
||||
|
||||
expect(useHistory().push).toHaveBeenCalledWith('/lighthouse');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the audits fail', () => {
|
||||
it('should render an error', async () => {
|
||||
(errorApi.post as jest.Mock).mockClear();
|
||||
mockFetch.mockRejectOnce(new Error('failed to post'));
|
||||
|
||||
const rendered = render(
|
||||
wrapInThemedTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<CreateAudit />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.change(rendered.getByLabelText(/URL/), {
|
||||
target: { value: 'https://spotify.com' },
|
||||
});
|
||||
fireEvent.click(rendered.getByText(/Create Audit/));
|
||||
|
||||
await wait(() => expect(rendered.getByLabelText(/URL/)).toBeEnabled());
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
|
||||
expect(errorApi.post).toHaveBeenCalledWith(expect.any(Error));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
* 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, useCallback, FC } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import {
|
||||
makeStyles,
|
||||
Grid,
|
||||
Button,
|
||||
List,
|
||||
ListItem,
|
||||
MenuItem,
|
||||
TextField,
|
||||
} from '@material-ui/core';
|
||||
import {
|
||||
errorApiRef,
|
||||
useApi,
|
||||
InfoCard,
|
||||
Header,
|
||||
Page,
|
||||
pageTheme,
|
||||
Content,
|
||||
ContentHeader,
|
||||
HeaderLabel,
|
||||
} from '@backstage/core';
|
||||
|
||||
import { lighthouseApiRef } from '../../api';
|
||||
import { useQuery } from '../../utils';
|
||||
import LighthouseSupportButton from '../SupportButton';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
input: {
|
||||
minWidth: 300,
|
||||
},
|
||||
buttonList: {
|
||||
marginLeft: theme.spacing(-1),
|
||||
marginRight: theme.spacing(-1),
|
||||
'& > *': {
|
||||
margin: theme.spacing(1),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const CreateAudit: FC<{}> = () => {
|
||||
const errorApi = useApi(errorApiRef);
|
||||
const lighthouseApi = useApi(lighthouseApiRef);
|
||||
const classes = useStyles();
|
||||
const query = useQuery();
|
||||
const history = useHistory();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [url, setUrl] = useState<string>(query.get('url') || '');
|
||||
const [emulatedFormFactor, setEmulatedFormFactor] = useState('mobile');
|
||||
|
||||
const triggerAudit = useCallback(async (): Promise<void> => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
// TODO use the id from the response to redirect to the audit page for that id when
|
||||
// FAILED and RUNNING audits are supported
|
||||
await lighthouseApi.triggerAudit({
|
||||
url,
|
||||
options: {
|
||||
lighthouseConfig: {
|
||||
settings: {
|
||||
emulatedFormFactor,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
history.push('/lighthouse');
|
||||
} catch (err) {
|
||||
errorApi.post(err);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [url, emulatedFormFactor, lighthouseApi, setSubmitting]);
|
||||
|
||||
return (
|
||||
<Page theme={pageTheme.tool}>
|
||||
<Header
|
||||
title="Lighthouse"
|
||||
subtitle="Website audits powered by Lighthouse"
|
||||
>
|
||||
<HeaderLabel label="Owner" value="Spotify" />
|
||||
<HeaderLabel label="Lifecycle" value="Alpha" />
|
||||
</Header>
|
||||
<Content>
|
||||
<ContentHeader
|
||||
title="Trigger a new audit"
|
||||
description="Submitting this form will immediately trigger and store a new Lighthouse audit. Trigger audits to track your website's accessibility, performance, SEO, and best practices over time."
|
||||
>
|
||||
<LighthouseSupportButton />
|
||||
</ContentHeader>
|
||||
<Grid container direction="column">
|
||||
<Grid item xs={12} sm={6}>
|
||||
<InfoCard>
|
||||
<form
|
||||
onSubmit={ev => {
|
||||
ev.preventDefault();
|
||||
triggerAudit();
|
||||
}}
|
||||
>
|
||||
<List>
|
||||
<ListItem>
|
||||
<TextField
|
||||
name="lighthouse-create-audit-url-tf"
|
||||
className={classes.input}
|
||||
label="URL"
|
||||
placeholder="https://spotify.com"
|
||||
helperText="The target URL for Lighthouse to use."
|
||||
required
|
||||
disabled={submitting}
|
||||
onChange={ev => setUrl(ev.target.value)}
|
||||
value={url}
|
||||
inputProps={{ 'aria-label': 'URL' }}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<TextField
|
||||
name="lighthouse-create-audit-emulated-form-factor-tf"
|
||||
className={classes.input}
|
||||
label="Emulated Form Factor"
|
||||
helperText="Device to simulate when auditing"
|
||||
select
|
||||
required
|
||||
disabled={submitting}
|
||||
onChange={ev => setEmulatedFormFactor(ev.target.value)}
|
||||
value={emulatedFormFactor}
|
||||
inputProps={{ 'aria-label': 'Emulated form factor' }}
|
||||
>
|
||||
<MenuItem value="mobile">Mobile</MenuItem>
|
||||
<MenuItem value="desktop">Desktop</MenuItem>
|
||||
</TextField>
|
||||
</ListItem>
|
||||
<ListItem className={classes.buttonList}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
href="/lighthouse"
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
>
|
||||
Create Audit
|
||||
</Button>
|
||||
</ListItem>
|
||||
</List>
|
||||
</form>
|
||||
</InfoCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Content>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateAudit;
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* 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 */
|
||||
|
||||
import React from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { wrapInThemedTestApp } from '@backstage/test-utils';
|
||||
|
||||
import LighthouseIntro from '.';
|
||||
|
||||
describe('LighthouseIntro', () => {
|
||||
it('renders successfully', () => {
|
||||
const rendered = render(wrapInThemedTestApp(<LighthouseIntro />));
|
||||
expect(
|
||||
rendered.queryByText('Welcome to Lighthouse in Backstage!'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('tabs', () => {
|
||||
const firstTabRe = /This plugin allows you to/;
|
||||
const secondTabRe = /you will need a running instance of/;
|
||||
|
||||
it('selects the first text element', () => {
|
||||
const rendered = render(wrapInThemedTestApp(<LighthouseIntro />));
|
||||
expect(rendered.queryByText(firstTabRe)).toBeInTheDocument();
|
||||
expect(rendered.queryByText(secondTabRe)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the other text when the tab is clicked', () => {
|
||||
const rendered = render(wrapInThemedTestApp(<LighthouseIntro />));
|
||||
fireEvent.click(rendered.getByText('Setup'));
|
||||
expect(rendered.queryByText(firstTabRe)).not.toBeInTheDocument();
|
||||
expect(rendered.queryByText(secondTabRe)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('closing', () => {
|
||||
it('hides the content on click', () => {
|
||||
const rendered = render(wrapInThemedTestApp(<LighthouseIntro />));
|
||||
const welcomeMessage = rendered.queryByText(
|
||||
'Welcome to Lighthouse in Backstage!',
|
||||
);
|
||||
expect(welcomeMessage).toBeInTheDocument();
|
||||
fireEvent.click(rendered.getByText('Hide intro'));
|
||||
|
||||
expect(welcomeMessage).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
* 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 } from 'react';
|
||||
import { useLocalStorage } from 'react-use';
|
||||
import Markdown from 'react-markdown';
|
||||
import { ContentHeader, InfoCard } from '@backstage/core';
|
||||
import { makeStyles, Button, Grid, Tabs, Tab } from '@material-ui/core';
|
||||
import CloseIcon from '@material-ui/icons/Close';
|
||||
|
||||
import LighthouseSupportButton from '../SupportButton';
|
||||
|
||||
export const LIGHTHOUSE_INTRO_LOCAL_STORAGE =
|
||||
'@backstage/lighthouse-plugin/intro-dismissed';
|
||||
|
||||
const 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).
|
||||
`;
|
||||
|
||||
const SETUP = `
|
||||
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;
|
||||
\`\`\`
|
||||
`;
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
tabs: { marginBottom: -18 },
|
||||
tab: { minWidth: 72, paddingLeft: 1, paddingRight: 1 },
|
||||
content: { marginBottom: theme.spacing(2) },
|
||||
closeButtonContainer: { height: '100%' },
|
||||
closeButtonItem: { paddingBottom: 0 },
|
||||
}));
|
||||
|
||||
function GettingStartedCard() {
|
||||
const classes = useStyles();
|
||||
const [value, setValue] = useState(0);
|
||||
return (
|
||||
<InfoCard
|
||||
title="Get started"
|
||||
subheader={
|
||||
<Tabs
|
||||
value={value}
|
||||
indicatorColor="primary"
|
||||
textColor="primary"
|
||||
onChange={(_ev, newValue: number) => setValue(newValue)}
|
||||
aria-label="get started tabs"
|
||||
className={classes.tabs}
|
||||
>
|
||||
<Tab className={classes.tab} label="Use cases" />
|
||||
<Tab className={classes.tab} label="Setup" />
|
||||
</Tabs>
|
||||
}
|
||||
divider
|
||||
actions={
|
||||
<>
|
||||
<Grid container direction="row" justify="flex-end">
|
||||
<Grid item>
|
||||
<Button
|
||||
component="a"
|
||||
href="https://github.com/spotify/lighthouse-audit-service"
|
||||
size="small"
|
||||
target="_blank"
|
||||
>
|
||||
Check out the README
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{value === 0 && <Markdown source={USE_CASES} />}
|
||||
{value === 1 && <Markdown source={SETUP} />}
|
||||
</InfoCard>
|
||||
);
|
||||
}
|
||||
|
||||
export interface Props {
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
export default function LighthouseIntro({ onDismiss = () => {} }: Props) {
|
||||
const classes = useStyles();
|
||||
const [dismissed, setDismissed] = useLocalStorage(
|
||||
LIGHTHOUSE_INTRO_LOCAL_STORAGE,
|
||||
false,
|
||||
);
|
||||
|
||||
if (dismissed) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContentHeader title="Welcome to Lighthouse in Backstage!">
|
||||
<LighthouseSupportButton />
|
||||
</ContentHeader>
|
||||
<Grid className={classes.content} container spacing={3} direction="row">
|
||||
<Grid item xs={12} sm={6} md={4}>
|
||||
<GettingStartedCard />
|
||||
</Grid>
|
||||
{/* TODO add link and image for blog post here */}
|
||||
{/* <Grid item xs={12} sm={6} md={4}>
|
||||
<InfoCard>Blog</InfoCard>
|
||||
</Grid> */}
|
||||
<Grid item xs={12} sm={6} md={8}>
|
||||
<Grid
|
||||
container
|
||||
justify="flex-end"
|
||||
alignItems="flex-end"
|
||||
className={classes.closeButtonContainer}
|
||||
>
|
||||
<Grid item className={classes.closeButtonItem}>
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={() => {
|
||||
onDismiss();
|
||||
setDismissed(true);
|
||||
}}
|
||||
>
|
||||
<CloseIcon /> Hide intro
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* 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 { SupportButton } from '@backstage/core';
|
||||
|
||||
export default function LighthouseSupportButton() {
|
||||
return (
|
||||
<SupportButton>
|
||||
Lighthouse audits run for any web domain, stored over time.
|
||||
</SupportButton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { default } from './plugin';
|
||||
export * from './api';
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* 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 plugin from './plugin';
|
||||
|
||||
describe('lighthouse', () => {
|
||||
it('should export plugin', () => {
|
||||
expect(plugin).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* 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 { createPlugin } from '@backstage/core';
|
||||
import AuditList from './components/AuditList';
|
||||
import AuditView from './components/AuditView';
|
||||
import CreateAudit from './components/CreateAudit';
|
||||
|
||||
export default createPlugin({
|
||||
id: 'lighthouse',
|
||||
register({ router }) {
|
||||
router.registerRoute('/lighthouse', AuditList);
|
||||
router.registerRoute('/lighthouse/audit/:id', AuditView);
|
||||
router.registerRoute('/lighthouse/create-audit', CreateAudit);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* 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 '@testing-library/jest-dom/extend-expect';
|
||||
require('jest-fetch-mock').enableMocks();
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* 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 { useLocation } from 'react-router-dom';
|
||||
|
||||
export function useQuery(): URLSearchParams {
|
||||
return new URLSearchParams(useLocation().search);
|
||||
}
|
||||
|
||||
export function formatTime(timestamp: string | Date) {
|
||||
let date: Date;
|
||||
if (timestamp instanceof Date) {
|
||||
date = timestamp;
|
||||
} else {
|
||||
date = new Date(timestamp);
|
||||
}
|
||||
return date.toUTCString();
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src"],
|
||||
"compilerOptions": {
|
||||
"resolveJsonModule": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user