pagean 8.0.4 → 10.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/LICENSE +1 -1
- package/README.md +238 -83
- package/bin/pagean.js +15 -10
- package/bin/pageanrc-lint.js +2 -2
- package/docs/upgrade-guide.md +14 -5
- package/index.js +12 -7
- package/lib/config.js +54 -15
- package/lib/default-config.json +3 -0
- package/lib/external-file-utils.js +8 -2
- package/lib/link-utils.js +9 -9
- package/lib/logger.js +14 -14
- package/lib/report-template.handlebars +63 -39
- package/lib/reporter.js +8 -6
- package/lib/schema-errors.js +2 -2
- package/lib/sitemap.js +120 -0
- package/lib/test-utils.js +4 -4
- package/lib/tests.js +14 -15
- package/package.json +28 -27
- package/schemas/pageanrc.schema.json +52 -1
package/lib/sitemap.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const axios = require('axios');
|
|
5
|
+
const { parseStringPromise } = require('xml2js');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Gets a sitemap, via file path or URL, and returns the string contents.
|
|
9
|
+
*
|
|
10
|
+
* @param {string} url The URL or file path to the sitemap.
|
|
11
|
+
* @returns {string} The string contents of the sitemap.
|
|
12
|
+
* @async
|
|
13
|
+
* @throws {Error} Throws if the sitemap cannot be retrieved.
|
|
14
|
+
* @static
|
|
15
|
+
* @private
|
|
16
|
+
*/
|
|
17
|
+
const getSitemap = async (url) => {
|
|
18
|
+
try {
|
|
19
|
+
if (url.startsWith('http')) {
|
|
20
|
+
const response = await axios.get(url);
|
|
21
|
+
return response.data;
|
|
22
|
+
}
|
|
23
|
+
// Allow users to specify a local sitemap filename.
|
|
24
|
+
// nosemgrep: eslint.detect-non-literal-fs-filename
|
|
25
|
+
return fs.readFileSync(url, 'utf8');
|
|
26
|
+
} catch {
|
|
27
|
+
throw new Error(`Error retrieving sitemap "${url}"`);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parses a sitemap string and returns an array of URLs.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} sitemapXml The string contents of the sitemap.
|
|
35
|
+
* @returns {string[]} The URLs from the sitemap.
|
|
36
|
+
* @async
|
|
37
|
+
* @static
|
|
38
|
+
* @private
|
|
39
|
+
*/
|
|
40
|
+
const parseSitemap = async (sitemapXml) => {
|
|
41
|
+
const sitemapData = await parseStringPromise(sitemapXml);
|
|
42
|
+
if (!sitemapData.urlset.url) {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
return sitemapData.urlset.url.map((url) => url.loc[0]);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Removes excluded URLs from the list of URLs.
|
|
50
|
+
*
|
|
51
|
+
* @param {string[]} urls The URLs from the sitemap.
|
|
52
|
+
* @param {string[]} exclude The URLs to exclude (regular expressions).
|
|
53
|
+
* @returns {string[]} The non-excluded URLs from the sitemap.
|
|
54
|
+
* @static
|
|
55
|
+
* @private
|
|
56
|
+
*/
|
|
57
|
+
const removeExcludedUrls = (urls, exclude = []) => {
|
|
58
|
+
// Allow URLs to be excluded by regular expression.
|
|
59
|
+
// nosemgrep: eslint.detect-non-literal-regexp
|
|
60
|
+
const excludeRegex = exclude.map((url) => new RegExp(url));
|
|
61
|
+
return urls.filter((url) => {
|
|
62
|
+
for (const excludeUrlRegex of excludeRegex) {
|
|
63
|
+
if (excludeUrlRegex.test(url)) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return true;
|
|
68
|
+
});
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Replaces the "find" string with the "replace" string in each of the URLs.
|
|
73
|
+
*
|
|
74
|
+
* @param {string[]} urls The URLs to process.
|
|
75
|
+
* @param {string} [find] The string to find in the URLs.
|
|
76
|
+
* @param {string} [replace] The string to replace in the URLs.
|
|
77
|
+
* @returns {string[]} The processed URLs.
|
|
78
|
+
* @async
|
|
79
|
+
* @static
|
|
80
|
+
* @private
|
|
81
|
+
*/
|
|
82
|
+
const replaceUrlStrings = (urls, find, replace) => {
|
|
83
|
+
if (!find || !replace) {
|
|
84
|
+
return urls;
|
|
85
|
+
}
|
|
86
|
+
return urls.map((url) => url.replace(find, replace));
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Retrieves a list of URLs from a sitemap, via a local file path or URL.
|
|
91
|
+
* Options are available to exclude URLs from the results, and to find and
|
|
92
|
+
* replace strings in the URLs (primarily intended for isolated testing,
|
|
93
|
+
* for example change "https://somewhere.test/" to "http://localhost:3000/").
|
|
94
|
+
*
|
|
95
|
+
* @param {object} options The sitemap processing options.
|
|
96
|
+
* @param {string} options.url The URL or file path to the sitemap.
|
|
97
|
+
* @param {string} [options.find] The string to find in the URLs.
|
|
98
|
+
* @param {string} [options.replace] The string to replace in the URLs.
|
|
99
|
+
* @param {string[]} [options.exclude] The URLs to exclude (regular expressions).
|
|
100
|
+
* @returns {string[]} The processed URLs from the sitemap.
|
|
101
|
+
* @async
|
|
102
|
+
* @throws {Error} Throws if the sitemap cannot be processed.
|
|
103
|
+
* @static
|
|
104
|
+
* @public
|
|
105
|
+
*/
|
|
106
|
+
const getUrlsFromSitemap = async ({ url, find, replace, exclude } = {}) => {
|
|
107
|
+
const sitemapXml = await getSitemap(url);
|
|
108
|
+
try {
|
|
109
|
+
const rawUrls = await parseSitemap(sitemapXml);
|
|
110
|
+
return replaceUrlStrings(
|
|
111
|
+
removeExcludedUrls(rawUrls, exclude),
|
|
112
|
+
find,
|
|
113
|
+
replace
|
|
114
|
+
);
|
|
115
|
+
} catch {
|
|
116
|
+
throw new Error(`Error processing sitemap "${url}"`);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
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,24 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pagean",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "10.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",
|
|
7
7
|
"pageanrc-lint": "./bin/pageanrc-lint.js"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
|
-
"hooks
|
|
11
|
-
"hooks
|
|
12
|
-
"lint": "npm run lint
|
|
13
|
-
"lint
|
|
14
|
-
"lint
|
|
15
|
-
"lint
|
|
16
|
-
"lint
|
|
17
|
-
"prettier
|
|
18
|
-
"prettier
|
|
10
|
+
"hooks:pre-commit": "npm run lint && npm run prettier:check",
|
|
11
|
+
"hooks:pre-push": "npm audit --audit-level=high && npm test",
|
|
12
|
+
"lint": "npm run lint:css && npm run lint:html && npm run lint:js && npm run lint:md",
|
|
13
|
+
"lint:css": "stylelint ./lib/report-template.handlebars",
|
|
14
|
+
"lint:html": "htmlhint ./lib/report-template.handlebars",
|
|
15
|
+
"lint:js": "eslint .",
|
|
16
|
+
"lint:md": "markdownlint-cli2 \"**/*.md\" \"#node_modules\" \"#Archive\"",
|
|
17
|
+
"prettier:check": "prettier --check .",
|
|
18
|
+
"prettier:fix": "prettier --write .",
|
|
19
19
|
"start": "node ./bin/pagean.js",
|
|
20
|
-
"start
|
|
21
|
-
"start
|
|
20
|
+
"start:lint": "node ./bin/pageanrc-lint.js",
|
|
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": {
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"author": "Aaron Goldenthal <npm@aarongoldenthal.com>",
|
|
38
38
|
"license": "MIT",
|
|
39
39
|
"engines": {
|
|
40
|
-
"node": "^
|
|
40
|
+
"node": "^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.
|
|
54
|
+
"@aarongoldenthal/eslint-config-standard": "^25.0.0",
|
|
55
|
+
"@aarongoldenthal/stylelint-config-standard": "^17.0.0",
|
|
56
|
+
"bin-tester": "^5.0.0",
|
|
57
|
+
"eslint": "^8.56.0",
|
|
58
|
+
"jest": "^29.7.0",
|
|
59
|
+
"jest-junit": "^16.0.0",
|
|
60
|
+
"markdownlint-cli2": "^0.12.1",
|
|
61
|
+
"prettier": "^3.2.4",
|
|
62
62
|
"strip-ansi": "^6.0.1",
|
|
63
|
-
"stylelint": "^
|
|
63
|
+
"stylelint": "^16.2.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.6.7",
|
|
69
|
+
"ci-logger": "^6.0.0",
|
|
70
|
+
"commander": "^11.1.0",
|
|
71
71
|
"cssesc": "^3.0.0",
|
|
72
|
-
"handlebars": "^4.7.
|
|
72
|
+
"handlebars": "^4.7.8",
|
|
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": "^21.9.0",
|
|
78
|
+
"xml2js": "^0.6.2"
|
|
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.",
|