Refactor test execution and linter to support new 'follow' command for window switching, enhance status tracking with visual indicators, and update extraction syntax for improved clarity

This commit is contained in:
seb
2025-07-17 13:13:48 +02:00
parent fe4ce936c6
commit a69911e874
5 changed files with 390 additions and 110 deletions

View File

@@ -59,7 +59,7 @@
"patterns": [ "patterns": [
{ {
"name": "keyword.control.playwrong", "name": "keyword.control.playwrong",
"match": "\\b(use|open|wait|click|fill|scroll|sleep|dump|break|switch to new window|switch to tab|extract)\\b" "match": "\\b(use|open|wait|click|fill|scroll|sleep|dump|break|follow|switch to tab|extract)\\b"
}, },
{ {
"name": "entity.name.function.playwrong", "name": "entity.name.function.playwrong",

View File

@@ -37,8 +37,24 @@ class TestExecutor {
try { try {
await this.setup(profileName); 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 (const command of commands) { for (const command of commands) {
// Update status before executing command
if (!this.headless) {
await this.updateStatus(command, false);
}
await this.executeCommand(command); await this.executeCommand(command);
// Update status after completing command
if (!this.headless) {
await this.updateStatus(command, true);
}
} }
} catch (error) { } catch (error) {
@@ -81,6 +97,11 @@ class TestExecutor {
this.page = await this.context.newPage(); this.page = await this.context.newPage();
// Create status window for headed mode AFTER main page so it appears on top
if (!this.headless) {
await this.createStatusWindow();
}
// Ensure consistent viewport size // Ensure consistent viewport size
await this.page.setViewportSize(profile.viewport); await this.page.setViewportSize(profile.viewport);
@@ -206,8 +227,8 @@ class TestExecutor {
await this.sleep(command); await this.sleep(command);
break; break;
case 'switchToNewWindow': case 'follow':
await this.switchToNewWindow(); await this.followToNewWindow();
break; break;
case 'switchToTab': case 'switchToTab':
@@ -580,82 +601,93 @@ class TestExecutor {
// Add visual sleep animation with countdown in headed mode // Add visual sleep animation with countdown in headed mode
if (!this.headless) { if (!this.headless) {
await this.page.addStyleTag({ try {
content: ` await this.page.addStyleTag({
.sleep-indicator { content: `
position: fixed; .sleep-indicator {
top: 50px; position: fixed;
left: 50%; top: 50px;
transform: translateX(-50%); left: 50%;
background: rgba(0, 0, 0, 0.8); transform: translateX(-50%);
color: white; background: rgba(0, 0, 0, 0.8);
padding: 20px 40px; color: white;
border-radius: 10px; padding: 20px 40px;
font-size: 24px; border-radius: 10px;
font-family: Arial, sans-serif; font-size: 24px;
z-index: 999999; font-family: Arial, sans-serif;
pointer-events: none; z-index: 999999;
text-align: center; pointer-events: none;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); 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; .sleep-icon {
transform: scale(1.1); font-size: 40px;
animation: sleepPulse 2s ease-in-out infinite;
display: block;
margin-bottom: 10px;
} }
}
.countdown { @keyframes sleepPulse {
font-size: 20px; 0%, 100% {
font-weight: bold; opacity: 0.6;
color: #4CAF50; transform: scale(1);
} }
` 50% {
}); opacity: 1;
transform: scale(1.1);
// 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 }); .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 });
} 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) => { return new Promise((resolve) => {
@@ -663,7 +695,229 @@ class TestExecutor {
}); });
} }
async switchToNewWindow() { async createStatusWindow() {
try {
// Create a separate browser context for the status window (opens in new window)
this.statusContext = await this.browser.newContext({
viewport: { width: 700, height: 500 }
});
// Create a page in the new context (this will be in a separate window)
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; }
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
</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>
</body>
</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;
}
}
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
const indicator = document.getElementById('status-indicator');
if (indicator) 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...'); console.log('🔄 Switching to new tab...');
// Get all current pages/tabs // Get all current pages/tabs
@@ -755,8 +1009,8 @@ class TestExecutor {
return `break "${command.message}"`; return `break "${command.message}"`;
case 'sleep': case 'sleep':
return command.message ? `sleep ${command.milliseconds} "${command.message}"` : `sleep ${command.milliseconds}`; return command.message ? `sleep ${command.milliseconds} "${command.message}"` : `sleep ${command.milliseconds}`;
case 'switchToNewWindow': case 'follow':
return 'switchToNewWindow'; return 'follow';
case 'switchToTab': case 'switchToTab':
return `switchToTab tabIndex=${command.tabIndex}`; return `switchToTab tabIndex=${command.tabIndex}`;
case 'extract': case 'extract':
@@ -973,6 +1227,7 @@ class TestExecutor {
}; };
} }
// Take screenshot of the main test page only (not status window)
await this.page.screenshot(screenshotOptions); await this.page.screenshot(screenshotOptions);
// Restore viewport after screenshot // Restore viewport after screenshot
@@ -992,7 +1247,7 @@ class TestExecutor {
const keepElements = ['button', 'input', 'textarea', 'span', 'a']; const keepElements = ['button', 'input', 'textarea', 'span', 'a'];
// Attributes we want to keep // Attributes we want to keep
const keepAttributes = ['id', 'class', 'name', 'value', 'type']; const keepAttributes = ['id', 'name', 'value', 'type'];
function processElement(element) { function processElement(element) {
const tagName = element.tagName.toLowerCase(); const tagName = element.tagName.toLowerCase();
@@ -1221,6 +1476,20 @@ class TestExecutor {
} }
async cleanup() { 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.page) await this.page.close(); if (this.page) await this.page.close();
if (this.context) await this.context.close(); if (this.context) await this.context.close();
if (this.browser) await this.browser.close(); if (this.browser) await this.browser.close();

View File

@@ -6,7 +6,7 @@ class TestLinter {
this.info = []; this.info = [];
// Valid commands // Valid commands
this.validCommands = ['use', 'open', 'wait', 'click', 'scroll', 'fill', 'break', 'sleep', 'dump', 'switchToNewWindow', 'switchToTab', 'extract']; this.validCommands = ['use', 'open', 'wait', 'click', 'scroll', 'fill', 'break', 'sleep', 'dump', 'follow', 'switchToTab', 'extract'];
// Valid HTML elements // Valid HTML elements
this.validElements = [ this.validElements = [
@@ -30,7 +30,7 @@ class TestLinter {
'break': [], 'break': [],
'sleep': ['milliseconds'], 'sleep': ['milliseconds'],
'dump': ['name'], 'dump': ['name'],
'switchToNewWindow': [], 'follow': [],
'switchToTab': ['tabIndex'], 'switchToTab': ['tabIndex'],
'extract': ['element', 'attribute'] 'extract': ['element', 'attribute']
}; };
@@ -256,11 +256,7 @@ class TestLinter {
const command = line.cleaned.split(' ')[0]; const command = line.cleaned.split(' ')[0];
// Handle multi-word commands // Handle multi-word commands
if (line.cleaned.startsWith('switch to new window')) { if (line.cleaned.startsWith('follow')) {
continue; // This is valid
}
if (line.cleaned.startsWith('extract ')) {
continue; // This is valid continue; // This is valid
} }
@@ -268,6 +264,10 @@ class TestLinter {
continue; // This is valid continue; // This is valid
} }
if (line.cleaned.startsWith('extract ')) {
continue; // This is valid
}
if (!this.validCommands.includes(command)) { if (!this.validCommands.includes(command)) {
this.addError(`Invalid command '${command}'. Valid commands: ${this.validCommands.join(', ')}`, line.lineNumber); this.addError(`Invalid command '${command}'. Valid commands: ${this.validCommands.join(', ')}`, line.lineNumber);
} }
@@ -332,6 +332,12 @@ class TestLinter {
if (!line.cleaned.match(/\d+/)) { if (!line.cleaned.match(/\d+/)) {
this.addError(`Command '${command}' requires numeric tabIndex`, line.lineNumber); this.addError(`Command '${command}' requires numeric tabIndex`, line.lineNumber);
} }
} else if (reqParam === 'attribute' && command === 'extract') {
// Special handling for extract command - attribute is part of command syntax
const extractMatch = line.cleaned.match(/extract\s+(\w+)\s+from\s+(.+?)\s+to\s+"([^"]+)"/);
if (!extractMatch) {
this.addError(`Command '${command}' has invalid syntax. Expected: extract <attribute> from <selector> to "<variable>"`, line.lineNumber);
}
} else if (!params[reqParam]) { } else if (!params[reqParam]) {
this.addError(`Command '${command}' missing required parameter '${reqParam}'`, line.lineNumber); this.addError(`Command '${command}' missing required parameter '${reqParam}'`, line.lineNumber);
} }

View File

@@ -186,10 +186,10 @@ class TestParser {
} }
} }
// Parse switch to new window command // Parse follow command (previously switch to new window)
if (line.startsWith('switch to new window')) { if (line.startsWith('follow')) {
return { return {
type: 'switchToNewWindow' type: 'follow'
}; };
} }
@@ -204,9 +204,9 @@ class TestParser {
} }
} }
// Parse extract command: extract href from element=a target="_blank" to variable="ORDER_URL" // Parse extract command: extract href from element=a childText="text" to "ORDER_URL"
if (line.startsWith('extract ')) { if (line.startsWith('extract ')) {
const match = line.match(/extract\s+(\w+)\s+from\s+(.+?)\s+to\s+variable="([^"]+)"/); const match = line.match(/extract\s+(\w+)\s+from\s+(.+?)\s+to\s+"([^"]+)"/);
if (match) { if (match) {
const attribute = match[1]; const attribute = match[1];
const selectorPart = match[2]; const selectorPart = match[2];

View File

@@ -9,7 +9,7 @@ Part 3: Login to the email account
use "Chrome" use "Chrome"
# Part 1 - Load Growheads, put one item in the cart and go to checkout # Part 1 - Load Growheads, put one item in the cart and go to checkout
/*open "https://growheads.de" open "https://dev.seedheads.de"
sleep 2000 "page load" sleep 2000 "page load"
wait element=a href=/Kategorie/Seeds wait element=a href=/Kategorie/Seeds
click element=a href=/Kategorie/Seeds click element=a href=/Kategorie/Seeds
@@ -63,7 +63,7 @@ sleep 1000 "checkbox checked"
wait element=button childText="Bestellung abschließen" wait element=button childText="Bestellung abschließen"
click element=button childText="Bestellung abschließen" click element=button childText="Bestellung abschließen"
sleep 3000 "order completion" sleep 3000 "order completion"
*/
# Part 3 - Login to the email account # Part 3 - Login to the email account
open "https://mail.growbnb.de/" open "https://mail.growbnb.de/"
sleep 100 "page load" sleep 100 "page load"
@@ -89,33 +89,38 @@ sleep 300 "mehr button click"
# Click on "In neuem Fenster öffnen" link # Click on "In neuem Fenster öffnen" link
wait element=a id="rcmbtn134" wait element=a id="rcmbtn134"
click element=a id="rcmbtn134" click element=a id="rcmbtn134"
sleep 3000 "new window open"
# Switch to the new window that was opened # Switch to the new window that was opened
switch to new window follow
# Wait a bit more for the new window to fully load
sleep 2000 "new window load"
# Verify that "Musteranmerkung" exists in the content # Verify that "Musteranmerkung" exists in the content
wait element=p childText="Musteranmerkung" wait element=p childText="Musteranmerkung"
# Extract the order URL from the link # Extract the order URL from the link
extract href from element=a childText="https://growheads.de/profile#W-" to variable="ORDER_URL" extract href from element=a childText="https://dev.seedheads.de/profile#W-" to "ORDER_URL"
sleep 300 "bestellbestätigung click"
dump "after_bestellbestaetigung_click"
sleep 3000 "bestellbestätigung click"
# Delete the email by clicking "Löschen"
wait element=a id="rcmbtn105" wait element=a id="rcmbtn105"
click element=a id="rcmbtn105" click element=a id="rcmbtn105"
sleep 1000 "email deleted" sleep 300 "email deleted"
# Now open the extracted URL # Now open the extracted URL
open "$ORDER_URL" open "$ORDER_URL"
sleep 2000 "order page load" wait element=input type="email"
dump "order_page_from_extracted_url" fill element=input type="email" value="autotest@growheads.de"
sleep 200 "email fill"
dump "after_new_window_click" wait element=input type="password"
fill element=input type="password" value="$PASSWORD"
sleep 30000 "login submit" sleep 200 "password fill"
wait element=button childText="ANMELDEN" class="MuiButton-fullWidth"
click element=button childText="ANMELDEN" class="MuiButton-fullWidth"
sleep 300 "anmelden click"
wait element=button childText="Schließen"
click element=button childText="Schließen"
sleep 100 "schließen click"
wait element=button class="MuiIconButton-colorError"
click element=button class="MuiIconButton-colorError"
sleep 100 "stornieren click"
wait element=button childText="Stornieren"
click element=button childText="Stornieren"
sleep 100 "stornieren click"
sleep 10000 "completed"