/** * 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, running Cypress tests, * and reporting broken links. * * Usage: node run-e2e-specs.js [file paths...] [--spec test-spec-path] * * Example: node run-e2e-specs.js content/influxdb/v2/write-data.md --spec cypress/e2e/content/article-links.cy.js */ 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 matter from 'gray-matter'; import { displayBrokenLinksReport } from './link-reporter.js'; import { HUGO_PORT, HUGO_LOG_FILE, 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'); }); } /** * Extract source information from frontmatter * @param {string} filePath - Path to the markdown file * @returns {string|null} Source information if present */ function getSourceFromFrontmatter(filePath) { if (!fs.existsSync(filePath)) { return null; } try { const fileContent = fs.readFileSync(filePath, 'utf8'); const { data } = matter(fileContent); return data.source || null; } catch (err) { console.warn( `Warning: Could not extract frontmatter from ${filePath}: ${err.message}` ); return null; } } /** * 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; // 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 { hugoProc.kill('SIGKILL'); // Use SIGKILL to ensure immediate termination } 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); }); const { fileArgs, specArgs } = parseArgs(process.argv); if (fileArgs.length === 0) { console.error('No file paths provided.'); process.exit(1); } // 1. Map file paths to URLs and write to file const mapProc = spawn('node', [MAP_SCRIPT, ...fileArgs], { 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 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:`); urlList.forEach(({ url, source }) => { console.log(` URL: ${url}`); console.log(` PAGE CONTENT SOURCE: ${source}`); console.log('---'); }); if (urlList.length === 0) { console.log('No URLs to test.'); process.exit(0); } // Write just the URLs 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 (checkErr) { 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({ configFile: 'config/testing/config.yml', port: HUGO_PORT, buildDrafts: true, 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...`); const cypressOptions = { reporter: 'junit', browser: 'chrome', config: { baseUrl: `http://localhost:${HUGO_PORT}`, video: true, trashAssetsBeforeRuns: false, // Prevent trash errors }, 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), }, }; if (specArgs.length > 0) { console.log(`Using specified test specs: ${specArgs.join(', ')}`); cypressOptions.spec = specArgs.join(','); } const results = await cypress.run(cypressOptions); // Process broken links report const brokenLinksCount = displayBrokenLinksReport(); if ( (results && results.totalFailed && results.totalFailed > 0) || brokenLinksCount > 0 ) { console.error( `⚠️ Tests failed: ${results.totalFailed || 0} test(s) failed, ${brokenLinksCount || 0} broken links found` ); cypressFailed = true; exitCode = 1; } else if (results) { console.log('✅ Tests completed successfully'); } } catch (err) { console.error(`❌ Cypress execution error: ${err.message}`); console.error( `Check Hugo server logs at ${HUGO_LOG_FILE} for any server issues` ); // Still try to display broken links report if available displayBrokenLinksReport(); 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})...`); if (cypressFailed) { hugoProc.kill('SIGKILL'); console.log('Hugo server forcibly terminated'); } else { const shutdownTimeout = setTimeout(() => { console.error( 'Hugo server did not shut down gracefully, forcing termination' ); hugoProc.kill('SIGKILL'); process.exit(exitCode); }, 2000); hugoProc.kill('SIGTERM'); hugoProc.on('close', () => { clearTimeout(shutdownTimeout); console.log('Hugo server shut down successfully'); process.exit(exitCode); }); // Return to prevent immediate exit return; } } 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: ${err}`); process.exit(1); });