Skip to content

Commit 74dd46d

Browse files
authored
feat: support multiple audits urls and paths (#45)
1 parent e5ef09f commit 74dd46d

File tree

14 files changed

+2653
-98
lines changed

14 files changed

+2653
-98
lines changed

.env.example

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
# Url to audit
2-
AUDIT_URL=https://www.example.com
3-
# Ignored when AUDIT_URL is configured
1+
# Audits
2+
AUDITS=[{"url":"https://www.example.com","thresholds":{"performance":0.5}},{"path":""},{"path":"route1"},{"path":"route2"}]
3+
# Ignored when a url is configured for an audit
44
PUBLISH_DIR=FULL_PATH_TO_LOCAL_BUILD_DIRECTORY
55
# JSON string of thresholds to enforce
66
THRESHOLDS={"performance":0.9,"accessibility":0.9,"best-practices":0.9,"seo":0.9,"pwa":0.9}

.eslintrc.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ module.exports = {
33
commonjs: true,
44
es6: true,
55
node: true,
6+
jest: true,
67
},
78
extends: 'eslint:recommended',
89
globals: {

.github/workflows/test.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,5 @@ jobs:
4040
run: yarn lint
4141
- name: check formatting
4242
run: yarn format:ci
43+
- name: run tests
44+
run: yarn test

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
node_modules
22
.env
33
.vscode
4-
yarn-error.log
4+
yarn-error.log
5+
# Local Netlify folder
6+
.netlify

README.md

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,12 @@ This plugin can be included via npm. Install it as a dependency with the followi
1010
npm install --save "@netlify/plugin-lighthouse"
1111
```
1212

13-
To use a build plugin, create a `plugins` in your `netlify.toml` like so:
13+
Add the plugin to your `netlify.toml` configuration file:
1414

1515
```toml
1616
[[plugins]]
17-
package = "@netlify/plugin-lighthouse"
18-
[plugins.inputs]
19-
# optional, defaults to scanning the current built version of the site
20-
audit_url = 'https://www.my-custom-site.com'
17+
package = "@netlify/plugin-lighthouse"
18+
2119
# optional, fails build when a category is below a threshold
2220
[plugins.inputs.thresholds]
2321
performance = 0.9
@@ -27,6 +25,30 @@ package = "@netlify/plugin-lighthouse"
2725
pwa = 0.9
2826
```
2927

28+
By default, the plugin will serve and audit the build directory of the site.
29+
You can customize the behavior via the `audits` input:
30+
31+
```toml
32+
[[plugins]]
33+
package = "@netlify/plugin-lighthouse"
34+
35+
[plugins.inputs.thresholds]
36+
performance = 0.9
37+
38+
# to audit a sub path of the build directory
39+
# route1 audit will use the top level thresholds
40+
[[plugins.inputs.audits]]
41+
path = "route1"
42+
43+
# to audit a specific absolute url
44+
[[plugins.inputs.audits]]
45+
url = "https://www.example.com"
46+
47+
# you can specify thresholds per audit
48+
[plugins.inputs.audits.thresholds]
49+
performance = 0.8
50+
```
51+
3052
## Running Locally
3153

3254
Create a `.env` file based on the [example](.env.example) and run

example/route1/index.html

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
<title>Route 1</title>
6+
</head>
7+
<body>
8+
Route 1
9+
</body>
10+
</html>

example/route2/index.html

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
<title>Route 2</title>
6+
</head>
7+
<body>
8+
Route 2
9+
</body>
10+
</html>

manifest.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
name: netlify-plugin-lighthouse
22
inputs:
3+
# Deprecated, use audits
34
- name: audit_url
45
required: false
56
description: Url of the site to audit, defaults to scanning the current built version of the site
7+
68
- name: thresholds
79
required: false
8-
description: Key value mapping of thresholds that will fail the build when not passed
10+
description: Key value mapping of thresholds that will fail the build when not passed.
11+
12+
- name: audits
13+
required: false
14+
description: A list of audits to perform. Each list item is an object with either a url/path to scan and an optional thresholds mapping.

netlify.toml

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,15 @@
77
YARN_VERSION = "1.22.4"
88

99
[[plugins]]
10-
package = "./src/index.js"
10+
package = "./src/index.js"
11+
1112
[plugins.inputs.thresholds]
1213
performance = 0.9
13-
accessibility = 0.9
14-
best-practices = 0.9
15-
seo = 0.9
16-
pwa = 0.9
14+
15+
[[plugins.inputs.audits]]
16+
path = "route1"
17+
[[plugins.inputs.audits]]
18+
path = "route2"
19+
[[plugins.inputs.audits]]
20+
path = ""
21+

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"local": "node -e 'require(\"./src/index.js\").onSuccess()'",
88
"lint": "eslint 'src/**/*.js'",
99
"format": "prettier --write 'src/**/*.js'",
10+
"test": "jest",
1011
"format:ci": "prettier --check 'src/**/*.js'",
1112
"release": "HUSKY_SKIP_HOOKS=1 CI=true semantic-release && npm publish"
1213
},
@@ -46,6 +47,7 @@
4647
"@semantic-release/git": "^9.0.0",
4748
"eslint": "^7.0.0",
4849
"husky": "^4.0.7",
50+
"jest": "^26.1.0",
4951
"prettier": "^2.0.0",
5052
"semantic-release": "^17.0.8"
5153
},

src/config.js

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
const chalk = require('chalk');
2+
const { join } = require('path');
3+
4+
const getServePath = (dir, path) => {
5+
if (typeof path !== 'string' || typeof dir !== 'string') {
6+
return { path: undefined };
7+
}
8+
9+
const resolvedPath = join(dir, path);
10+
if (!resolvedPath.startsWith(dir)) {
11+
throw new Error(
12+
chalk.red(
13+
`resolved path for ${chalk.red(
14+
path,
15+
)} is outside publish directory ${chalk.red(dir)}`,
16+
),
17+
);
18+
}
19+
20+
return { path: resolvedPath };
21+
};
22+
23+
const getConfiguration = ({ constants, inputs } = {}) => {
24+
const serveDir =
25+
(constants && constants.PUBLISH_DIR) || process.env.PUBLISH_DIR;
26+
27+
const auditUrl = (inputs && inputs.audit_url) || process.env.AUDIT_URL;
28+
29+
if (auditUrl) {
30+
console.warn(
31+
`${chalk.yellow(
32+
'inputs.audit_url',
33+
)} is deprecated, please use ${chalk.green('inputs.audits')}`,
34+
);
35+
}
36+
37+
let thresholds =
38+
(inputs && inputs.thresholds) || process.env.THRESHOLDS || {};
39+
40+
if (typeof thresholds === 'string') {
41+
thresholds = JSON.parse(thresholds);
42+
}
43+
44+
let audits = (inputs && inputs.audits) || process.env.AUDITS;
45+
if (typeof audits === 'string') {
46+
audits = JSON.parse(audits);
47+
}
48+
49+
if (!Array.isArray(audits)) {
50+
audits = [{ path: serveDir, url: auditUrl, thresholds }];
51+
} else {
52+
audits = audits.map((a) => {
53+
return {
54+
...a,
55+
thresholds: a.thresholds || thresholds,
56+
...getServePath(serveDir, a.path),
57+
};
58+
});
59+
}
60+
61+
return { audits };
62+
};
63+
64+
module.exports = {
65+
getConfiguration,
66+
};

src/config.test.js

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
const { getConfiguration } = require('./config');
2+
3+
jest.spyOn(console, 'warn').mockImplementation(() => {});
4+
jest.mock('chalk', () => {
5+
return {
6+
green: (m) => m,
7+
yellow: (m) => m,
8+
red: (m) => m,
9+
};
10+
});
11+
12+
describe('config', () => {
13+
beforeEach(() => {
14+
delete process.env.PUBLISH_DIR;
15+
delete process.env.AUDIT_URL;
16+
delete process.env.THRESHOLDS;
17+
delete process.env.AUDITS;
18+
jest.clearAllMocks();
19+
});
20+
it('should empty config when constants, inputs are undefined', () => {
21+
const config = getConfiguration();
22+
23+
expect(config).toEqual({
24+
audits: [
25+
{
26+
path: undefined,
27+
url: undefined,
28+
thresholds: {},
29+
},
30+
],
31+
});
32+
});
33+
34+
it('should return config from process.env when constants, inputs are undefined', () => {
35+
process.env.PUBLISH_DIR = 'PUBLISH_DIR';
36+
process.env.AUDIT_URL = 'AUDIT_URL';
37+
process.env.THRESHOLDS = JSON.stringify({ performance: 0.9 });
38+
const config = getConfiguration();
39+
40+
expect(config).toEqual({
41+
audits: [
42+
{
43+
path: 'PUBLISH_DIR',
44+
url: 'AUDIT_URL',
45+
thresholds: { performance: 0.9 },
46+
},
47+
],
48+
});
49+
});
50+
51+
it('should return config from process.env.AUDITS', () => {
52+
process.env.PUBLISH_DIR = 'PUBLISH_DIR';
53+
process.env.AUDITS = JSON.stringify([
54+
{ url: 'https://www.test.com', thresholds: { performance: 0.9 } },
55+
{ path: 'route1', thresholds: { seo: 0.9 } },
56+
]);
57+
const config = getConfiguration();
58+
59+
expect(config).toEqual({
60+
audits: [
61+
{ url: 'https://www.test.com', thresholds: { performance: 0.9 } },
62+
{ path: 'PUBLISH_DIR/route1', thresholds: { seo: 0.9 } },
63+
],
64+
});
65+
});
66+
67+
it('should print deprecated warning when using audit_url', () => {
68+
const constants = {};
69+
const inputs = { audit_url: 'url' };
70+
getConfiguration({ constants, inputs });
71+
72+
expect(console.warn).toHaveBeenCalledTimes(1);
73+
expect(console.warn).toHaveBeenCalledWith(
74+
'inputs.audit_url is deprecated, please use inputs.audits',
75+
);
76+
});
77+
78+
it('should return config from constants and inputs', () => {
79+
const constants = { PUBLISH_DIR: 'PUBLISH_DIR' };
80+
const inputs = { audit_url: 'url', thresholds: { seo: 1 } };
81+
const config = getConfiguration({ constants, inputs });
82+
83+
expect(config).toEqual({
84+
audits: [{ path: 'PUBLISH_DIR', url: 'url', thresholds: { seo: 1 } }],
85+
});
86+
});
87+
88+
it('should append audits path to PUBLISH_DIR', () => {
89+
const constants = { PUBLISH_DIR: 'PUBLISH_DIR' };
90+
const inputs = { audits: [{ path: 'route1', thresholds: { seo: 1 } }] };
91+
const config = getConfiguration({ constants, inputs });
92+
93+
expect(config).toEqual({
94+
audits: [{ path: 'PUBLISH_DIR/route1', thresholds: { seo: 1 } }],
95+
});
96+
});
97+
98+
it('should use default thresholds when no audit thresholds is configured', () => {
99+
const constants = { PUBLISH_DIR: 'PUBLISH_DIR' };
100+
const inputs = {
101+
thresholds: { performance: 1 },
102+
audits: [{ path: 'route1', thresholds: { seo: 1 } }, { path: 'route2' }],
103+
};
104+
const config = getConfiguration({ constants, inputs });
105+
106+
expect(config).toEqual({
107+
audits: [
108+
{ path: 'PUBLISH_DIR/route1', thresholds: { seo: 1 } },
109+
{ path: 'PUBLISH_DIR/route2', thresholds: { performance: 1 } },
110+
],
111+
});
112+
});
113+
114+
it('should throw error on path traversal', () => {
115+
const constants = { PUBLISH_DIR: 'PUBLISH_DIR' };
116+
const inputs = {
117+
thresholds: { performance: 1 },
118+
audits: [{ path: '../' }],
119+
};
120+
121+
expect(() => getConfiguration({ constants, inputs })).toThrow(
122+
new Error(
123+
'resolved path for ../ is outside publish directory PUBLISH_DIR',
124+
),
125+
);
126+
});
127+
128+
it('should treat audit path as relative path', () => {
129+
const constants = { PUBLISH_DIR: 'PUBLISH_DIR' };
130+
const inputs = {
131+
audits: [{ path: '/a/b' }],
132+
};
133+
134+
const config = getConfiguration({ constants, inputs });
135+
136+
expect(config).toEqual({
137+
audits: [{ path: 'PUBLISH_DIR/a/b', thresholds: {} }],
138+
});
139+
});
140+
});

0 commit comments

Comments
 (0)