UNPKG

@mmisty/cypress-allure-adapter

Version:

cypress allure adapter to generate allure results during tests execution (Allure TestOps compatible)

848 lines (847 loc) 35.1 kB
#!/usr/bin/env node "use strict"; /** * Allure Task Server * * Unified server that handles all Allure operations in a separate process: * - Filesystem operations (mkdir, writeFile, readFile, etc.) * - High-level Allure operations (attachVideo, moveToWatch, etc.) * * This prevents blocking the main Cypress process. */ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.runServer = exports.AllureTaskServer = exports.findAvailablePort = void 0; const http_1 = __importDefault(require("http")); const net_1 = __importDefault(require("net")); const path_1 = __importStar(require("path")); const fs_1 = require("fs"); const promises_1 = require("fs/promises"); const fast_glob_1 = __importDefault(require("fast-glob")); const allure_js_parser_1 = require("allure-js-parser"); const crypto_1 = require("crypto"); const debug_1 = __importDefault(require("debug")); const allure_js_commons_1 = require("allure-js-commons"); const allure_operations_1 = require("./allure-operations"); const debug = (0, debug_1.default)('cypress-allure:task-server'); const debugOps = (0, debug_1.default)('cypress-allure:task-server:ops'); /** * Operation queue with concurrency control */ class OperationQueue { constructor(maxConcurrent = 10) { this.queue = []; this.running = 0; this.maxConcurrent = maxConcurrent; } enqueue(operation) { return __awaiter(this, void 0, void 0, function* () { return new Promise(resolve => { this.queue.push({ operation, resolve }); this.processNext(); }); }); } processNext() { return __awaiter(this, void 0, void 0, function* () { if (this.running >= this.maxConcurrent || this.queue.length === 0) { return; } this.running++; const item = this.queue.shift(); if (!item) { this.running--; return; } try { const result = yield executeOperation(item.operation); item.resolve(result); } catch (err) { item.resolve({ success: false, error: err.message, }); } finally { this.running--; setImmediate(() => this.processNext()); } }); } get pendingCount() { return this.queue.length; } get runningCount() { return this.running; } } /** * Execute a filesystem operation */ function executeFsOperation(op) { return __awaiter(this, void 0, void 0, function* () { var _a, _b; debugOps(`FS operation: ${op.type}`); switch (op.type) { case 'fs:mkdir': { if ((0, fs_1.existsSync)(op.path)) { return { success: true }; } for (let i = 0; i < 5; i++) { try { yield (0, promises_1.mkdir)(op.path, { recursive: (_b = (_a = op.options) === null || _a === void 0 ? void 0 : _a.recursive) !== null && _b !== void 0 ? _b : true }); return { success: true }; } catch (err) { if (err.code === 'EEXIST') { return { success: true }; } yield new Promise(resolve => setTimeout(resolve, 50)); } } return { success: true }; } case 'fs:mkdirSync': { try { (0, fs_1.mkdirSync)(op.path, op.options); return { success: true }; } catch (err) { return { success: false, error: err.message }; } } case 'fs:writeFile': { const content = op.encoding === 'base64' ? Buffer.from(op.content, 'base64') : op.content; yield (0, promises_1.writeFile)(op.path, content); return { success: true }; } case 'fs:appendFile': { yield (0, promises_1.appendFile)(op.path, op.content); return { success: true }; } case 'fs:readFile': { const data = yield (0, promises_1.readFile)(op.path); return { success: true, data: data.toString('base64') }; } case 'fs:copyFile': { yield (0, promises_1.copyFile)(op.from, op.to); if (op.removeSource && op.from !== op.to) { try { yield (0, promises_1.rm)(op.from); } catch (_c) { // Ignore removal errors } } return { success: true }; } case 'fs:removeFile': { yield (0, promises_1.rm)(op.path, { recursive: true, force: true }); return { success: true }; } case 'fs:removeFileSync': { try { (0, fs_1.rmSync)(op.path, { recursive: true, force: true }); return { success: true }; } catch (err) { return { success: false, error: err.message }; } } case 'fs:exists': { try { yield (0, promises_1.stat)(op.path); return { success: true, data: true }; } catch (_d) { return { success: true, data: false }; } } case 'fs:existsSync': { return { success: true, data: (0, fs_1.existsSync)(op.path) }; } default: return { success: false, error: `Unknown FS operation: ${op.type}` }; } }); } /** * Execute an Allure high-level operation */ function executeAllureOperation(op) { return __awaiter(this, void 0, void 0, function* () { debugOps(`Allure operation: ${op.type}`); switch (op.type) { case 'allure:attachVideo': { return yield attachVideoToContainers(op.allureResults, op.videoPath, op.allureAddVideoOnPass); } case 'allure:moveToWatch': { return yield moveResultsToWatch(op.allureResults, op.allureResultsWatch); } case 'allure:attachScreenshots': { return yield attachScreenshots(op.allureResults, op.screenshots, op.allTests); } case 'allure:copyScreenshot': { return yield copyScreenshot(op.allureResults, op.screenshotPath, op.targetName); } case 'allure:writeTestMessage': { return yield writeTestMessage(op.path, op.message); } default: return { success: false, error: `Unknown Allure operation: ${op.type}` }; } }); } /** * Execute a batch of operations */ function executeBatchOperation(op) { return __awaiter(this, void 0, void 0, function* () { const results = []; for (const subOp of op.operations) { const result = yield executeOperation(subOp); results.push(result); } const allSuccess = results.every(r => r.success); if (allSuccess) { return { success: true, data: results }; } return { success: false, error: 'Some batch operations failed' }; }); } /** * Main operation dispatcher */ function executeOperation(operation) { return __awaiter(this, void 0, void 0, function* () { if (operation.type === 'shutdown') { return { success: true }; } if (operation.type === 'health') { return { success: true, data: { status: 'ok' } }; } if (operation.type === 'batch') { return executeBatchOperation(operation); } if (operation.type.startsWith('fs:')) { return executeFsOperation(operation); } if (operation.type.startsWith('allure:')) { return executeAllureOperation(operation); } return { success: false, error: `Unknown operation type: ${operation.type}` }; }); } // ============================================================================ // High-level Allure Operations Implementation // ============================================================================ /** * Attach video to test containers */ function attachVideoToContainers(allureResults, videoPath, allureAddVideoOnPass) { return __awaiter(this, void 0, void 0, function* () { try { debug(`attachVideoToContainers: ${videoPath}`); const ext = '.mp4'; const specname = (0, path_1.basename)(videoPath, ext); // Check video exists try { yield (0, promises_1.stat)(videoPath); } catch (_a) { return { success: false, error: `Video does not exist: ${videoPath}` }; } const res = (0, allure_js_parser_1.parseAllure)(allureResults); const tests = res .filter(t => (allureAddVideoOnPass ? true : t.status !== 'passed' && t.status !== 'skipped')) .map(t => { var _a; return ({ path: (_a = t.labels.find((l) => l.name === 'path')) === null || _a === void 0 ? void 0 : _a.value, id: t.uuid, fullName: t.fullName, parent: t.parent, }); }); const testsAttach = tests.filter(t => t.path && t.path.indexOf(specname) !== -1); const testsWithSameParent = Array.from(new Map(testsAttach.filter(test => test.parent).map(test => { var _a; return [(_a = test.parent) === null || _a === void 0 ? void 0 : _a.uuid, test]; })).values()); for (const test of testsWithSameParent) { if (!test.parent) { continue; } const containerFile = `${allureResults}/${test.parent.uuid}-container.json`; try { const contents = yield (0, promises_1.readFile)(containerFile); const uuid = (0, crypto_1.randomUUID)(); const nameAttach = `${uuid}-attachment${ext}`; const newPath = path_1.default.join(allureResults, nameAttach); // Parse and update container const containerJSON = JSON.parse(contents.toString()); const after = { name: 'video', attachments: [ { name: `${specname}${ext}`, type: 'video/mp4', source: nameAttach, }, ], parameters: [], start: Date.now(), stop: Date.now(), status: 'passed', statusDetails: {}, stage: allure_js_commons_1.Stage.FINISHED, steps: [], }; if (!containerJSON.afters) { containerJSON.afters = []; } containerJSON.afters.push(after); // Copy video if not exists try { yield (0, promises_1.stat)(newPath); } catch (_b) { yield (0, promises_1.copyFile)(videoPath, newPath); } yield (0, promises_1.writeFile)(containerFile, JSON.stringify(containerJSON)); } catch (err) { debug(`Error updating container: ${err.message}`); } } return { success: true }; } catch (err) { return { success: false, error: err.message }; } }); } /** * Move results to watch folder for TestOps */ function moveResultsToWatch(allureResults, allureResultsWatch) { return __awaiter(this, void 0, void 0, function* () { try { if (allureResults === allureResultsWatch) { return { success: true }; } // Ensure watch directory exists if (!(0, fs_1.existsSync)(allureResultsWatch)) { yield (0, promises_1.mkdir)(allureResultsWatch, { recursive: true }); } // Helper to copy if source exists and target doesn't const copyIfNeeded = (src_1, target_1, ...args_1) => __awaiter(this, [src_1, target_1, ...args_1], void 0, function* (src, target, removeSource = false) { try { yield (0, promises_1.stat)(src); try { yield (0, promises_1.stat)(target); // Target exists, skip or remove source if (removeSource && src !== target) { yield (0, promises_1.rm)(src); } } catch (_a) { // Target doesn't exist, copy yield (0, promises_1.copyFile)(src, target); if (removeSource && src !== target) { yield (0, promises_1.rm)(src); } } } catch (_b) { // Source doesn't exist, skip } }); const targetPath = (src) => src.replace(allureResults, allureResultsWatch); // Copy environment, executor, categories yield copyIfNeeded(`${allureResults}/environment.properties`, targetPath(`${allureResults}/environment.properties`), true); yield copyIfNeeded(`${allureResults}/executor.json`, targetPath(`${allureResults}/executor.json`), true); yield copyIfNeeded(`${allureResults}/categories.json`, targetPath(`${allureResults}/categories.json`), true); // Parse and move tests const tests = (0, allure_js_parser_1.parseAllure)(allureResults); // Scan attachments ONCE outside the loop (was inside loop - major perf bug) const allAttachments = fast_glob_1.default.sync(`${allureResults}/*-attachment.*`); debug(`Found ${allAttachments.length} attachments to process`); // Track moved attachments to avoid duplicate moves const movedAttachments = new Set(); // Get parent container UUIDs helper const getAllParentUuids = (t) => { const uuids = []; let current = t.parent; while (current) { if (current.uuid) { uuids.push(current.uuid); } current = current.parent; } return uuids; }; for (const test of tests) { const testSource = `${allureResults}/${test.uuid}-result.json`; const testTarget = targetPath(testSource); const containerSources = getAllParentUuids(test).map(uuid => `${allureResults}/${uuid}-container.json`); // Find attachments referenced in test or containers let testContents = ''; try { testContents = (yield (0, promises_1.readFile)(testSource)).toString(); } catch (_a) { continue; } const containerContents = []; for (const containerSource of containerSources) { try { containerContents.push((yield (0, promises_1.readFile)(containerSource)).toString()); } catch (_b) { // Skip } } const testAttachments = allAttachments.filter(attachFile => { const attachBasename = (0, path_1.basename)(attachFile); return (testContents.includes(attachBasename) || containerContents.some(content => content.includes(attachBasename))); }); // Move attachments (skip already moved) for (const attachFile of testAttachments) { if (movedAttachments.has(attachFile)) continue; movedAttachments.add(attachFile); const attachTarget = targetPath(attachFile); yield copyIfNeeded(attachFile, attachTarget, true); } // Move test result yield copyIfNeeded(testSource, testTarget, true); // Move containers for (const containerSource of containerSources) { const containerTarget = targetPath(containerSource); yield copyIfNeeded(containerSource, containerTarget, true); } } return { success: true }; } catch (err) { return { success: false, error: err.message }; } }); } /** * Attach screenshots to test results */ function attachScreenshots(allureResults, screenshots, allTests) { return __awaiter(this, void 0, void 0, function* () { try { for (const screenshot of screenshots) { const uuids = allTests .filter(t => { var _a; return t.status !== 'passed' && t.retryIndex === screenshot.testAttemptIndex && (0, path_1.basename)((_a = t.specRelative) !== null && _a !== void 0 ? _a : '') === screenshot.specName && (screenshot.testId ? t.mochaId === screenshot.testId : true); }) .map(t => t.uuid); if (uuids.length === 0) { continue; } for (const uuid of uuids) { const testFile = `${allureResults}/${uuid}-result.json`; try { const contents = yield (0, promises_1.readFile)(testFile); const ext = path_1.default.extname(screenshot.path); const name = path_1.default.basename(screenshot.path); const testCon = JSON.parse(contents.toString()); const uuidNew = (0, crypto_1.randomUUID)(); const nameAttach = `${uuidNew}-attachment${ext}`; const newPath = path_1.default.join(allureResults, nameAttach); // Copy screenshot if not exists try { yield (0, promises_1.stat)(newPath); } catch (_a) { yield (0, promises_1.copyFile)(screenshot.path, newPath); } if (!testCon.attachments) { testCon.attachments = []; } testCon.attachments.push({ name: name, type: 'image/png', source: nameAttach, }); yield (0, promises_1.writeFile)(testFile, JSON.stringify(testCon)); } catch (err) { debug(`Could not attach screenshot: ${err.message}`); } } } return { success: true }; } catch (err) { return { success: false, error: err.message }; } }); } /** * Copy a screenshot to allure results */ function copyScreenshot(allureResults, screenshotPath, targetName) { return __awaiter(this, void 0, void 0, function* () { try { const targetPath = `${allureResults}/${targetName}`; // Ensure directory exists if (!(0, fs_1.existsSync)(allureResults)) { yield (0, promises_1.mkdir)(allureResults, { recursive: true }); } // Check source exists try { yield (0, promises_1.stat)(screenshotPath); } catch (_a) { return { success: false, error: `Screenshot does not exist: ${screenshotPath}` }; } // Copy if target doesn't exist try { yield (0, promises_1.stat)(targetPath); } catch (_b) { yield (0, promises_1.copyFile)(screenshotPath, targetPath); } return { success: true }; } catch (err) { return { success: false, error: err.message }; } }); } /** * Write test message (for testing) */ function writeTestMessage(filePath, message) { return __awaiter(this, void 0, void 0, function* () { try { const dirPath = (0, path_1.dirname)(filePath); if (!(0, fs_1.existsSync)(dirPath)) { yield (0, promises_1.mkdir)(dirPath, { recursive: true }); } try { yield (0, promises_1.stat)(filePath); } catch (_a) { yield (0, promises_1.writeFile)(filePath, ''); } yield (0, promises_1.appendFile)(filePath, `${message}\n`); return { success: true }; } catch (err) { return { success: false, error: err.message }; } }); } // ============================================================================ // Server Implementation // ============================================================================ /** * Find an available port */ const findAvailablePort = (...args_1) => __awaiter(void 0, [...args_1], void 0, function* (startPort = 46000) { return new Promise((resolve, reject) => { const tryPort = (port, attempts = 0) => { if (attempts > 100) { reject(new Error('Could not find available port for task server')); return; } const server = net_1.default.createServer(); server.listen(port, () => { server.close(() => { resolve(port); }); }); server.on('error', () => { tryPort(port + 1, attempts + 1); }); }; tryPort(startPort); }); }); exports.findAvailablePort = findAvailablePort; /** * Allure Task Server */ class AllureTaskServer { constructor(maxConcurrentOps = 10) { this.server = null; this.port = null; this.lastActivityTime = Date.now(); this.inactivityTimer = null; this.isShuttingDown = false; this.operationQueue = new OperationQueue(maxConcurrentOps); } resetInactivityTimer() { this.lastActivityTime = Date.now(); if (this.inactivityTimer) { clearTimeout(this.inactivityTimer); } this.inactivityTimer = setTimeout(() => { const idleTime = Date.now() - this.lastActivityTime; debug(`Inactivity timeout reached (idle for ${idleTime}ms), shutting down`); this.forceShutdown(); }, AllureTaskServer.INACTIVITY_TIMEOUT_MS); // Don't keep Node alive just for this timer this.inactivityTimer.unref(); } forceShutdown() { if (this.isShuttingDown) return; this.isShuttingDown = true; debug('Force shutdown initiated'); // Give a short time for graceful stop, then force exit this.stop() .then(() => { debug('Graceful shutdown completed'); process.exit(0); }) .catch(() => { debug('Graceful shutdown failed, forcing exit'); process.exit(0); }); // Force exit after timeout regardless setTimeout(() => { debug('Shutdown timeout, forcing exit'); process.exit(0); }, 2000).unref(); } start(requestedPort) { return __awaiter(this, void 0, void 0, function* () { if (this.server) { return this.port; } this.port = requestedPort !== null && requestedPort !== void 0 ? requestedPort : (yield (0, exports.findAvailablePort)()); // Start inactivity timer this.resetInactivityTimer(); return new Promise((resolve, reject) => { this.server = http_1.default.createServer((req, res) => __awaiter(this, void 0, void 0, function* () { var _a; // Reset inactivity timer on each request this.resetInactivityTimer(); res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); if (req.method === 'OPTIONS') { res.writeHead(200); res.end(); return; } if (req.method === 'GET' && req.url === allure_operations_1.SERVER_HEALTH_PATH) { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'ok', pending: this.operationQueue.pendingCount, running: this.operationQueue.runningCount, })); return; } if (req.method !== 'POST' || !((_a = req.url) === null || _a === void 0 ? void 0 : _a.startsWith(allure_operations_1.SERVER_PATH))) { res.writeHead(404); res.end('Not found'); return; } let body = ''; req.on('data', chunk => { body += chunk.toString(); }); req.on('end', () => __awaiter(this, void 0, void 0, function* () { try { const operation = JSON.parse(body); if (operation.type === 'shutdown') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true })); setTimeout(() => { this.forceShutdown(); }, 100); return; } const result = yield this.operationQueue.enqueue(operation); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(result)); } catch (err) { debug(`Error processing request: ${err.message}`); res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: false, error: err.message })); } })); })); this.server.on('error', err => { debug(`Task server error: ${err.message}`); reject(err); }); this.server.listen(this.port, () => { debug(`Task server started on port ${this.port}`); resolve(this.port); }); }); }); } stop() { return __awaiter(this, void 0, void 0, function* () { // Clear inactivity timer if (this.inactivityTimer) { clearTimeout(this.inactivityTimer); this.inactivityTimer = null; } return new Promise(resolve => { if (!this.server) { resolve(); return; } const startTime = Date.now(); const waitForPending = () => __awaiter(this, void 0, void 0, function* () { while (this.operationQueue.pendingCount > 0 || this.operationQueue.runningCount > 0) { // Add timeout to prevent hanging forever if (Date.now() - startTime > AllureTaskServer.SHUTDOWN_TIMEOUT_MS) { debug(`Shutdown timeout reached, ${this.operationQueue.pendingCount} pending, ${this.operationQueue.runningCount} running`); break; } yield new Promise(r => setTimeout(r, 100)); } }); waitForPending().then(() => { this.server.close(() => { debug('Task server stopped'); this.server = null; this.port = null; resolve(); }); // Force close all connections after a short delay setTimeout(() => { if (this.server) { debug('Force closing server'); this.server = null; this.port = null; resolve(); } }, 1000); }); }); }); } getPort() { return this.port; } } exports.AllureTaskServer = AllureTaskServer; // Inactivity timeout - auto-shutdown if no requests for this duration AllureTaskServer.INACTIVITY_TIMEOUT_MS = 120000; // 2 minutes // Max wait time for pending operations during shutdown AllureTaskServer.SHUTDOWN_TIMEOUT_MS = 10000; // 10 seconds /** * Entry point when running as standalone script */ const runServer = () => __awaiter(void 0, void 0, void 0, function* () { const args = process.argv.slice(2); let port; for (let i = 0; i < args.length; i++) { if (args[i] === '--port' && args[i + 1]) { port = parseInt(args[i + 1], 10); } } const server = new AllureTaskServer(); let isShuttingDown = false; const shutdown = (reason) => __awaiter(void 0, void 0, void 0, function* () { if (isShuttingDown) return; isShuttingDown = true; debug(`Shutdown initiated: ${reason}`); // Set a hard timeout to force exit const forceExitTimer = setTimeout(() => { debug('Force exit timeout reached'); process.exit(0); }, 5000); forceExitTimer.unref(); try { yield server.stop(); debug('Graceful shutdown completed'); } catch (err) { debug(`Shutdown error: ${err.message}`); } process.exit(0); }); try { const actualPort = yield server.start(port); process.stdout.write(`ALLURE_SERVER_PORT:${actualPort}\n`); // Handle various shutdown signals process.on('SIGINT', () => shutdown('SIGINT')); process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('disconnect', () => shutdown('IPC disconnect')); // Monitor parent process - if stdin closes, parent likely died process.stdin.on('end', () => { debug('Parent process stdin closed'); shutdown('stdin closed'); }); process.stdin.on('close', () => { debug('Parent process stdin close event'); shutdown('stdin close'); }); // Resume stdin to receive events (required for 'end' event) process.stdin.resume(); debug('Task server running, waiting for requests...'); } catch (err) { // eslint-disable-next-line no-console console.error(`Failed to start task server: ${err.message}`); process.exit(1); } }); exports.runServer = runServer; if (require.main === module) { (0, exports.runServer)(); }