Genesis
This commit is contained in:
998
src/executor.js
Normal file
998
src/executor.js
Normal file
@@ -0,0 +1,998 @@
|
||||
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.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);
|
||||
|
||||
for (const command of commands) {
|
||||
await this.executeCommand(command);
|
||||
}
|
||||
|
||||
} 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
|
||||
'--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
|
||||
});
|
||||
|
||||
this.page = await this.context.newPage();
|
||||
|
||||
// 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':
|
||||
await this.page.goto(command.url, { 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;
|
||||
|
||||
default:
|
||||
console.warn(`Unknown command type: ${command.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
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 (80-200ms between keystrokes)
|
||||
const typingDelay = Math.random() * 120 + 80;
|
||||
|
||||
// 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
|
||||
if (!this.headless) {
|
||||
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 = `
|
||||
<div class="sleep-icon">💤</div>
|
||||
<div>SLEEP: ${message || 'waiting'}</div>
|
||||
<div class="countdown" id="countdown">${Math.ceil(duration / 1000)}s</div>
|
||||
`;
|
||||
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';
|
||||
}
|
||||
if (remaining <= 0) {
|
||||
clearInterval(interval);
|
||||
const element = document.getElementById('sleep-indicator');
|
||||
if (element) {
|
||||
element.remove();
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
}, { duration: command.milliseconds, message: command.message });
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, command.milliseconds);
|
||||
});
|
||||
}
|
||||
|
||||
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}`;
|
||||
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 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
|
||||
};
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
beautifyHtml(html) {
|
||||
let beautified = html;
|
||||
|
||||
// Put head in one line
|
||||
beautified = beautified.replace(/<head[^>]*>[\s\S]*?<\/head>/gi, (match) => {
|
||||
return match.replace(/\s+/g, ' ').trim();
|
||||
});
|
||||
|
||||
// Put script tags in one line
|
||||
beautified = beautified.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, (match) => {
|
||||
return match.replace(/\s+/g, ' ').trim();
|
||||
});
|
||||
|
||||
// Put style tags in one line
|
||||
beautified = beautified.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, (match) => {
|
||||
return match.replace(/\s+/g, ' ').trim();
|
||||
});
|
||||
|
||||
// Put link tags in one line
|
||||
beautified = beautified.replace(/<link[^>]*>/gi, (match) => {
|
||||
return match.replace(/\s+/g, ' ').trim();
|
||||
});
|
||||
|
||||
// Beautify body content with proper tree structure
|
||||
beautified = beautified.replace(/<body[^>]*>([\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+</g, '><').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('</')) {
|
||||
// Closing tag
|
||||
indentLevel--;
|
||||
result += '\n' + indent.repeat(indentLevel) + token;
|
||||
} else if (token.endsWith('/>')) {
|
||||
// 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.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
|
||||
return value.replace(/\$\{?([A-Z_][A-Z0-9_]*)\}?/g, (match, varName) => {
|
||||
const envValue = process.env[varName];
|
||||
if (envValue === undefined) {
|
||||
console.warn(`Warning: Environment variable ${varName} is not defined`);
|
||||
return match; // Return original if not found
|
||||
}
|
||||
return envValue;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TestExecutor;
|
||||
Reference in New Issue
Block a user