UNPKG

cy-parallel

Version:

A powerful tool to run Cypress tests in parallel, optimizing test execution time.

536 lines (523 loc) 17.4 kB
#!/usr/bin/env node "use strict"; var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); // src/index.ts var import_process = __toESM(require("process")); // src/utils/fileUtils.ts var import_fs = __toESM(require("fs")); var import_path = __toESM(require("path")); // src/utils/envUtils.ts var import_os = __toESM(require("os")); function getEnvVar(key, defaultValue) { const value = process.env[key]; if (value === void 0) { return defaultValue; } if (typeof defaultValue === "number") { const parsed = parseInt(value, 10); return isNaN(parsed) ? defaultValue : parsed; } else if (typeof defaultValue === "boolean") { return value === "true"; } return value; } function getConfig() { return { WEIGHT_PER_TEST: getEnvVar("WEIGHT_PER_TEST", 1), BASE_WEIGHT: getEnvVar("BASE_WEIGHT", 1), WORKERS: Math.min(getEnvVar("WORKERS", import_os.default.cpus().length), import_os.default.cpus().length), DIR: getEnvVar("DIR", "cypress/e2e"), COMMAND: getEnvVar("COMMAND", "npx cypress run"), POLL: getEnvVar("POLL", false), BASE_DISPLAY_NUMBER: getEnvVar("BASE_DISPLAY_NUMBER", 99), VERBOSE: getEnvVar("VERBOSE", true), CYPRESS_LOG: getEnvVar("CYPRESS_LOG", true), IS_LINUX: process.platform === "linux" }; } // src/utils/logging.ts async function log(message, options = {}) { const { workerId, type = "info" } = options; const { VERBOSE } = getConfig(); if (!VERBOSE) { return; } const chalk = await import("chalk"); const workerPart = workerId !== void 0 ? ` - Worker #${workerId} ` : ""; let prefix; switch (type) { case "error": prefix = chalk.default.red(`cy-parallel(error)${workerPart}`); break; case "success": prefix = chalk.default.greenBright(`cy-parallel(success)${workerPart}`); break; case "warn": prefix = chalk.default.yellow(`cy-parallel(warn)${workerPart}`); break; case "info": default: prefix = chalk.default.blue(`cy-parallel(info)${workerPart}`); break; } console.log(`${prefix}: ${message}`); } // src/utils/fileUtils.ts function validateDir(dir) { const resolvedPath = import_path.default.resolve(dir); let stats; try { stats = import_fs.default.statSync(resolvedPath); if (!stats.isDirectory()) { log(`Provided DIR is not a directory: ${resolvedPath}`, { type: "error" }); process.exit(1); } } catch (error) { log(`Error accessing DIR directory: ${resolvedPath}. Error: ${error}`, { type: "error" }); process.exit(1); } return resolvedPath; } function getTestFiles(dir) { let testFiles = []; const entries = import_fs.default.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = import_path.default.join(dir, entry.name); if (entry.isDirectory()) { testFiles = testFiles.concat(getTestFiles(fullPath)); } else { testFiles.push(fullPath); } } return testFiles; } function collectTestFiles(directory) { const testFiles = getTestFiles(directory); if (testFiles.length === 0) { log("No test files found in the provided DIR directory.", { type: "error" }); process.exit(1); } log(`Found ${testFiles.length} test files in '${directory}'.`, { type: "info" }); return testFiles; } // src/utils/weightUtils.ts var import_fs2 = __toESM(require("fs")); var import_typescript2 = __toESM(require("typescript")); // src/utils/isCallTo.ts var import_typescript = __toESM(require("typescript")); var isCallTo = (node, objectName, methodName) => { if (import_typescript.default.isCallExpression(node) && node.expression) { if (import_typescript.default.isPropertyAccessExpression(node.expression)) { const { expression, name } = node.expression; const objName = expression.getText(); const method = name.getText(); return objName === objectName && method === methodName; } else if (import_typescript.default.isIdentifier(node.expression)) { const method = node.expression.getText(); return objectName === null && method === methodName; } } return false; }; // src/utils/weightUtils.ts function getFileInfo(filePath, baseWeight, weightPerTest) { try { const contents = import_fs2.default.readFileSync(filePath, "utf8"); let testCount = 0; const skippedDescribeStack = []; const sourceFile = import_typescript2.default.createSourceFile( filePath, contents, import_typescript2.default.ScriptTarget.Latest, true ); const visit = (node) => { let isEnteringDescribe = false; if (import_typescript2.default.isCallExpression(node)) { if (isCallTo(node, "describe", "skip")) { skippedDescribeStack.push(true); isEnteringDescribe = true; } else if (isCallTo(node, null, "describe")) { skippedDescribeStack.push(false); isEnteringDescribe = true; } if (isCallTo(node, null, "it.skip") || isCallTo(node, null, "it")) { const isSkippedIt = isCallTo(node, null, "it.skip"); const isSkipped = skippedDescribeStack.includes(true) || isSkippedIt; if (!isSkipped) { testCount += 1; } } } import_typescript2.default.forEachChild(node, visit); if (isEnteringDescribe) { skippedDescribeStack.pop(); } }; import_typescript2.default.forEachChild(sourceFile, visit); const weight = testCount > 0 ? baseWeight + weightPerTest * testCount : baseWeight; return { file: filePath, weight }; } catch (error) { console.error(`Error processing file ${filePath}: ${error}`); return null; } } // src/runners/cypressRunner.ts var import_child_process2 = require("child_process"); // src/utils/xvfb.ts var import_child_process = require("child_process"); async function startXvfb(display) { const xvfbProcess = (0, import_child_process.spawn)("Xvfb", [`:${display}`], { stdio: "ignore", // Ignore stdio since we don't need to interact with Xvfb detached: true // Run Xvfb in its own process group }); xvfbProcess.unref(); await new Promise((resolve, reject) => { setTimeout(() => { try { resolve(); } catch (err) { reject( new Error( `Failed to start Xvfb on display :${display}, error: ${err}` ) ); } }, 1e3); }); return xvfbProcess; } // src/runners/cypressRunner.ts async function runCypress(tests, index, display, command) { try { const { CYPRESS_LOG, IS_LINUX } = getConfig(); if (IS_LINUX) { await startXvfb(display); } const env = { ...process.env, ...IS_LINUX ? { DISPLAY: `:${display}` } : {} }; const testList = tests.join(","); log( `Starting Cypress for the following test(s): ${tests.map((test) => `- ${test}`).join("\n")}`, { type: "info", workerId: index + 1 } ); const cypressCommand = `${command} --spec "${testList}"`; const cypressProcess = (0, import_child_process2.spawn)(cypressCommand, { shell: true, env, stdio: CYPRESS_LOG ? "inherit" : "ignore" }); const exitCode = await new Promise((resolve, reject) => { cypressProcess.on("close", (code) => { resolve(code); }); cypressProcess.on("error", (err) => { reject(err); }); }); if (exitCode !== 0) { log(`Cypress process failed with exit code ${exitCode}.`, { type: "error", workerId: index + 1 }); return { status: "rejected", index, code: exitCode }; } else { log(`Cypress process completed successfully.`, { type: "success", workerId: index + 1 }); return { status: "fulfilled", index, code: exitCode }; } } catch (error) { log( `There was a problem running Cypress process for worker ${index + 1}. Error: ${error}`, { type: "error", workerId: index + 1 } ); return { status: "rejected", index }; } } // src/index.ts function getFileBucketsCustom(bucketsCount, filesInfo) { if (!Array.isArray(filesInfo)) { log("Error: filesInfo is not an array.", { type: "error" }); return []; } log(`Total Test Files Found: ${filesInfo.length}`, { type: "info" }); const sortedFiles = filesInfo.sort((a, b) => b.weight - a.weight); const buckets = Array.from({ length: bucketsCount }, () => []); const bucketWeights = Array(bucketsCount).fill(0); for (const fileInfo of sortedFiles) { let minIndex = 0; for (let i = 1; i < bucketsCount; i++) { if (bucketWeights[i] < bucketWeights[minIndex]) { minIndex = i; } } buckets[minIndex].push(fileInfo.file); bucketWeights[minIndex] += fileInfo.weight; } buckets.forEach((bucket, idx) => { const totalWeight = bucket.reduce( (acc, file) => acc + (filesInfo.find((f) => f.file === file)?.weight || 0), 0 ); log( `Bucket ${idx + 1}: ${bucket.length} test file(s), weight: ${totalWeight}`, { type: "info" } ); bucket.forEach((test) => log(` - ${test}`, { type: "info" })); }); return buckets; } function createCypressResult(status, index, code) { return { status, index, code }; } async function runParallelCypress() { const { WEIGHT_PER_TEST, BASE_WEIGHT, WORKERS, DIR, COMMAND, POLL, BASE_DISPLAY_NUMBER } = getConfig(); const resolvedDir = validateDir(DIR); const testFiles = collectTestFiles(resolvedDir); const totalTests = testFiles.length; let completedTests = 0; function logProgress() { if (completedTests >= totalTests) { return; } const remainingTests = totalTests - completedTests; const progressPercentage = (completedTests / totalTests * 100).toFixed(2); log( `Progress: ${completedTests}/${totalTests} tests completed (${progressPercentage}% done). ${remainingTests} test file(s) remaining.`, { type: "info" } ); } if (totalTests === 0) { log("No test files found. Exiting.", { type: "info" }); import_process.default.exit(0); } if (!POLL) { log("Running in Weighted Bucketing Mode.", { type: "info" }); const filesInfo = testFiles.map((file) => getFileInfo(file, BASE_WEIGHT, WEIGHT_PER_TEST)).filter((info) => info !== null); const testBuckets = getFileBucketsCustom(WORKERS, filesInfo); const promises = testBuckets.map((bucket, index) => ({ bucket, index })).filter(({ bucket }) => bucket.length > 0).map(async ({ bucket, index }) => { const display = BASE_DISPLAY_NUMBER + index; log(`Starting Cypress process with ${bucket.length} test file(s).`, { type: "info", workerId: index + 1 }); try { const result = await runCypress(bucket, index, display, COMMAND); if (result.status === "fulfilled") { completedTests += bucket.length; logProgress(); log(`Cypress process completed successfully.`, { type: "success", workerId: index + 1 }); } else { log(`Cypress process failed with code ${result.code}.`, { type: "error", workerId: index + 1 }); } return result; } catch (error) { log(`Cypress process encountered an error: ${error}`, { type: "error", workerId: index + 1 }); return createCypressResult("rejected", index, 1); } }); try { const results = await Promise.all(promises); let hasFailures = false; results.forEach((result) => { if (result.status === "rejected") { hasFailures = true; log(`Worker ${result.index + 1} had at least one failed run.`, { type: "error", workerId: result.index + 1 }); } else { log(`Worker ${result.index + 1} completed all runs successfully.`, { type: "success", workerId: result.index + 1 }); } }); if (hasFailures) { log("One or more workers failed.", { type: "error" }); setTimeout(() => import_process.default.exit(1), 100); } else { log("All tests completed successfully.", { type: "success" }); setTimeout(() => import_process.default.exit(0), 100); } } catch (overallError) { log( `An unexpected error occurred during Weighted Bucketing Mode execution: ${overallError}`, { type: "error" } ); setTimeout(() => import_process.default.exit(1), 100); } } else { log("Running in Polling Mode.", { type: "info" }); const numWorkers = Math.min(WORKERS, totalTests); const queue = [...testFiles]; const promises = []; const worker = async (workerIndex) => { log(`Worker ${workerIndex + 1} started.`, { type: "info", workerId: workerIndex + 1 }); let hasFailed = false; while (true) { let test; if (queue.length > 0) { test = queue.shift(); if (test) { log(`Worker ${workerIndex + 1} picked test: ${test}`, { type: "info", workerId: workerIndex + 1 }); } } if (!test) { log(`Worker ${workerIndex + 1} found no more tests to run.`, { type: "info", workerId: workerIndex + 1 }); break; } const display = BASE_DISPLAY_NUMBER + workerIndex; try { const result = await runCypress( [test], workerIndex, display, COMMAND ); if (result.status === "fulfilled") { completedTests += 1; logProgress(); log(`Worker ${workerIndex + 1} completed test: ${test}`, { type: "info", workerId: workerIndex + 1 }); } else { hasFailed = true; log( `Worker ${workerIndex + 1} encountered a failed Cypress run with code ${result.code}.`, { type: "error", workerId: workerIndex + 1 } ); } } catch (error) { hasFailed = true; log( `Worker ${workerIndex + 1} encountered a failed Cypress run: ${error}`, { type: "error", workerId: workerIndex + 1 } ); } } log( `Worker ${workerIndex + 1} is finishing with status: ${hasFailed ? "rejected" : "fulfilled"}.`, { type: "info", workerId: workerIndex + 1 } ); return createCypressResult( hasFailed ? "rejected" : "fulfilled", workerIndex, hasFailed ? 1 : 0 ); }; for (let i = 0; i < numWorkers; i++) { promises.push(worker(i)); } try { const results = await Promise.all(promises); let hasFailures = false; results.forEach((result) => { if (result.status === "rejected") { hasFailures = true; log( `Worker ${result.index + 1} had at least one failed Cypress run.`, { type: "error", workerId: result.index + 1 } ); } else { log( `Worker ${result.index + 1} completed all Cypress runs successfully.`, { type: "success", workerId: result.index + 1 } ); } }); if (hasFailures) { log("One or more Cypress workers failed.", { type: "error" }); setTimeout(() => import_process.default.exit(1), 100); } else { log("All Cypress tests completed successfully.", { type: "success" }); setTimeout(() => import_process.default.exit(0), 100); } } catch (overallError) { log( `An unexpected error occurred during Polling Mode execution: ${overallError}`, { type: "error" } ); setTimeout(() => import_process.default.exit(1), 100); } } } runParallelCypress(); import_process.default.on("unhandledRejection", (reason) => { log(`Unhandled Rejection: ${reason}`, { type: "error" }); setTimeout(() => import_process.default.exit(1), 100); });