UNPKG

js-tests-results-collector

Version:

Universal test results collector for Jest, Jasmine, Mocha, Cypress, and Playwright that sends results to Buddy Works API

324 lines (277 loc) 11.7 kB
const sessionManager = require('../../core/session-manager'); const TestResultMapper = require('../../utils/test-result-mapper'); const Logger = require('../../utils/logger'); class CypressReporter { constructor(runner, options) { this.runner = runner; this.options = options; this.logger = new Logger('CypressReporter'); this.sessionClosed = false; // Hook into Cypress test lifecycle events this.runner.on('start', this.onStart.bind(this)); this.runner.on('test end', this.onTestEnd.bind(this)); this.runner.on('end', this.onEnd.bind(this)); // Add process exit handlers to close session when Cypress exits this.setupProcessExitHandlers(); // Initialize spec tracking on first instance this.initializeSpecTracking(); } // Static flag to prevent race conditions between after:run and process exit static sessionClosedByAfterRun = false; // Static spec tracking for CI session closing static totalSpecs = 0; static completedSpecs = 0; static specTrackingInitialized = false; initializeSpecTracking() { // Only initialize once across all reporter instances if (!CypressReporter.specTrackingInitialized) { // Try to get total specs from Cypress arguments or environment const cypressSpecs = this.getTotalSpecCount(); CypressReporter.totalSpecs = cypressSpecs; CypressReporter.completedSpecs = 0; CypressReporter.specTrackingInitialized = true; this.logger.debug(`Initialized spec tracking: ${CypressReporter.totalSpecs} total specs expected, completed: ${CypressReporter.completedSpecs}`); } else { this.logger.debug(`Spec tracking already initialized: ${CypressReporter.totalSpecs} total, ${CypressReporter.completedSpecs} completed`); } } getTotalSpecCount() { // Try to determine total spec count from various sources // Method 1: Check if Cypress provides spec info in environment if (process.env.CYPRESS_SPEC_COUNT) { return parseInt(process.env.CYPRESS_SPEC_COUNT, 10); } // Method 2: Parse from command line arguments const args = process.argv.join(' '); if (args.includes('--spec')) { // Count comma-separated specs in --spec argument const specMatch = args.match(/--spec\s+([^\s]+)/); if (specMatch) { const specCount = specMatch[1].split(',').length; this.logger.debug(`Found ${specCount} specs from --spec argument`); return specCount; } } // Method 3: Default assumption based on common patterns // If no specific spec is mentioned, assume multiple specs (default: 2 for your test setup) const defaultSpecCount = 2; this.logger.debug(`Using default spec count: ${defaultSpecCount}`); return defaultSpecCount; } setupProcessExitHandlers() { // Handle normal process exit with beforeExit (allows async operations) process.once('beforeExit', async () => { this.logger.debug('Process about to exit, closing session'); await this.closeSessionSafely(); }); // Handle SIGINT (Ctrl+C) process.on('SIGINT', async () => { this.logger.debug('SIGINT received, closing session'); await this.closeSessionSafely(); process.exit(0); }); // Handle SIGTERM process.on('SIGTERM', async () => { this.logger.debug('SIGTERM received, closing session'); await this.closeSessionSafely(); process.exit(0); }); // Handle uncaught exceptions process.on('uncaughtException', async (error) => { this.logger.error('Uncaught exception, closing session', error); await this.closeSessionSafely(); process.exit(1); }); } async closeSessionSafely() { // Check if session was already closed by after:run hook if (CypressReporter.sessionClosedByAfterRun) { this.logger.debug('Session already closed by after:run hook, skipping process exit cleanup'); return; } if (this.sessionClosed) { return; } try { await sessionManager.closeSession(); this.sessionClosed = true; this.logger.debug('Session closed safely'); } catch (error) { this.logger.error('Error closing session safely', error); } } onStart() { this.logger.debug('Cypress test run started'); // Create session immediately at test run start sessionManager.createSession() .then(() => { this.logger.debug('Session created at test run start'); }) .catch((error) => { this.logger.error('Error creating session at test run start', error); // Mark this as a framework error sessionManager.markFrameworkError(); }); } async onTestEnd(test) { try { const mappedResult = TestResultMapper.mapCypressResult(test); await sessionManager.submitTestCase(mappedResult); } catch (error) { this.logger.error('Error processing Cypress test result', error); // Mark this as a framework error since we failed to process test results sessionManager.markFrameworkError(); } } onEnd() { this.logger.debug('Cypress test run completed'); // Increment completed specs counter CypressReporter.completedSpecs++; this.logger.debug(`Spec completed. Progress: ${CypressReporter.completedSpecs}/${CypressReporter.totalSpecs}`); // Check if this is the last spec and we should close the session const shouldCloseSession = CypressReporter.completedSpecs >= CypressReporter.totalSpecs; if (shouldCloseSession) { this.logger.debug('All specs completed, closing session automatically'); try { // Use synchronous approach to ensure completion before process exit this.closeSessionSync(); this.sessionClosed = true; CypressReporter.sessionClosedByAfterRun = true; // Prevent duplicate closure attempts this.logger.debug('Session closed successfully after all specs completed'); } catch (error) { this.logger.error('Error closing session after all specs completed', error); } } else { // Don't close the session here - let it remain open for the entire test run // The session will be closed when the process exits or via after:run hook this.logger.debug(`Spec file completed, waiting for remaining ${CypressReporter.totalSpecs - CypressReporter.completedSpecs} specs before closing session`); } } closeSessionSync() { // Synchronous session closing using Node.js built-in modules // Get session ID let sessionId = sessionManager.getCurrentSessionId(); if (!sessionId && process.env.BUDDY_SESSION_ID) { sessionId = process.env.BUDDY_SESSION_ID; } if (!sessionId) { // Try to read from file const fs = require('fs'); const path = require('path'); const os = require('os'); const filePath = path.join(os.tmpdir(), 'buddy-session-id.txt'); if (fs.existsSync(filePath)) { sessionId = fs.readFileSync(filePath, 'utf8').trim(); } } if (!sessionId) { this.logger.debug('No session ID found for synchronous close'); return; } this.logger.debug(`Closing session synchronously: ${sessionId}`); try { // Use Node.js built-in modules for HTTP request const https = require('https'); const http = require('http'); const url = require('url'); const apiUrl = `${process.env.BUDDY_API_URL || 'https://api.buddy.works'}/unit-tests/sessions/${sessionId}/close`; const token = process.env.BUDDY_UT_TOKEN; const parsedUrl = url.parse(apiUrl); const isHttps = parsedUrl.protocol === 'https:'; const client = isHttps ? https : http; const options = { hostname: parsedUrl.hostname, port: parsedUrl.port || (isHttps ? 443 : 80), path: parsedUrl.path, method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, timeout: 3000 }; // Make the request and handle it asynchronously but immediately const req = client.request(options, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { if (res.statusCode >= 200 && res.statusCode < 300) { this.logger.debug('Session close request completed successfully'); } else { this.logger.error(`Session close failed with status ${res.statusCode}: ${data}`); } }); }); req.on('error', (error) => { this.logger.error('Session close request error:', error.message); }); req.on('timeout', () => { req.destroy(); this.logger.error('Session close request timed out'); }); // Since this is a fallback synchronous close and we don't have access to the full // test result tracking, we'll determine status based on available information // Default to SUCCESS since this is typically called when tests completed normally // but the async close failed const status = 'SUCCESS'; const payload = JSON.stringify({ status: status }); this.logger.debug(`Synchronous close using status: ${status}`); req.write(payload); req.end(); this.logger.debug('Session close request sent'); // Clean up session files immediately this.clearSessionFiles(); } catch (error) { this.logger.error('Failed to close session synchronously', error); } } clearSessionFiles() { try { const fs = require('fs'); const path = require('path'); const os = require('os'); // Clear environment variable delete process.env.BUDDY_SESSION_ID; // Clear session file const filePath = path.join(os.tmpdir(), 'buddy-session-id.txt'); if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); this.logger.debug('Session files cleared'); } } catch (error) { this.logger.error('Failed to clear session files', error); } } // Static method to be called from cypress.config.js after:run hook static async closeSession() { const logger = new Logger('CypressReporter'); logger.debug('Closing session from after:run hook'); try { // Force ensure the session manager is initialized await sessionManager.initialize(); logger.debug('SessionManager initialized for after:run hook'); const currentSessionId = sessionManager.getCurrentSessionId(); logger.debug(`Current session ID in memory: ${currentSessionId || 'null'}`); if (currentSessionId) { logger.debug(`Found active session: ${currentSessionId}`); await sessionManager.closeSession(); // Set flag to prevent process exit handlers from trying to close again CypressReporter.sessionClosedByAfterRun = true; logger.debug('Session closed successfully from after:run hook'); } else { logger.debug('No active session found from after:run hook'); // Try to trigger the session manager's close logic which will check file await sessionManager.closeSession(); if (sessionManager.getCurrentSessionId() === null) { logger.debug('Session was found and closed via file-based lookup'); CypressReporter.sessionClosedByAfterRun = true; } } } catch (error) { logger.error('Error closing session from after:run hook', error); } } } module.exports = CypressReporter;