const { chromium } = require('playwright'); const fs = require('fs-extra'); const path = require('path'); const { js: beautify } = require('js-beautify'); const readline = require('readline'); require('dotenv').config(); class TestExecutor { constructor(options = {}) { this.browser = null; this.context = null; this.page = null; this.outputDir = 'test-results'; this.headless = options.headless !== false; // Default to headless this.disableAnimations = options.disableAnimations !== false; // Default to disable animations this.fullPageScreenshots = options.fullPageScreenshots || false; // Default to viewport screenshots this.enableScreenshots = options.enableScreenshots !== false; // Default to enable screenshots this.variables = {}; // Store extracted variables this.profiles = { Chrome: { viewport: { width: 1280, height: 720 }, userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' }, Mobile: { viewport: { width: 768, height: 1024 }, userAgent: 'Mozilla/5.0 (iPad; CPU OS 14_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.3 Mobile/15E148 Safari/604.1' }, MobileSmall: { viewport: { width: 390, height: 844 }, userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.3 Mobile/15E148 Safari/604.1' } }; } async execute(commands, profileName = 'Chrome') { try { await this.setup(profileName); // Set total commands for status tracking this.totalCommands = commands.length; if (this.statusPage && !this.headless) { await this.updateStatus({ type: 'initializing' }, false); } for (let i = 0; i < commands.length; i++) { const command = commands[i]; // Check for pause before executing command (only for interactive commands) if (!this.headless && this.isInteractiveCommand(command)) { const wasStepRequested = await this.checkPauseState(); // If this was a step request, set pause state after this command if (wasStepRequested) { this.shouldPauseAfterStep = true; } } // Update status before executing command if (!this.headless) { await this.updateStatus(command, false); } // Check for manual dump requests if (!this.headless) { await this.checkDumpRequests(); } const jumpCount = await this.executeCommand(command); // Handle jump commands if (jumpCount > 0) { console.log(`πŸ”„ Jumping ${jumpCount} commands`); i += jumpCount; // Skip the specified number of commands } // Update status after completing command if (!this.headless) { await this.updateStatus(command, true); // If this was a step request, pause again after this interactive command if (this.shouldPauseAfterStep && this.isInteractiveCommand(command)) { this.shouldPauseAfterStep = false; await this.statusPage.evaluate(() => { window.playwrongPaused = true; }); console.log('⏸️ Paused after executing one interactive step'); } } } } catch (error) { console.error('Test execution failed:', error); throw error; } finally { await this.cleanup(); } } async setup(profileName) { const profile = this.profiles[profileName]; if (!profile) { throw new Error(`Unknown profile: ${profileName}`); } // Store current profile for viewport consistency this.currentProfile = profile; this.browser = await chromium.launch({ headless: this.headless, args: this.headless ? [] : [ `--window-size=${profile.viewport.width},${profile.viewport.height + 100}`, // Add space for browser chrome '--window-position=0,0', // Position main window at top-left '--disable-web-security', '--disable-features=VizDisplayCompositor', '--disable-infobars', '--disable-extensions' ] }); this.context = await this.browser.newContext({ viewport: profile.viewport, userAgent: profile.userAgent, // Disable animations to reduce flickering reducedMotion: 'reduce', // Force consistent viewport screen: profile.viewport, deviceScaleFactor: 1, // Allow popups to be opened javaScriptEnabled: true, // Handle popups properly ignoreHTTPSErrors: true }); this.page = await this.context.newPage(); // Handle popup events for follow command this.page.on('popup', async (popup) => { console.log('πŸ”„ Popup detected, adding to context'); // The popup is automatically added to the context.pages() array }); // Create status window for headed mode AFTER main page so it appears on top if (!this.headless) { await this.createStatusWindow(); } // Ensure consistent viewport size await this.page.setViewportSize(profile.viewport); // Add CSS to reduce visual flickering and fix viewport if (!this.headless) { const viewportCSS = ` html { width: ${profile.viewport.width}px !important; height: ${profile.viewport.height}px !important; max-width: ${profile.viewport.width}px !important; min-width: ${profile.viewport.width}px !important; overflow-x: hidden !important; overflow-y: auto !important; } body { width: ${profile.viewport.width}px !important; max-width: ${profile.viewport.width}px !important; min-width: ${profile.viewport.width}px !important; margin: 0 !important; padding: 0 !important; overflow-x: hidden !important; } * { box-sizing: border-box !important; } `; const animationCSS = this.disableAnimations ? ` *, *::before, *::after { transition-duration: 0s !important; transition-delay: 0s !important; animation-duration: 0s !important; animation-delay: 0s !important; animation-fill-mode: none !important; } ` : ''; await this.page.addStyleTag({ content: viewportCSS + animationCSS }); } // Capture console messages this.consoleMessages = []; this.page.on('console', msg => { this.consoleMessages.push({ type: msg.type(), text: msg.text(), timestamp: new Date().toISOString() }); }); // Ensure output directory exists await fs.ensureDir(this.outputDir); } async executeCommand(command) { // Create a clean one-line representation of the command const commandStr = this.formatCommandForOutput(command); console.log(`Executing: ${commandStr}`); switch (command.type) { case 'use': // This is handled at the executor level, not per command break; case 'open': const resolvedUrl = this.resolveEnvironmentVariables(command.url); await this.page.goto(resolvedUrl, { waitUntil: 'networkidle' }); // Small delay to ensure page is stable if (!this.headless) { await this.page.waitForTimeout(200); await this.ensureViewportSize(); } break; case 'dump': await this.createDump(command.name); // Ensure viewport stays consistent after dump operations if (!this.headless) { await this.page.waitForTimeout(100); await this.ensureViewportSize(); } break; case 'wait': await this.waitForElement(command); if (!this.headless) { await this.ensureViewportSize(); } break; case 'click': await this.clickElement(command); // Ensure viewport stays consistent after click if (!this.headless) { await this.page.waitForTimeout(100); await this.ensureViewportSize(); } break; case 'scroll': await this.scrollToElement(command); if (!this.headless) { await this.ensureViewportSize(); } break; case 'fill': await this.fillElement(command); if (!this.headless) { await this.ensureViewportSize(); } break; case 'break': await this.waitForUserInput(command); break; case 'sleep': await this.sleep(command); break; case 'follow': await this.followToNewWindow(); break; case 'switchToTab': await this.switchToTab(command); break; case 'extract': await this.extractToVariable(command); break; case 'jumpIf': return await this.jumpIf(command); case 'jumpIfNot': return await this.jumpIfNot(command); default: console.warn(`Unknown command type: ${command.type}`); } return 0; // No jump by default } async waitForElement(command) { const selector = this.buildSelector(command); await this.page.waitForSelector(selector, { timeout: 30000 }); } async clickElement(command) { const selector = this.buildSelector(command); // Add visual feedback for headed mode if (!this.headless) { // Get element position for animation const element = await this.page.locator(selector).first(); const box = await element.boundingBox(); if (box) { // Inject CSS for click animation await this.page.addStyleTag({ content: ` .click-animation { position: fixed; pointer-events: none; border: 6px solid #ff0000; border-radius: 50%; background: rgba(255, 0, 0, 0.3); z-index: 99999; animation: clickPulse 2s ease-out; box-shadow: 0 0 20px rgba(255, 0, 0, 0.8); } @keyframes clickPulse { 0% { transform: scale(0.3); opacity: 1; border-width: 8px; } 25% { transform: scale(1.5); opacity: 0.9; border-width: 6px; } 50% { transform: scale(2.2); opacity: 0.7; border-width: 4px; } 75% { transform: scale(2.8); opacity: 0.4; border-width: 2px; } 100% { transform: scale(3.5); opacity: 0; border-width: 1px; } } ` }); // Calculate center position of the element const centerX = box.x + box.width / 2; const centerY = box.y + box.height / 2; // Create and show click animation await this.page.evaluate(({ x, y }) => { const indicator = document.createElement('div'); indicator.className = 'click-animation'; indicator.style.left = (x - 40) + 'px'; indicator.style.top = (y - 40) + 'px'; indicator.style.width = '80px'; indicator.style.height = '80px'; document.body.appendChild(indicator); // Remove after animation completes setTimeout(() => { if (indicator.parentNode) { indicator.parentNode.removeChild(indicator); } }, 2000); }, { x: centerX, y: centerY }); // Small delay to show the animation before clicking await this.page.waitForTimeout(300); } } await this.page.click(selector); } async scrollToElement(command) { const selector = this.buildSelector(command); // Add visual scroll animation in headed mode if (!this.headless) { // Add CSS for scroll arrow animation await this.page.addStyleTag({ content: ` .scroll-arrow { position: fixed; right: 50px; top: 50%; transform: translateY(-50%); font-size: 60px; color: #2196F3; z-index: 999999; pointer-events: none; animation: scrollBounce 1s ease-in-out infinite; } @keyframes scrollBounce { 0%, 20%, 50%, 80%, 100% { transform: translateY(-50%); } 40% { transform: translateY(-40%); } 60% { transform: translateY(-60%); } } ` }); // Create scroll arrow indicator await this.page.evaluate(() => { const arrow = document.createElement('div'); arrow.className = 'scroll-arrow'; arrow.innerHTML = '⬇️'; arrow.id = 'scroll-indicator'; document.body.appendChild(arrow); // Remove arrow after animation setTimeout(() => { const element = document.getElementById('scroll-indicator'); if (element) { element.remove(); } }, 1500); }); // Wait for animation to be visible await this.page.waitForTimeout(500); } await this.page.locator(selector).scrollIntoViewIfNeeded(); } async fillElement(command) { const selector = this.buildSelector(command); let value = command.value || ''; // Resolve environment variables in the value value = this.resolveEnvironmentVariables(value); // Clear the field first await this.page.fill(selector, ''); // Focus on the input field await this.page.focus(selector); // Add CSS for typing animation overlay if not already added await this.page.addStyleTag({ content: ` /* Flying letter overlay container */ #typing-overlay { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; pointer-events: none; z-index: 999999; overflow: hidden; } /* Flying letter animation */ .flying-letter { position: absolute; font-family: inherit; font-size: inherit; font-weight: inherit; color: #2196F3; pointer-events: none; animation: flyAndShrink 0.6s ease-out forwards; } @keyframes flyAndShrink { 0% { transform: scale(3) translateY(-20px); opacity: 0.8; } 30% { transform: scale(2.2) translateY(-10px); opacity: 0.9; } 60% { transform: scale(1.5) translateY(-5px); opacity: 1; } 100% { transform: scale(1) translateY(0); opacity: 0; } } /* Input field highlight during typing */ .typing-active { box-shadow: 0 0 10px rgba(33, 150, 243, 0.3) !important; border-color: #2196F3 !important; transition: all 0.1s ease !important; } /* Hide actual text in input field during typing animation */ .typing-hidden { color: transparent !important; text-shadow: none !important; -webkit-text-fill-color: transparent !important; } /* Show placeholder-like dots for password fields */ .typing-password-mask { background-image: repeating-linear-gradient( 90deg, transparent, transparent 0.5ch, #666 0.5ch, #666 1ch ) !important; background-size: 1ch 1em !important; background-repeat: repeat-x !important; background-position: 0 50% !important; } ` }); // Create overlay container for flying letters await this.page.evaluate(() => { if (!document.getElementById('typing-overlay')) { const overlay = document.createElement('div'); overlay.id = 'typing-overlay'; document.body.appendChild(overlay); } }); // Add typing highlight and hide text only for password fields const isPasswordField = command.htmlType === 'password'; await this.page.evaluate(({ sel, isPassword }) => { const element = document.querySelector(sel); if (element) { element.classList.add('typing-active'); if (isPassword) { element.classList.add('typing-hidden'); element.classList.add('typing-password-mask'); } } }, { sel: selector, isPassword: isPasswordField }); // Type each character with human-like timing and flying animation for (let i = 0; i < value.length; i++) { const char = value[i]; // Human-like typing speed variation (40-100ms between keystrokes) - 50% faster const typingDelay = Math.random() * 60 + 40; // Create flying letter animation await this.page.evaluate(({ sel, character, isPassword, currentIndex, totalLength }) => { const element = document.querySelector(sel); const overlay = document.getElementById('typing-overlay'); if (element && overlay) { // Get input field position const rect = element.getBoundingClientRect(); const inputCenterX = rect.left + rect.width / 2; const inputCenterY = rect.top + rect.height / 2; // Create flying letter const flyingLetter = document.createElement('span'); // Show actual character for non-password fields, bullet for passwords flyingLetter.textContent = isPassword ? 'β€’' : character; flyingLetter.className = 'flying-letter'; // Position at input field center flyingLetter.style.left = inputCenterX + 'px'; flyingLetter.style.top = inputCenterY + 'px'; // Copy font styles from input const computedStyle = window.getComputedStyle(element); flyingLetter.style.fontFamily = computedStyle.fontFamily; flyingLetter.style.fontSize = computedStyle.fontSize; flyingLetter.style.fontWeight = computedStyle.fontWeight; overlay.appendChild(flyingLetter); // Remove flying letter after animation completes setTimeout(() => { if (flyingLetter.parentNode) { flyingLetter.parentNode.removeChild(flyingLetter); } }, 600); } }, { sel: selector, character: char, isPassword: isPasswordField, currentIndex: i, totalLength: value.length }); // Type the character await this.page.type(selector, char, { delay: 0 }); // Wait for the typing delay await this.page.waitForTimeout(typingDelay); } // Remove typing highlight and restore text visibility await this.page.evaluate(({ sel }) => { const element = document.querySelector(sel); if (element) { element.classList.remove('typing-active'); element.classList.remove('typing-hidden'); element.classList.remove('typing-password-mask'); } }, { sel: selector }); // Final validation that the value was set correctly const actualValue = await this.page.inputValue(selector); if (actualValue !== value) { console.warn(`Warning: Expected value "${value}" but got "${actualValue}"`); } } async waitForUserInput(command) { console.log(`πŸ”Ά BREAK: ${command.message}`); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); return new Promise((resolve) => { rl.question('', () => { rl.close(); resolve(); }); }); } async sleep(command) { const sleepMsg = command.message ? `πŸ’€ SLEEP: ${command.message} (${command.milliseconds}ms)` : `πŸ’€ SLEEP: ${command.milliseconds}ms`; console.log(sleepMsg); // Add visual sleep animation with countdown in headed mode // Only show visualization for sleeps >= 1000ms if (!this.headless && command.milliseconds >= 1000) { try { await this.page.addStyleTag({ content: ` .sleep-indicator { position: fixed; top: 50px; left: 50%; transform: translateX(-50%); background: rgba(0, 0, 0, 0.8); color: white; padding: 20px 40px; border-radius: 10px; font-size: 24px; font-family: Arial, sans-serif; z-index: 999999; pointer-events: none; text-align: center; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); } .sleep-icon { font-size: 40px; animation: sleepPulse 2s ease-in-out infinite; display: block; margin-bottom: 10px; } @keyframes sleepPulse { 0%, 100% { opacity: 0.6; transform: scale(1); } 50% { opacity: 1; transform: scale(1.1); } } .countdown { font-size: 20px; font-weight: bold; color: #4CAF50; } ` }); // Create sleep indicator with countdown await this.page.evaluate(({ duration, message }) => { const indicator = document.createElement('div'); indicator.className = 'sleep-indicator'; indicator.id = 'sleep-indicator'; indicator.innerHTML = `
πŸ’€
SLEEP: ${message || 'waiting'}
${Math.ceil(duration / 1000)}s
`; document.body.appendChild(indicator); // Update countdown every second let remaining = Math.ceil(duration / 1000); const countdownEl = document.getElementById('countdown'); const interval = setInterval(() => { remaining--; if (countdownEl) { countdownEl.textContent = remaining + 's'; } // Hide visualization when less than 0.5 seconds remaining if (remaining <= 0.5) { clearInterval(interval); const element = document.getElementById('sleep-indicator'); if (element) { element.remove(); } } }, 1000); }, { duration: command.milliseconds, message: command.message }); } catch (error) { // Ignore errors related to destroyed execution context // This happens when the page navigates during sleep if (error.message.includes('Execution context was destroyed') || error.message.includes('Target page, context or browser has been closed')) { console.log('⚠️ Page navigated during sleep - skipping visual animation'); } else { throw error; } } } return new Promise((resolve) => { setTimeout(resolve, command.milliseconds); }); } async createStatusWindow() { try { // Calculate position for status window (to the right of main window) const mainWindowWidth = this.currentProfile.viewport.width; const statusWindowX = mainWindowWidth + 50; // 50px gap between windows // Launch a separate browser instance for the status window this.statusBrowser = await chromium.launch({ headless: false, args: [ '--window-size=700,680', `--window-position=${statusWindowX},0`, // Position to the right '--disable-web-security', '--disable-features=VizDisplayCompositor', '--disable-infobars', '--disable-extensions' ] }); // Create a context and page for the status window this.statusContext = await this.statusBrowser.newContext({ viewport: { width: 700, height: 680 } }); this.statusPage = await this.statusContext.newPage(); // Create the status window HTML const statusHTML = ` PlayWrong Test Status
Test Execution Status
Current Command
Initializing...
Statistics
0 Completed
0 Total
0s Elapsed
Controls
`; // Load the status HTML await this.statusPage.setContent(statusHTML); // Initialize status tracking this.startTime = Date.now(); this.completedCommands = 0; this.totalCommands = 0; console.log('🎭 PlayWrong status window created'); } catch (error) { console.warn('Failed to create status window:', error.message); this.statusPage = null; } } isInteractiveCommand(command) { const interactiveCommands = ['fill', 'click', 'open', 'scroll', 'extract']; return interactiveCommands.includes(command.type); } async checkPauseState() { if (!this.statusPage || this.headless) return false; try { // Check if status page is still valid if (this.statusPage.isClosed()) { this.statusPage = null; return false; } const isPaused = await this.statusPage.evaluate(() => { return window.playwrongPaused === true; }); if (isPaused) { console.log('⏸️ Test execution paused by user'); // Wait until user resumes, but also check for dump requests and step requests while paused while (true) { await this.page.waitForTimeout(500); // Check every 500ms // Check for dump requests even while paused await this.checkDumpRequests(); // Check for step requests const stepRequested = await this.statusPage.evaluate(() => { if (window.playwrongStepRequest) { window.playwrongStepRequest = false; return true; } return false; }); if (stepRequested) { console.log('⏭️ Executing one step by user request'); return true; // Return true to indicate this was a step request } const stillPaused = await this.statusPage.evaluate(() => { return window.playwrongPaused === true; }); if (!stillPaused) { console.log('▢️ Test execution resumed by user'); break; } } } } catch (error) { // Silently ignore pause check errors } return false; } async checkDumpRequests() { if (!this.statusPage || this.headless) return; try { // Check if status page is still valid if (this.statusPage.isClosed()) { this.statusPage = null; return; } const dumpRequest = await this.statusPage.evaluate(() => { const request = window.playwrongDumpRequest; if (request) { window.playwrongDumpRequest = null; return request; } return null; }); // Debug: Log when we check for dump requests if (process.env.DEBUG_DUMP) { console.log(`πŸ” Checking for dump requests: ${dumpRequest ? 'Found' : 'None'}`); } if (dumpRequest) { console.log(`πŸ“Έ Manual dump requested: ${dumpRequest.name} (${dumpRequest.type})`); try { // Temporarily override screenshot settings for this dump const originalScreenshots = this.enableScreenshots; const originalFullPage = this.fullPageScreenshots; this.enableScreenshots = true; if (dumpRequest.type === 'screen') { this.fullPageScreenshots = false; } else if (dumpRequest.type === 'fullscreen') { this.fullPageScreenshots = true; } else { // For 'standard' dump, use original screenshot settings this.enableScreenshots = originalScreenshots; } await this.createDump(dumpRequest.name); // Restore original settings this.enableScreenshots = originalScreenshots; this.fullPageScreenshots = originalFullPage; console.log(`βœ… Manual dump completed: ${dumpRequest.name}`); } catch (error) { console.error(`❌ Manual dump failed: ${error.message}`); } } } catch (error) { // Silently ignore dump request errors } } async updateStatus(command, isCompleted = false) { if (!this.statusPage || this.headless) return; try { // Check if status page is still valid if (this.statusPage.isClosed()) { this.statusPage = null; return; } const currentTime = Date.now(); const elapsedSeconds = Math.floor((currentTime - this.startTime) / 1000); if (isCompleted) { this.completedCommands++; } const progress = this.totalCommands > 0 ? (this.completedCommands / this.totalCommands) * 100 : 0; await this.statusPage.evaluate(({ command, completed, total, elapsed, progress }) => { // Update current command const commandEl = document.getElementById('current-command'); if (commandEl) commandEl.textContent = command; // Update statistics const completedEl = document.getElementById('completed-count'); if (completedEl) completedEl.textContent = completed; const totalEl = document.getElementById('total-count'); if (totalEl) totalEl.textContent = total; const elapsedEl = document.getElementById('elapsed-time'); if (elapsedEl) elapsedEl.textContent = elapsed + 's'; // Update progress bar const progressEl = document.getElementById('progress-fill'); if (progressEl) progressEl.style.width = progress + '%'; // Update status indicator (only if not paused) const indicator = document.getElementById('status-indicator'); if (indicator && !window.playwrongPaused) { indicator.className = 'status-indicator status-running'; } }, { command: this.formatCommandForOutput(command), completed: this.completedCommands, total: this.totalCommands, elapsed: elapsedSeconds, progress: progress }); } catch (error) { // Silently ignore status window errors to not interfere with main test // console.log('Status window update failed:', error.message); } } async followToNewWindow() { console.log('πŸ”„ Switching to new tab...'); const pages = this.context.pages(); console.log(`πŸ”„ Debug: Found ${pages.length} pages total`); pages.forEach((page, index) => { console.log(`πŸ”„ Page ${index}: ${page.url()}`); }); // Check if we already have multiple pages (popup already created) if (pages.length >= 2) { // Find the newest tab (not the current one) let newTab = null; const currentUrl = this.page.url(); // Look for a tab that's different from the current one for (let i = pages.length - 1; i >= 0; i--) { if (pages[i].url() !== currentUrl) { newTab = pages[i]; break; } } if (newTab) { console.log(`πŸ”„ Found existing new tab: ${newTab.url()}`); // Wait for the tab to be ready await newTab.waitForLoadState('networkidle'); // Switch to the new tab this.page = newTab; console.log('πŸ”„ Switched to new tab'); return; } } // Fallback: Wait for a new tab to be created const initialPageCount = pages.length; let newTab = null; let retryCount = 0; const maxRetries = 20; // 10 seconds total (500ms * 20) while (retryCount < maxRetries) { const currentPages = this.context.pages(); if (currentPages.length > initialPageCount) { // New tab found, get the most recently opened one newTab = currentPages[currentPages.length - 1]; break; } // Wait a bit before retrying await this.page.waitForTimeout(500); retryCount++; if (retryCount % 5 === 0) { console.log(`πŸ”„ Still waiting for new tab... (${retryCount}/${maxRetries})`); } } if (!newTab) { throw new Error('No additional tabs found to switch to after waiting'); } console.log(`πŸ”„ New tab found: ${newTab.url()}`); // Wait for the tab to be ready await newTab.waitForLoadState('networkidle'); // Switch to the new tab this.page = newTab; console.log('πŸ”„ Switched to new tab'); } async switchToTab(command) { const tabIndex = command.tabIndex; if (tabIndex === undefined || tabIndex < 0 || tabIndex >= this.context.pages().length) { throw new Error(`Invalid tab index: ${tabIndex}`); } const targetPage = this.context.pages()[tabIndex]; this.page = targetPage; console.log(`πŸ”„ Switched to tab index ${tabIndex}`); } async extractToVariable(command) { const selector = this.buildSelector(command); const attribute = command.attribute; const variableName = command.variableName; if (!variableName) { throw new Error('extract command requires a variableName'); } let value = ''; try { const element = await this.page.locator(selector).first(); if (attribute === 'text') { value = await element.textContent(); } else if (attribute === 'innerHTML') { value = await element.innerHTML(); } else { // Extract attribute value value = await element.getAttribute(attribute); } if (value === null || value === undefined) { throw new Error(`Attribute "${attribute}" not found on element`); } console.log(`πŸ“‹ Extracted ${attribute} from "${selector}" to variable "${variableName}": "${value}"`); } catch (e) { console.warn(`Could not extract ${attribute} from "${selector}" to variable "${variableName}":`, e.message); throw new Error(`Could not extract ${attribute} from "${selector}" to variable "${variableName}"`); } this.variables[variableName] = value; console.log(`βœ… Variable "${variableName}" set to: "${value}"`); } async jumpIf(command) { const selector = this.buildSelector(command); try { // Check if element exists (with a short timeout) await this.page.locator(selector).first().waitFor({ timeout: 1000 }); console.log(`πŸ”„ jumpIf: Element found, jumping ${command.jumpCount} commands`); return command.jumpCount; } catch (error) { console.log(`πŸ”„ jumpIf: Element not found, continuing normally`); return 0; } } async jumpIfNot(command) { const selector = this.buildSelector(command); try { // Check if element exists (with a short timeout) await this.page.locator(selector).first().waitFor({ timeout: 1000 }); console.log(`πŸ”„ jumpIfNot: Element found, continuing normally`); return 0; } catch (error) { console.log(`πŸ”„ jumpIfNot: Element not found, jumping ${command.jumpCount} commands`); return command.jumpCount; } } formatCommandForOutput(command) { switch (command.type) { case 'use': return `use "${command.profiles.join('" ( "')}"`; case 'open': return `open "${command.url}"`; case 'dump': return `dump "${command.name}"`; case 'wait': return `wait ${this.formatSelector(command)}`; case 'click': return `click ${this.formatSelector(command)}`; case 'scroll': return `scroll ${this.formatSelector(command)}`; case 'fill': // Mask password values for security const isPasswordField = command.htmlType === 'password'; const displayValue = isPasswordField ? '*'.repeat(8) : command.value; return `fill ${this.formatSelector(command)} value="${displayValue}"`; case 'break': return `break "${command.message}"`; case 'sleep': return command.message ? `sleep ${command.milliseconds} "${command.message}"` : `sleep ${command.milliseconds}`; case 'follow': return 'follow'; case 'switchToTab': return `switchToTab tabIndex=${command.tabIndex}`; case 'extract': return `extract ${this.formatSelector(command)} variableName="${command.variableName}"`; case 'jumpIf': return `jumpIf ${this.formatSelector(command)} jump=${command.jumpCount}`; case 'jumpIfNot': return `jumpIfNot ${this.formatSelector(command)} jump=${command.jumpCount}`; default: return `${command.type} ${JSON.stringify(command)}`; } } formatSelector(command) { let selector = `element=${command.element || 'div'}`; if (command.childText) { selector += ` childText="${command.childText}"`; } if (command.name) { selector += ` name="${command.name}"`; } if (command.id) { selector += ` id="${command.id}"`; } if (command.class) { selector += ` class="${command.class}"`; } if (command.href) { selector += ` href="${command.href}"`; } if (command.child) { // Handle new parentheses syntax if (command.child.startsWith('child=')) { selector += `(${command.child})`; } else { selector += `(child=${command.child})`; } } return selector; } async ensureViewportSize() { if (!this.currentProfile) return; // Get the current profile viewport const currentViewport = await this.page.viewportSize(); const targetViewport = this.currentProfile.viewport; // Only reset if viewport has changed if (currentViewport.width !== targetViewport.width || currentViewport.height !== targetViewport.height) { console.log(`πŸ”§ Viewport changed from ${currentViewport.width}x${currentViewport.height} to ${targetViewport.width}x${targetViewport.height}, resetting...`); await this.page.setViewportSize(targetViewport); // Re-apply viewport CSS after reset await this.page.addStyleTag({ content: ` html { width: ${targetViewport.width}px !important; height: ${targetViewport.height}px !important; max-width: ${targetViewport.width}px !important; min-width: ${targetViewport.width}px !important; } body { width: ${targetViewport.width}px !important; max-width: ${targetViewport.width}px !important; min-width: ${targetViewport.width}px !important; } ` }); } } buildSelector(params) { let selector = params.element || 'div'; if (params.name) { selector += `[name="${params.name}"]`; } if (params.id) { selector += `#${params.id}`; } if (params.class) { selector += `.${params.class.replace(/\s+/g, '.')}`; } if (params.href) { selector += `[href="${params.href}"]`; } if (params.htmlType) { selector += `[type="${params.htmlType}"]`; } // Handle child selectors (nested elements) if (params.child) { // Parse child selector like "span class="MuiBadge-badge"" const childSelector = this.parseChildSelector(params.child); selector += ` ${childSelector}`; } if (params.childText) { selector += `:has-text("${params.childText}")`; } return selector; } parseChildSelector(childString) { // Parse child selector - handles both new and legacy syntax let selector = ''; // For new syntax: "child=span class="MuiBadge-badge" childText="1"" // For legacy syntax: "child=span class="MuiBadge-badge" childText="1"" // Extract element name from child=elementName const elementMatch = childString.match(/child=(\w+)/); if (elementMatch) { selector = elementMatch[1]; } // Extract class attribute const classMatch = childString.match(/class=(?:"([^"]*)"|([^\s]+))/); if (classMatch) { const className = classMatch[1] || classMatch[2]; selector += `.${className.replace(/\s+/g, '.')}`; } // Extract id attribute const idMatch = childString.match(/id=(?:"([^"]*)"|([^\s]+))/); if (idMatch) { const id = idMatch[1] || idMatch[2]; selector += `#${id}`; } // Extract name attribute const nameMatch = childString.match(/name=(?:"([^"]*)"|([^\s]+))/); if (nameMatch) { const name = nameMatch[1] || nameMatch[2]; selector += `[name="${name}"]`; } // Extract href attribute const hrefMatch = childString.match(/href=(?:"([^"]*)"|([^\s]+))/); if (hrefMatch) { const href = hrefMatch[1] || hrefMatch[2]; selector += `[href="${href}"]`; } // Extract childText for the child element const childTextMatch = childString.match(/childText=(?:"([^"]*)"|([^\s]+))/); if (childTextMatch) { const text = childTextMatch[1] || childTextMatch[2]; selector += `:has-text("${text}")`; } return selector; } async createDump(stepName) { const dumpDir = path.join(this.outputDir, stepName); await fs.ensureDir(dumpDir); // Store current viewport before dump operations const currentViewport = await this.page.viewportSize(); // Create context dump const context = { url: this.page.url(), title: await this.page.title(), timestamp: new Date().toISOString(), viewport: currentViewport }; await fs.writeFile(path.join(dumpDir, 'context.json'), JSON.stringify(context, null, 2)); // Create console dump await fs.writeFile(path.join(dumpDir, 'console.json'), JSON.stringify(this.consoleMessages, null, 2)); // Create DOM dump (beautified) const html = await this.page.content(); const beautifiedHtml = this.beautifyHtml(html); await fs.writeFile(path.join(dumpDir, 'dom.html'), beautifiedHtml); // Create simplified DOM dump for test development const simplifiedDom = await this.createSimplifiedDom(); await fs.writeFile(path.join(dumpDir, 'simplified-dom.html'), simplifiedDom); // Create screenshot with viewport lock (if enabled) if (this.enableScreenshots) { if (!this.headless) { // Ensure viewport is stable before screenshot await this.page.setViewportSize(currentViewport); await this.page.waitForTimeout(50); // Brief stabilization } const screenshotOptions = { path: path.join(dumpDir, 'screenshot.png'), fullPage: this.fullPageScreenshots || this.headless }; // Add clipping for viewport screenshots in headed mode if (!this.fullPageScreenshots && !this.headless) { screenshotOptions.clip = { x: 0, y: 0, width: currentViewport.width, height: currentViewport.height }; } // Take screenshot of the main test page only (not status window) await this.page.screenshot(screenshotOptions); // Restore viewport after screenshot if (!this.headless) { await this.page.setViewportSize(currentViewport); } } else { // Create empty screenshot file to indicate screenshots are disabled await fs.writeFile(path.join(dumpDir, 'screenshot.txt'), 'Screenshots disabled'); } } async createSimplifiedDom() { // Extract simplified DOM structure with only essential elements and attributes const simplifiedStructure = await this.page.evaluate(() => { // Elements we want to keep const keepElements = ['button', 'input', 'textarea', 'span', 'a']; // Attributes we want to keep const keepAttributes = ['id', 'name', 'value', 'type']; function processElement(element) { const tagName = element.tagName.toLowerCase(); // Only process elements we want to keep if (!keepElements.includes(tagName)) { return null; } // Create simplified element representation const simplified = { tag: tagName, attributes: {}, text: '', children: [] }; // Extract relevant attributes keepAttributes.forEach(attr => { if (element.hasAttribute(attr)) { const value = element.getAttribute(attr); if (value && value.trim()) { simplified.attributes[attr] = value.trim(); } } }); // Get all text content from this element and its descendants // This captures text even when it's nested in elements we don't keep const allTextContent = element.textContent.trim(); if (allTextContent) { simplified.text = allTextContent; } // Process children recursively Array.from(element.children).forEach(child => { const processedChild = processElement(child); if (processedChild) { simplified.children.push(processedChild); } }); return simplified; } function processContainer(container) { const results = []; Array.from(container.children).forEach(child => { const processed = processElement(child); if (processed) { results.push(processed); } else { // If the element itself isn't kept, still check its children results.push(...processContainer(child)); } }); return results; } // Start from body and extract all relevant elements // Also check the entire document for elements that might be in hidden containers const bodyResults = processContainer(document.body); // Additionally, search the entire document for our target elements // This catches elements that might be in hidden containers or shadow DOM const allElements = document.querySelectorAll(keepElements.join(',')); const additionalResults = []; allElements.forEach(element => { const processed = processElement(element); if (processed) { // Check if this element is already in bodyResults const alreadyExists = bodyResults.some(existing => existing.tag === processed.tag && existing.attributes.id === processed.attributes.id && existing.attributes.class === processed.attributes.class ); if (!alreadyExists) { additionalResults.push(processed); } } }); return [...bodyResults, ...additionalResults]; }); // Convert to readable HTML format function renderElement(element, indent = 0) { const spaces = ' '.repeat(indent); let html = `${spaces}<${element.tag}`; // Add attributes Object.entries(element.attributes).forEach(([key, value]) => { html += ` ${key}="${value}"`; }); if (element.children.length === 0 && !element.text) { html += ' />'; return html; } html += '>'; // Add text content if (element.text) { html += element.text; } // Add children if (element.children.length > 0) { if (element.text) html += '\n'; element.children.forEach(child => { if (element.text) html += '\n'; html += renderElement(child, indent + 1); }); if (element.text) html += '\n' + spaces; } html += ``; return html; } // Generate HTML header and structure let html = '\n\n\n Simplified DOM - Test Development Helper\n \n\n\n'; html += '
\n';
    
    // Add the simplified structure
    if (simplifiedStructure.length === 0) {
      html += '\n';
    } else {
      simplifiedStructure.forEach(element => {
        html += renderElement(element) + '\n';
      });
    }
    
    html += '  
\n\n'; return html; } beautifyHtml(html) { let beautified = html; // Put head in one line beautified = beautified.replace(/]*>[\s\S]*?<\/head>/gi, (match) => { return match.replace(/\s+/g, ' ').trim(); }); // Put script tags in one line beautified = beautified.replace(/]*>[\s\S]*?<\/script>/gi, (match) => { return match.replace(/\s+/g, ' ').trim(); }); // Put style tags in one line beautified = beautified.replace(/]*>[\s\S]*?<\/style>/gi, (match) => { return match.replace(/\s+/g, ' ').trim(); }); // Put link tags in one line beautified = beautified.replace(/]*>/gi, (match) => { return match.replace(/\s+/g, ' ').trim(); }); // Beautify body content with proper tree structure beautified = beautified.replace(/]*>([\s\S]*?)<\/body>/gi, (match, bodyContent) => { const beautifiedBody = this.beautifyBodyContent(bodyContent); return match.replace(bodyContent, beautifiedBody); }); return beautified; } beautifyBodyContent(content) { // Remove existing whitespace and newlines let cleaned = content.replace(/>\s+<').trim(); // Add newlines and indentation let result = ''; let indentLevel = 0; const indent = ' '; // Split by tags const tokens = cleaned.split(/(<[^>]*>)/); for (let i = 0; i < tokens.length; i++) { const token = tokens[i]; if (token.startsWith('<')) { // This is a tag if (token.startsWith('')) { // Self-closing tag result += '\n' + indent.repeat(indentLevel) + token; } else { // Opening tag result += '\n' + indent.repeat(indentLevel) + token; // Check if this is a void element that doesn't need closing const tagName = token.match(/<(\w+)/)?.[1]?.toLowerCase(); const voidElements = ['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']; if (!voidElements.includes(tagName)) { indentLevel++; } } } else if (token.trim()) { // This is text content const lines = token.split('\n'); for (const line of lines) { const trimmedLine = line.trim(); if (trimmedLine) { result += '\n' + indent.repeat(indentLevel) + trimmedLine; } } } } return result + '\n'; } async cleanup() { if (this.statusPage && !this.headless) { try { await this.statusPage.close(); } catch (error) { // Ignore errors when closing status window } } if (this.statusContext && !this.headless) { try { await this.statusContext.close(); } catch (error) { // Ignore errors when closing status context } } if (this.statusBrowser && !this.headless) { try { await this.statusBrowser.close(); } catch (error) { // Ignore errors when closing status browser } } if (this.page) await this.page.close(); if (this.context) await this.context.close(); if (this.browser) await this.browser.close(); } resolveEnvironmentVariables(value) { if (typeof value !== 'string') { return value; } // Replace $VARIABLE or ${VARIABLE} with environment variable values or stored variables return value.replace(/\$\{?([A-Z_][A-Z0-9_]*)\}?/g, (match, varName) => { // First check stored variables if (this.variables[varName] !== undefined) { return this.variables[varName]; } // Then check environment variables const envValue = process.env[varName]; if (envValue === undefined) { console.warn(`Warning: Variable ${varName} is not defined in stored variables or environment`); return match; // Return original if not found } return envValue; }); } } module.exports = TestExecutor;