chore(test): e2e test improvements:

- Link checker should report the first broken link
- Link checker should only test external links if the domains are in the allowed list
- If test subjects don't start with 'content/', treat them as URL paths and don't send them to map-files-to-urls.js.
pull/6075/head
Jason Stirnaman 2025-05-16 17:13:15 -05:00
parent 02e10068ad
commit da767f5228
5 changed files with 323 additions and 74 deletions

View File

@ -4,6 +4,7 @@ import * as fs from 'fs';
import * as yaml from 'js-yaml';
import {
BROKEN_LINKS_FILE,
FIRST_BROKEN_LINK_FILE,
initializeReport,
readBrokenLinksReport,
} from './cypress/support/link-reporter.js';
@ -90,8 +91,23 @@ export default defineConfig({
return initializeReport();
},
// Special case domains are now handled directly in the test without additional reporting
// This task is kept for backward compatibility but doesn't do anything special
reportSpecialCaseLink(linkData) {
console.log(
`✅ Expected status code: ${linkData.url} (status: ${linkData.status}) is valid for this domain`
);
return true;
},
reportBrokenLink(linkData) {
try {
// Validate link data
if (!linkData || !linkData.url || !linkData.page) {
console.error('Invalid link data provided');
return false;
}
// Read current report
const report = readBrokenLinksReport();
@ -102,29 +118,63 @@ export default defineConfig({
report.push(pageReport);
}
// Add the broken link to the page's report
pageReport.links.push({
url: linkData.url,
status: linkData.status,
type: linkData.type,
linkText: linkData.linkText,
});
// Write updated report back to file
fs.writeFileSync(
BROKEN_LINKS_FILE,
JSON.stringify(report, null, 2)
// Check if link is already in the report to avoid duplicates
const isDuplicate = pageReport.links.some(
(link) => link.url === linkData.url && link.type === linkData.type
);
// Log the broken link immediately to console
console.error(
`❌ BROKEN LINK: ${linkData.url} (${linkData.status}) - ${linkData.type} on page ${linkData.page}`
);
if (!isDuplicate) {
// Add the broken link to the page's report
pageReport.links.push({
url: linkData.url,
status: linkData.status,
type: linkData.type,
linkText: linkData.linkText,
});
// Write updated report back to file
fs.writeFileSync(
BROKEN_LINKS_FILE,
JSON.stringify(report, null, 2)
);
// Store first broken link if not already recorded
const firstBrokenLinkExists =
fs.existsSync(FIRST_BROKEN_LINK_FILE) &&
fs.readFileSync(FIRST_BROKEN_LINK_FILE, 'utf8').trim() !== '';
if (!firstBrokenLinkExists) {
// Store first broken link with complete information
const firstBrokenLink = {
url: linkData.url,
status: linkData.status,
type: linkData.type,
linkText: linkData.linkText,
page: linkData.page,
time: new Date().toISOString(),
};
fs.writeFileSync(
FIRST_BROKEN_LINK_FILE,
JSON.stringify(firstBrokenLink, null, 2)
);
console.error(
`🔴 FIRST BROKEN LINK: ${linkData.url} (${linkData.status}) - ${linkData.type} on page ${linkData.page}`
);
}
// Log the broken link immediately to console
console.error(
`❌ BROKEN LINK: ${linkData.url} (${linkData.status}) - ${linkData.type} on page ${linkData.page}`
);
}
return true;
} catch (error) {
console.error(`Error reporting broken link: ${error.message}`);
return false;
// Even if there's an error, we want to ensure the test knows there was a broken link
return true;
}
},
});

View File

@ -5,6 +5,12 @@ describe('Article', () => {
// Always use HEAD for downloads to avoid timeouts
const useHeadForDownloads = true;
// Set up initialization for tests
before(() => {
// Initialize the broken links report
cy.task('initializeBrokenLinksReport');
});
// Helper function to identify download links
function isDownloadLink(href) {
// Check for common download file extensions
@ -56,7 +62,7 @@ describe('Article', () => {
};
function handleFailedLink(url, status, type, redirectChain = '') {
// Report broken link to the task which will handle reporting
// Report the broken link
cy.task('reportBrokenLink', {
url: url + redirectChain,
status,
@ -65,6 +71,7 @@ describe('Article', () => {
page: pageUrl,
});
// Throw error for broken links
throw new Error(
`BROKEN ${type.toUpperCase()} LINK: ${url} (status: ${status})${redirectChain} on ${pageUrl}`
);
@ -109,11 +116,7 @@ describe('Article', () => {
}
}
// Before all tests, initialize the report
before(() => {
cy.task('initializeBrokenLinksReport');
});
// Test implementation for subjects
subjects.forEach((subject) => {
it(`${subject} has valid internal links`, function () {
cy.visit(`${subject}`, { timeout: 20000 });
@ -186,8 +189,19 @@ describe('Article', () => {
});
it(`${subject} has valid external links`, function () {
// Check if we should skip external links entirely
if (Cypress.env('skipExternalLinks') === true) {
cy.log(
'Skipping all external links as configured by skipExternalLinks'
);
return;
}
cy.visit(`${subject}`);
// Define allowed external domains to test
const allowedExternalDomains = ['github.com', 'kapa.ai'];
// Test external links
cy.get('article, .api-content').then(($article) => {
// Find links without failing the test if none are found
@ -197,8 +211,29 @@ describe('Article', () => {
return;
}
cy.debug(`Found ${$links.length} external links`);
cy.wrap($links).each(($a) => {
// Filter links to only include allowed domains
const $allowedLinks = $links.filter((_, el) => {
const href = el.getAttribute('href');
try {
const url = new URL(href);
return allowedExternalDomains.some(
(domain) =>
url.hostname === domain || url.hostname.endsWith(`.${domain}`)
);
} catch (e) {
return false;
}
});
if ($allowedLinks.length === 0) {
cy.log('No links to allowed external domains found on this page');
return;
}
cy.log(
`Found ${$allowedLinks.length} links to allowed external domains to test`
);
cy.wrap($allowedLinks).each(($a) => {
const href = $a.attr('href');
const linkText = $a.text().trim();
testLink(href, linkText, subject);

View File

@ -5,6 +5,7 @@
import fs from 'fs';
export const BROKEN_LINKS_FILE = '/tmp/broken_links_report.json';
export const FIRST_BROKEN_LINK_FILE = '/tmp/first_broken_link.json';
const SOURCES_FILE = '/tmp/test_subjects_sources.json';
/**
@ -18,7 +19,29 @@ export function readBrokenLinksReport() {
try {
const fileContent = fs.readFileSync(BROKEN_LINKS_FILE, 'utf8');
return fileContent && fileContent !== '[]' ? JSON.parse(fileContent) : [];
// Check if the file is empty or contains only an empty array
if (!fileContent || fileContent.trim() === '' || fileContent === '[]') {
return [];
}
// Try to parse the JSON content
try {
const parsedContent = JSON.parse(fileContent);
// Ensure the parsed content is an array
if (!Array.isArray(parsedContent)) {
console.error('Broken links report is not an array');
return [];
}
return parsedContent;
} catch (parseErr) {
console.error(
`Error parsing broken links report JSON: ${parseErr.message}`
);
return [];
}
} catch (err) {
console.error(`Error reading broken links report: ${err.message}`);
return [];
@ -57,11 +80,29 @@ export function displayBrokenLinksReport(brokenLinksReport = null) {
brokenLinksReport = readBrokenLinksReport();
}
if (!brokenLinksReport || brokenLinksReport.length === 0) {
console.log('✅ No broken links detected');
// Check both the report and first broken link file to determine if we have broken links
const firstBrokenLink = readFirstBrokenLink();
// Only report "no broken links" if both checks pass
if (
(!brokenLinksReport || brokenLinksReport.length === 0) &&
!firstBrokenLink
) {
console.log('✅ No broken links detected in the validation report');
return 0;
}
// Special case: check if the single broken link file could be missing from the report
if (
firstBrokenLink &&
(!brokenLinksReport || brokenLinksReport.length === 0)
) {
console.error(
'\n⚠ Warning: First broken link record exists but no links in the report.'
);
console.error('This could indicate a reporting issue.');
}
// Load sources mapping
const sourcesMapping = readSourcesMapping();
@ -70,6 +111,21 @@ export function displayBrokenLinksReport(brokenLinksReport = null) {
console.error(' 🚨 BROKEN LINKS DETECTED 🚨 ');
console.error('='.repeat(80));
// Show first failing link if available
if (firstBrokenLink) {
console.error('\n🔴 FIRST FAILING LINK:');
console.error(` URL: ${firstBrokenLink.url}`);
console.error(` Status: ${firstBrokenLink.status}`);
console.error(` Type: ${firstBrokenLink.type}`);
console.error(` Page: ${firstBrokenLink.page}`);
if (firstBrokenLink.linkText) {
console.error(
` Link text: "${firstBrokenLink.linkText.substring(0, 50)}${firstBrokenLink.linkText.length > 50 ? '...' : ''}"`
);
}
console.error('-'.repeat(40));
}
let totalBrokenLinks = 0;
brokenLinksReport.forEach((report) => {
@ -106,12 +162,51 @@ export function displayBrokenLinksReport(brokenLinksReport = null) {
}
/**
* Initialize the broken links report file
* Reads the first broken link info from the file system
* @returns {Object|null} First broken link data or null if not found
*/
export function readFirstBrokenLink() {
if (!fs.existsSync(FIRST_BROKEN_LINK_FILE)) {
return null;
}
try {
const fileContent = fs.readFileSync(FIRST_BROKEN_LINK_FILE, 'utf8');
// Check if the file is empty or contains whitespace only
if (!fileContent || fileContent.trim() === '') {
return null;
}
// Try to parse the JSON content
try {
return JSON.parse(fileContent);
} catch (parseErr) {
console.error(
`Error parsing first broken link JSON: ${parseErr.message}`
);
return null;
}
} catch (err) {
console.error(`Error reading first broken link: ${err.message}`);
return null;
}
}
/**
* Initialize the broken links report files
* @returns {boolean} True if initialization was successful
*/
export function initializeReport() {
try {
// Create an empty array for the broken links report
fs.writeFileSync(BROKEN_LINKS_FILE, '[]', 'utf8');
// Reset the first broken link file by creating an empty file
// Using empty string as a clear indicator that no broken link has been recorded yet
fs.writeFileSync(FIRST_BROKEN_LINK_FILE, '', 'utf8');
console.debug('🔄 Initialized broken links reporting system');
return true;
} catch (err) {
console.error(`Error initializing broken links report: ${err.message}`);

View File

@ -5,8 +5,30 @@
* It handles starting a local Hugo server, mapping content files to their URLs, running Cypress tests,
* and reporting broken links.
*
* Usage: node run-e2e-specs.js [file paths...] [--spec test-spec-path]
*
* Usage: node run-e2e-specs.js [file paths...] [--spec test // Display broken links report
const brokenLinksCount = displayBrokenLinksReport();
// Check if we might have special case failures
const hasSpecialCaseFailures =
results &&
results.totalFailed > 0 &&
brokenLinksCount === 0;
if (hasSpecialCaseFailures) {
console.warn(
` Note: Tests failed (${results.totalFailed}) but no broken links were reported. This may be due to special case URLs (like Reddit) that return expected status codes.`
);
}
if (
(results && results.totalFailed && results.totalFailed > 0 && !hasSpecialCaseFailures) ||
brokenLinksCount > 0
) {
console.error(
`⚠️ Tests failed: ${results.totalFailed || 0} test(s) failed, ${brokenLinksCount || 0} broken links found`
);
cypressFailed = true;
exitCode = 1; *
* Example: node run-e2e-specs.js content/influxdb/v2/write-data.md --spec cypress/e2e/content/article-links.cy.js
*/
@ -17,7 +39,7 @@ import path from 'path';
import cypress from 'cypress';
import net from 'net';
import matter from 'gray-matter';
import { displayBrokenLinksReport } from './link-reporter.js';
import { displayBrokenLinksReport, initializeReport } from './link-reporter.js';
import {
HUGO_PORT,
HUGO_LOG_FILE,
@ -144,53 +166,82 @@ async function main() {
process.exit(1);
}
// 1. Map file paths to URLs and write to file
const mapProc = spawn('node', [MAP_SCRIPT, ...fileArgs], {
stdio: ['ignore', 'pipe', 'inherit'],
// Separate content files from non-content files
const contentFiles = fileArgs.filter((file) => file.startsWith('content/'));
const nonContentFiles = fileArgs.filter(
(file) => !file.startsWith('content/')
);
// Log what we're processing
if (contentFiles.length > 0) {
console.log(
`Processing ${contentFiles.length} content files for URL mapping...`
);
}
if (nonContentFiles.length > 0) {
console.log(
`Found ${nonContentFiles.length} non-content files that will be passed directly to tests...`
);
}
let urlList = [];
// Only run the mapper if we have content files
if (contentFiles.length > 0) {
// 1. Map file paths to URLs and write to file
const mapProc = spawn('node', [MAP_SCRIPT, ...contentFiles], {
stdio: ['ignore', 'pipe', 'inherit'],
});
const mappingOutput = [];
mapProc.stdout.on('data', (chunk) => {
mappingOutput.push(chunk.toString());
});
await new Promise((res) => mapProc.on('close', res));
// Process the mapping output
urlList = mappingOutput
.join('')
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
// Parse the URL|SOURCE format
if (line.includes('|')) {
const [url, source] = line.split('|');
return { url, source };
} else if (line.startsWith('/')) {
// Handle URLs without source (should not happen with our new code)
return { url: line, source: null };
} else {
// Skip log messages
return null;
}
})
.filter(Boolean); // Remove null entries
}
// Add non-content files directly to be tested, using their path as both URL and source
nonContentFiles.forEach((file) => {
urlList.push({ url: file, source: file });
});
const mappingOutput = [];
mapProc.stdout.on('data', (chunk) => {
mappingOutput.push(chunk.toString());
});
await new Promise((res) => mapProc.on('close', res));
// Process the mapping output
const urlList = mappingOutput
.join('')
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
// Parse the URL|SOURCE format
if (line.includes('|')) {
const [url, source] = line.split('|');
return { url, source };
} else if (line.startsWith('/')) {
// Handle URLs without source (should not happen with our new code)
return { url: line, source: null };
} else {
// Skip log messages
return null;
}
})
.filter(Boolean); // Remove null entries
// Log the URLs and sources we'll be testing
console.log(`Found ${urlList.length} URLs to test:`);
console.log(`Found ${urlList.length} items to test:`);
urlList.forEach(({ url, source }) => {
console.log(` URL: ${url}`);
console.log(` PAGE CONTENT SOURCE: ${source}`);
console.log(` URL/FILE: ${url}`);
console.log(` SOURCE: ${source}`);
console.log('---');
});
if (urlList.length === 0) {
console.log('No URLs to test.');
console.log('No URLs or files to test.');
process.exit(0);
}
// Write just the URLs to the test_subjects file for Cypress
// Write just the URLs/files to the test_subjects file for Cypress
fs.writeFileSync(URLS_FILE, urlList.map((item) => item.url).join(','));
// Add source information to a separate file for reference during reporting
@ -320,6 +371,10 @@ async function main() {
// 4. Run Cypress tests
let cypressFailed = false;
try {
// Initialize/clear broken links report before running tests
console.log('Initializing broken links report...');
initializeReport();
console.log(`Running Cypress tests for ${urlList.length} URLs...`);
const cypressOptions = {
reporter: 'junit',
@ -334,6 +389,8 @@ async function main() {
test_subjects: urlList.map((item) => item.url).join(','),
// Add new structured data with source information
test_subjects_data: JSON.stringify(urlList),
// Skip testing external links (non-influxdata.com URLs)
skipExternalLinks: true,
},
};
@ -347,12 +404,24 @@ async function main() {
// Process broken links report
const brokenLinksCount = displayBrokenLinksReport();
if (
(results && results.totalFailed && results.totalFailed > 0) ||
brokenLinksCount > 0
) {
// Determine why tests failed
const testFailureCount = results?.totalFailed || 0;
if (testFailureCount > 0 && brokenLinksCount === 0) {
console.warn(
` Note: ${testFailureCount} test(s) failed but no broken links were detected in the report.`
);
console.warn(
` This usually indicates test errors unrelated to link validation.`
);
// We should not consider special case domains (those with expected errors) as failures
// but we'll still report other test failures
cypressFailed = true;
exitCode = 1;
} else if (brokenLinksCount > 0) {
console.error(
`⚠️ Tests failed: ${results.totalFailed || 0} test(s) failed, ${brokenLinksCount || 0} broken links found`
`⚠️ Tests failed: ${brokenLinksCount} broken link(s) detected`
);
cypressFailed = true;
exitCode = 1;

View File

@ -56,7 +56,7 @@
"test:links:kapacitor": "node cypress/support/run-e2e-specs.js --spec \"cypress/e2e/content/article-links.cy.js\" content/kapacitor/**/*.{md,html}",
"test:links:telegraf": "node cypress/support/run-e2e-specs.js --spec \"cypress/e2e/content/article-links.cy.js\" content/telegraf/**/*.{md,html}",
"test:links:shared": "node cypress/support/run-e2e-specs.js --spec \"cypress/e2e/content/article-links.cy.js\" content/shared/**/*.{md,html}",
"test:links:api-docs": "export cypress_base_url=\"http://localhost:1315\" cypress_test_subjects=\"/influxdb3/core/api/,/influxdb3/enterprise/api/,/influxdb3/cloud-dedicated/api/,/influxdb3/cloud-dedicated/api/v1/,/influxdb/cloud-dedicated/api/v1/,/influxdb/cloud-dedicated/api/management/,/influxdb3/cloud-dedicated/api/management/\"; npx cypress run --spec cypress/e2e/content/article-links.cy.js",
"test:links:api-docs": "node cypress/support/run-e2e-specs.js --spec \"cypress/e2e/content/article-links.cy.js\" /influxdb3/core/api/,/influxdb3/enterprise/api/,/influxdb3/cloud-dedicated/api/,/influxdb3/cloud-dedicated/api/v1/,/influxdb/cloud-dedicated/api/v1/,/influxdb/cloud-dedicated/api/management/,/influxdb3/cloud-dedicated/api/management/",
"test:shortcode-examples": "node cypress/support/run-e2e-specs.js --spec \"cypress/e2e/content/article-links.cy.js\" content/example.md"
},
"main": "assets/js/main.js",