cy-parallel
Version:
A powerful tool to run Cypress tests in parallel, optimizing test execution time.
536 lines (523 loc) • 17.4 kB
JavaScript
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);
});
;