From a69911e874f003ef537e877554bbafc109d08b6e Mon Sep 17 00:00:00 2001 From: seb Date: Thu, 17 Jul 2025 13:13:48 +0200 Subject: [PATCH] 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 --- .../syntaxes/playwrong.tmLanguage.json | 2 +- src/executor.js | 425 ++++++++++++++---- src/linter.js | 20 +- src/parser.js | 10 +- step1.test | 43 +- 5 files changed, 390 insertions(+), 110 deletions(-) diff --git a/.vscode/extensions/playwrong-syntax/syntaxes/playwrong.tmLanguage.json b/.vscode/extensions/playwrong-syntax/syntaxes/playwrong.tmLanguage.json index 6e935ea..db707ce 100644 --- a/.vscode/extensions/playwrong-syntax/syntaxes/playwrong.tmLanguage.json +++ b/.vscode/extensions/playwrong-syntax/syntaxes/playwrong.tmLanguage.json @@ -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", diff --git a/src/executor.js b/src/executor.js index d309272..981f71f 100644 --- a/src/executor.js +++ b/src/executor.js @@ -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 = ` +
💤
+
SLEEP: ${message || 'waiting'}
+
${Math.ceil(duration / 1000)}s
+ `; + 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 = ` -
💤
-
SLEEP: ${message || 'waiting'}
-
${Math.ceil(duration / 1000)}s
- `; - 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 = ` + + + + PlayWrong Test Status + + + +
+ +
Test Execution Status
+
+ +
+
+ + Current Command +
+
Initializing...
+
+
+
+
+ +
+
Statistics
+
+
+ 0 + Completed +
+
+ 0 + Total +
+
+ 0s + Elapsed +
+
+
+ + + `; + + // 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(); diff --git a/src/linter.js b/src/linter.js index 80fbbf9..b509877 100644 --- a/src/linter.js +++ b/src/linter.js @@ -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 from to ""`, line.lineNumber); + } } else if (!params[reqParam]) { this.addError(`Command '${command}' missing required parameter '${reqParam}'`, line.lineNumber); } diff --git a/src/parser.js b/src/parser.js index d15e13f..cd7d318 100644 --- a/src/parser.js +++ b/src/parser.js @@ -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]; diff --git a/step1.test b/step1.test index e4caca2..af7d7fb 100644 --- a/step1.test +++ b/step1.test @@ -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" \ No newline at end of file +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" \ No newline at end of file