413 lines
13 KiB
JavaScript
413 lines
13 KiB
JavaScript
/**
|
|
* 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: 'hugo.testing.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);
|
|
});
|