pagean 8.0.3 → 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 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:8080/brokenLinks.html#notlinked",
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 six major properties:
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/test-cases/consoleLog.html",
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 [`http-server`](https://www.npmjs.com/package/http-server) 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 `test-cases` 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.
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. Have wait-on check by IP based on DNS
293
- # change in Node 17 and issues with it resolving `localhost`. Pagean is able
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 should NOT have fewer than 1 items
330
- <pageanrc>.reporters[0] should be equal to one of the allowed values (cli, html, json)
331
- <pageanrc>.settings.consoleOutputTest should be either boolean or object with the appropriate properties
332
- <pageanrc>.settings.pageLoadTimeTest.foo should NOT contain additional properties: "foo"
333
- <pageanrc>.settings.pageLoadTimeTest should be either boolean or object with the appropriate properties
334
- <pageanrc>.urls[2].settings.consoleOutputTest should be either boolean or object with the appropriate properties
335
- <pageanrc>.urls[3] should be either URL string or object with the appropriate properties
336
- <pageanrc>.urls[5] should have required property url
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
- try {
23
- const config = getConfig(options.config);
24
- pagean.executeAllTests(config);
25
- } catch (error) {
26
- log({
27
- message: `Error executing pagean tests\n${error.message}`,
28
- level: Levels.Error,
29
- exitOnError: true,
30
- errorCode: 1
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
- }
@@ -19,13 +19,13 @@ program
19
19
  const options = program.opts();
20
20
 
21
21
  const logError = (message, exitOnError = true) => {
22
- log({ message, level: Levels.Error, exitOnError, errorCode: 1 });
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
- type: message.type(),
36
+ location: message.location(),
35
37
  text: message.text(),
36
- location: message.location()
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').default;
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 processUrls = (config) => {
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 { isValid, errors: validate.errors || [] };
130
+ return { errors: validate.errors || [], isValid };
107
131
  };
108
132
 
109
- const getConfigFromFile = (configFileName) => {
110
- return JSON.parse(fs.readFileSync(configFileName, 'utf8'));
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
- reporters: config.reporters || defaultConfig.reporters
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
 
@@ -32,6 +32,9 @@
32
32
  "ignoreDuplicates": true
33
33
  }
34
34
  },
35
+ "puppeteerLaunchOptions": {
36
+ "headless": "new"
37
+ },
35
38
  "reporters": ["cli", "html", "json"],
36
39
  "urls": ["ignored, required to validate schema"]
37
40
  }
package/lib/link-utils.js CHANGED
@@ -9,6 +9,7 @@
9
9
  const https = require('https');
10
10
  const axios = require('axios');
11
11
  const normalizeUrl = require('normalize-url');
12
+ const cssesc = require('cssesc');
12
13
 
13
14
  const msPerSec = 1000;
14
15
  const timeoutSeconds = 120;
@@ -23,6 +24,7 @@ const timeoutSeconds = 120;
23
24
  const httpResponse = Object.freeze({
24
25
  continue: 100,
25
26
  ok: 200,
27
+ // eslint-disable-next-line sort-keys -- order by response code
26
28
  badRequest: 400,
27
29
  notFound: 404,
28
30
  tooManyRequests: 429,
@@ -51,7 +53,6 @@ const normalizeLink = (url) =>
51
53
  stripHash: true,
52
54
  stripWWW: false
53
55
  });
54
- /* eslint-enable jsdoc/require-description-complete-sentence */
55
56
 
56
57
  /**
57
58
  * Checks settings to determine if the provided link should be ignored.
@@ -94,7 +95,10 @@ const checkSamePageLink = async (page, link) => {
94
95
  return httpResponse.ok;
95
96
  }
96
97
 
97
- const element = await page.$(selector);
98
+ const escapedSelector = `#${cssesc(selector.slice(1), {
99
+ isIdentifier: true
100
+ })}`;
101
+ const element = await page.$(escapedSelector);
98
102
  return element ? httpResponse.ok : `${selector} Not Found`;
99
103
  };
100
104
 
@@ -117,7 +121,7 @@ const checkExternalPageLinkBrowser = async (page, link) => {
117
121
  } catch (error) {
118
122
  // Errors are returned in the format: "ENOTFOUND at https://this.url.does.not.exist/",
119
123
  // so extract error only and remove URL
120
- status = error.message.replace(/^(.*) at .*$/, '$1');
124
+ status = error.message.replace(/^(?<message>.*) at .*$/, '$1');
121
125
  }
122
126
  return status;
123
127
  };
@@ -144,15 +148,16 @@ const checkExternalPageLink = async (page, link, useGet = false) => {
144
148
 
145
149
  try {
146
150
  const options = {
151
+ decompress: false,
147
152
  headers: { 'User-Agent': userAgent },
148
- timeout: timeoutSeconds * msPerSec,
153
+ timeout: timeoutSeconds * msPerSec
149
154
  // Axios can generate an error trying to decompress an empty compressed
150
155
  // HEAD response, see https://github.com/axios/axios/issues/5102.
151
156
  // The response body is also not used, so more efficient to skip.
152
- decompress: false
153
157
  };
154
158
  // Using internal browser property since not exposed
155
- // eslint-disable-next-line no-underscore-dangle
159
+ /* eslint-disable-next-line no-underscore-dangle -- require to match
160
+ Puppeteer API */
156
161
  if (page.browser()._ignoreHTTPSErrors) {
157
162
  const agent = new https.Agent({ rejectUnauthorized: false });
158
163
  options.httpsAgent = agent;
@@ -179,7 +184,7 @@ const checkExternalPageLink = async (page, link, useGet = false) => {
179
184
  };
180
185
 
181
186
  /**
182
- * Factory function returning a linkChecker object with a {@link checkLink}
187
+ * Factory function returning a linkChecker object with a checkLink
183
188
  * function that caches checked link results.
184
189
  *
185
190
  * @returns {object} Link checker object.
@@ -200,7 +205,7 @@ const createLinkChecker = () => {
200
205
  * @returns {(string|number)} The link status (HTTP response code or error).
201
206
  * @public
202
207
  */
203
- // eslint-disable-next-line sonarjs/cognitive-complexity
208
+ // eslint-disable-next-line sonarjs/cognitive-complexity -- allow < 10
204
209
  checkLink: async (context, link) => {
205
210
  let status = httpResponse.unknownError;
206
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
- tests: 0,
45
+ failed: 0,
45
46
  passed: 0,
46
- warning: 0,
47
- failed: 0
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
- url,
63
- tests: []
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
- startUrlTests,
110
+ getTestResults,
111
111
  logTestResults,
112
- getTestResults
112
+ startUrlTests
113
113
  };
114
114
  };
@@ -11,7 +11,7 @@
11
11
 
12
12
  body {
13
13
  color: #333333;
14
- font-family: Arial, Helvetica, sans-serif;
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
- content: " ⏵";
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
- return JSON.stringify(context, undefined, 2);
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(
@@ -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
- .replace(/~0/g, '~')
28
- .replace(/~1/g, '/');
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 = { result: testResultStates.failed, data: { error } };
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
- return document.scrollingElement.scrollLeft === 1;
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
- return [...document.querySelectorAll('script[src]')].map(
191
- (s) => s.src
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
- return [...document.querySelectorAll('a[href]')].map(
235
- (a) => a.href
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(/(http(s?)|file):\/\//)
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": "8.0.3",
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,18 +12,18 @@
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 \"**/*.js\"",
16
- "lint-md": "markdownlint **/*.md --ignore node_modules",
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",
20
20
  "start-lint": "node ./bin/pageanrc-lint.js",
21
- "start-lint-all": "npm run start-lint && npm run start-lint -c static-server.pageanrc.json && npm run start-lint -c ./lib/default-config.json",
21
+ "start-lint-all": "npm run start-lint && npm run start-lint static-server.pageanrc.json && npm run start-lint ./lib/default-config.json",
22
22
  "test": "jest --ci --config jest.config.json"
23
23
  },
24
24
  "repository": {
25
25
  "type": "git",
26
- "url": "git+https://gitlab.com/gitlab-ci-utils/pagean.git"
26
+ "url": "https://gitlab.com/gitlab-ci-utils/pagean.git"
27
27
  },
28
28
  "keywords": [
29
29
  "analysis",
@@ -37,7 +37,7 @@
37
37
  "author": "Aaron Goldenthal <npm@aarongoldenthal.com>",
38
38
  "license": "MIT",
39
39
  "engines": {
40
- "node": "^14.15.0 || ^16.13.0 || >=18.0.0"
40
+ "node": "^16.13.0 || ^18.12.0 || >=20.0.0"
41
41
  },
42
42
  "files": [
43
43
  "index.js",
@@ -51,28 +51,30 @@
51
51
  },
52
52
  "homepage": "https://gitlab.com/gitlab-ci-utils/pagean",
53
53
  "devDependencies": {
54
- "@aarongoldenthal/eslint-config-standard": "^20.0.0",
55
- "@aarongoldenthal/stylelint-config-standard": "^12.0.1",
56
- "bin-tester": "^3.0.0",
57
- "eslint": "^8.33.0",
58
- "jest": "^29.4.1",
59
- "jest-junit": "^15.0.0",
60
- "markdownlint-cli": "^0.33.0",
61
- "prettier": "^2.8.3",
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": "^14.16.1"
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.3.1",
69
- "ci-logger": "^5.1.0",
70
- "commander": "^10.0.0",
68
+ "axios": "^1.4.0",
69
+ "ci-logger": "^6.0.0",
70
+ "commander": "^11.0.0",
71
+ "cssesc": "^3.0.0",
71
72
  "handlebars": "^4.7.7",
72
73
  "htmlhint": "^1.1.4",
73
74
  "kleur": "^4.1.5",
74
75
  "normalize-url": "^6.1.0",
75
76
  "protocolify": "^3.0.0",
76
- "puppeteer": "^19.6.3"
77
+ "puppeteer": "^20.7.4",
78
+ "xml2js": "^0.6.0"
77
79
  }
78
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
- "required": ["urls"],
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.",