feat: implement Lighthouse plugin using lighthouse-audit-service

This commit is contained in:
Paul Marbach
2020-04-06 12:07:25 -04:00
parent 05ddedb0ad
commit 06228b71e9
39 changed files with 3138 additions and 33 deletions
+2 -2
View File
@@ -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",
+50
View File
@@ -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;
```
+50
View File
@@ -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
}
}
}
}
+143
View File
@@ -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>
);
}
+18
View File
@@ -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';
+23
View File
@@ -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();
});
});
+29
View File
@@ -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);
},
});
+18
View File
@@ -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();
+30
View File
@@ -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();
}
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.json",
"include": ["src"],
"compilerOptions": {
"resolveJsonModule": true
}
}
+2 -2
View File
@@ -22,8 +22,8 @@
"@types/testing-library__jest-dom": "5.0.2"
},
"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",