/** * InfluxData Documentation E2E Test Runner * * This script automates running Cypress end-to-end tests for the InfluxData documentation site. * It handles starting a local Hugo server, mapping content files to their URLs, and running Cypress tests, * and reporting broken links. * * Usage: node run-e2e-specs.js [file paths...] [--spec test specs...] */ import { spawn } from 'child_process'; import process from 'process'; import fs from 'fs'; import path from 'path'; import cypress from 'cypress'; import net from 'net'; import { Buffer } from 'buffer'; import { HUGO_ENVIRONMENT, HUGO_PORT, HUGO_LOG_FILE, HUGO_SHUTDOWN_TIMEOUT, startHugoServer, waitForHugoReady, } from './hugo-server.js'; const MAP_SCRIPT = path.resolve('cypress/support/map-files-to-urls.js'); const URLS_FILE = '/tmp/test_subjects.txt'; /** * Parses command line arguments into file and spec arguments * @param {string[]} argv - Command line arguments (process.argv) * @returns {Object} Object containing fileArgs and specArgs arrays */ function parseArgs(argv) { const fileArgs = []; const specArgs = []; let i = 2; // Start at index 2 to skip 'node' and script name while (i < argv.length) { if (argv[i] === '--spec') { i++; if (i < argv.length) { specArgs.push(argv[i]); i++; } } else { fileArgs.push(argv[i]); i++; } } return { fileArgs, specArgs }; } // Check if port is already in use async function isPortInUse(port) { return new Promise((resolve) => { const tester = net .createServer() .once('error', () => resolve(true)) .once('listening', () => { tester.close(); resolve(false); }) .listen(port, '127.0.0.1'); }); } /** * Ensures a directory exists, creating it if necessary * Also creates an empty file to ensure the directory is not empty * @param {string} dirPath - The directory path to ensure exists */ function ensureDirectoryExists(dirPath) { if (!fs.existsSync(dirPath)) { try { fs.mkdirSync(dirPath, { recursive: true }); console.log(`Created directory: ${dirPath}`); // Create an empty .gitkeep file to ensure the directory exists and isn't empty fs.writeFileSync(path.join(dirPath, '.gitkeep'), ''); } catch (err) { console.warn( `Warning: Could not create directory ${dirPath}: ${err.message}` ); } } } async function main() { // Keep track of processes to cleanly shut down let hugoProc = null; let exitCode = 0; let hugoStarted = false; // (Lines 124-126 removed; no replacement needed) // Add this signal handler to ensure cleanup on unexpected termination const cleanupAndExit = (code = 1) => { console.log(`Performing cleanup before exit with code ${code}...`); if (hugoProc && hugoStarted) { try { // Use SIGTERM first, then SIGKILL if needed hugoProc.kill('SIGTERM'); const timeoutId = setTimeout(() => { if (!hugoProc.killed) { hugoProc.kill('SIGKILL'); } }, 1000); // Clear the timeout if the process exits cleanly hugoProc.on('exit', () => clearTimeout(timeoutId)); } catch (err) { console.error(`Error killing Hugo process: ${err.message}`); } } process.exit(code); }; // Handle various termination signals process.on('SIGINT', () => cleanupAndExit(1)); process.on('SIGTERM', () => cleanupAndExit(1)); process.on('uncaughtException', (err) => { console.error(`Uncaught exception: ${err.message}`); cleanupAndExit(1); }); process.on('unhandledRejection', (reason, promise) => { console.error('Unhandled Rejection at:', promise, 'reason:', reason); cleanupAndExit(1); }); const { fileArgs, specArgs } = parseArgs(process.argv); if (fileArgs.length === 0) { console.error('No file paths provided.'); process.exit(1); } // 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 }); }); // Log the URLs and sources we'll be testing console.log(`Found ${urlList.length} items to test:`); urlList.forEach(({ url, source }) => { console.log(` URL/FILE: ${url}`); console.log(` SOURCE: ${source}`); console.log('---'); }); if (urlList.length === 0) { console.log('No URLs or files to test.'); process.exit(0); } // 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 fs.writeFileSync( '/tmp/test_subjects_sources.json', JSON.stringify(urlList, null, 2) ); // 2. Check if port is in use before starting Hugo const portInUse = await isPortInUse(HUGO_PORT); if (portInUse) { console.log( `Port ${HUGO_PORT} is already in use. Checking if Hugo is running...` ); try { // Try to connect to verify it's a working server await waitForHugoReady(5000); // Short timeout - if it's running, it should respond quickly console.log( `Hugo server already running on port ${HUGO_PORT}, will use existing instance` ); } catch (err) { console.error( `Port ${HUGO_PORT} is in use but not responding as expected: ${err.message}` ); console.error('Please stop any processes using this port and try again.'); process.exit(1); } } else { // Start Hugo server using the imported function try { console.log( `Starting Hugo server (logs will be written to ${HUGO_LOG_FILE})...` ); // Create or clear the log file fs.writeFileSync(HUGO_LOG_FILE, ''); // First, check if Hugo is installed and available try { // Try running a simple Hugo version command to check if Hugo is available const hugoCheck = spawn('hugo', ['version'], { shell: true }); await new Promise((resolve, reject) => { hugoCheck.on('close', (code) => { if (code === 0) { resolve(); } else { reject(new Error(`Hugo check failed with code ${code}`)); } }); hugoCheck.on('error', (err) => reject(err)); }); console.log('Hugo is available on the system'); } catch { console.log( 'Hugo not found on PATH, will use project-local Hugo via yarn' ); } // Use the startHugoServer function from hugo-server.js hugoProc = await startHugoServer({ environment: HUGO_ENVIRONMENT, port: HUGO_PORT, noHTTPCache: true, logFile: HUGO_LOG_FILE, }); // Ensure hugoProc is a valid process object with kill method if (!hugoProc || typeof hugoProc.kill !== 'function') { throw new Error('Failed to get a valid Hugo process object'); } hugoStarted = true; console.log(`Started Hugo process with PID: ${hugoProc.pid}`); // Wait for Hugo to be ready await waitForHugoReady(); console.log(`Hugo server ready on port ${HUGO_PORT}`); } catch (err) { console.error(`Error starting or waiting for Hugo: ${err.message}`); if (hugoProc && typeof hugoProc.kill === 'function') { hugoProc.kill('SIGTERM'); } process.exit(1); } } // 3. Prepare Cypress directories try { const screenshotsDir = path.resolve('cypress/screenshots'); const videosDir = path.resolve('cypress/videos'); const specScreenshotDir = path.join(screenshotsDir, 'article-links.cy.js'); // Ensure base directories exist ensureDirectoryExists(screenshotsDir); ensureDirectoryExists(videosDir); // Create spec-specific screenshot directory with a placeholder file ensureDirectoryExists(specScreenshotDir); // Create a dummy screenshot file to prevent trash errors const dummyScreenshotPath = path.join(specScreenshotDir, 'dummy.png'); if (!fs.existsSync(dummyScreenshotPath)) { // Create a minimal valid PNG file (1x1 transparent pixel) const minimalPng = Buffer.from([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, ]); fs.writeFileSync(dummyScreenshotPath, minimalPng); console.log(`Created dummy screenshot file: ${dummyScreenshotPath}`); } console.log('Cypress directories prepared successfully'); } catch (err) { console.warn( `Warning: Error preparing Cypress directories: ${err.message}` ); // Continue execution - this is not a fatal error } // 4. Run Cypress tests let cypressFailed = false; try { console.log(`Running Cypress tests for ${urlList.length} URLs...`); // Add CI-specific configuration const isCI = process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true'; const cypressOptions = { reporter: 'junit', browser: 'chrome', config: { baseUrl: `http://localhost:${HUGO_PORT}`, video: !isCI, // Disable video in CI to reduce resource usage trashAssetsBeforeRuns: false, // Add CI-specific timeouts defaultCommandTimeout: isCI ? 15000 : 10000, pageLoadTimeout: isCI ? 45000 : 30000, responseTimeout: isCI ? 45000 : 30000, // Reduce memory usage in CI experimentalMemoryManagement: true, numTestsKeptInMemory: isCI ? 1 : 5, }, env: { // Pass URLs as a comma-separated string for backward compatibility 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, }, }; if (specArgs.length > 0) { console.log(`Using specified test specs: ${specArgs.join(', ')}`); cypressOptions.spec = specArgs.join(','); } // Add error handling for Hugo process monitoring during Cypress execution let hugoHealthCheckInterval; if (hugoProc && hugoStarted) { hugoHealthCheckInterval = setInterval(() => { if (hugoProc.killed || hugoProc.exitCode !== null) { console.error('❌ Hugo server died during Cypress execution'); if (hugoHealthCheckInterval) { clearInterval(hugoHealthCheckInterval); } cypressFailed = true; // Don't exit immediately, let Cypress finish gracefully } }, 5000); } const results = await cypress.run(cypressOptions); // Clear health check interval if (hugoHealthCheckInterval) { clearInterval(hugoHealthCheckInterval); } // Determine why tests failed const testFailureCount = results?.totalFailed || 0; if (testFailureCount > 0) { console.warn( `ℹ️ Note: ${testFailureCount} test(s) failed but no broken links were detected in the report.` ); // Provide detailed failure analysis if (results) { console.warn('📊 Detailed Test Results:'); console.warn(` • Total Tests: ${results.totalTests || 0}`); console.warn(` • Tests Passed: ${results.totalPassed || 0}`); console.warn(` • Tests Failed: ${results.totalFailed || 0}`); console.warn(` • Tests Pending: ${results.totalPending || 0}`); console.warn(` • Tests Skipped: ${results.totalSkipped || 0}`); console.warn(` • Duration: ${results.totalDuration || '0'}ms`); // Show run-level information if (results.runs && results.runs.length > 0) { console.warn(` • Spec Files: ${results.runs.length}`); // Show failures by spec file const failedRuns = results.runs.filter( (run) => run.stats?.failures > 0 ); if (failedRuns.length > 0) { console.warn('📋 Failed Spec Files:'); failedRuns.forEach((run) => { console.warn( ` • ${run.spec?.relative || run.spec?.name || 'Unknown spec'}` ); if (run.stats) { console.warn(` - Failures: ${run.stats.failures}`); console.warn(` - Duration: ${run.stats.duration || 0}ms`); } // Show test-level failures if available if (run.tests) { const failedTests = run.tests.filter( (test) => test.state === 'failed' ); if (failedTests.length > 0) { console.warn(` - Failed Tests:`); failedTests.forEach((test, idx) => { if (idx < 3) { // Limit to first 3 failed tests per spec console.warn(` * ${test.title || 'Unnamed test'}`); if (test.err?.message) { // Truncate very long error messages const errorMsg = test.err.message.length > 200 ? test.err.message.substring(0, 200) + '...' : test.err.message; console.warn(` Error: ${errorMsg}`); } } }); if (failedTests.length > 3) { console.warn( ` ... and ${failedTests.length - 3} more failed tests` ); } } } }); } } // Check for browser/system level issues if (results.browserName) { console.warn( ` • Browser: ${results.browserName} ${results.browserVersion || ''}` ); } // Suggest common solutions console.warn('💡 Common Causes & Solutions:'); console.warn( ' • Page load timeouts: Check if Hugo server is responding properly' ); console.warn( ' • Network timeouts: Verify external link connectivity' ); console.warn( ' • Browser crashes: Check for memory or resource issues' ); console.warn( ' • Test logic errors: Review test assertions and selectors' ); console.warn( ` • Hugo server logs: Check ${HUGO_LOG_FILE} for errors` ); } // 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 (results) { console.log('✅ e2e tests completed successfully'); } } catch (err) { console.error(`❌ Cypress execution error: ${err.message}`); // Handle EPIPE errors specifically if (err.code === 'EPIPE' || err.message.includes('EPIPE')) { console.error('🔧 EPIPE Error Detected:'); console.error( ' • This usually indicates the Hugo server process was terminated unexpectedly' ); console.error(' • Common causes in CI:'); console.error(' - Memory constraints causing process termination'); console.error(' - CI runner timeout or resource limits'); console.error(' - Hugo server crash due to build errors'); console.error(` • Check Hugo logs: ${HUGO_LOG_FILE}`); // Try to provide more context about Hugo server state if (hugoProc) { console.error( ` • Hugo process state: killed=${hugoProc.killed}, exitCode=${hugoProc.exitCode}` ); } } // Provide more detailed error information if (err.stack) { console.error('📋 Error Stack Trace:'); // Only show the first few lines of the stack trace to avoid overwhelming output const stackLines = err.stack.split('\n').slice(0, 5); stackLines.forEach((line) => console.error(` ${line}`)); if (err.stack.split('\n').length > 5) { console.error(' ... (truncated)'); } } // Check if error is related to common issues const errorMsg = err.message.toLowerCase(); if (errorMsg.includes('timeout') || errorMsg.includes('timed out')) { console.error('🕐 Timeout detected - possible causes:'); console.error( ' • Hugo server not responding (check if it started properly)' ); console.error(' • Network connectivity issues'); console.error(' • External links taking too long to respond'); console.error(' • Page load timeouts (heavy pages or slow rendering)'); } else if ( errorMsg.includes('connection') || errorMsg.includes('econnrefused') ) { console.error('🔌 Connection issues detected:'); console.error(' • Hugo server may not be running or accessible'); console.error(` • Check if port ${HUGO_PORT} is available`); console.error(' • Firewall or network restrictions'); } else if (errorMsg.includes('browser') || errorMsg.includes('chrome')) { console.error('🌐 Browser issues detected:'); console.error( ' • Chrome/browser may not be available in CI environment' ); console.error(' • Browser crashed or failed to start'); console.error(' • Insufficient memory or resources'); } console.error( `📝 Hugo server logs: Check ${HUGO_LOG_FILE} for server issues` ); console.error('💡 Additional debugging steps:'); console.error(' • Verify Hugo server started successfully'); console.error(' • Check if test URLs are accessible manually'); console.error(' • Review Cypress screenshots/videos if available'); cypressFailed = true; exitCode = 1; } finally { // Stop Hugo server only if we started it if (hugoProc && hugoStarted && typeof hugoProc.kill === 'function') { console.log(`Stopping Hugo server (fast shutdown: ${cypressFailed})...`); try { if (cypressFailed) { // Fast shutdown for failed tests hugoProc.kill('SIGKILL'); console.log('Hugo server forcibly terminated'); } else { // Graceful shutdown for successful tests const shutdownTimeout = setTimeout(() => { console.error( 'Hugo server did not shut down gracefully, forcing termination' ); try { hugoProc.kill('SIGKILL'); } catch (killErr) { console.error(`Error force-killing Hugo: ${killErr.message}`); } process.exit(exitCode); }, HUGO_SHUTDOWN_TIMEOUT); // Configurable timeout for CI hugoProc.kill('SIGTERM'); hugoProc.on('close', () => { clearTimeout(shutdownTimeout); console.log('Hugo server shut down successfully'); process.exit(exitCode); }); hugoProc.on('error', (err) => { console.error(`Error during Hugo shutdown: ${err.message}`); clearTimeout(shutdownTimeout); process.exit(exitCode); }); // Return to prevent immediate exit return; } } catch (shutdownErr) { console.error( `Error during Hugo server shutdown: ${shutdownErr.message}` ); } } else if (hugoStarted) { console.log('Hugo process was started but is not available for cleanup'); } process.exit(exitCode); } } main().catch((err) => { console.error(`💥 Fatal error during test execution: ${err.message || err}`); if (err.stack) { console.error('📋 Fatal Error Stack Trace:'); console.error(err.stack); } console.error( '🔍 This error occurred in the main test runner flow, not within Cypress tests.' ); console.error('💡 Common causes:'); console.error(' • File system permissions issues'); console.error(' • Missing dependencies or modules'); console.error(' • Hugo server startup failures'); console.error(' • Process management errors'); process.exit(1); });