This commit is contained in:
seb
2025-07-17 05:32:02 +02:00
commit a492223e45
24 changed files with 3880 additions and 0 deletions

126
src/cli.js Normal file
View File

@@ -0,0 +1,126 @@
#!/usr/bin/env node
const fs = require('fs-extra');
const path = require('path');
const TestParser = require('./parser');
const TestExecutor = require('./executor');
const TestLinter = require('./linter');
class TestRunner {
constructor(options = {}) {
this.parser = new TestParser();
this.executor = new TestExecutor(options);
this.linter = new TestLinter();
this.options = options;
}
async runTestFile(filePath, profile = 'Chrome') {
try {
console.log(`Running test file: ${filePath} with profile: ${profile}`);
console.log(`Mode: ${this.executor.headless ? 'Headless' : 'Headed'}`);
// Read test file
const content = await fs.readFile(filePath, 'utf8');
// Optional linting
if (this.options.lint) {
console.log('🔍 Linting test file...');
const lintResult = this.linter.lint(content, filePath);
if (lintResult.hasErrors) {
console.log('❌ Linting failed with errors:');
lintResult.errors.forEach(error => {
console.log(` Line ${error.line}: ${error.message}`);
});
process.exit(1);
}
if (lintResult.hasWarnings) {
console.log('⚠️ Linting warnings:');
lintResult.warnings.forEach(warning => {
console.log(` Line ${warning.line}: ${warning.message}`);
});
if (this.options.strict) {
console.log('❌ Strict mode: treating warnings as errors');
process.exit(1);
}
}
console.log('✅ Linting passed');
}
// Parse commands
const commands = this.parser.parse(content);
console.log(`Parsed ${commands.length} commands`);
// Find use command to determine profiles
const useCommand = commands.find(cmd => cmd.type === 'use');
const profilesToRun = useCommand ? useCommand.profiles : [profile];
// Run tests for each profile
for (const profileName of profilesToRun) {
console.log(`\n=== Running with profile: ${profileName} ===`);
// Filter out the use command for execution
const execCommands = commands.filter(cmd => cmd.type !== 'use');
// Set output directory for this profile
this.executor.outputDir = path.join('test-results', profileName);
await this.executor.execute(execCommands, profileName);
console.log(`✓ Completed test run for profile: ${profileName}`);
}
console.log('\n✅ All tests completed successfully!');
} catch (error) {
console.error('❌ Test execution failed:', error.message);
process.exit(1);
}
}
}
// CLI handling
async function main() {
const args = process.argv.slice(2);
if (args.length === 0) {
console.log('Usage: node src/cli.js <test-file> [profile] [options]');
console.log('Profiles: Chrome, Mobile, MobileSmall');
console.log('Options:');
console.log(' --headed Run in headed mode (show browser)');
console.log(' --headless Run in headless mode (default)');
console.log(' --enable-animations Enable CSS animations (default: disabled)');
console.log(' --lint Run linter before execution');
console.log(' --strict Treat linter warnings as errors');
console.log('Examples:');
console.log(' node src/cli.js tests/example.test Chrome');
console.log(' node src/cli.js tests/example.test Chrome --headed');
console.log(' node src/cli.js tests/example.test Chrome --headed --enable-animations');
console.log(' node src/cli.js tests/example.test Chrome --lint --strict');
process.exit(1);
}
const testFile = args[0];
const profile = args[1] || 'Chrome';
const headless = !args.includes('--headed');
const disableAnimations = !args.includes('--enable-animations');
const lint = args.includes('--lint');
const strict = args.includes('--strict');
if (!await fs.pathExists(testFile)) {
console.error(`Test file not found: ${testFile}`);
process.exit(1);
}
const runner = new TestRunner({ headless, disableAnimations, lint, strict });
await runner.runTestFile(testFile, profile);
}
// Run if called directly
if (require.main === module) {
main().catch(console.error);
}
module.exports = TestRunner;

998
src/executor.js Normal file
View 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;

172
src/linter-cli.js Normal file
View File

@@ -0,0 +1,172 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const TestLinter = require('./linter');
class LinterCLI {
constructor() {
this.linter = new TestLinter();
}
run(args) {
const options = this.parseArgs(args);
if (options.help) {
this.showHelp();
return;
}
if (options.files.length === 0) {
console.error('Error: No test files specified');
this.showHelp();
process.exit(1);
}
let totalErrors = 0;
let totalWarnings = 0;
let totalInfo = 0;
for (const file of options.files) {
const result = this.lintFile(file, options);
totalErrors += result.errors.length;
totalWarnings += result.warnings.length;
totalInfo += result.info.length;
}
// Summary
console.log('\n' + '='.repeat(50));
console.log(`Summary: ${totalErrors} errors, ${totalWarnings} warnings, ${totalInfo} info`);
if (totalErrors > 0) {
console.log('❌ Linting failed with errors');
process.exit(1);
} else if (totalWarnings > 0 && options.strict) {
console.log('⚠️ Linting failed in strict mode due to warnings');
process.exit(1);
} else {
console.log('✅ Linting passed');
}
}
parseArgs(args) {
const options = {
files: [],
strict: false,
verbose: false,
format: 'default',
help: false
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--help' || arg === '-h') {
options.help = true;
} else if (arg === '--strict' || arg === '-s') {
options.strict = true;
} else if (arg === '--verbose' || arg === '-v') {
options.verbose = true;
} else if (arg === '--format' || arg === '-f') {
options.format = args[++i] || 'default';
} else if (arg.startsWith('--')) {
console.error(`Unknown option: ${arg}`);
process.exit(1);
} else {
options.files.push(arg);
}
}
return options;
}
lintFile(filePath, options) {
try {
if (!fs.existsSync(filePath)) {
console.error(`Error: File not found: ${filePath}`);
process.exit(1);
}
const content = fs.readFileSync(filePath, 'utf8');
const result = this.linter.lint(content, filePath);
this.displayResults(result, options);
return result;
} catch (error) {
console.error(`Error reading file ${filePath}: ${error.message}`);
process.exit(1);
}
}
displayResults(result, options) {
const { errors, warnings, info } = result;
if (options.format === 'json') {
console.log(JSON.stringify(result, null, 2));
return;
}
// Default format
console.log(`\n📁 ${result.errors[0]?.file || result.warnings[0]?.file || 'test file'}`);
// Display errors
if (errors.length > 0) {
console.log('\n❌ Errors:');
errors.forEach(error => {
console.log(` Line ${error.line}: ${error.message}`);
});
}
// Display warnings
if (warnings.length > 0) {
console.log('\n⚠ Warnings:');
warnings.forEach(warning => {
console.log(` Line ${warning.line}: ${warning.message}`);
});
}
// Display info (only in verbose mode)
if (options.verbose && info.length > 0) {
console.log('\n💡 Info:');
info.forEach(infoItem => {
console.log(` Line ${infoItem.line}: ${infoItem.message}`);
});
}
// File summary
const status = errors.length > 0 ? '❌' : warnings.length > 0 ? '⚠️' : '✅';
console.log(`\n${status} ${errors.length} errors, ${warnings.length} warnings${options.verbose ? `, ${info.length} info` : ''}`);
}
showHelp() {
console.log(`
PlayWrong Test Linter
Usage: node src/linter-cli.js [options] <file1> [file2] ...
Options:
-h, --help Show this help message
-s, --strict Treat warnings as errors
-v, --verbose Show info messages
-f, --format Output format (default|json)
Examples:
node src/linter-cli.js step1.test
node src/linter-cli.js --strict --verbose *.test
node src/linter-cli.js --format json step1.test
Exit codes:
0 - No errors (warnings allowed unless --strict)
1 - Errors found or warnings in strict mode
`);
}
}
// Run CLI if called directly
if (require.main === module) {
const cli = new LinterCLI();
cli.run(process.argv.slice(2));
}
module.exports = LinterCLI;

369
src/linter.js Normal file
View File

@@ -0,0 +1,369 @@
class TestLinter {
constructor() {
this.rules = [];
this.errors = [];
this.warnings = [];
this.info = [];
// Valid commands
this.validCommands = ['use', 'open', 'wait', 'click', 'scroll', 'fill', 'break', 'sleep', 'dump'];
// Valid HTML elements
this.validElements = [
'div', 'span', 'button', 'input', 'a', 'form', 'select', 'option',
'textarea', 'label', 'img', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'table', 'tr', 'td', 'th', 'ul', 'ol', 'li', 'nav', 'header', 'footer',
'section', 'article', 'aside', 'main'
];
// Valid browser profiles
this.validProfiles = ['Chrome', 'Mobile', 'MobileSmall', 'Firefox', 'Safari', 'Edge'];
// Required parameters for each command
this.requiredParams = {
'use': ['profiles'],
'open': ['url'],
'wait': ['element'],
'click': ['element'],
'scroll': ['element'],
'fill': ['element', 'value'],
'break': [],
'sleep': ['milliseconds'],
'dump': ['name']
};
// Initialize rules
this.initializeRules();
}
initializeRules() {
// Add all linting rules
this.rules = [
this.validateCommandSyntax.bind(this),
this.validateQuotes.bind(this),
this.validateParameters.bind(this),
this.validateElementSelectors.bind(this),
this.validateVariables.bind(this),
this.validateFlowLogic.bind(this),
this.validateSemanticRules.bind(this)
];
}
removeLineComments(line) {
let result = '';
let inQuote = false;
let quoteChar = '';
for (let i = 0; i < line.length; i++) {
const char = line[i];
const nextChar = i < line.length - 1 ? line[i + 1] : '';
const prevChar = i > 0 ? line[i - 1] : '';
// Handle quotes
if (char === '"' && prevChar !== '\\' && !inQuote) {
inQuote = true;
quoteChar = char;
result += char;
} else if (char === quoteChar && prevChar !== '\\' && inQuote) {
inQuote = false;
quoteChar = '';
result += char;
} else if (!inQuote && char === '/' && nextChar === '/') {
// Found // comment outside quotes - stop here
break;
} else if (!inQuote && char === '#') {
// Found # comment outside quotes - stop here
break;
} else {
result += char;
}
}
return result;
}
removeMultiLineComments(content) {
// Remove /* ... */ comments, including nested ones
let result = '';
let i = 0;
let inComment = false;
let commentDepth = 0;
while (i < content.length) {
if (!inComment && i < content.length - 1 && content[i] === '/' && content[i + 1] === '*') {
inComment = true;
commentDepth = 1;
i += 2;
} else if (inComment && i < content.length - 1 && content[i] === '/' && content[i + 1] === '*') {
commentDepth++;
i += 2;
} else if (inComment && i < content.length - 1 && content[i] === '*' && content[i + 1] === '/') {
commentDepth--;
if (commentDepth === 0) {
inComment = false;
}
i += 2;
} else if (!inComment) {
result += content[i];
i++;
} else {
i++;
}
}
return result;
}
lint(content, filename = 'test.test') {
this.errors = [];
this.warnings = [];
this.info = [];
this.filename = filename;
// Remove multi-line comments first (like the actual parser does)
const contentWithoutMultiLineComments = this.removeMultiLineComments(content);
// Parse content into lines with line numbers
const lines = contentWithoutMultiLineComments.split('\n');
const cleanedLines = this.preprocessLines(lines);
// Run all rules
for (const rule of this.rules) {
rule(cleanedLines, lines);
}
return {
errors: this.errors,
warnings: this.warnings,
info: this.info,
hasErrors: this.errors.length > 0,
hasWarnings: this.warnings.length > 0
};
}
preprocessLines(lines) {
const result = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
let cleanLine = line;
// Remove single line comments (but not inside quotes)
cleanLine = this.removeLineComments(cleanLine);
result.push({
original: line,
cleaned: cleanLine.trim(),
lineNumber: i + 1
});
}
return result.filter(line => line.cleaned.length > 0);
}
addError(message, lineNumber, column = 0) {
this.errors.push({
type: 'error',
message,
file: this.filename,
line: lineNumber,
column
});
}
addWarning(message, lineNumber, column = 0) {
this.warnings.push({
type: 'warning',
message,
file: this.filename,
line: lineNumber,
column
});
}
addInfo(message, lineNumber, column = 0) {
this.info.push({
type: 'info',
message,
file: this.filename,
line: lineNumber,
column
});
}
validateCommandSyntax(lines) {
for (const line of lines) {
const command = line.cleaned.split(' ')[0];
if (!this.validCommands.includes(command)) {
this.addError(`Invalid command '${command}'. Valid commands: ${this.validCommands.join(', ')}`, line.lineNumber);
}
}
}
validateQuotes(lines) {
for (const line of lines) {
const content = line.cleaned;
let inQuote = false;
let quoteChar = '';
for (let i = 0; i < content.length; i++) {
const char = content[i];
const prevChar = i > 0 ? content[i - 1] : '';
if (char === '"' && !inQuote && prevChar !== '\\') {
inQuote = true;
quoteChar = char;
} else if (char === quoteChar && inQuote && prevChar !== '\\') {
inQuote = false;
quoteChar = '';
}
}
if (inQuote) {
this.addError(`Unclosed quote in line`, line.lineNumber);
}
}
}
validateParameters(lines) {
for (const line of lines) {
const parts = line.cleaned.split(' ');
const command = parts[0];
if (!this.validCommands.includes(command)) continue;
const required = this.requiredParams[command] || [];
const params = this.parseLineParameters(line.cleaned);
// Check required parameters
for (const reqParam of required) {
if (reqParam === 'profiles' && command === 'use') {
// Special handling for 'use' command
if (!line.cleaned.includes('"')) {
this.addError(`Command '${command}' requires browser profile in quotes`, line.lineNumber);
}
} else if (reqParam === 'url' && command === 'open') {
if (!line.cleaned.includes('"')) {
this.addError(`Command '${command}' requires URL in quotes`, line.lineNumber);
}
} else if (reqParam === 'name' && command === 'dump') {
if (!line.cleaned.includes('"')) {
this.addError(`Command '${command}' requires name in quotes`, line.lineNumber);
}
} else if (reqParam === 'milliseconds' && command === 'sleep') {
if (!line.cleaned.match(/\d+/)) {
this.addError(`Command '${command}' requires numeric milliseconds`, line.lineNumber);
}
} else if (!params[reqParam]) {
this.addError(`Command '${command}' missing required parameter '${reqParam}'`, line.lineNumber);
}
}
}
}
validateElementSelectors(lines) {
for (const line of lines) {
const command = line.cleaned.split(' ')[0];
if (['wait', 'click', 'scroll', 'fill'].includes(command)) {
const elementMatch = line.cleaned.match(/element=(\w+)/);
if (elementMatch) {
const element = elementMatch[1];
if (!this.validElements.includes(element)) {
this.addWarning(`Unknown HTML element '${element}'. Consider using standard HTML elements.`, line.lineNumber);
}
}
}
}
}
validateVariables(lines) {
for (const line of lines) {
const variables = line.cleaned.match(/\$([A-Z_][A-Z0-9_]*)/g);
if (variables) {
for (const variable of variables) {
// Check variable naming convention
if (!variable.match(/^\$[A-Z_][A-Z0-9_]*$/)) {
this.addWarning(`Variable '${variable}' should use UPPER_CASE naming convention`, line.lineNumber);
}
// Info about common variables
if (['$PASSWORD', '$PASSWORDMAIL', '$EMAIL', '$USERNAME'].includes(variable)) {
this.addInfo(`Using environment variable '${variable}' - ensure it's defined in your environment`, line.lineNumber);
}
}
}
}
}
validateFlowLogic(lines) {
let hasUseCommand = false;
let useLineNumber = 0;
for (const line of lines) {
const command = line.cleaned.split(' ')[0];
if (command === 'use') {
if (hasUseCommand) {
this.addWarning(`Multiple 'use' commands found. Consider using multi-profile syntax instead.`, line.lineNumber);
}
hasUseCommand = true;
useLineNumber = line.lineNumber;
}
}
if (!hasUseCommand) {
this.addWarning(`No 'use' command found. Tests should specify browser profile.`, 1);
} else if (useLineNumber > 1) {
this.addInfo(`'use' command found at line ${useLineNumber}. Consider placing it at the beginning of the test.`, useLineNumber);
}
}
validateSemanticRules(lines) {
for (const line of lines) {
const command = line.cleaned.split(' ')[0];
// fill should be used with input elements
if (command === 'fill') {
const elementMatch = line.cleaned.match(/element=(\w+)/);
if (elementMatch) {
const element = elementMatch[1];
if (!['input', 'textarea', 'select'].includes(element)) {
this.addWarning(`'fill' command used with '${element}' element. Consider using 'input', 'textarea', or 'select'.`, line.lineNumber);
}
}
}
// click should not be used with input elements (use fill instead)
if (command === 'click') {
const elementMatch = line.cleaned.match(/element=(\w+)/);
if (elementMatch) {
const element = elementMatch[1];
if (['input', 'textarea'].includes(element) && line.cleaned.includes('type=')) {
const typeMatch = line.cleaned.match(/type="?(\w+)"?/);
if (typeMatch && ['text', 'email', 'password', 'number'].includes(typeMatch[1])) {
this.addWarning(`Consider using 'fill' instead of 'click' for text input elements.`, line.lineNumber);
}
}
}
}
}
}
parseLineParameters(line) {
const params = {};
const regex = /(\w+)=(?:"([^"]*)"|([^\s]+))/g;
let match;
while ((match = regex.exec(line)) !== null) {
params[match[1]] = match[2] !== undefined ? match[2] : match[3];
}
return params;
}
}
module.exports = TestLinter;

338
src/parser.js Normal file
View File

@@ -0,0 +1,338 @@
class TestParser {
constructor() {
this.commands = [];
}
parse(content) {
// Remove multi-line comments /* ... */
content = this.removeMultiLineComments(content);
const lines = content.split('\n').map(line => line.trim()).filter(line => line && !line.startsWith('//') && !line.startsWith('#'));
for (const line of lines) {
const command = this.parseLine(line);
if (command) {
this.commands.push(command);
}
}
return this.commands;
}
removeMultiLineComments(content) {
// Remove /* ... */ comments, including nested ones
let result = '';
let i = 0;
let inComment = false;
let commentDepth = 0;
while (i < content.length) {
if (!inComment && i < content.length - 1 && content[i] === '/' && content[i + 1] === '*') {
inComment = true;
commentDepth = 1;
i += 2;
} else if (inComment && i < content.length - 1 && content[i] === '/' && content[i + 1] === '*') {
commentDepth++;
i += 2;
} else if (inComment && i < content.length - 1 && content[i] === '*' && content[i + 1] === '/') {
commentDepth--;
if (commentDepth === 0) {
inComment = false;
}
i += 2;
} else if (!inComment) {
result += content[i];
i++;
} else {
i++;
}
}
return result;
}
parseLine(line) {
// Parse use command: use "Chrome" ( "Mobile" , "MobileSmall") or use "Chrome"
if (line.startsWith('use ')) {
// Multi-profile format: use "Chrome" ( "Mobile" , "MobileSmall")
const multiMatch = line.match(/use\s+"([^"]+)"\s*\(\s*"([^"]+)"\s*,\s*"([^"]+)"\s*\)/);
if (multiMatch) {
return {
type: 'use',
profiles: [multiMatch[1], multiMatch[2], multiMatch[3]]
};
}
// Single profile format: use "Chrome"
const singleMatch = line.match(/use\s+"([^"]+)"/);
if (singleMatch) {
return {
type: 'use',
profiles: [singleMatch[1]]
};
}
}
// Parse open command: open "https://xyz.com"
if (line.startsWith('open ')) {
const match = line.match(/open\s+"([^"]+)"/);
if (match) {
return {
type: 'open',
url: match[1]
};
}
}
// Parse dump command: dump "step 1"
if (line.startsWith('dump ')) {
const match = line.match(/dump\s+"([^"]+)"/);
if (match) {
return {
type: 'dump',
name: match[1]
};
}
}
// Parse wait command: wait element=div childText=Login
if (line.startsWith('wait ')) {
const params = this.parseParameters(line.substring(5));
return {
type: 'wait',
element: params.element,
// Preserve all other attributes including HTML type
name: params.name,
id: params.id,
class: params.class,
href: params.href,
htmlType: params.type, // Store HTML type as htmlType to avoid conflict
child: params.child,
childText: params.childText
};
}
// Parse click command: click element=div childText=Login
if (line.startsWith('click ')) {
const params = this.parseParameters(line.substring(6));
return {
type: 'click',
element: params.element,
// Preserve all other attributes including HTML type
name: params.name,
id: params.id,
class: params.class,
href: params.href,
htmlType: params.type, // Store HTML type as htmlType to avoid conflict
child: params.child,
childText: params.childText
};
}
// Parse scroll command: scroll element=div childText=Login
if (line.startsWith('scroll ')) {
const params = this.parseParameters(line.substring(7));
return {
type: 'scroll',
element: params.element,
// Preserve all other attributes including HTML type
name: params.name,
id: params.id,
class: params.class,
href: params.href,
htmlType: params.type, // Store HTML type as htmlType to avoid conflict
child: params.child,
childText: params.childText
};
}
// Parse fill command: fill element=input name=firstName value=abc
if (line.startsWith('fill ')) {
const params = this.parseParameters(line.substring(5));
return {
type: 'fill',
element: params.element,
value: params.value,
// Preserve all other attributes including HTML type
name: params.name,
id: params.id,
class: params.class,
href: params.href,
htmlType: params.type, // Store HTML type as htmlType to avoid conflict
child: params.child,
childText: params.childText
};
}
// Parse break command: break "waiting for user input"
if (line.startsWith('break')) {
const match = line.match(/break(?:\s+"([^"]+)")?/);
return {
type: 'break',
message: match && match[1] ? match[1] : 'Press any key to continue...'
};
}
// Parse sleep command: sleep 1000 or sleep 1000 "waiting for animation"
if (line.startsWith('sleep ')) {
// Handle both: sleep 1000 and sleep 1000 "message"
const match = line.match(/sleep\s+(\d+)(?:\s+"([^"]+)")?/);
if (match) {
return {
type: 'sleep',
milliseconds: parseInt(match[1]),
message: match[2] || null
};
}
}
return null;
}
parseParameters(paramString) {
const params = {};
// Handle new parentheses syntax: element=button(child=span class="MuiBadge-badge" childText="1")
const elementWithParensMatch = paramString.match(/element=(\w+)\(([^)]+)\)/);
if (elementWithParensMatch) {
params.element = elementWithParensMatch[1];
params.child = elementWithParensMatch[2];
// Parse any remaining parameters outside the parentheses
const remainingParams = paramString.replace(elementWithParensMatch[0], '').trim();
if (remainingParams) {
this.parseSimpleParameters(remainingParams, params);
}
} else {
// Handle legacy syntax or simple cases
const elementMatch = paramString.match(/element=(\w+)/);
if (elementMatch) {
params.element = elementMatch[1];
}
// Check for legacy child parameter (backward compatibility)
const childMatch = paramString.match(/child=(\w+)/);
if (childMatch) {
// Everything after child= belongs to the child selector
const childStart = paramString.indexOf('child=');
const beforeChild = paramString.substring(0, childStart).trim();
const childPart = paramString.substring(childStart);
// Parse the child selector completely
params.child = childPart;
// Parse any parameters before the child
this.parseSimpleParameters(beforeChild, params);
} else {
// No child, parse all parameters normally
this.parseSimpleParameters(paramString, params);
}
}
return params;
}
parseSimpleParameters(paramString, params) {
const regex = /(\w+)=(?:"([^"]*)"|([^\s]+))/g;
let match;
while ((match = regex.exec(paramString)) !== null) {
// Use quoted value if present, otherwise use unquoted value
params[match[1]] = match[2] !== undefined ? match[2] : match[3];
}
}
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.type) {
selector += `[type="${params.type}"]`;
}
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;
}
}
module.exports = TestParser;