UNPKG

dlest

Version:

Jest for your data layer - test runner for analytics tracking implementations

734 lines (630 loc) 24.3 kB
const { BrowserManager } = require('./browser'); const matchers = require('../matchers'); const fs = require('fs'); const path = require('path'); const chalk = require('chalk'); /** * Test Runner Core * * Main engine for executing DLest tests */ class TestRunner { constructor(config = {}) { this.config = config; this.browserManager = new BrowserManager(config); this.stats = { total: 0, passed: 0, failed: 0, skipped: 0, startTime: null, endTime: null, }; this.failures = []; this.currentSuite = null; this.currentTest = null; this.collectedTests = []; this.collectedSuites = []; this.currentContext = null; } /** * Run test files */ async runTests(testFiles) { this.stats.startTime = Date.now(); console.log(chalk.cyan('🧪 DLest - Data Layer Test Runner\n')); try { // Setup browser await this.browserManager.launch(); for (const testFile of testFiles) { await this.runTestFile(testFile); } await this.browserManager.close(); } catch (error) { console.error(chalk.red('Fatal error during test execution:'), error); throw error; } finally { this.stats.endTime = Date.now(); this.printSummary(); if (this.config.verbose) { console.log(chalk.gray('\n[DEBUG] All tests completed, returning stats')); } } return this.stats; } /** * Run single test file */ async runTestFile(testFilePath) { console.log(chalk.gray(`\n📄 ${path.relative(process.cwd(), testFilePath)}`)); try { // Create test context const testContext = await this.createTestContext(); this.currentContext = testContext; // Clear collected tests for this file this.collectedTests = []; this.collectedSuites = []; // Load test file to collect tests await this.collectTestsFromFile(testFilePath, testContext); // Execute collected tests await this.executeCollectedTests(); // Cleanup context await testContext.cleanup(); } catch (error) { console.error(chalk.red(`Error running test file ${testFilePath}:`), error); this.failures.push({ testFile: testFilePath, error: error.message, stack: error.stack, }); } } /** * Create test execution context */ async createTestContext() { const { page, context } = await this.browserManager.createPage(); const dataLayer = this.browserManager.createDataLayerProxy(page); // Setup expect with custom matchers const expect = this.createExpectFunction(dataLayer); // Test functions const testFunctions = { test: this.createTestFunction(), describe: this.createDescribeFunction(), expect, beforeEach: (fn) => { this.beforeEachFn = fn; }, afterEach: (fn) => { this.afterEachFn = fn; }, beforeAll: (fn) => { this.beforeAllFn = fn; }, afterAll: (fn) => { this.afterAllFn = fn; }, }; return { page, context, dataLayer, testFunctions, cleanup: async () => { try { await context.close(); } catch (error) { console.warn('Error closing test context:', error.message); } } }; } /** * Collect tests from file */ async collectTestsFromFile(testFilePath, testContext) { const { testFunctions } = testContext; // Make test functions globally available Object.assign(global, testFunctions); try { // Clear Node.js module cache to ensure fresh execution delete require.cache[path.resolve(testFilePath)]; // Execute test file to collect tests require(testFilePath); } catch (error) { throw new Error(`Failed to collect tests from file: ${error.message}`); } finally { // Cleanup globals except for what we need during execution delete global.test; delete global.describe; delete global.beforeEach; delete global.afterEach; delete global.beforeAll; delete global.afterAll; } } /** * Execute all collected tests */ async executeCollectedTests() { // Group tests by suite const suites = {}; const standaloneTests = []; for (const test of this.collectedTests) { if (test.suite) { if (!suites[test.suite]) { suites[test.suite] = []; } suites[test.suite].push(test); } else { standaloneTests.push(test); } } // Execute suites for (const [suiteName, suiteTests] of Object.entries(suites)) { console.log(chalk.blue(`\n 📋 ${suiteName}`)); for (const test of suiteTests) { await this.runSingleTest(test.name, test.testFn); } } // Execute standalone tests for (const test of standaloneTests) { await this.runSingleTest(test.name, test.testFn); } } /** * Create test function - collects tests for later execution */ createTestFunction() { const testFn = (name, testFn) => { this.collectedTests.push({ name, testFn, suite: this.currentSuite }); }; // Add describe as a method of test testFn.describe = this.createDescribeFunction(); return testFn; } /** * Create describe function - collects suites and their tests */ createDescribeFunction() { return (name, describeFn) => { const previousSuite = this.currentSuite; this.currentSuite = name; this.collectedSuites.push({ name, tests: [] }); try { describeFn(); } finally { this.currentSuite = previousSuite; } }; } /** * Create expect function with custom matchers */ createExpectFunction(dataLayer) { const expect = (received) => { // If received is the dataLayer, return enhanced matcher if (received === dataLayer) { return this.createDataLayerMatchers(received); } // Basic expect functionality for other values return this.createBasicMatchers(received); }; // Add Jest-like static methods expect.any = (constructor) => ({ asymmetricMatch: (value) => value != null && value.constructor === constructor, toString: () => `Any<${constructor.name}>`, }); expect.arrayContaining = (expectedArray) => ({ asymmetricMatch: (array) => { if (!Array.isArray(array)) return false; return expectedArray.every(expected => array.some(item => JSON.stringify(item) === JSON.stringify(expected)) ); }, toString: () => `ArrayContaining<${JSON.stringify(expectedArray)}>`, }); expect.objectContaining = (expectedObject) => ({ asymmetricMatch: (object) => { if (typeof object !== 'object' || object === null) return false; return Object.keys(expectedObject).every(key => JSON.stringify(object[key]) === JSON.stringify(expectedObject[key]) ); }, toString: () => `ObjectContaining<${JSON.stringify(expectedObject)}>`, }); expect.stringContaining = (expectedString) => ({ asymmetricMatch: (string) => { if (typeof string !== 'string') return false; return string.includes(expectedString); }, toString: () => `StringContaining<${expectedString}>`, }); return expect; } /** * Create DataLayer-specific matchers */ createDataLayerMatchers(received) { const matcherContext = { isNot: false, promise: false, verbose: this.config.verbose }; // Store for sharing with .not matchers let capturedEvents = null; return { toHaveEvent: async (eventName, eventData) => { // Capture events BEFORE executing the matcher (to avoid navigation issues) let allEvents = []; let dataLayerInfo = null; // Always capture events to ensure consistency try { allEvents = await received.getEvents(); // Only get debug info if verbose and no events found if (this.config.verbose && allEvents.length === 0) { const page = await received.getPage(); dataLayerInfo = await page.evaluate(() => { return { exists: typeof window.dataLayer !== 'undefined', isArray: Array.isArray(window.dataLayer), length: window.dataLayer ? window.dataLayer.length : 0, content: window.dataLayer ? JSON.stringify(window.dataLayer.slice(0, 5)) : null, spyExists: typeof window.__dlest_events !== 'undefined', spyLength: window.__dlest_events ? window.__dlest_events.length : 0, spyContent: window.__dlest_events ? JSON.stringify(window.__dlest_events.slice(0, 5)) : null }; }); } } catch (e) { if (this.config.verbose) { console.log(chalk.gray(` ❌ Error getting events before test: ${e.message}`)); } } if (this.config.verbose) { const testId = Math.random().toString(36).substr(2, 9); console.log(chalk.gray(` 🔧 [DEBUG] Test ID: ${testId} - About to execute matcher for "${eventName}"`)); } // Create a mock received object that uses the pre-captured events const mockReceived = { getEvents: async () => allEvents, getPage: received.getPage.bind(received) }; const result = await matchers.toHaveEvent.call(matcherContext, mockReceived, eventName, eventData); // Store for .not matchers capturedEvents = allEvents; if (this.config.verbose) { console.log(chalk.gray(` 🔧 [DEBUG] Matcher result: ${result.pass ? 'PASS' : 'FAIL'}`)); } // Show verbose information using pre-captured data if (this.config.verbose) { const matchingEvents = allEvents.filter(event => { return event.event === eventName || event.eventName === eventName || event.name === eventName; }); console.log(chalk.gray(` 🔍 Expected: event "${eventName}"${eventData ? ` with data ${JSON.stringify(eventData)}` : ''}`)); console.log(chalk.gray(` 📊 Found: ${matchingEvents.length} matching event(s) out of ${allEvents.length} total`)); if (matchingEvents.length > 0) { console.log(chalk.gray(` ✅ Matching events:`)); matchingEvents.forEach((event, index) => { console.log(chalk.gray(` ${index + 1}. ${JSON.stringify(event, null, 8)}`)); }); } if (allEvents.length > 0) { console.log(chalk.gray(` 📋 All captured events:`)); allEvents.forEach((event, index) => { const isMatch = event.event === eventName || event.eventName === eventName || event.name === eventName; const prefix = isMatch ? ' ✅' : ' ⚪'; console.log(chalk.gray(`${prefix} ${index + 1}. ${JSON.stringify(event, null, 8)}`)); }); } else { console.log(chalk.gray(` ⚠️ No events captured`)); if (dataLayerInfo) { console.log(chalk.gray(` 🔍 DataLayer debug:`)); console.log(chalk.gray(` - window.dataLayer exists: ${dataLayerInfo.exists}`)); console.log(chalk.gray(` - is array: ${dataLayerInfo.isArray}`)); console.log(chalk.gray(` - length: ${dataLayerInfo.length}`)); console.log(chalk.gray(` - content: ${dataLayerInfo.content}`)); console.log(chalk.gray(` 🔍 DLest spy debug:`)); console.log(chalk.gray(` - spy exists: ${dataLayerInfo.spyExists}`)); console.log(chalk.gray(` - spy length: ${dataLayerInfo.spyLength}`)); console.log(chalk.gray(` - spy content: ${dataLayerInfo.spyContent}`)); } } } if (!result.pass) { let errorMessage = result.message(); // Add verbose information if enabled for failures if (this.config.verbose) { try { const allEvents = await received.getEvents(); if (allEvents.length > 0) { errorMessage += '\n\n' + chalk.gray('📋 All captured events:'); allEvents.forEach((event, index) => { errorMessage += '\n' + chalk.gray(`${index + 1}. ${JSON.stringify(event, null, 2)}`); }); } } catch (e) { // Ignore errors when getting events for verbose output } } throw new Error(errorMessage); } return result; }, toHaveEventData: async (eventData) => { const result = await matchers.toHaveEventData.call(matcherContext, received, eventData); if (!result.pass) { let errorMessage = result.message(); if (this.config.verbose) { try { const allEvents = await received.getEvents(); if (allEvents.length > 0) { errorMessage += '\n\n' + chalk.gray('📋 All captured events:'); allEvents.forEach((event, index) => { errorMessage += '\n' + chalk.gray(`${index + 1}. ${JSON.stringify(event, null, 2)}`); }); } } catch (e) { // Ignore errors when getting events for verbose output } } throw new Error(errorMessage); } return result; }, toHaveEventCount: async (eventName, count) => { const result = await matchers.toHaveEventCount.call(matcherContext, received, eventName, count); if (!result.pass) { let errorMessage = result.message(); if (this.config.verbose) { try { const allEvents = await received.getEvents(); if (allEvents.length > 0) { errorMessage += '\n\n' + chalk.gray('📋 All captured events:'); allEvents.forEach((event, index) => { errorMessage += '\n' + chalk.gray(`${index + 1}. ${JSON.stringify(event, null, 2)}`); }); } } catch (e) { // Ignore errors when getting events for verbose output } } throw new Error(errorMessage); } return result; }, toHaveEventSequence: async (sequence) => { const result = await matchers.toHaveEventSequence.call(matcherContext, received, sequence); if (!result.pass) { let errorMessage = result.message(); if (this.config.verbose) { try { const allEvents = await received.getEvents(); if (allEvents.length > 0) { errorMessage += '\n\n' + chalk.gray('📋 All captured events:'); allEvents.forEach((event, index) => { errorMessage += '\n' + chalk.gray(`${index + 1}. ${JSON.stringify(event, null, 2)}`); }); } } catch (e) { // Ignore errors when getting events for verbose output } } throw new Error(errorMessage); } return result; }, not: { toHaveEvent: async (eventName, eventData) => { const notMatcherContext = { isNot: true, promise: false }; // Use capturedEvents if available (from verbose mode), otherwise get fresh const dataLayerToUse = capturedEvents ? { getEvents: async () => capturedEvents, getPage: received.getPage.bind(received) } : received; const result = await matchers.toHaveEvent.call(notMatcherContext, dataLayerToUse, eventName, eventData); if (result.pass) { // For .not matchers, we throw when it DOES pass (because we expected it NOT to) throw new Error(result.message()); } return result; }, } }; } /** * Create basic matchers for non-dataLayer values */ createBasicMatchers(received) { const matcherContext = { isNot: false, promise: false }; return { toBe: (expected) => { if (received !== expected) { throw new Error(`Expected ${received} to be ${expected}`); } }, toEqual: (expected) => { if (JSON.stringify(received) !== JSON.stringify(expected)) { throw new Error(`Expected ${JSON.stringify(received)} to equal ${JSON.stringify(expected)}`); } }, toBeTruthy: () => { const result = matchers.toBeTruthy.call(matcherContext, received); if (!result.pass) { throw new Error(result.message()); } }, toBeFalsy: () => { const result = matchers.toBeFalsy.call(matcherContext, received); if (!result.pass) { throw new Error(result.message()); } }, toBeDefined: () => { const result = matchers.toBeDefined.call(matcherContext, received); if (!result.pass) { throw new Error(result.message()); } }, toBeUndefined: () => { const result = matchers.toBeUndefined.call(matcherContext, received); if (!result.pass) { throw new Error(result.message()); } }, toContain: (expected) => { if (typeof received === 'string') { if (!received.includes(expected)) { throw new Error(`Expected "${received}" to contain "${expected}"`); } } else if (Array.isArray(received)) { if (!received.includes(expected)) { throw new Error(`Expected [${received.join(', ')}] to contain ${expected}`); } } else { throw new Error(`Expected ${received} to be a string or array for toContain matcher`); } }, }; } /** * Run single test */ async runSingleTest(name, testFn) { this.currentTest = name; this.stats.total++; const testContext = { page: this.currentContext.page, dataLayer: this.currentContext.dataLayer, }; // Make context available globally global.expect = this.currentContext.testFunctions.expect; global.page = testContext.page; global.dataLayer = testContext.dataLayer; try { // Run beforeEach if defined if (this.beforeEachFn) { await this.beforeEachFn(testContext); } // Clear dataLayer events before test await testContext.dataLayer.clearEvents(); // Run the test await testFn(testContext); // Test passed this.stats.passed++; console.log(chalk.green(` ✓ ${name}`)); } catch (error) { // Test failed this.stats.failed++; console.log(chalk.red(` ✗ ${name}`)); // Enhanced error handling with helpful messages const enhancedError = this.enhanceErrorMessage(error); console.log(chalk.red(` ${enhancedError.message}`)); // Show helpful tips for common errors if (enhancedError.tip) { console.log(chalk.yellow(` 💡 Tip: ${enhancedError.tip}`)); } this.failures.push({ suite: this.currentSuite, test: name, error: enhancedError.message, tip: enhancedError.tip, stack: error.stack, }); } finally { // Run afterEach if defined if (this.afterEachFn) { try { await this.afterEachFn(testContext); } catch (error) { console.warn(chalk.yellow(`Warning: afterEach hook failed: ${error.message}`)); } } } } /** * Enhance error messages with helpful tips */ enhanceErrorMessage(error) { const message = error.message || ''; let enhancedMessage = message; let tip = null; // Timeout errors if (message.includes('Timeout') || message.includes('timeout')) { if (message.includes('waiting for selector')) { const selectorMatch = message.match(/waiting for selector "([^"]+)"/); const selector = selectorMatch ? selectorMatch[1] : 'element'; enhancedMessage = `Timeout waiting for element "${selector}"`; tip = `Verifique se o elemento "${selector}" existe na página e se o seletor está correto. Use as ferramentas de desenvolvedor para inspecionar o elemento.`; } else if (message.includes('click')) { enhancedMessage = 'Timeout trying to click element'; tip = 'Verifique se o elemento está visível e clicável. O elemento pode não ter carregado ainda ou estar coberto por outro elemento.'; } else if (message.includes('fill') || message.includes('type')) { enhancedMessage = 'Timeout trying to fill input field'; tip = 'Verifique se o campo de input existe e está habilitado para preenchimento.'; } else if (message.includes('goto') || message.includes('navigation')) { enhancedMessage = 'Timeout during page navigation'; tip = 'Verifique se a URL está correta e se o servidor está rodando. Para aplicações locais, confirme que está rodando em localhost:3000.'; } else { enhancedMessage = 'Operation timed out'; tip = 'A operação demorou mais que o esperado. Verifique se todos os elementos e serviços necessários estão funcionando.'; } } // Element not found errors else if (message.includes('Element not found') || message.includes('No element found') || message.includes('strict mode violation')) { const selectorMatch = message.match(/selector "([^"]+)"/); const selector = selectorMatch ? selectorMatch[1] : 'element'; enhancedMessage = `Element "${selector}" not found`; tip = `Verifique se o elemento "${selector}" existe na página. Use o inspector do navegador para confirmar o seletor correto.`; } // Network/connection errors else if (message.includes('net::') || message.includes('ECONNREFUSED') || message.includes('Connection refused')) { enhancedMessage = 'Cannot connect to the application'; tip = 'Verifique se sua aplicação está rodando. Para Next.js execute "npm run dev" em outro terminal.'; } // Navigation errors else if (message.includes('Navigation failed') || message.includes('ERR_CONNECTION_REFUSED')) { enhancedMessage = 'Failed to navigate to page'; tip = 'Verifique se a URL está correta e se o servidor está rodando na porta especificada.'; } return { message: enhancedMessage, tip: tip }; } /** * Print test summary */ printSummary() { const duration = this.stats.endTime - this.stats.startTime; console.log(chalk.cyan('\n📊 Test Results')); console.log(chalk.cyan('─'.repeat(50))); if (this.stats.passed > 0) { console.log(chalk.green(`✓ ${this.stats.passed} passed`)); } if (this.stats.failed > 0) { console.log(chalk.red(`✗ ${this.stats.failed} failed`)); } if (this.stats.skipped > 0) { console.log(chalk.yellow(`⊘ ${this.stats.skipped} skipped`)); } console.log(chalk.gray(`⏱ ${duration}ms`)); if (this.failures.length > 0) { console.log(chalk.red('\n💥 Failures:')); this.failures.forEach((failure, index) => { console.log(chalk.red(`\n${index + 1}. ${failure.suite ? `${failure.suite} > ` : ''}${failure.test}`)); console.log(chalk.red(` ${failure.error}`)); if (failure.tip) { console.log(chalk.yellow(` 💡 ${failure.tip}`)); } }); } console.log(''); // Final newline } } module.exports = { TestRunner };