Skip to content
Merged
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"test:cdn": "$npm_package_config_testCommand --tags @cdn",
"test:performancehints": "$npm_package_config_testCommand --tags @performancehints",
"test:cloudflare-compatibility": "$npm_package_config_testCommand --tags @cloudflare-compatibility",
"test:brokenlinks": "$npm_package_config_testCommand --tags @brokenlinks",
"healthcheck": "ts-node healthcheck.ts",
"push-report": "ts-node report.ts",
"wp-env": "wp-env",
Expand Down
10 changes: 9 additions & 1 deletion src/common/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,4 +293,12 @@ export const selectors: Selectors = {
}
}
}
}
}

/**
* Global selectors for link validation and general page elements.
*/
export const linkValidationSelectors = {
allLinksInContent: '#wpbody-content a[href]',
tabLinksInContent: '#wpbody-content a[href*="page=wprocket#"]',
} as const;
8 changes: 8 additions & 0 deletions src/features/broken-links.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
@setup @smoke @brokenlinks
Feature: Broken links in WP Rocket settings UI

Scenario: WP Rocket settings links are not broken
Given I am logged in
And plugin is installed 'new_release'
And plugin is activated
Then WP Rocket settings links are not broken
74 changes: 74 additions & 0 deletions src/support/steps/broken-links.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* @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}
* @requires {@link ../../../utils/helpers}
*/
import { Then } from '@cucumber/cucumber';
import { expect } from '@playwright/test';
import { ICustomWorld } from '../../common/custom-world';
import { linkValidationSelectors } from '../../common/selectors';
import { collectHrefsFromSelector, normalizeUrls, validateLinks } from '../../../utils/helpers';

/**
* 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 4xx client errors except external 401/403 (auth-gated content).
* Logs warnings for 5xx server errors 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) {
// Visit WP Rocket settings and store base URL for consistent resolution
await this.utils.visitPage('wp-admin/options-general.php?page=wprocket');
const basePageUrl = this.page.url();
const currentHost = new URL(basePageUrl).host;

// Collect links from base page
let allHrefs = await collectHrefsFromSelector(this.page, linkValidationSelectors.allLinksInContent);

// Get all tab URLs and visit each to collect their links
const tabHrefs = await collectHrefsFromSelector(this.page, linkValidationSelectors.tabLinksInContent);

const uniqueTabHrefs = Array.from(new Set(tabHrefs));
const tabUrls: string[] = [];

for (const href of uniqueTabHrefs) {
try {
const url = new URL(href, basePageUrl);
tabUrls.push(url.toString());
} catch (error: unknown) {
// Skip malformed tab URLs - they can't be navigated to anyway
// eslint-disable-next-line no-console
console.warn(`Skipping malformed tab href: ${href}`, error);
}
}

for (const tabUrl of tabUrls) {
await this.page.goto(tabUrl);
await this.page.waitForLoadState('load');
const tabLinks = await collectHrefsFromSelector(this.page, linkValidationSelectors.allLinksInContent);
allHrefs = new Set([...allHrefs, ...tabLinks]);
}

// Normalize and filter URLs
const normalizedUrls = normalizeUrls(allHrefs, basePageUrl);

// Warn if no valid URLs found to validate
if (normalizedUrls.size === 0) {
// eslint-disable-next-line no-console
console.warn('No HTTP/HTTPS links found to validate in WP Rocket settings');
return;
}

// Validate all collected URLs
const brokenLinks = await validateLinks(this.page, normalizedUrls, currentHost);

// Report all broken links at once
expect(brokenLinks, 'Broken links detected').toHaveLength(0);
});
100 changes: 100 additions & 0 deletions utils/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -580,3 +580,103 @@ export const isWprRelatedError = async(contents: string): Promise<boolean> => {

return false;
}

/**
* Collects all href attributes from elements matching a selector.
*
* @async
* @param {Page} page - The Playwright page object
* @param {string} selector - CSS selector to find elements with href
* @return {Promise<Set<string>>} - Set of collected hrefs
*/
export const collectHrefsFromSelector = async (page: Page, selector: string): Promise<Set<string>> => {
const hrefs = new Set<string>();
const links = await page.$$eval(selector, (elements) =>
elements
.map((el) => el.getAttribute('href'))
.filter((href): href is string => Boolean(href))
);
links.forEach((href) => hrefs.add(href));
return hrefs;
};

/**
* Normalizes and filters URLs, removing anchors, special protocols, and invalid URLs.
*
* @param {Set<string>} hrefs - Set of href strings to normalize
* @param {string} baseUrl - Base URL for resolving relative URLs
* @param {string[]} [skipProtocols=['mailto:', 'tel:', 'javascript:']] - Protocols to skip
* @return {Set<string>} - Set of normalized, valid URLs
*/
export const normalizeUrls = (
hrefs: Set<string>,
baseUrl: string,
skipProtocols: string[] = ['mailto:', 'tel:', 'javascript:']
): Set<string> => {
const normalizedUrls = new Set<string>();

for (const href of hrefs) {
const lower = href.toLowerCase();

// Skip anchors, special protocols, and admin-post actions
if (lower.startsWith('#') || skipProtocols.some((p) => lower.startsWith(p))) {
continue;
}

try {
const url = new URL(href, baseUrl);

if (!['http:', 'https:'].includes(url.protocol) || url.pathname.endsWith('/wp-admin/admin-post.php')) {
continue;
}

url.hash = '';
normalizedUrls.add(url.toString());
} catch (error) {
// Skip malformed URLs silently - they can't be validated anyway
continue;
}
}

return normalizedUrls;
};

/**
* Validates HTTP/HTTPS URLs and collects broken links, handling client/server errors appropriately.
* Fails on internal 4xx errors, allows external 401/403 (gated content), warns on 5xx and network errors.
*
* @async
* @param {Page} page - The Playwright page object
* @param {Set<string>} urls - Set of URLs to validate
* @param {string} currentHost - Current host to distinguish internal from external URLs
* @return {Promise<string[]>} - Array of broken link strings in format "STATUS: url"
*/
export const validateLinks = async (page: Page, urls: Set<string>, currentHost: string): Promise<string[]> => {
const brokenLinks: string[] = [];

for (const url of urls) {
try {
const response = await page.request.get(url, { maxRedirects: 5, timeout: 30000 });
const status = response.status();
const isExternal = new URL(url).host !== currentHost;

if (status >= 400 && status < 500) {
if (!(isExternal && (status === 401 || status === 403))) {
brokenLinks.push(`${status}: ${url}`);
}
}

if (status >= 500) {
// eslint-disable-next-line no-console
console.warn(`Warning: ${url} returned ${status} (server error, not failing test)`);
}
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : String(error);
brokenLinks.push(`NETWORK: ${url} (${msg})`);
// eslint-disable-next-line no-console
console.warn(`Network error for ${url}: ${msg}`);
}
}

return brokenLinks;
};