UNPKG

estructura_automation

Version:

Paquete de estructura de automation de paguetodo

743 lines (653 loc) 27.7 kB
/* eslint-disable no-unused-vars */ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable no-console */ import { Reporter, TestCase, TestResult, FullConfig, Suite, FullResult, TestError } from '@playwright/test/reporter'; import { relative } from 'path'; /** * ======================================================================================== * REPORTER PERSONALIZADO * ======================================================================================== * * Este reporter personalizado proporciona una salida detallada y estructurada de las * pruebas de Playwright, con manejo especializado para diferentes tipos de errores * y un formato similar al reporter "list" nativo pero con características adicionales. * * Características principales: * - Detección automática de errores de selectores y timeouts * - Resaltado de líneas importantes en stack traces * - Manejo de errores globales y de configuración * - Estadísticas detalladas y tasa de éxito * * ======================================================================================== */ // ======================================================================================== // TYPES & INTERFACES // ======================================================================================== /** * Información de un test que ha fallado */ interface FailedTestInfo { /** Título completo del test */ readonly title: string; /** Mensaje de error (si está disponible) */ readonly error?: string; /** Ubicación del archivo del test */ readonly location?: string; /** Duración del test en segundos */ readonly duration?: number; } /** * Estadísticas de ejecución de tests */ interface TestStats { /** Número de tests que pasaron */ passed: number; /** Número de tests que fallaron */ failed: number; /** Número de tests omitidos */ skipped: number; } /** * Información de tiempo de ejecución */ interface TimeInfo { /** Minutos transcurridos */ readonly minutes: number; /** Segundos transcurridos (resto) */ readonly seconds: number; /** Total de segundos transcurridos */ readonly totalSeconds: number; } /** * Tipos de errores que puede detectar el reporter */ type ErrorType = 'timeout' | 'selector' | 'element' | 'business' | 'expect' | 'unknown'; // ======================================================================================== // ENUMS & CONSTANTS // ======================================================================================== /** * Estados posibles de un test en Playwright */ enum TestStatus { PASSED = 'passed', FAILED = 'failed', SKIPPED = 'skipped', TIMED_OUT = 'timedOut' } /** * Patrones de texto para detectar diferentes tipos de errores */ const ERROR_PATTERNS: Record<ErrorType, readonly string[]> = { /** Errores relacionados con timeouts */ timeout: ['Test timeout', 'timeout', 'exceeded'] as const, /** Errores relacionados con selectores y locators */ selector: ['locator.', 'waiting for', 'Locator', 'getByText', 'getBy', 'selector'] as const, /** Errores relacionados con elementos no encontrados */ element: ['not found', 'not visible', 'not attached'] as const, /** Errores de assertions/expect */ expect: [ 'expect(', 'toBeVisible', 'toHaveText', 'toHaveValue', 'toBeChecked', 'toBeEnabled', 'toHaveURL', 'toHaveTitle', 'toContainText', 'toBe', 'toEqual', 'toBeDisabled', 'toBeEditable', 'toBeEmpty', 'toBeHidden', 'toHaveAttribute', 'toHaveClass', 'toHaveCSS', 'toHaveId', 'toHaveJSProperty', 'toHaveScreenshot', 'toHaveValues', 'toBeAttached', 'toBeInViewport', 'toHaveAccessibleDescription', 'toHaveAccessibleName', 'toHaveRole', 'toBeChecked', 'toBeDisabled', 'toBeEditable', 'toBeEmpty', 'toBeEnabled', 'toBeHidden', 'toBeVisible', 'toBeFocused' ] as const, /** Errores de lógica de negocio (sin patrones específicos) */ business: [] as const, /** Errores desconocidos */ unknown: [] as const } as const; /** * Patrones para resaltar líneas importantes en el stack trace * Estas líneas contienen información crucial sobre qué selector o acción falló */ const STACK_HIGHLIGHT_PATTERNS = [ 'waiting for', 'locator.', 'getByText', 'getBy', 'click', 'fill' ] as const; /** * Símbolos de consola para mejorar la legibilidad de la salida */ const CONSOLE_SYMBOLS = { SUCCESS: '✅', ERROR: '❌', SKIP: '⏩', WARNING: '⚠️', GLOBAL_ERROR: '🚨', STATS: '📊', LOCATION: '📍', TIME: '⏱️', SUCCESS_RATE: '🎯', STATUS: '📌', DETAIL: '🔍', ROCKET: '🚀', SPARKLES: '✨' } as const; // ======================================================================================== // MAIN REPORTER CLASS // ======================================================================================== /** * Reporter personalizado que proporciona salida detallada * con manejo especializado de diferentes tipos de errores */ class customReporter implements Reporter { /** Estadísticas de ejecución de tests */ private readonly stats: TestStats = { passed: 0, failed: 0, skipped: 0 }; /** Lista de tests que han fallado con información detallada */ private readonly failedTests: FailedTestInfo[] = []; /** Número del test actual (para mostrar progreso) */ private currentTestNumber = 0; /** Número total de tests a ejecutar */ private totalTests = 0; /** Timestamp de inicio de la ejecución */ private readonly startTime: number = Date.now(); /** * Contador de reintentos para cada test */ private readonly retryCounts: Record<string, number> = {}; /** * Tests que están siendo reintentados (para evitar mostrar encabezado duplicado) */ private readonly testsBeingRetried: Set<string> = new Set(); // ======================================================================================== // PUBLIC REPORTER METHODS - Métodos requeridos por la interfaz Reporter // ======================================================================================== /** * Se ejecuta al inicio de la suite de tests * @param config - Configuración de Playwright * @param suite - Suite de tests a ejecutar */ onBegin(config: FullConfig, suite: Suite): void { void config; this.totalTests = suite.allTests().length; this.logInitialMessage(); } /** * Se ejecuta cuando un test individual comienza * @param test - Información del test que está comenzando */ onTestBegin(test: TestCase): void { // Solo incrementar el contador si no es un reintento // Playwright llama a onTestBegin para cada intento del mismo test const testKey = `${test.title}`; const isRetry = this.testsBeingRetried.has(testKey); if (!this.retryCounts[testKey]) { this.currentTestNumber++; this.retryCounts[testKey] = 1; // Marcar que ya contamos este test } // Solo mostrar encabezado si no es un reintento if (!isRetry) { const testInfo = this.extractTestInfo(test); this.logTestStart(testInfo); } } /** * Se ejecuta cuando un test individual termina * @param test - Información del test que terminó * @param result - Resultado del test (passed, failed, skipped, etc.) */ onTestEnd(test: TestCase, result: TestResult): void { const duration = this.calculateDuration(result.duration); // Registrar información de depuración if (process.env.NODE_ENV === 'development') { this.logDebugInfo(result); } // Detectar configuración de reintentos const maxRetries = test.retries || 0; const totalAttempts = maxRetries + 1; // Si el test falló O hizo timeout Y aún hay reintentos disponibles, solo mostrar mensaje de reintento const isFailedOrTimedOut = result.status === TestStatus.FAILED || result.status === TestStatus.TIMED_OUT; if (isFailedOrTimedOut && result.retry < maxRetries) { const nextAttempt = result.retry + 2; // El próximo intento que se ejecutará const statusText = result.status === TestStatus.TIMED_OUT ? 'timeout' : 'fallo'; console.log(` 🔄 Reintentando test por ${statusText} (${test.title}), intento: ${nextAttempt}/${totalAttempts}`); // Marcar este test como siendo reintentado const testKey = `${test.title}`; this.testsBeingRetried.add(testKey); return; // No procesar como resultado final, esperamos el siguiente intento } // Procesar resultado final (último intento, sin importar si pasó o falló) // Limpiar el marcador de reintento ya que este es el resultado final const testKey = `${test.title}`; this.testsBeingRetried.delete(testKey); switch (result.status as TestStatus) { case TestStatus.PASSED: this.handlePassedTest(duration); break; case TestStatus.FAILED: this.handleFailedTest(test, result, duration); break; case TestStatus.SKIPPED: this.handleSkippedTest(); break; case TestStatus.TIMED_OUT: this.handleTimedOutTest(duration); break; default: this.handleUnknownStatus(result); break; } } /** * Se ejecuta cuando ocurre un error global (no específico de un test) * @param error - Error global que ocurrió */ onError(error: TestError): void { this.logGlobalError(error); } /** * Se ejecuta al final de toda la suite de tests * @param result - Resultado final de la ejecución */ onEnd(result: FullResult): void { const timeInfo = this.calculateTimeInfo(); this.logFinalSummary(result, timeInfo); } // ======================================================================================== // PRIVATE HELPER METHODS - Métodos auxiliares para organizar la lógica // ======================================================================================== /** * Muestra el mensaje inicial cuando comienzan las pruebas */ private logInitialMessage(): void { console.log(`\n=== ${CONSOLE_SYMBOLS.ROCKET} INICIANDO PRUEBAS ===`); console.log('=== Por favor no toque el PC, hasta que terminen las pruebas, si no explotamos todos ==='); console.log(`📋 Total de pruebas a ejecutar: ${this.totalTests}`); console.log('==========================================\n'); } /** * Extrae información relevante de un test para mostrar en la consola * @param test - Test del cual extraer información * @returns Objeto con información formateada del test */ private extractTestInfo(test: TestCase): { browser: string; location: string; line: string; column: string; fullTitle: string } { // Obtener el nombre real del proyecto desde la configuración const projectName = test.parent?.project?.name || 'unknown'; const realProjectName = projectName === 'project' ? 'pos-admin' : projectName; const location = test.location?.file ? relative(process.cwd(), test.location.file) : 'Unknown location'; const line = test.location?.line?.toString() || '?'; const column = test.location?.column?.toString() || '?'; // Construir el título completo del test incluyendo describe y test const fullTitle = this.buildFullTestTitle(test); return { browser: realProjectName, location, line, column, fullTitle }; } /** * Construye el título completo del test incluyendo todos los describe anidados * @param test - Test del cual extraer el título * @returns Título completo del test */ private buildFullTestTitle(test: TestCase): string { const titles: string[] = []; let current: any = test.parent; // Recorrer hacia arriba para obtener todos los describe while (current && current.title) { titles.unshift(current.title); current = current.parent; } // Agregar el título del test al final titles.push(test.title); return titles.join(' › '); } /** * Muestra información del test que está comenzando * @param testInfo - Información del test formateada */ private logTestStart(testInfo: { browser: string; location: string; line: string; column: string; fullTitle: string }): void { console.log(`\n${this.currentTestNumber}/${this.totalTests}) [${testInfo.browser}] › ${testInfo.location}:${testInfo.line}:${testInfo.column}${testInfo.fullTitle}`); } /** * Calcula la duración de un test en segundos * @param durationMs - Duración en milisegundos * @returns Objeto con duración en segundos y formato string */ private calculateDuration(durationMs: number): { seconds: number; formatted: string } { const seconds = durationMs / 1000; return { seconds, formatted: seconds.toFixed(2) }; } /** * Muestra información de debug sobre el resultado del test * @param result - Resultado del test */ private logDebugInfo(result: TestResult): void { const statusMessages = { passed: '✅ Completado exitosamente', failed: '❌ Falló durante la ejecución', skipped: '⏩ Omitido', timedOut: '⏰ Tiempo excedido' }; const statusMessage = statusMessages[result.status as keyof typeof statusMessages] || `⚠️ Estado: ${result.status}`; console.log(` ${statusMessage}`); if (result.error && result.status === 'failed') { console.log(` 💬 Razón: ${result.error.message}`); } } /** * Maneja un test que pasó exitosamente * @param duration - Información de duración del test */ private handlePassedTest(duration: { formatted: string }): void { this.stats.passed++; console.log(` ${CONSOLE_SYMBOLS.SUCCESS} Pasó (${duration.formatted}s)`); } /** * Maneja un test que falló * @param test - Información del test que falló * @param result - Resultado del test fallido * @param duration - Información de duración del test */ private handleFailedTest(test: TestCase, result: TestResult, duration: { seconds: number; formatted: string }): void { this.stats.failed++; const location = test.location?.file ? relative(process.cwd(), test.location.file) : 'Unknown location'; const fullTitle = this.buildFullTestTitle(test); this.addFailedTest(fullTitle, result.error?.message, location, duration.seconds); const errorType = this.detectErrorType(result.error?.message || ''); this.logFailedTest(errorType, duration.formatted, test.timeout); if (result.error) { this.logErrorDetails(result.error, errorType); } else if (this.hasMultipleErrors(result)) { this.logMultipleErrors(result); } else { this.logUnknownError(); } } /** * Maneja un test que fue omitido */ private handleSkippedTest(): void { this.stats.skipped++; console.log(` ${CONSOLE_SYMBOLS.SKIP} Omitido`); } /** * Maneja un test que excedió el tiempo límite * @param duration - Información de duración del test */ private handleTimedOutTest(duration: { formatted: string }): void { this.stats.failed++; console.log(`\n ${CONSOLE_SYMBOLS.ERROR} Test timeout (${duration.formatted}s)\n`); console.error(` Error: Test excedió el tiempo límite`); } /** * Maneja un test con estado desconocido * @param result - Resultado del test con estado desconocido */ private handleUnknownStatus(result: TestResult): void { console.log(` ${CONSOLE_SYMBOLS.WARNING} Status desconocido: ${result.status}`); if (result.error) { console.error(` Error: ${result.error.message}`); if (result.error.stack) { console.error(` Stack: ${result.error.stack}`); } } } /** * Agrega un test fallido a la lista para el resumen final * @param title - Título del test * @param error - Mensaje de error (opcional) * @param location - Ubicación del archivo (opcional) * @param duration - Duración en segundos (opcional) */ private addFailedTest(title: string, error?: string, location?: string, duration?: number): void { this.failedTests.push({ title, error, location, duration }); } /** * Detecta el tipo de error basado en el mensaje * @param errorMessage - Mensaje de error a analizar * @returns Tipo de error detectado */ private detectErrorType(errorMessage: string): ErrorType { for (const [type, patterns] of Object.entries(ERROR_PATTERNS)) { if (patterns.some(pattern => errorMessage.includes(pattern))) { return type as ErrorType; } } return 'business'; } /** * Muestra el mensaje apropiado para un test fallido según el tipo de error * @param errorType - Tipo de error detectado * @param durationFormatted - Duración formateada como string * @param testTimeout - Timeout configurado para el test (opcional) */ private logFailedTest(errorType: ErrorType, durationFormatted: string, testTimeout?: number): void { const messages: Record<ErrorType, string> = { timeout: `\n ${CONSOLE_SYMBOLS.ERROR} Test timeout of ${testTimeout || 30000}ms exceeded.\n`, selector: `\n ${CONSOLE_SYMBOLS.ERROR} Selector/Element error (${durationFormatted}s)\n`, element: `\n ${CONSOLE_SYMBOLS.ERROR} Selector/Element error (${durationFormatted}s)\n`, expect: `\n ${CONSOLE_SYMBOLS.ERROR} Assertion failed (${durationFormatted}s)\n`, business: `\n ${CONSOLE_SYMBOLS.ERROR} Test falló (${durationFormatted}s)\n`, unknown: `\n ${CONSOLE_SYMBOLS.ERROR} Test falló (${durationFormatted}s)\n` }; console.log(messages[errorType]); } /** * Muestra los detalles de un error específico * @param error - Error a mostrar * @param errorType - Tipo de error para formateo especial */ private logErrorDetails(error: TestError, errorType: ErrorType): void { console.error(` Error: ${error.message || 'Sin mensaje de error'}`); // Extraer y mostrar el selector que falló const failedSelector = this.extractFailedSelector(error.message || '', error.stack || ''); if (failedSelector) { console.error(` 🎯 Selector que falló: ${failedSelector}`); } if (error.stack) { console.error('\n Call log:'); this.logStackTrace(error.stack, errorType); } } /** * Extrae el selector específico que falló del mensaje de error o stack trace * @param errorMessage - Mensaje de error * @param stackTrace - Stack trace completo * @returns El selector que falló o null si no se puede determinar */ private extractFailedSelector(errorMessage: string, stackTrace: string): string | null { // Patrones para extraer TODOS los selectores de Playwright const selectorPatterns = [ // Todos los métodos getBy* de Playwright { pattern: /waiting for getByText\('([^']+)'\)/, method: 'getByText' }, { pattern: /waiting for getByText\("([^"]+)"\)/, method: 'getByText' }, { pattern: /waiting for getByRole\('([^']+)',\s*\{\s*name:\s*'([^']+)'\s*\}\)/, method: 'getByRole' }, { pattern: /waiting for getByRole\("([^"]+)",\s*\{\s*name:\s*"([^"]+)"\s*\}\)/, method: 'getByRole' }, { pattern: /waiting for getByRole\('([^']+)'\)/, method: 'getByRole' }, { pattern: /waiting for getByRole\("([^"]+)"\)/, method: 'getByRole' }, { pattern: /waiting for getByPlaceholder\('([^']+)'\)/, method: 'getByPlaceholder' }, { pattern: /waiting for getByPlaceholder\("([^"]+)"\)/, method: 'getByPlaceholder' }, { pattern: /waiting for getByTestId\('([^']+)'\)/, method: 'getByTestId' }, { pattern: /waiting for getByTestId\("([^"]+)"\)/, method: 'getByTestId' }, { pattern: /waiting for getByLabel\('([^']+)'\)/, method: 'getByLabel' }, { pattern: /waiting for getByLabel\("([^"]+)"\)/, method: 'getByLabel' }, { pattern: /waiting for getByAltText\('([^']+)'\)/, method: 'getByAltText' }, { pattern: /waiting for getByAltText\("([^"]+)"\)/, method: 'getByAltText' }, { pattern: /waiting for getByTitle\('([^']+)'\)/, method: 'getByTitle' }, { pattern: /waiting for getByTitle\("([^"]+)"\)/, method: 'getByTitle' }, // Para selectores CSS, XPath y otros { pattern: /waiting for locator\('([^']+)'\)/, method: 'locator' }, { pattern: /waiting for locator\("([^"]+)"\)/, method: 'locator' }, { pattern: /locator\('([^']+)'\)/, method: 'locator' }, { pattern: /locator\("([^"]+)"\)/, method: 'locator' }, // Para frameLocator { pattern: /waiting for frameLocator\('([^']+)'\)/, method: 'frameLocator' }, { pattern: /waiting for frameLocator\("([^"]+)"\)/, method: 'frameLocator' }, // Para errores de expect/assertions con todos los getBy* { pattern: /expect\(.*?getByText\('([^']+)'\)\)/, method: 'getByText' }, { pattern: /expect\(.*?getByText\("([^"]+)"\)\)/, method: 'getByText' }, { pattern: /expect\(.*?getByRole\('([^']+)',\s*\{\s*name:\s*'([^']+)'\s*\}\)\)/, method: 'getByRole' }, { pattern: /expect\(.*?getByRole\('([^']+)'\)\)/, method: 'getByRole' }, { pattern: /expect\(.*?getByTestId\('([^']+)'\)\)/, method: 'getByTestId' }, { pattern: /expect\(.*?getByLabel\('([^']+)'\)\)/, method: 'getByLabel' }, { pattern: /expect\(.*?getByPlaceholder\('([^']+)'\)\)/, method: 'getByPlaceholder' }, { pattern: /expect\(.*?getByAltText\('([^']+)'\)\)/, method: 'getByAltText' }, { pattern: /expect\(.*?getByTitle\('([^']+)'\)\)/, method: 'getByTitle' }, { pattern: /expect\(.*?locator\('([^']+)'\)\)/, method: 'locator' }, ]; // Intentar extraer del mensaje de error primero for (const { pattern, method } of selectorPatterns) { const match = errorMessage.match(pattern); if (match) { if (match[2] && method === 'getByRole') { // Para casos como getByRole con name if (errorMessage.includes('expect(')) { return `expect(page.getByRole('${match[1]}', { name: '${match[2]}' }))`; } return `page.getByRole('${match[1]}', { name: '${match[2]}' })`; } // Si es un error de expect, mostrar con expect() if (errorMessage.includes('expect(')) { return `expect(page.${method}('${match[1]}'))`; } return `page.${method}('${match[1]}')`; } } // Si no se encuentra en el mensaje, buscar en el stack trace const stackLines = stackTrace.split('\n'); for (const line of stackLines) { // Buscar líneas que contengan información del selector if (line.includes('waiting for') || line.includes('locator(') || line.includes('expect(')) { for (const { pattern, method } of selectorPatterns) { const match = line.match(pattern); if (match) { if (match[2] && method === 'getByRole') { if (line.includes('expect(')) { return `expect(page.getByRole('${match[1]}', { name: '${match[2]}' }))`; } return `page.getByRole('${match[1]}', { name: '${match[2]}' })`; } if (line.includes('expect(')) { return `expect(page.${method}('${match[1]}'))`; } return `page.${method}('${match[1]}')`; } } } } return null; } /** * Muestra el stack trace con resaltado especial para errores de selector * @param stack - Stack trace completo * @param errorType - Tipo de error para determinar si resaltar líneas */ private logStackTrace(stack: string, errorType: ErrorType): void { const stackLines = stack.split('\n'); const shouldHighlight = errorType === 'selector' || errorType === 'element'; stackLines.forEach(line => { if (!line.trim()) return; if (shouldHighlight && this.shouldHighlightLine(line)) { console.error(` - ${line.trim()}`); } else { console.error(` ${line.trim()}`); } }); } /** * Determina si una línea del stack trace debe ser resaltada * @param line - Línea del stack trace a evaluar * @returns true si la línea debe ser resaltada */ private shouldHighlightLine(line: string): boolean { return STACK_HIGHLIGHT_PATTERNS.some(pattern => line.includes(pattern)); } /** * Verifica si un resultado tiene múltiples errores * @param result - Resultado del test a verificar * @returns true si tiene múltiples errores */ private hasMultipleErrors(result: TestResult): boolean { return Array.isArray((result as any).errors) && (result as any).errors.length > 0; } /** * Muestra múltiples errores de un test * @param result - Resultado del test con múltiples errores */ private logMultipleErrors(result: TestResult): void { const errors = (result as any).errors as any[]; errors.forEach((err: any, idx: number) => { console.error(` [${idx + 1}] Error: ${err.message || err}`); if (err.stack) { console.error('\n Call log:'); this.logStackTrace(err.stack, 'unknown'); } }); } /** * Muestra mensaje para errores desconocidos */ private logUnknownError(): void { console.error(` ${CONSOLE_SYMBOLS.ERROR} Error desconocido - No se pudo obtener información del error`); } /** * Muestra información de errores globales * @param error - Error global que ocurrió */ private logGlobalError(error: TestError): void { console.error(`\n${CONSOLE_SYMBOLS.GLOBAL_ERROR} ERROR GLOBAL DETECTADO:`); console.error(` Mensaje: ${error.message || 'Sin mensaje'}`); if (error.stack) { console.error('\n Stack trace:'); this.logStackTrace(error.stack, 'unknown'); } console.error(''); } /** * Calcula información de tiempo transcurrido * @returns Objeto con información de tiempo formateada */ private calculateTimeInfo(): TimeInfo { const totalSeconds = (Date.now() - this.startTime) / 1000; const minutes = Math.floor(totalSeconds / 60); const seconds = Math.floor(totalSeconds % 60); return { minutes, seconds, totalSeconds }; } /** * Calcula la tasa de éxito de los tests * @returns Porcentaje de éxito (0-100) */ private calculateSuccessRate(): number { const totalExecuted = this.totalTests - this.stats.skipped; return totalExecuted > 0 ? (this.stats.passed / totalExecuted) * 100 : 0; } /** * Muestra el resumen final de la ejecución * @param result - Resultado final de la suite * @param timeInfo - Información de tiempo transcurrido */ private logFinalSummary(result: FullResult, timeInfo: TimeInfo): void { console.log(`\n=== ${CONSOLE_SYMBOLS.STATS} RESUMEN FINAL ===`); console.log(`${CONSOLE_SYMBOLS.SUCCESS} Pruebas exitosas: ${this.stats.passed}`); console.log(`${CONSOLE_SYMBOLS.ERROR} Pruebas fallidas: ${this.stats.failed}`); console.log(`${CONSOLE_SYMBOLS.SKIP} Pruebas omitidas: ${this.stats.skipped}`); console.log(`${CONSOLE_SYMBOLS.TIME} Tiempo total: ${timeInfo.minutes}m ${timeInfo.seconds}s`); console.log(`${CONSOLE_SYMBOLS.SUCCESS_RATE} Tasa de éxito: ${this.calculateSuccessRate().toFixed(1)}%`); console.log(`${CONSOLE_SYMBOLS.STATUS} Estado final: ${result.status.toUpperCase()}`); if (this.stats.failed > 0) { this.logFailedTestsDetails(); } console.log(`\n${CONSOLE_SYMBOLS.SPARKLES} Ejecución completada ${CONSOLE_SYMBOLS.SPARKLES}\n`); } /** * Muestra los detalles de todos los tests que fallaron */ private logFailedTestsDetails(): void { console.log(`\n=== ${CONSOLE_SYMBOLS.ERROR} DETALLE DE FALLOS ===`); this.failedTests.forEach((test, index) => { console.log(`\n${index + 1}. Test: ${test.title}`); console.log(` ${CONSOLE_SYMBOLS.LOCATION} Ubicación: ${test.location}`); console.log(` ${CONSOLE_SYMBOLS.TIME} Duración: ${test.duration?.toFixed(2)}s`); if (test.error) { console.error(` ${CONSOLE_SYMBOLS.DETAIL} Error: ${test.error}`); } }); } } export default customReporter;