1956 lines
62 KiB
JavaScript
1956 lines
62 KiB
JavaScript
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 = `
|
|
<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';
|
|
}
|
|
// 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 = `
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>PlayWrong Test Status</title>
|
|
<style>
|
|
body {
|
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
margin: 0;
|
|
padding: 20px;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
color: white;
|
|
min-height: 100vh;
|
|
box-sizing: border-box;
|
|
}
|
|
.header {
|
|
text-align: center;
|
|
margin-bottom: 20px;
|
|
border-bottom: 2px solid rgba(255,255,255,0.3);
|
|
padding-bottom: 15px;
|
|
}
|
|
.logo {
|
|
font-size: 24px;
|
|
font-weight: bold;
|
|
margin-bottom: 5px;
|
|
}
|
|
.subtitle {
|
|
font-size: 14px;
|
|
opacity: 0.8;
|
|
}
|
|
.status-section {
|
|
background: rgba(255,255,255,0.1);
|
|
border-radius: 10px;
|
|
padding: 15px;
|
|
margin-bottom: 15px;
|
|
backdrop-filter: blur(10px);
|
|
}
|
|
.status-title {
|
|
font-weight: bold;
|
|
margin-bottom: 10px;
|
|
font-size: 16px;
|
|
}
|
|
.current-command {
|
|
font-family: 'Courier New', monospace;
|
|
background: rgba(0,0,0,0.3);
|
|
padding: 20px;
|
|
border-radius: 5px;
|
|
font-size: 18px;
|
|
font-weight: bold;
|
|
word-break: break-all;
|
|
min-height: 80px;
|
|
line-height: 1.4;
|
|
}
|
|
.stats {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
margin-top: 10px;
|
|
}
|
|
.stat {
|
|
text-align: center;
|
|
}
|
|
.stat-number {
|
|
font-size: 20px;
|
|
font-weight: bold;
|
|
display: block;
|
|
}
|
|
.stat-label {
|
|
font-size: 12px;
|
|
opacity: 0.8;
|
|
}
|
|
.progress-bar {
|
|
background: rgba(255,255,255,0.2);
|
|
height: 8px;
|
|
border-radius: 4px;
|
|
overflow: hidden;
|
|
margin-top: 10px;
|
|
}
|
|
.progress-fill {
|
|
background: #4CAF50;
|
|
height: 100%;
|
|
width: 0%;
|
|
transition: width 0.3s ease;
|
|
}
|
|
.status-indicator {
|
|
display: inline-block;
|
|
width: 12px;
|
|
height: 12px;
|
|
border-radius: 50%;
|
|
margin-right: 8px;
|
|
}
|
|
.status-running { background: #4CAF50; animation: pulse 2s infinite; }
|
|
.status-waiting { background: #FF9800; }
|
|
.status-error { background: #F44336; }
|
|
.status-paused { background: #FF5722; }
|
|
|
|
@keyframes pulse {
|
|
0% { opacity: 1; }
|
|
50% { opacity: 0.5; }
|
|
100% { opacity: 1; }
|
|
}
|
|
|
|
.controls-section {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-top: 15px;
|
|
}
|
|
|
|
.control-button {
|
|
flex: 1;
|
|
padding: 12px;
|
|
border: none;
|
|
border-radius: 8px;
|
|
font-size: 14px;
|
|
font-weight: bold;
|
|
cursor: pointer;
|
|
transition: all 0.3s ease;
|
|
background: rgba(255,255,255,0.2);
|
|
color: white;
|
|
backdrop-filter: blur(5px);
|
|
}
|
|
|
|
.control-button:hover {
|
|
background: rgba(255,255,255,0.3);
|
|
transform: translateY(-2px);
|
|
}
|
|
|
|
.control-button:active {
|
|
transform: translateY(0);
|
|
}
|
|
|
|
.control-button.pause-btn {
|
|
background: rgba(255, 87, 34, 0.8);
|
|
}
|
|
|
|
.control-button.pause-btn:hover {
|
|
background: rgba(255, 87, 34, 1);
|
|
}
|
|
|
|
.control-button.resume-btn {
|
|
background: rgba(76, 175, 80, 0.8);
|
|
}
|
|
|
|
.control-button.resume-btn:hover {
|
|
background: rgba(76, 175, 80, 1);
|
|
}
|
|
|
|
.control-button.dump-btn {
|
|
background: rgba(33, 150, 243, 0.8);
|
|
}
|
|
|
|
.control-button.dump-btn:hover {
|
|
background: rgba(33, 150, 243, 1);
|
|
}
|
|
|
|
.control-button.step-btn {
|
|
background: rgba(156, 39, 176, 0.8);
|
|
}
|
|
|
|
.control-button.step-btn:hover {
|
|
background: rgba(156, 39, 176, 1);
|
|
}
|
|
|
|
.control-button:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
transform: none;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<div class="logo">🎭 PlayWrong</div>
|
|
<div class="subtitle">Test Execution Status</div>
|
|
</div>
|
|
|
|
<div class="status-section">
|
|
<div class="status-title">
|
|
<span id="status-indicator" class="status-indicator status-waiting"></span>
|
|
Current Command
|
|
</div>
|
|
<div id="current-command" class="current-command">Initializing...</div>
|
|
<div class="progress-bar">
|
|
<div id="progress-fill" class="progress-fill"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="status-section">
|
|
<div class="status-title">Statistics</div>
|
|
<div class="stats">
|
|
<div class="stat">
|
|
<span id="completed-count" class="stat-number">0</span>
|
|
<span class="stat-label">Completed</span>
|
|
</div>
|
|
<div class="stat">
|
|
<span id="total-count" class="stat-number">0</span>
|
|
<span class="stat-label">Total</span>
|
|
</div>
|
|
<div class="stat">
|
|
<span id="elapsed-time" class="stat-number">0s</span>
|
|
<span class="stat-label">Elapsed</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="status-section">
|
|
<div class="status-title">Controls</div>
|
|
<div class="controls-section">
|
|
<button id="pause-btn" class="control-button pause-btn">⏸️ Pause</button>
|
|
<button id="step-btn" class="control-button step-btn" disabled>⏭️ +1 Step</button>
|
|
<button id="dump-btn" class="control-button dump-btn">📸 Dump</button>
|
|
</div>
|
|
<div class="controls-section">
|
|
<button id="dump-screen-btn" class="control-button dump-btn">📷 Dump + Screen</button>
|
|
<button id="dump-fullscreen-btn" class="control-button dump-btn">🖼️ Dump + Fullscreen</button>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
<script>
|
|
// Global state for pause/resume
|
|
let isPaused = false;
|
|
let dumpCounter = 1;
|
|
|
|
// Button event handlers
|
|
document.getElementById('pause-btn').addEventListener('click', function() {
|
|
isPaused = !isPaused;
|
|
const button = this;
|
|
const stepBtn = document.getElementById('step-btn');
|
|
const indicator = document.getElementById('status-indicator');
|
|
|
|
if (isPaused) {
|
|
button.textContent = '▶️ Resume';
|
|
button.className = 'control-button resume-btn';
|
|
indicator.className = 'status-indicator status-paused';
|
|
stepBtn.disabled = false;
|
|
|
|
// Store pause state in window to communicate with executor
|
|
window.playwrongPaused = true;
|
|
} else {
|
|
button.textContent = '⏸️ Pause';
|
|
button.className = 'control-button pause-btn';
|
|
indicator.className = 'status-indicator status-running';
|
|
stepBtn.disabled = true;
|
|
|
|
// Resume execution
|
|
window.playwrongPaused = false;
|
|
}
|
|
});
|
|
|
|
// Step button handler
|
|
document.getElementById('step-btn').addEventListener('click', function() {
|
|
// Request one step execution
|
|
window.playwrongStepRequest = true;
|
|
console.log('Step request set');
|
|
});
|
|
|
|
// Dump button handlers
|
|
document.getElementById('dump-btn').addEventListener('click', function() {
|
|
const dumpName = 'manual_dump_' + dumpCounter++;
|
|
window.playwrongDumpRequest = {
|
|
name: dumpName,
|
|
type: 'standard'
|
|
};
|
|
console.log('Dump request set:', window.playwrongDumpRequest);
|
|
});
|
|
|
|
document.getElementById('dump-screen-btn').addEventListener('click', function() {
|
|
const dumpName = 'manual_dump_screen_' + dumpCounter++;
|
|
window.playwrongDumpRequest = {
|
|
name: dumpName,
|
|
type: 'screen'
|
|
};
|
|
console.log('Dump request set:', window.playwrongDumpRequest);
|
|
});
|
|
|
|
document.getElementById('dump-fullscreen-btn').addEventListener('click', function() {
|
|
const dumpName = 'manual_dump_fullscreen_' + dumpCounter++;
|
|
window.playwrongDumpRequest = {
|
|
name: dumpName,
|
|
type: 'fullscreen'
|
|
};
|
|
console.log('Dump request set:', window.playwrongDumpRequest);
|
|
});
|
|
|
|
// Initialize pause state
|
|
window.playwrongPaused = false;
|
|
window.playwrongDumpRequest = null;
|
|
window.playwrongStepRequest = false;
|
|
</script>
|
|
</html>
|
|
`;
|
|
|
|
// 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 += `</${element.tag}>`;
|
|
return html;
|
|
}
|
|
|
|
// Generate HTML header and structure
|
|
let html = '<!DOCTYPE html>\n<html>\n<head>\n <title>Simplified DOM - Test Development Helper</title>\n <style>\n';
|
|
html += ' body { font-family: Arial, sans-serif; margin: 20px; }\n';
|
|
html += ' .info { background: #f0f0f0; padding: 10px; border-radius: 5px; margin-bottom: 20px; }\n';
|
|
html += ' pre { background: #f8f8f8; padding: 15px; border-radius: 5px; overflow-x: auto; }\n';
|
|
html += ' </style>\n</head>\n<body>\n';
|
|
html += ' <pre>\n';
|
|
|
|
// Add the simplified structure
|
|
if (simplifiedStructure.length === 0) {
|
|
html += '<!-- No interactive elements found -->\n';
|
|
} else {
|
|
simplifiedStructure.forEach(element => {
|
|
html += renderElement(element) + '\n';
|
|
});
|
|
}
|
|
|
|
html += ' </pre>\n</body>\n</html>';
|
|
|
|
return html;
|
|
}
|
|
|
|
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.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;
|