Skip to content

Commit d414a99

Browse files
authored
Merge pull request #329 from wp-media/test/wpr-settings-links
Add WP Rocket settings no-broken-links check
2 parents f903bc2 + 7381d20 commit d414a99

File tree

5 files changed

+192
-1
lines changed

5 files changed

+192
-1
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"test:cdn": "$npm_package_config_testCommand --tags @cdn",
3030
"test:performancehints": "$npm_package_config_testCommand --tags @performancehints",
3131
"test:cloudflare-compatibility": "$npm_package_config_testCommand --tags @cloudflare-compatibility",
32+
"test:brokenlinks": "$npm_package_config_testCommand --tags @brokenlinks",
3233
"healthcheck": "ts-node healthcheck.ts",
3334
"push-report": "ts-node report.ts",
3435
"wp-env": "wp-env",

src/common/selectors.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,4 +293,12 @@ export const selectors: Selectors = {
293293
}
294294
}
295295
}
296-
}
296+
}
297+
298+
/**
299+
* Global selectors for link validation and general page elements.
300+
*/
301+
export const linkValidationSelectors = {
302+
allLinksInContent: '#wpbody-content a[href]',
303+
tabLinksInContent: '#wpbody-content a[href*="page=wprocket#"]',
304+
} as const;

src/features/broken-links.feature

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
@setup @smoke @brokenlinks
2+
Feature: Broken links in WP Rocket settings UI
3+
4+
Scenario: WP Rocket settings links are not broken
5+
Given I am logged in
6+
And plugin is installed 'new_release'
7+
And plugin is activated
8+
Then WP Rocket settings links are not broken

src/support/steps/broken-links.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
* @fileoverview
3+
* This module contains Cucumber step definitions for validating links in WP Rocket settings UI.
4+
*
5+
* @requires {@link ../../common/custom-world}
6+
* @requires {@link @cucumber/cucumber}
7+
* @requires {@link ../../common/selectors}
8+
* @requires {@link ../../../utils/helpers}
9+
*/
10+
import { Then } from '@cucumber/cucumber';
11+
import { expect } from '@playwright/test';
12+
import { ICustomWorld } from '../../common/custom-world';
13+
import { linkValidationSelectors } from '../../common/selectors';
14+
import { collectHrefsFromSelector, normalizeUrls, validateLinks } from '../../../utils/helpers';
15+
16+
/**
17+
* Step definition that verifies WP Rocket settings links are not broken by collecting all links from settings tabs
18+
* and checking their HTTP status codes. Fails on 4xx client errors except external 401/403 (auth-gated content).
19+
* Logs warnings for 5xx server errors to avoid flakiness from transient backend issues.
20+
*
21+
* @function
22+
* @async
23+
* @param {ICustomWorld} this - The Cucumber world context for the current scenario.
24+
* @return {Promise<void>} - A Promise that resolves when all links have been validated.
25+
*/
26+
Then('WP Rocket settings links are not broken', async function (this: ICustomWorld) {
27+
// Visit WP Rocket settings and store base URL for consistent resolution
28+
await this.utils.visitPage('wp-admin/options-general.php?page=wprocket');
29+
const basePageUrl = this.page.url();
30+
const currentHost = new URL(basePageUrl).host;
31+
32+
// Collect links from base page
33+
let allHrefs = await collectHrefsFromSelector(this.page, linkValidationSelectors.allLinksInContent);
34+
35+
// Get all tab URLs and visit each to collect their links
36+
const tabHrefs = await collectHrefsFromSelector(this.page, linkValidationSelectors.tabLinksInContent);
37+
38+
const uniqueTabHrefs = Array.from(new Set(tabHrefs));
39+
const tabUrls: string[] = [];
40+
41+
for (const href of uniqueTabHrefs) {
42+
try {
43+
const url = new URL(href, basePageUrl);
44+
tabUrls.push(url.toString());
45+
} catch (error: unknown) {
46+
// Skip malformed tab URLs - they can't be navigated to anyway
47+
// eslint-disable-next-line no-console
48+
console.warn(`Skipping malformed tab href: ${href}`, error);
49+
}
50+
}
51+
52+
for (const tabUrl of tabUrls) {
53+
await this.page.goto(tabUrl);
54+
await this.page.waitForLoadState('load');
55+
const tabLinks = await collectHrefsFromSelector(this.page, linkValidationSelectors.allLinksInContent);
56+
allHrefs = new Set([...allHrefs, ...tabLinks]);
57+
}
58+
59+
// Normalize and filter URLs
60+
const normalizedUrls = normalizeUrls(allHrefs, basePageUrl);
61+
62+
// Warn if no valid URLs found to validate
63+
if (normalizedUrls.size === 0) {
64+
// eslint-disable-next-line no-console
65+
console.warn('No HTTP/HTTPS links found to validate in WP Rocket settings');
66+
return;
67+
}
68+
69+
// Validate all collected URLs
70+
const brokenLinks = await validateLinks(this.page, normalizedUrls, currentHost);
71+
72+
// Report all broken links at once
73+
expect(brokenLinks, 'Broken links detected').toHaveLength(0);
74+
});

utils/helpers.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,3 +580,103 @@ export const isWprRelatedError = async(contents: string): Promise<boolean> => {
580580

581581
return false;
582582
}
583+
584+
/**
585+
* Collects all href attributes from elements matching a selector.
586+
*
587+
* @async
588+
* @param {Page} page - The Playwright page object
589+
* @param {string} selector - CSS selector to find elements with href
590+
* @return {Promise<Set<string>>} - Set of collected hrefs
591+
*/
592+
export const collectHrefsFromSelector = async (page: Page, selector: string): Promise<Set<string>> => {
593+
const hrefs = new Set<string>();
594+
const links = await page.$$eval(selector, (elements) =>
595+
elements
596+
.map((el) => el.getAttribute('href'))
597+
.filter((href): href is string => Boolean(href))
598+
);
599+
links.forEach((href) => hrefs.add(href));
600+
return hrefs;
601+
};
602+
603+
/**
604+
* Normalizes and filters URLs, removing anchors, special protocols, and invalid URLs.
605+
*
606+
* @param {Set<string>} hrefs - Set of href strings to normalize
607+
* @param {string} baseUrl - Base URL for resolving relative URLs
608+
* @param {string[]} [skipProtocols=['mailto:', 'tel:', 'javascript:']] - Protocols to skip
609+
* @return {Set<string>} - Set of normalized, valid URLs
610+
*/
611+
export const normalizeUrls = (
612+
hrefs: Set<string>,
613+
baseUrl: string,
614+
skipProtocols: string[] = ['mailto:', 'tel:', 'javascript:']
615+
): Set<string> => {
616+
const normalizedUrls = new Set<string>();
617+
618+
for (const href of hrefs) {
619+
const lower = href.toLowerCase();
620+
621+
// Skip anchors, special protocols, and admin-post actions
622+
if (lower.startsWith('#') || skipProtocols.some((p) => lower.startsWith(p))) {
623+
continue;
624+
}
625+
626+
try {
627+
const url = new URL(href, baseUrl);
628+
629+
if (!['http:', 'https:'].includes(url.protocol) || url.pathname.endsWith('/wp-admin/admin-post.php')) {
630+
continue;
631+
}
632+
633+
url.hash = '';
634+
normalizedUrls.add(url.toString());
635+
} catch (error) {
636+
// Skip malformed URLs silently - they can't be validated anyway
637+
continue;
638+
}
639+
}
640+
641+
return normalizedUrls;
642+
};
643+
644+
/**
645+
* Validates HTTP/HTTPS URLs and collects broken links, handling client/server errors appropriately.
646+
* Fails on internal 4xx errors, allows external 401/403 (gated content), warns on 5xx and network errors.
647+
*
648+
* @async
649+
* @param {Page} page - The Playwright page object
650+
* @param {Set<string>} urls - Set of URLs to validate
651+
* @param {string} currentHost - Current host to distinguish internal from external URLs
652+
* @return {Promise<string[]>} - Array of broken link strings in format "STATUS: url"
653+
*/
654+
export const validateLinks = async (page: Page, urls: Set<string>, currentHost: string): Promise<string[]> => {
655+
const brokenLinks: string[] = [];
656+
657+
for (const url of urls) {
658+
try {
659+
const response = await page.request.get(url, { maxRedirects: 5, timeout: 30000 });
660+
const status = response.status();
661+
const isExternal = new URL(url).host !== currentHost;
662+
663+
if (status >= 400 && status < 500) {
664+
if (!(isExternal && (status === 401 || status === 403))) {
665+
brokenLinks.push(`${status}: ${url}`);
666+
}
667+
}
668+
669+
if (status >= 500) {
670+
// eslint-disable-next-line no-console
671+
console.warn(`Warning: ${url} returned ${status} (server error, not failing test)`);
672+
}
673+
} catch (error: unknown) {
674+
const msg = error instanceof Error ? error.message : String(error);
675+
brokenLinks.push(`NETWORK: ${url} (${msg})`);
676+
// eslint-disable-next-line no-console
677+
console.warn(`Network error for ${url}: ${msg}`);
678+
}
679+
}
680+
681+
return brokenLinks;
682+
};

0 commit comments

Comments
 (0)