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": [
{
"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",

View File

@@ -37,8 +37,24 @@ class TestExecutor {
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 (const command of commands) {
// Update status before executing command
if (!this.headless) {
await this.updateStatus(command, false);
}
await this.executeCommand(command);
// Update status after completing command
if (!this.headless) {
await this.updateStatus(command, true);
}
}
} catch (error) {
@@ -81,6 +97,11 @@ class TestExecutor {
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
await this.page.setViewportSize(profile.viewport);
@@ -206,8 +227,8 @@ class TestExecutor {
await this.sleep(command);
break;
case 'switchToNewWindow':
await this.switchToNewWindow();
case 'follow':
await this.followToNewWindow();
break;
case 'switchToTab':
@@ -580,82 +601,93 @@ class TestExecutor {
// 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);
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);
}
50% {
opacity: 1;
transform: scale(1.1);
.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);
.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();
// 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';
}
}
}, 1000);
}, { duration: command.milliseconds, message: command.message });
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) => {
@@ -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...');
// Get all current pages/tabs
@@ -755,8 +1009,8 @@ class TestExecutor {
return `break "${command.message}"`;
case 'sleep':
return command.message ? `sleep ${command.milliseconds} "${command.message}"` : `sleep ${command.milliseconds}`;
case 'switchToNewWindow':
return 'switchToNewWindow';
case 'follow':
return 'follow';
case 'switchToTab':
return `switchToTab tabIndex=${command.tabIndex}`;
case 'extract':
@@ -973,6 +1227,7 @@ class TestExecutor {
};
}
// Take screenshot of the main test page only (not status window)
await this.page.screenshot(screenshotOptions);
// Restore viewport after screenshot
@@ -992,7 +1247,7 @@ class TestExecutor {
const keepElements = ['button', 'input', 'textarea', 'span', 'a'];
// Attributes we want to keep
const keepAttributes = ['id', 'class', 'name', 'value', 'type'];
const keepAttributes = ['id', 'name', 'value', 'type'];
function processElement(element) {
const tagName = element.tagName.toLowerCase();
@@ -1221,6 +1476,20 @@ class TestExecutor {
}
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.context) await this.context.close();
if (this.browser) await this.browser.close();

View File

@@ -6,7 +6,7 @@ class TestLinter {
this.info = [];
// 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
this.validElements = [
@@ -30,7 +30,7 @@ class TestLinter {
'break': [],
'sleep': ['milliseconds'],
'dump': ['name'],
'switchToNewWindow': [],
'follow': [],
'switchToTab': ['tabIndex'],
'extract': ['element', 'attribute']
};
@@ -256,11 +256,7 @@ class TestLinter {
const command = line.cleaned.split(' ')[0];
// Handle multi-word commands
if (line.cleaned.startsWith('switch to new window')) {
continue; // This is valid
}
if (line.cleaned.startsWith('extract ')) {
if (line.cleaned.startsWith('follow')) {
continue; // This is valid
}
@@ -268,6 +264,10 @@ class TestLinter {
continue; // This is valid
}
if (line.cleaned.startsWith('extract ')) {
continue; // This is valid
}
if (!this.validCommands.includes(command)) {
this.addError(`Invalid command '${command}'. Valid commands: ${this.validCommands.join(', ')}`, line.lineNumber);
}
@@ -332,6 +332,12 @@ class TestLinter {
if (!line.cleaned.match(/\d+/)) {
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]) {
this.addError(`Command '${command}' missing required parameter '${reqParam}'`, line.lineNumber);
}

View File

@@ -186,10 +186,10 @@ class TestParser {
}
}
// Parse switch to new window command
if (line.startsWith('switch to new window')) {
// Parse follow command (previously switch to new window)
if (line.startsWith('follow')) {
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 ')) {
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) {
const attribute = match[1];
const selectorPart = match[2];

View File

@@ -9,7 +9,7 @@ Part 3: Login to the email account
use "Chrome"
# 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"
wait 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"
click element=button childText="Bestellung abschließen"
sleep 3000 "order completion"
*/
# Part 3 - Login to the email account
open "https://mail.growbnb.de/"
sleep 100 "page load"
@@ -89,33 +89,38 @@ sleep 300 "mehr button click"
# Click on "In neuem Fenster öffnen" link
wait element=a id="rcmbtn134"
click element=a id="rcmbtn134"
sleep 3000 "new window open"
# Switch to the new window that was opened
switch to new window
# Wait a bit more for the new window to fully load
sleep 2000 "new window load"
follow
# Verify that "Musteranmerkung" exists in the content
wait element=p childText="Musteranmerkung"
# 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"
click element=a id="rcmbtn105"
sleep 1000 "email deleted"
sleep 300 "email deleted"
# Now open the extracted URL
open "$ORDER_URL"
sleep 2000 "order page load"
dump "order_page_from_extracted_url"
dump "after_new_window_click"
sleep 30000 "login submit"
wait element=input type="email"
fill element=input type="email" value="autotest@growheads.de"
sleep 200 "email fill"
wait element=input type="password"
fill element=input type="password" value="$PASSWORD"
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"