pagean 8.0.4 → 9.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -16
- package/bin/pagean.js +15 -10
- package/bin/pageanrc-lint.js +2 -2
- package/index.js +10 -7
- package/lib/config.js +41 -13
- package/lib/default-config.json +3 -0
- package/lib/link-utils.js +8 -8
- package/lib/logger.js +14 -14
- package/lib/report-template.handlebars +10 -5
- package/lib/reporter.js +4 -4
- package/lib/schema-errors.js +2 -2
- package/lib/sitemap.js +117 -0
- package/lib/test-utils.js +4 -4
- package/lib/tests.js +14 -15
- package/package.json +18 -17
- package/schemas/pageanrc.schema.json +52 -1
package/README.md
CHANGED
|
@@ -133,7 +133,7 @@ For any failing test, the `data` array in the test report includes the original
|
|
|
133
133
|
"status": 404
|
|
134
134
|
},
|
|
135
135
|
{
|
|
136
|
-
"href": "http://localhost:
|
|
136
|
+
"href": "http://localhost:3000/brokenLinks.html#notlinked",
|
|
137
137
|
"status": "#notlinked Not Found"
|
|
138
138
|
},
|
|
139
139
|
{
|
|
@@ -164,7 +164,7 @@ Complete reports for the example case in this project (the tests as specified in
|
|
|
164
164
|
|
|
165
165
|
Pagean looks for a configuration file as specified via the CLI, or defaults to a file named `.pageanrc.json` in the project root. If the configuration file is not found, is not valid JSON, or does not contain any URLs to check the job will fail.
|
|
166
166
|
|
|
167
|
-
Below is an example `.pageanrc.json` file, which is broken into
|
|
167
|
+
Below is an example `.pageanrc.json` file, which is broken into seven major properties:
|
|
168
168
|
|
|
169
169
|
- `htmlhintrc`: An optional path to an htmlhintrc file to be used in the rendered HTML test
|
|
170
170
|
- `project`: An optional name of the project, which is included in HTML and JSON reports.
|
|
@@ -197,6 +197,11 @@ is equivalent to the longhand:
|
|
|
197
197
|
|
|
198
198
|
All available settings with the default values are shown below.
|
|
199
199
|
|
|
200
|
+
- `sitemap`: Specify a sitemap with URLs to test. If a sitemap is specified, the URLs from the sitemap are added to the `urls` array. If a URL is in the `urls` array with `settings`, those settings are retained. Note that `<sitemapindex>` is currently not supported. The `sitemap` object can have the following properties:
|
|
201
|
+
- `url`: The URL of the sitemap (required if `sitemap` is included). This can be either an actual URL or a local file.
|
|
202
|
+
- `find`: A string to search for in sitemap URLs (e.g. `https://somehere.test`) (required if `replace` is specified).
|
|
203
|
+
- `replace`: The string to replace the `find` string with (e.g. `http://localhost:3000`) (required if `find` is specified).
|
|
204
|
+
- `exclude`: An array of strings with regular expressions to exclude URLs from the sitemap (e.g. `['\.pdf$']` to exclude any PDF files). Since these are string representations of regular expressions, the backslash must be escaped (e.g. `\\.`). Exclude is performed before find/replace, so uses the original URLs from the sitemap.
|
|
200
205
|
- `urls`: An array of URLs to be tested, which must contain at least one value. Each array entry can either be a URL string, or an object that contains a `url` string and an optional `settings` object. This object can contain any of the `settings` values identified above and will override that setting for testing that URL. The `url` string can be either an actual URL or a local file, as shown in the example below.
|
|
201
206
|
|
|
202
207
|
```json
|
|
@@ -242,7 +247,7 @@ All available settings with the default values are shown below.
|
|
|
242
247
|
"urls": [
|
|
243
248
|
"https://gitlab.com/gitlab-ci-utils/pagean/",
|
|
244
249
|
{
|
|
245
|
-
"url": "./tests/
|
|
250
|
+
"url": "./tests/fixtures/site/consoleLog.html",
|
|
246
251
|
"settings": {
|
|
247
252
|
"consoleOutputTest": false
|
|
248
253
|
}
|
|
@@ -281,7 +286,7 @@ pagean:
|
|
|
281
286
|
|
|
282
287
|
### Testing With Static HTTP Server
|
|
283
288
|
|
|
284
|
-
The Docker image shown above includes [`
|
|
289
|
+
The Docker image shown above includes [`serve`](https://www.npmjs.com/package/serve) and [`wait-on`](https://www.npmjs.com/package/wait-on) installed globally to run a local HTTP server for testing static content. The example job below illustrates how to use this for Pagean tests. The script starts the server in this project's `./tests/fixtures/site` directory and uses `wait-on` to hold the script until the server is running and returns a valid response. The referenced `pageanrc` file is the same as the project default `pageanrc`, but references all test URLs from the local server.
|
|
285
290
|
|
|
286
291
|
```yaml
|
|
287
292
|
pagean:
|
|
@@ -289,10 +294,8 @@ pagean:
|
|
|
289
294
|
stage: test
|
|
290
295
|
before_script:
|
|
291
296
|
# Start static server in test cases directory, discarding any console output,
|
|
292
|
-
# and wait until the server is running.
|
|
293
|
-
|
|
294
|
-
# to resolve localhost with no issue, see example in static-server.pageanrc.json.
|
|
295
|
-
- http-server ./tests/test-cases > /dev/null 2>&1 & wait-on http://127.0.0.1:8080
|
|
297
|
+
# and wait until the server is running.
|
|
298
|
+
- serve ./tests/fixtures/site > /dev/null 2>&1 & wait-on http://localhost:3000
|
|
296
299
|
script:
|
|
297
300
|
- pagean -c static-server.pageanrc.json
|
|
298
301
|
artifacts:
|
|
@@ -326,14 +329,15 @@ The `--json` option outputs the JSON results to stdout in all cases for consiste
|
|
|
326
329
|
|
|
327
330
|
```sh
|
|
328
331
|
.\tests\test-configs\cli-tests\some-test.pageanrc.json
|
|
329
|
-
<pageanrc>.puppeteerLaunchOptions
|
|
330
|
-
<pageanrc>.reporters[0]
|
|
331
|
-
<pageanrc>.settings.consoleOutputTest
|
|
332
|
-
<pageanrc>.settings.pageLoadTimeTest.foo
|
|
333
|
-
<pageanrc>.settings.pageLoadTimeTest
|
|
334
|
-
<pageanrc>.
|
|
335
|
-
<pageanrc>.urls[
|
|
336
|
-
<pageanrc>.urls[
|
|
332
|
+
<pageanrc>.puppeteerLaunchOptions must NOT have fewer than 1 properties
|
|
333
|
+
<pageanrc>.reporters[0] must be equal to one of the allowed values (cli, html, json)
|
|
334
|
+
<pageanrc>.settings.consoleOutputTest must be either boolean or object with the appropriate properties
|
|
335
|
+
<pageanrc>.settings.pageLoadTimeTest.foo must NOT contain additional properties: "foo"
|
|
336
|
+
<pageanrc>.settings.pageLoadTimeTest must be either boolean or object with the appropriate properties
|
|
337
|
+
<pageanrc>.sitemap must use 'find' and 'replace' together
|
|
338
|
+
<pageanrc>.urls[2].settings.consoleOutputTest must be either boolean or object with the appropriate properties
|
|
339
|
+
<pageanrc>.urls[3] must be either URL string or object with the appropriate properties
|
|
340
|
+
<pageanrc>.urls[5] must have required property 'url'
|
|
337
341
|
```
|
|
338
342
|
|
|
339
343
|
In some cases, a single error might result in multiple messages based on the options in the schema definition, especially for cases that can be either a single value or an object with specific properties (e.g. the errors for `<pageanrc>.settings.pageLoadTimeTest` in the example above).
|
package/bin/pagean.js
CHANGED
|
@@ -19,14 +19,19 @@ program
|
|
|
19
19
|
.parse(process.argv);
|
|
20
20
|
const options = program.opts();
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
22
|
+
getConfig(options.config)
|
|
23
|
+
/* eslint-disable-next-line promise/always-return -- required instead
|
|
24
|
+
of top level await */
|
|
25
|
+
.then((config) => {
|
|
26
|
+
pagean.executeAllTests(config);
|
|
27
|
+
})
|
|
28
|
+
/* eslint-disable-next-line promise/prefer-await-to-callbacks --
|
|
29
|
+
required instead of top level await */
|
|
30
|
+
.catch((error) => {
|
|
31
|
+
log({
|
|
32
|
+
errorCode: 1,
|
|
33
|
+
exitOnError: true,
|
|
34
|
+
level: Levels.Error,
|
|
35
|
+
message: `Error executing pagean tests\n${error.message}`
|
|
36
|
+
});
|
|
31
37
|
});
|
|
32
|
-
}
|
package/bin/pageanrc-lint.js
CHANGED
|
@@ -19,13 +19,13 @@ program
|
|
|
19
19
|
const options = program.opts();
|
|
20
20
|
|
|
21
21
|
const logError = (message, exitOnError = true) => {
|
|
22
|
-
log({
|
|
22
|
+
log({ errorCode: 1, exitOnError, level: Levels.Error, message });
|
|
23
23
|
};
|
|
24
24
|
|
|
25
25
|
const outputJsonResults = (configFileName, lintResults) => {
|
|
26
26
|
// Log JSON to stdout for consistency, and eliminates extraneous stderr
|
|
27
27
|
// output in PowerShell which produces invalid JSON if piped to file.
|
|
28
|
-
// eslint-disable-next-line no-magic-numbers -- index
|
|
28
|
+
// eslint-disable-next-line no-magic-numbers -- no-magic-numbers - index
|
|
29
29
|
log({ message: JSON.stringify(lintResults.errors, undefined, 2) });
|
|
30
30
|
if (!lintResults.isValid) {
|
|
31
31
|
logError(`Errors found in file ${configFileName}`);
|
package/index.js
CHANGED
|
@@ -12,13 +12,15 @@ const testReporter = require('./lib/reporter');
|
|
|
12
12
|
const { ...testFunctions } = require('./lib/tests');
|
|
13
13
|
const { createLinkChecker } = require('./lib/link-utils');
|
|
14
14
|
|
|
15
|
+
/* eslint-disable no-await-in-loop, max-lines-per-function --
|
|
16
|
+
no-await-in-loop - tests are deliberately performed synchronously,
|
|
17
|
+
max-lines-per-function - allowed to test executor */
|
|
15
18
|
/**
|
|
16
19
|
* Executes Pagean tests as specified in config and reports results.
|
|
17
20
|
*
|
|
18
21
|
* @param {object} config The Pagean test configuration.
|
|
19
22
|
* @static
|
|
20
23
|
*/
|
|
21
|
-
// eslint-disable-next-line max-lines-per-function
|
|
22
24
|
const executeAllTests = async (config) => {
|
|
23
25
|
const logger = testLogger(config);
|
|
24
26
|
const linkChecker = createLinkChecker();
|
|
@@ -31,22 +33,22 @@ const executeAllTests = async (config) => {
|
|
|
31
33
|
const page = await browser.newPage();
|
|
32
34
|
page.on('console', (message) =>
|
|
33
35
|
consoleLog.push({
|
|
34
|
-
|
|
36
|
+
location: message.location(),
|
|
35
37
|
text: message.text(),
|
|
36
|
-
|
|
38
|
+
type: message.type()
|
|
37
39
|
})
|
|
38
40
|
);
|
|
39
41
|
await page.goto(testUrl.url, { waitUntil: 'load' });
|
|
40
42
|
|
|
41
43
|
const testContext = {
|
|
42
|
-
page,
|
|
43
44
|
consoleLog,
|
|
45
|
+
linkChecker,
|
|
46
|
+
logger,
|
|
47
|
+
page,
|
|
44
48
|
urlSettings: {
|
|
45
49
|
...testUrl.settings,
|
|
46
50
|
htmlHintConfig: config.htmlHintConfig
|
|
47
|
-
}
|
|
48
|
-
logger,
|
|
49
|
-
linkChecker
|
|
51
|
+
}
|
|
50
52
|
};
|
|
51
53
|
for (const test of Object.keys(testFunctions)) {
|
|
52
54
|
await testFunctions[test](testContext);
|
|
@@ -64,5 +66,6 @@ const executeAllTests = async (config) => {
|
|
|
64
66
|
process.exit(2);
|
|
65
67
|
}
|
|
66
68
|
};
|
|
69
|
+
/* eslint-enable no-await-in-loop, max-lines-per-function -- enable */
|
|
67
70
|
|
|
68
71
|
module.exports.executeAllTests = executeAllTests;
|
package/lib/config.js
CHANGED
|
@@ -8,11 +8,13 @@
|
|
|
8
8
|
|
|
9
9
|
const fs = require('fs');
|
|
10
10
|
const path = require('path');
|
|
11
|
-
const Ajv = require('ajv')
|
|
11
|
+
const Ajv = require('ajv/dist/2019');
|
|
12
|
+
const draft7MetaSchema = require('ajv/dist/refs/json-schema-draft-07.json');
|
|
12
13
|
const ajvErrors = require('ajv-errors');
|
|
13
14
|
const protocolify = require('protocolify');
|
|
14
15
|
|
|
15
16
|
const { normalizeLink } = require('./link-utils');
|
|
17
|
+
const { getUrlsFromSitemap } = require('./sitemap');
|
|
16
18
|
|
|
17
19
|
const defaultConfig = require('./default-config.json');
|
|
18
20
|
const defaultHtmlHintConfigFilename = './.htmlhintrc';
|
|
@@ -43,6 +45,8 @@ const processAllTestSettings = (settings) => {
|
|
|
43
45
|
for (const key of Object.keys(settings)) {
|
|
44
46
|
processedSettings[key] = processTestSetting(settings[key]);
|
|
45
47
|
}
|
|
48
|
+
/* eslint-disable-next-line consistent-return -- return undefined
|
|
49
|
+
if no settings */
|
|
46
50
|
return processedSettings;
|
|
47
51
|
};
|
|
48
52
|
|
|
@@ -70,18 +74,35 @@ const consolidateTestSettings = (
|
|
|
70
74
|
return processedSettings;
|
|
71
75
|
};
|
|
72
76
|
|
|
73
|
-
const
|
|
77
|
+
const addSitemapUrls = (config, sitemapUrls) => {
|
|
78
|
+
if (config.urls) {
|
|
79
|
+
for (const url of config.urls) {
|
|
80
|
+
const { rawUrl } = getUrl(url);
|
|
81
|
+
const index = sitemapUrls.indexOf(rawUrl);
|
|
82
|
+
index > -1 && sitemapUrls.splice(index, 1);
|
|
83
|
+
}
|
|
84
|
+
config.urls.push(...sitemapUrls);
|
|
85
|
+
} else {
|
|
86
|
+
config.urls = sitemapUrls;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const processUrls = async (config) => {
|
|
74
91
|
const processedConfigSettings = processAllTestSettings(config.settings);
|
|
92
|
+
if (config.sitemap) {
|
|
93
|
+
const sitemapUrls = await getUrlsFromSitemap(config.sitemap);
|
|
94
|
+
addSitemapUrls(config, sitemapUrls);
|
|
95
|
+
}
|
|
75
96
|
return config.urls.map((testUrl) => {
|
|
76
97
|
const { rawUrl, urlSettings } = getUrl(testUrl);
|
|
77
98
|
return {
|
|
78
|
-
url: protocolify(rawUrl),
|
|
79
99
|
rawUrl,
|
|
80
100
|
settings: consolidateTestSettings(
|
|
81
101
|
defaultConfig.settings,
|
|
82
102
|
processedConfigSettings,
|
|
83
103
|
processAllTestSettings(urlSettings)
|
|
84
|
-
)
|
|
104
|
+
),
|
|
105
|
+
url: protocolify(rawUrl)
|
|
85
106
|
};
|
|
86
107
|
});
|
|
87
108
|
};
|
|
@@ -90,6 +111,8 @@ const getHtmlHintConfig = (htmlHintConfigFilename) => {
|
|
|
90
111
|
if (!fs.existsSync(htmlHintConfigFilename)) {
|
|
91
112
|
return;
|
|
92
113
|
}
|
|
114
|
+
/* eslint-disable-next-line consistent-return -- return undefined
|
|
115
|
+
if no settings */
|
|
93
116
|
return JSON.parse(fs.readFileSync(htmlHintConfigFilename, 'utf8'));
|
|
94
117
|
};
|
|
95
118
|
|
|
@@ -100,15 +123,15 @@ const validateConfigSchema = (config) => {
|
|
|
100
123
|
)
|
|
101
124
|
);
|
|
102
125
|
const ajv = new Ajv({ allErrors: true });
|
|
126
|
+
ajv.addMetaSchema(draft7MetaSchema);
|
|
103
127
|
ajvErrors(ajv);
|
|
104
128
|
const validate = ajv.compile(schema);
|
|
105
129
|
const isValid = validate(config);
|
|
106
|
-
return {
|
|
130
|
+
return { errors: validate.errors || [], isValid };
|
|
107
131
|
};
|
|
108
132
|
|
|
109
|
-
const getConfigFromFile = (configFileName) =>
|
|
110
|
-
|
|
111
|
-
};
|
|
133
|
+
const getConfigFromFile = (configFileName) =>
|
|
134
|
+
JSON.parse(fs.readFileSync(configFileName, 'utf8'));
|
|
112
135
|
|
|
113
136
|
/**
|
|
114
137
|
* Loads config from file and returns consolidated config with
|
|
@@ -116,10 +139,12 @@ const getConfigFromFile = (configFileName) => {
|
|
|
116
139
|
*
|
|
117
140
|
* @param {string} configFileName Pagean configuration file name.
|
|
118
141
|
* @returns {object} Consolidated Pagean configuration.
|
|
142
|
+
* @async
|
|
119
143
|
* @throws {TypeError} Throws if config file has an invalid schema.
|
|
120
144
|
* @static
|
|
145
|
+
* @public
|
|
121
146
|
*/
|
|
122
|
-
const processConfig = (configFileName) => {
|
|
147
|
+
const processConfig = async (configFileName) => {
|
|
123
148
|
const config = getConfigFromFile(configFileName);
|
|
124
149
|
const { isValid } = validateConfigSchema(config);
|
|
125
150
|
if (!isValid) {
|
|
@@ -128,13 +153,16 @@ const processConfig = (configFileName) => {
|
|
|
128
153
|
);
|
|
129
154
|
}
|
|
130
155
|
return {
|
|
131
|
-
project: config.project || '',
|
|
132
|
-
puppeteerLaunchOptions: config.puppeteerLaunchOptions,
|
|
133
|
-
urls: processUrls(config),
|
|
134
156
|
htmlHintConfig: getHtmlHintConfig(
|
|
135
157
|
config.htmlhintrc || defaultHtmlHintConfigFilename
|
|
136
158
|
),
|
|
137
|
-
|
|
159
|
+
project: config.project || '',
|
|
160
|
+
puppeteerLaunchOptions: {
|
|
161
|
+
...defaultConfig.puppeteerLaunchOptions,
|
|
162
|
+
...config.puppeteerLaunchOptions
|
|
163
|
+
},
|
|
164
|
+
reporters: config.reporters || defaultConfig.reporters,
|
|
165
|
+
urls: await processUrls(config)
|
|
138
166
|
};
|
|
139
167
|
};
|
|
140
168
|
|
package/lib/default-config.json
CHANGED
package/lib/link-utils.js
CHANGED
|
@@ -24,6 +24,7 @@ const timeoutSeconds = 120;
|
|
|
24
24
|
const httpResponse = Object.freeze({
|
|
25
25
|
continue: 100,
|
|
26
26
|
ok: 200,
|
|
27
|
+
// eslint-disable-next-line sort-keys -- order by response code
|
|
27
28
|
badRequest: 400,
|
|
28
29
|
notFound: 404,
|
|
29
30
|
tooManyRequests: 429,
|
|
@@ -52,7 +53,6 @@ const normalizeLink = (url) =>
|
|
|
52
53
|
stripHash: true,
|
|
53
54
|
stripWWW: false
|
|
54
55
|
});
|
|
55
|
-
/* eslint-enable jsdoc/require-description-complete-sentence */
|
|
56
56
|
|
|
57
57
|
/**
|
|
58
58
|
* Checks settings to determine if the provided link should be ignored.
|
|
@@ -99,7 +99,6 @@ const checkSamePageLink = async (page, link) => {
|
|
|
99
99
|
isIdentifier: true
|
|
100
100
|
})}`;
|
|
101
101
|
const element = await page.$(escapedSelector);
|
|
102
|
-
// const element = await page.$(selector);
|
|
103
102
|
return element ? httpResponse.ok : `${selector} Not Found`;
|
|
104
103
|
};
|
|
105
104
|
|
|
@@ -122,7 +121,7 @@ const checkExternalPageLinkBrowser = async (page, link) => {
|
|
|
122
121
|
} catch (error) {
|
|
123
122
|
// Errors are returned in the format: "ENOTFOUND at https://this.url.does.not.exist/",
|
|
124
123
|
// so extract error only and remove URL
|
|
125
|
-
status = error.message.replace(/^(
|
|
124
|
+
status = error.message.replace(/^(?<message>.*) at .*$/, '$1');
|
|
126
125
|
}
|
|
127
126
|
return status;
|
|
128
127
|
};
|
|
@@ -149,15 +148,16 @@ const checkExternalPageLink = async (page, link, useGet = false) => {
|
|
|
149
148
|
|
|
150
149
|
try {
|
|
151
150
|
const options = {
|
|
151
|
+
decompress: false,
|
|
152
152
|
headers: { 'User-Agent': userAgent },
|
|
153
|
-
timeout: timeoutSeconds * msPerSec
|
|
153
|
+
timeout: timeoutSeconds * msPerSec
|
|
154
154
|
// Axios can generate an error trying to decompress an empty compressed
|
|
155
155
|
// HEAD response, see https://github.com/axios/axios/issues/5102.
|
|
156
156
|
// The response body is also not used, so more efficient to skip.
|
|
157
|
-
decompress: false
|
|
158
157
|
};
|
|
159
158
|
// Using internal browser property since not exposed
|
|
160
|
-
|
|
159
|
+
/* eslint-disable-next-line no-underscore-dangle -- require to match
|
|
160
|
+
Puppeteer API */
|
|
161
161
|
if (page.browser()._ignoreHTTPSErrors) {
|
|
162
162
|
const agent = new https.Agent({ rejectUnauthorized: false });
|
|
163
163
|
options.httpsAgent = agent;
|
|
@@ -184,7 +184,7 @@ const checkExternalPageLink = async (page, link, useGet = false) => {
|
|
|
184
184
|
};
|
|
185
185
|
|
|
186
186
|
/**
|
|
187
|
-
* Factory function returning a linkChecker object with a
|
|
187
|
+
* Factory function returning a linkChecker object with a checkLink
|
|
188
188
|
* function that caches checked link results.
|
|
189
189
|
*
|
|
190
190
|
* @returns {object} Link checker object.
|
|
@@ -205,7 +205,7 @@ const createLinkChecker = () => {
|
|
|
205
205
|
* @returns {(string|number)} The link status (HTTP response code or error).
|
|
206
206
|
* @public
|
|
207
207
|
*/
|
|
208
|
-
// eslint-disable-next-line sonarjs/cognitive-complexity
|
|
208
|
+
// eslint-disable-next-line sonarjs/cognitive-complexity -- allow < 10
|
|
209
209
|
checkLink: async (context, link) => {
|
|
210
210
|
let status = httpResponse.unknownError;
|
|
211
211
|
try {
|
package/lib/logger.js
CHANGED
|
@@ -10,8 +10,8 @@ const consoleLogger = require('ci-logger');
|
|
|
10
10
|
const { reporterTypes } = require('../lib/reporter');
|
|
11
11
|
|
|
12
12
|
const testResultSymbols = Object.freeze({
|
|
13
|
-
passed: ' √',
|
|
14
13
|
failed: ' ×',
|
|
14
|
+
passed: ' √',
|
|
15
15
|
warning: ' ‼'
|
|
16
16
|
});
|
|
17
17
|
|
|
@@ -25,7 +25,7 @@ const nullFunction = () => {};
|
|
|
25
25
|
* @returns {object} A logger object.
|
|
26
26
|
* @static
|
|
27
27
|
*/
|
|
28
|
-
// eslint-disable-next-line max-lines-per-function
|
|
28
|
+
// eslint-disable-next-line max-lines-per-function -- factory function with state
|
|
29
29
|
module.exports = (config) => {
|
|
30
30
|
const cliReporter = {
|
|
31
31
|
// If cli reporter is enabled, set console log function, otherwise null function
|
|
@@ -38,15 +38,15 @@ module.exports = (config) => {
|
|
|
38
38
|
};
|
|
39
39
|
|
|
40
40
|
const testResults = {
|
|
41
|
-
project: config.project,
|
|
42
41
|
executionStart: new Date(),
|
|
42
|
+
project: config.project,
|
|
43
|
+
results: [],
|
|
43
44
|
summary: {
|
|
44
|
-
|
|
45
|
+
failed: 0,
|
|
45
46
|
passed: 0,
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
results: []
|
|
47
|
+
tests: 0,
|
|
48
|
+
warning: 0
|
|
49
|
+
}
|
|
50
50
|
};
|
|
51
51
|
|
|
52
52
|
let currentUrlTests;
|
|
@@ -59,8 +59,8 @@ module.exports = (config) => {
|
|
|
59
59
|
*/
|
|
60
60
|
const startUrlTests = (url) => {
|
|
61
61
|
const urlTestResults = {
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
tests: [],
|
|
63
|
+
url
|
|
64
64
|
};
|
|
65
65
|
testResults.results.push(urlTestResults);
|
|
66
66
|
currentUrlTests = urlTestResults;
|
|
@@ -72,7 +72,7 @@ module.exports = (config) => {
|
|
|
72
72
|
* by the last call to StartUrlTests).
|
|
73
73
|
*
|
|
74
74
|
* @instance
|
|
75
|
-
* @param {object} testResult The test results.
|
|
75
|
+
* @param {object} testResult The test results object.
|
|
76
76
|
*/
|
|
77
77
|
const logTestResults = (testResult) => {
|
|
78
78
|
if (testResult) {
|
|
@@ -81,8 +81,8 @@ module.exports = (config) => {
|
|
|
81
81
|
testResults.summary[testResult.result]++;
|
|
82
82
|
|
|
83
83
|
cliReporter.log({
|
|
84
|
-
message: `${testResult.name} (${testResult.result})`,
|
|
85
84
|
isResult: true,
|
|
85
|
+
message: `${testResult.name} (${testResult.result})`,
|
|
86
86
|
resultPrefix: testResultSymbols[testResult.result]
|
|
87
87
|
});
|
|
88
88
|
}
|
|
@@ -107,8 +107,8 @@ module.exports = (config) => {
|
|
|
107
107
|
};
|
|
108
108
|
|
|
109
109
|
return {
|
|
110
|
-
|
|
110
|
+
getTestResults,
|
|
111
111
|
logTestResults,
|
|
112
|
-
|
|
112
|
+
startUrlTests
|
|
113
113
|
};
|
|
114
114
|
};
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
body {
|
|
13
13
|
color: #333333;
|
|
14
|
-
font-family:
|
|
14
|
+
font-family: system-ui, sans-serif;
|
|
15
15
|
font-size: 0.85rem;
|
|
16
16
|
margin: auto;
|
|
17
17
|
max-width: 1000px;
|
|
@@ -98,12 +98,17 @@
|
|
|
98
98
|
margin-block-start: 1rem;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
details summary .name::after
|
|
102
|
-
|
|
101
|
+
details summary .name::after,
|
|
102
|
+
details[open] summary .name::after {
|
|
103
|
+
content: "+";
|
|
104
|
+
font-family: monospace;
|
|
105
|
+
font-weight: bold;
|
|
106
|
+
opacity: 0.8;
|
|
107
|
+
padding-left: 0.5rem;
|
|
103
108
|
}
|
|
104
109
|
|
|
105
110
|
details[open] summary .name::after {
|
|
106
|
-
content: "
|
|
111
|
+
content: "–";
|
|
107
112
|
}
|
|
108
113
|
|
|
109
114
|
summary {
|
|
@@ -192,7 +197,7 @@
|
|
|
192
197
|
<li class="test {{result}}">
|
|
193
198
|
{{#if data}}
|
|
194
199
|
<details>
|
|
195
|
-
<summary class="header">
|
|
200
|
+
<summary class="header" title="Show/hide details">
|
|
196
201
|
<div class="name">{{name}}</div>
|
|
197
202
|
<div class="result">{{result}}</div>
|
|
198
203
|
</summary>
|
package/lib/reporter.js
CHANGED
|
@@ -13,10 +13,10 @@ const htmlReportTemplateName = 'report-template.handlebars';
|
|
|
13
13
|
const htmlReportFileName = './pagean-results.html';
|
|
14
14
|
const jsonReportFileName = './pagean-results.json';
|
|
15
15
|
|
|
16
|
-
handlebars.registerHelper('json', (context) =>
|
|
17
|
-
// eslint-disable-next-line no-magic-numbers -- count
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
handlebars.registerHelper('json', (context) =>
|
|
17
|
+
// eslint-disable-next-line no-magic-numbers -- no-magic-numbers - count
|
|
18
|
+
JSON.stringify(context, undefined, 2)
|
|
19
|
+
);
|
|
20
20
|
|
|
21
21
|
const saveHtmlReport = (results) => {
|
|
22
22
|
const templateFile = path.resolve(
|
package/lib/schema-errors.js
CHANGED
|
@@ -24,8 +24,8 @@ const getDataKey = (instancePath) => {
|
|
|
24
24
|
// eslint-disable-next-line unicorn/no-array-reduce -- reduce for string concatenation
|
|
25
25
|
.reduce((accumulator, currentValue) => {
|
|
26
26
|
const unencodedValue = currentValue
|
|
27
|
-
.
|
|
28
|
-
.
|
|
27
|
+
.replaceAll('~0', '~')
|
|
28
|
+
.replaceAll('~1', '/');
|
|
29
29
|
const encodedValue = Number.isNaN(Number(unencodedValue))
|
|
30
30
|
? `.${unencodedValue}`
|
|
31
31
|
: `[${unencodedValue}]`;
|
package/lib/sitemap.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const axios = require('axios');
|
|
4
|
+
const { parseStringPromise } = require('xml2js');
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Gets a sitemap, via file path or URL, and returns the string contents.
|
|
10
|
+
*
|
|
11
|
+
* @param {string} url The URL or file path to the sitemap.
|
|
12
|
+
* @returns {string} The string contents of the sitemap.
|
|
13
|
+
* @async
|
|
14
|
+
* @throws {Error} Throws if the sitemap cannot be retrieved.
|
|
15
|
+
* @static
|
|
16
|
+
* @private
|
|
17
|
+
*/
|
|
18
|
+
const getSitemap = async (url) => {
|
|
19
|
+
try {
|
|
20
|
+
if (url.startsWith('http')) {
|
|
21
|
+
const response = await axios.get(url);
|
|
22
|
+
return response.data;
|
|
23
|
+
}
|
|
24
|
+
return fs.readFileSync(url, 'utf8');
|
|
25
|
+
} catch {
|
|
26
|
+
throw new Error(`Error retrieving sitemap "${url}"`);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Parses a sitemap string and returns an array of URLs.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} sitemapXml The string contents of the sitemap.
|
|
34
|
+
* @returns {string[]} The URLs from the sitemap.
|
|
35
|
+
* @async
|
|
36
|
+
* @static
|
|
37
|
+
* @private
|
|
38
|
+
*/
|
|
39
|
+
const parseSitemap = async (sitemapXml) => {
|
|
40
|
+
const sitemapData = await parseStringPromise(sitemapXml);
|
|
41
|
+
if (!sitemapData.urlset.url) {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
return sitemapData.urlset.url.map((url) => url.loc[0]);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Removes excluded URLs from the list of URLs.
|
|
49
|
+
*
|
|
50
|
+
* @param {string[]} urls The URLs from the sitemap.
|
|
51
|
+
* @param {string[]} exclude The URLs to exclude (regular expressions).
|
|
52
|
+
* @returns {string[]} The non-excluded URLs from the sitemap.
|
|
53
|
+
* @static
|
|
54
|
+
* @private
|
|
55
|
+
*/
|
|
56
|
+
const removeExcludedUrls = (urls, exclude = []) => {
|
|
57
|
+
const excludeRegex = exclude.map((url) => new RegExp(url));
|
|
58
|
+
return urls.filter((url) => {
|
|
59
|
+
for (const excludeUrlRegex of excludeRegex) {
|
|
60
|
+
if (excludeUrlRegex.test(url)) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return true;
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Replaces the "find" string with the "replace" string in each of the URLs.
|
|
70
|
+
*
|
|
71
|
+
* @param {string[]} urls The URLs to process.
|
|
72
|
+
* @param {string} [find] The string to find in the URLs.
|
|
73
|
+
* @param {string} [replace] The string to replace in the URLs.
|
|
74
|
+
* @returns {string[]} The processed URLs.
|
|
75
|
+
* @async
|
|
76
|
+
* @static
|
|
77
|
+
* @private
|
|
78
|
+
*/
|
|
79
|
+
const replaceUrlStrings = (urls, find, replace) => {
|
|
80
|
+
if (!find || !replace) {
|
|
81
|
+
return urls;
|
|
82
|
+
}
|
|
83
|
+
return urls.map((url) => url.replace(find, replace));
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Retrieves a list of URLs from a sitemap, via a local file path or URL.
|
|
88
|
+
* Options are available to exclude URLs from the results, and to find and
|
|
89
|
+
* replace strings in the URLs (primarily intended for isolated testing,
|
|
90
|
+
* for example change "https://somewhere.test/" to "http://localhost:3000/").
|
|
91
|
+
*
|
|
92
|
+
* @param {object} options The sitemap processing options.
|
|
93
|
+
* @param {string} options.url The URL or file path to the sitemap.
|
|
94
|
+
* @param {string} [options.find] The string to find in the URLs.
|
|
95
|
+
* @param {string} [options.replace] The string to replace in the URLs.
|
|
96
|
+
* @param {string[]} [options.exclude] The URLs to exclude (regular expressions).
|
|
97
|
+
* @returns {string[]} The processed URLs from the sitemap.
|
|
98
|
+
* @async
|
|
99
|
+
* @throws {Error} Throws if the sitemap cannot be processed.
|
|
100
|
+
* @static
|
|
101
|
+
* @public
|
|
102
|
+
*/
|
|
103
|
+
const getUrlsFromSitemap = async ({ url, find, replace, exclude } = {}) => {
|
|
104
|
+
const sitemapXml = await getSitemap(url);
|
|
105
|
+
try {
|
|
106
|
+
const rawUrls = await parseSitemap(sitemapXml);
|
|
107
|
+
return replaceUrlStrings(
|
|
108
|
+
removeExcludedUrls(rawUrls, exclude),
|
|
109
|
+
find,
|
|
110
|
+
replace
|
|
111
|
+
);
|
|
112
|
+
} catch {
|
|
113
|
+
throw new Error(`Error processing sitemap "${url}"`);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
module.exports.getUrlsFromSitemap = getUrlsFromSitemap;
|
package/lib/test-utils.js
CHANGED
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
const testResultStates = Object.freeze({
|
|
10
|
+
failed: 'failed',
|
|
10
11
|
passed: 'passed',
|
|
11
|
-
warning: 'warning'
|
|
12
|
-
failed: 'failed'
|
|
12
|
+
warning: 'warning'
|
|
13
13
|
});
|
|
14
14
|
|
|
15
15
|
const getTestSettings = (testSettingProperty, urlSettings) => {
|
|
@@ -26,7 +26,7 @@ const getTestSettings = (testSettingProperty, urlSettings) => {
|
|
|
26
26
|
* test-specific settings from configuration, passing the test context,
|
|
27
27
|
* and logging results.
|
|
28
28
|
*
|
|
29
|
-
* @param {string} name The name of the test.
|
|
29
|
+
* @param {string} name The name of the test being run.
|
|
30
30
|
* @param {Function} testFunction The test function to be executed.
|
|
31
31
|
* @param {object} testContext The test execution context.
|
|
32
32
|
* @param {string} testSettingProperty The name of the config property
|
|
@@ -50,7 +50,7 @@ const pageanTest = async (
|
|
|
50
50
|
try {
|
|
51
51
|
results = await testFunction(testContext);
|
|
52
52
|
} catch (error) {
|
|
53
|
-
results = {
|
|
53
|
+
results = { data: { error }, result: testResultStates.failed };
|
|
54
54
|
}
|
|
55
55
|
if (
|
|
56
56
|
results.result === testResultStates.failed &&
|
package/lib/tests.js
CHANGED
|
@@ -27,7 +27,10 @@ const horizontalScrollbarTest = async (context) => {
|
|
|
27
27
|
// istanbul ignore next: injects script causing puppeteer error, see #48
|
|
28
28
|
const scrollbar = await context.page.evaluate(() => {
|
|
29
29
|
document.scrollingElement.scrollLeft = 1;
|
|
30
|
-
|
|
30
|
+
// The scrollLeft value may not end up exactly 1. But, any
|
|
31
|
+
// value greater than 0 indicates the presence of a horizontal
|
|
32
|
+
// scrollbar.
|
|
33
|
+
return document.scrollingElement.scrollLeft > 0;
|
|
31
34
|
});
|
|
32
35
|
return {
|
|
33
36
|
result:
|
|
@@ -136,7 +139,7 @@ const renderedHtmlTest = async (context) => {
|
|
|
136
139
|
* @param {object} context Test execution context.
|
|
137
140
|
* @static
|
|
138
141
|
*/
|
|
139
|
-
// eslint-disable-next-line max-lines-per-function
|
|
142
|
+
// eslint-disable-next-line max-lines-per-function -- exceeded due to formatting
|
|
140
143
|
const pageLoadTimeTest = async (context) => {
|
|
141
144
|
const testSettingName = 'pageLoadTimeTest';
|
|
142
145
|
await pageanTest(
|
|
@@ -179,18 +182,16 @@ const pageLoadTimeTest = async (context) => {
|
|
|
179
182
|
* @param {object} context Test execution context.
|
|
180
183
|
* @static
|
|
181
184
|
*/
|
|
182
|
-
// eslint-disable-next-line max-lines-per-function
|
|
185
|
+
// eslint-disable-next-line max-lines-per-function -- exceeded due to formatting
|
|
183
186
|
const externalScriptTest = async (context) => {
|
|
184
187
|
await pageanTest(
|
|
185
188
|
'should not have external scripts',
|
|
186
189
|
// eslint-disable-next-line no-shadow -- less intuitive
|
|
187
190
|
async (context) => {
|
|
188
191
|
// istanbul ignore next: injects script causing puppeteer error, see #48
|
|
189
|
-
const scripts = await context.page.evaluate(() =>
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
);
|
|
193
|
-
});
|
|
192
|
+
const scripts = await context.page.evaluate(() =>
|
|
193
|
+
[...document.querySelectorAll('script[src]')].map((s) => s.src)
|
|
194
|
+
);
|
|
194
195
|
|
|
195
196
|
const pageUrl = context.page.url();
|
|
196
197
|
const externalScripts = scripts.filter((script) =>
|
|
@@ -223,23 +224,21 @@ const externalScriptTest = async (context) => {
|
|
|
223
224
|
* @param {object} context Test execution context.
|
|
224
225
|
* @static
|
|
225
226
|
*/
|
|
226
|
-
// eslint-disable-next-line max-lines-per-function
|
|
227
|
+
// eslint-disable-next-line max-lines-per-function -- exceeded due to formatting
|
|
227
228
|
const brokenLinkTest = async (context) => {
|
|
228
229
|
await pageanTest(
|
|
229
230
|
'should not have broken links',
|
|
230
231
|
// eslint-disable-next-line no-shadow -- less intuitive
|
|
231
232
|
async (context) => {
|
|
232
233
|
// istanbul ignore next: injects script causing puppeteer error, see #48
|
|
233
|
-
const links = await context.page.evaluate(() =>
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
);
|
|
237
|
-
});
|
|
234
|
+
const links = await context.page.evaluate(() =>
|
|
235
|
+
[...document.querySelectorAll('a[href]')].map((a) => a.href)
|
|
236
|
+
);
|
|
238
237
|
|
|
239
238
|
// All links are returned from puppeteer as absolute links, so this filters out
|
|
240
239
|
// javascript and other values and leaves only pages to request.
|
|
241
240
|
const httpLinks = links.filter((link) =>
|
|
242
|
-
link.match(/(
|
|
241
|
+
link.match(/(?<protocol>https?|file):\/\//)
|
|
243
242
|
);
|
|
244
243
|
// Reduce to unique page links so only checked once
|
|
245
244
|
const uniqueHttpLinks = [...new Set(httpLinks)];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pagean",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "9.0.0",
|
|
4
4
|
"description": "Pagean is a web page analysis tool designed to automate tests requiring web pages to be loaded in a browser window (e.g. horizontal scrollbar, console errors)",
|
|
5
5
|
"bin": {
|
|
6
6
|
"pagean": "./bin/pagean.js",
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
"lint": "npm run lint-css && npm run lint-html && npm run lint-js && npm run lint-md",
|
|
13
13
|
"lint-css": "stylelint ./lib/report-template.handlebars",
|
|
14
14
|
"lint-html": "htmlhint ./lib/report-template.handlebars",
|
|
15
|
-
"lint-js": "eslint
|
|
16
|
-
"lint-md": "markdownlint **/*.md
|
|
15
|
+
"lint-js": "eslint .",
|
|
16
|
+
"lint-md": "markdownlint-cli2 \"**/*.md\" \"#node_modules\" \"#Archive\"",
|
|
17
17
|
"prettier-check": "prettier --check .",
|
|
18
18
|
"prettier-fix": "prettier --write .",
|
|
19
19
|
"start": "node ./bin/pagean.js",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"author": "Aaron Goldenthal <npm@aarongoldenthal.com>",
|
|
38
38
|
"license": "MIT",
|
|
39
39
|
"engines": {
|
|
40
|
-
"node": "^
|
|
40
|
+
"node": "^16.13.0 || ^18.12.0 || >=20.0.0"
|
|
41
41
|
},
|
|
42
42
|
"files": [
|
|
43
43
|
"index.js",
|
|
@@ -51,29 +51,30 @@
|
|
|
51
51
|
},
|
|
52
52
|
"homepage": "https://gitlab.com/gitlab-ci-utils/pagean",
|
|
53
53
|
"devDependencies": {
|
|
54
|
-
"@aarongoldenthal/eslint-config-standard": "^
|
|
55
|
-
"@aarongoldenthal/stylelint-config-standard": "^
|
|
56
|
-
"bin-tester": "^
|
|
57
|
-
"eslint": "^8.
|
|
58
|
-
"jest": "^29.
|
|
59
|
-
"jest-junit": "^
|
|
60
|
-
"markdownlint-
|
|
61
|
-
"prettier": "^2.8.
|
|
54
|
+
"@aarongoldenthal/eslint-config-standard": "^22.1.0",
|
|
55
|
+
"@aarongoldenthal/stylelint-config-standard": "^14.0.0",
|
|
56
|
+
"bin-tester": "^4.0.1",
|
|
57
|
+
"eslint": "^8.44.0",
|
|
58
|
+
"jest": "^29.6.0",
|
|
59
|
+
"jest-junit": "^16.0.0",
|
|
60
|
+
"markdownlint-cli2": "^0.8.1",
|
|
61
|
+
"prettier": "^2.8.8",
|
|
62
62
|
"strip-ansi": "^6.0.1",
|
|
63
|
-
"stylelint": "^15.
|
|
63
|
+
"stylelint": "^15.10.0"
|
|
64
64
|
},
|
|
65
65
|
"dependencies": {
|
|
66
66
|
"ajv": "^8.12.0",
|
|
67
67
|
"ajv-errors": "^3.0.0",
|
|
68
|
-
"axios": "^1.
|
|
69
|
-
"ci-logger": "^
|
|
70
|
-
"commander": "^
|
|
68
|
+
"axios": "^1.4.0",
|
|
69
|
+
"ci-logger": "^6.0.0",
|
|
70
|
+
"commander": "^11.0.0",
|
|
71
71
|
"cssesc": "^3.0.0",
|
|
72
72
|
"handlebars": "^4.7.7",
|
|
73
73
|
"htmlhint": "^1.1.4",
|
|
74
74
|
"kleur": "^4.1.5",
|
|
75
75
|
"normalize-url": "^6.1.0",
|
|
76
76
|
"protocolify": "^3.0.0",
|
|
77
|
-
"puppeteer": "^
|
|
77
|
+
"puppeteer": "^20.7.4",
|
|
78
|
+
"xml2js": "^0.6.0"
|
|
78
79
|
}
|
|
79
80
|
}
|
|
@@ -30,6 +30,48 @@
|
|
|
30
30
|
"description": "The global test settings to apply to all tests, unless overridden for a specific URL.",
|
|
31
31
|
"$ref": "#/definitions/allTestSettings"
|
|
32
32
|
},
|
|
33
|
+
"sitemap": {
|
|
34
|
+
"description": "The sitemap to test",
|
|
35
|
+
"type": "object",
|
|
36
|
+
"properties": {
|
|
37
|
+
"url": {
|
|
38
|
+
"description": "The URL of the sitemap to test",
|
|
39
|
+
"type": "string"
|
|
40
|
+
},
|
|
41
|
+
"find": {
|
|
42
|
+
"description": "The string or regex pattern to find in sitemap URLs",
|
|
43
|
+
"type": "string"
|
|
44
|
+
},
|
|
45
|
+
"replace": {
|
|
46
|
+
"description": "The replacement string in sitemap URLs",
|
|
47
|
+
"type": "string"
|
|
48
|
+
},
|
|
49
|
+
"exclude": {
|
|
50
|
+
"description": "An array of strings or regex patterns to exclude from the sitemap",
|
|
51
|
+
"type": "array",
|
|
52
|
+
"items": {
|
|
53
|
+
"type": "string"
|
|
54
|
+
},
|
|
55
|
+
"uniqueItems": true,
|
|
56
|
+
"minItems": 1,
|
|
57
|
+
"maxItems": 100
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"allOf": [
|
|
61
|
+
{ "required": ["url"], "errorMessage": "must provide 'url'" },
|
|
62
|
+
{
|
|
63
|
+
"dependentRequired": {
|
|
64
|
+
"find": ["replace"],
|
|
65
|
+
"replace": ["find"]
|
|
66
|
+
},
|
|
67
|
+
"errorMessage": "must use 'find' and 'replace' together"
|
|
68
|
+
}
|
|
69
|
+
],
|
|
70
|
+
"additionalProperties": {
|
|
71
|
+
"not": true,
|
|
72
|
+
"errorMessage": "must NOT contain additional properties: ${0#}"
|
|
73
|
+
}
|
|
74
|
+
},
|
|
33
75
|
"urls": {
|
|
34
76
|
"description": "The collection of URLs to test, with any URL-specific test settings.",
|
|
35
77
|
"type": "array",
|
|
@@ -71,7 +113,16 @@
|
|
|
71
113
|
"not": true,
|
|
72
114
|
"errorMessage": "must NOT contain additional properties: ${0#}"
|
|
73
115
|
},
|
|
74
|
-
"
|
|
116
|
+
"anyOf": [
|
|
117
|
+
{
|
|
118
|
+
"required": ["sitemap"],
|
|
119
|
+
"errorMessage": "must have either property 'sitemap' or 'urls' (or both)"
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
"required": ["urls"],
|
|
123
|
+
"errorMessage": "must have either property 'sitemap' or 'urls' (or both)"
|
|
124
|
+
}
|
|
125
|
+
],
|
|
75
126
|
"definitions": {
|
|
76
127
|
"detailedSetting": {
|
|
77
128
|
"description": "The complete set of test-specific settings for most tests.",
|