-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathbroken-links.ts
More file actions
148 lines (126 loc) · 5.83 KB
/
broken-links.ts
File metadata and controls
148 lines (126 loc) · 5.83 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
/**
* @fileoverview
* This module contains Cucumber step definitions for validating links in WP Rocket settings UI.
*
* @requires {@link ../../common/custom-world}
* @requires {@link @cucumber/cucumber}
* @requires {@link ../../common/selectors}
*/
import { Then } from '@cucumber/cucumber';
import { ICustomWorld } from '../../common/custom-world';
import { linkValidationSelectors } from '../../common/selectors';
/**
* Step definition that verifies WP Rocket settings links are not broken by collecting all links from settings tabs
* and checking their HTTP status codes. Fails on client errors (4xx) and logs warnings for server errors (5xx)
* to avoid flakiness from transient backend issues.
*
* @function
* @async
* @param {ICustomWorld} this - The Cucumber world context for the current scenario.
* @return {Promise<void>} - A Promise that resolves when all links have been validated.
*/
Then('WP Rocket settings links are not broken', async function (this: ICustomWorld) {
const hrefs = new Set<string>();
// Visit WP Rocket settings.
await this.utils.visitPage('wp-admin/options-general.php?page=wprocket');
const collectLinks = async (): Promise<void> => {
const links = await this.page.$$eval(linkValidationSelectors.allLinksInContent, (elements) =>
elements
.map((element) => element.getAttribute('href'))
.filter((href): href is string => Boolean(href))
);
for (const href of links) {
hrefs.add(href);
}
};
// Collect links from the base settings page first.
await collectLinks();
// Collect tab links from within the WP Rocket settings content.
// Links are collected from each tab separately because different tabs may display different content
// and therefore different links. This ensures comprehensive link coverage across all settings sections.
const tabHrefs = await this.page.$$eval(linkValidationSelectors.tabLinksInContent, (links) =>
links
.map((link) => link.getAttribute('href'))
.filter((href): href is string => Boolean(href))
);
const tabUrls = Array.from(new Set(tabHrefs)).map((href) => {
return new URL(href, this.page.url()).toString();
});
// Visit each tab and collect its links to ensure all links across all settings sections are validated
for (const tabUrl of tabUrls) {
await this.page.goto(tabUrl);
await this.page.waitForLoadState('load');
await collectLinks();
}
const normalizedUrls = new Set<string>();
const skipProtocols = ['mailto:', 'tel:', 'javascript:'];
for (const href of hrefs) {
const lowerHref = href.toLowerCase();
if (lowerHref.startsWith('#')) {
continue;
}
if (skipProtocols.some((protocol) => lowerHref.startsWith(protocol))) {
continue;
}
try {
const url = new URL(href, this.page.url());
if (!['http:', 'https:'].includes(url.protocol)) {
continue;
}
if (url.pathname.endsWith('/wp-admin/admin-post.php')) {
// Avoid triggering admin-post actions.
continue;
}
url.hash = '';
normalizedUrls.add(url.toString());
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
// Log URL parsing issues for debugging while continuing to skip malformed URLs.
// This helps understand why some links might not be checked.
// eslint-disable-next-line no-console
console.debug(
`Skipping malformed or unsupported URL href="${href}" on page "${this.page.url()}": ${message}`
);
continue;
}
}
const currentHost = new URL(this.page.url()).host;
for (const url of normalizedUrls) {
try {
const response = await this.page.request.get(url, { maxRedirects: 5, timeout: 30000 });
const status = response.status();
const urlHost = new URL(url).host;
// Treat client errors (4xx) as failures unless they are external 401/403 (expected gated content).
if (status >= 400 && status < 500) {
const isExternalHost = urlHost !== currentHost;
// For external docs/checkout/account links, 401/403 are expected gates; log and continue.
if (isExternalHost && (status === 401 || status === 403)) {
// eslint-disable-next-line no-console
console.warn(
`Skipping external ${status} for ${url} (expected auth/gated content).`
);
} else {
throw new Error(`Client error: ${url} returned status ${status}`);
}
}
// For server errors (5xx), log but don't fail to avoid flakiness from transient backend issues.
if (status >= 500) {
// eslint-disable-next-line no-console
console.warn(
`Warning: ${url} returned server error status ${status}, but continuing test to avoid flakiness.`
);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
// If this is a client error (4xx), re-throw it to fail the test.
if (message.startsWith('Client error:')) {
throw error;
}
// For network errors (timeouts, connection issues, etc.), log but don't fail.
// eslint-disable-next-line no-console
console.warn(
`Warning: Network or non-client error while requesting ${url}: ${message}. Continuing test.`
);
}
}
});