From 85f7f81236f731a45d7b89fd68650391cccfd9bd Mon Sep 17 00:00:00 2001 From: seb Date: Thu, 17 Jul 2025 14:06:41 +0200 Subject: [PATCH] Add 'jumpIf' and 'jumpIfNot' commands to README and executor; implement parsing, execution, and linter support for conditional command jumps in test scripts. --- .cursor/rules/projectinfo.mdc | 2 + README.md | 14 +++ loop-test.bat | 26 +++++ loop-test.js | 204 ++++++++++++++++++++++++++++++++++ loop-test.sh | 25 +++++ src/executor.js | 128 +++++++++++++++++++-- src/linter.js | 14 ++- src/parser.js | 46 ++++++++ step1.test | 6 +- 9 files changed, 454 insertions(+), 11 deletions(-) create mode 100644 loop-test.bat create mode 100644 loop-test.js create mode 100644 loop-test.sh diff --git a/.cursor/rules/projectinfo.mdc b/.cursor/rules/projectinfo.mdc index 5eddd59..6f42bcf 100644 --- a/.cursor/rules/projectinfo.mdc +++ b/.cursor/rules/projectinfo.mdc @@ -37,6 +37,8 @@ The `dump` command creates HTML snapshots in `test-results/Chrome//` t - `break` - Pause execution (press any key to continue) - `break "waiting for user input"` - Pause with message - `dump "step_name"` - Take screenshot +- `jumpIf element=span childText="Server-Warenkorb" jump=4` - Jump over 4 commands if element exists +- `jumpIfNot element=span childText="Server-Warenkorb" jump=4` - Jump over 4 commands if element doesn't exist ### Element Selectors - `element=tagname` - HTML tag (div, span, button, input, a, form, etc.) diff --git a/README.md b/README.md index 868f888..ec58a28 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,20 @@ sleep 2500 "waiting for animation" sleep 500 "let page settle" ``` +#### jumpIf +Jump over a specified number of commands if an element exists: +``` +jumpIf element=span childText="Server-Warenkorb" jump=4 +jumpIf element=div id="error-message" jump=2 +``` + +#### jumpIfNot +Jump over a specified number of commands if an element doesn't exist: +``` +jumpIfNot element=span childText="Server-Warenkorb" jump=4 +jumpIfNot element=div id="success-message" jump=2 +``` + ### Element Selectors You can combine multiple attributes: diff --git a/loop-test.bat b/loop-test.bat new file mode 100644 index 0000000..08d807d --- /dev/null +++ b/loop-test.bat @@ -0,0 +1,26 @@ +@echo off +echo PlayWrong Test Loop Script +echo ======================== +echo. +echo This script will run the PlayWrong test in a loop until it fails. +echo Press Ctrl+C to stop the loop manually. +echo. + +if "%~1"=="" ( + echo Usage: loop-test.bat ^ [profile] [options] + echo. + echo Examples: + echo loop-test.bat step1.test Chrome + echo loop-test.bat step1.test Chrome --delay 1000 + echo loop-test.bat step1.test Chrome --log my-test.log + echo. + echo Press any key to exit... + pause >nul + exit /b 1 +) + +echo Starting test loop for: %1 with profile: %2 +echo Log will be saved to a timestamped file +echo. + +node loop-test.js %* \ No newline at end of file diff --git a/loop-test.js b/loop-test.js new file mode 100644 index 0000000..3f2ddcb --- /dev/null +++ b/loop-test.js @@ -0,0 +1,204 @@ +#!/usr/bin/env node + +const { spawn } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +class TestLooper { + constructor(testFile, profile = 'Chrome', options = {}) { + this.testFile = testFile; + this.profile = profile; + this.options = options; + this.runCount = 0; + this.successCount = 0; + this.failureCount = 0; + this.startTime = Date.now(); + this.logFile = options.logFile || `test-loop-${Date.now()}.log`; + } + + log(message) { + const timestamp = new Date().toISOString(); + const logMessage = `[${timestamp}] ${message}`; + console.log(logMessage); + + // Also write to log file + fs.appendFileSync(this.logFile, logMessage + '\n'); + } + + async runSingleTest() { + return new Promise((resolve, reject) => { + this.runCount++; + const startTime = Date.now(); + + this.log(`Starting test run #${this.runCount}`); + + const child = spawn('node', ['src/cli.js', this.testFile, this.profile], { + stdio: 'pipe', + env: process.env + }); + + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (data) => { + const output = data.toString(); + stdout += output; + // Show real-time progress by forwarding key output lines + const lines = output.split('\n').filter(line => line.trim()); + for (const line of lines) { + if (line.includes('Executing:') || line.includes('βœ“ Completed') || line.includes('Running test file:')) { + console.log(` ${line}`); + } + } + }); + + child.stderr.on('data', (data) => { + const output = data.toString(); + stderr += output; + // Show errors in real-time + const lines = output.split('\n').filter(line => line.trim()); + for (const line of lines) { + console.log(` ERROR: ${line}`); + } + }); + + child.on('close', (code) => { + const duration = Date.now() - startTime; + const minutes = Math.floor(duration / 60000); + const seconds = ((duration % 60000) / 1000).toFixed(1); + + if (code === 0) { + this.successCount++; + this.log(`βœ… Test run #${this.runCount} PASSED (${minutes}m ${seconds}s) - Total passed: ${this.successCount}`); + resolve({ success: true, code, stdout, stderr, duration }); + } else { + this.failureCount++; + this.log(`❌ Test run #${this.runCount} FAILED with exit code ${code} (${minutes}m ${seconds}s) - Total passed: ${this.successCount}`); + this.log(`STDOUT: ${stdout}`); + this.log(`STDERR: ${stderr}`); + resolve({ success: false, code, stdout, stderr, duration }); + } + }); + + child.on('error', (error) => { + this.failureCount++; + this.log(`❌ Test run #${this.runCount} ERROR: ${error.message}`); + reject(error); + }); + }); + } + + printStatistics() { + const totalTime = Date.now() - this.startTime; + const totalMinutes = Math.floor(totalTime / 60000); + const totalSeconds = ((totalTime % 60000) / 1000).toFixed(1); + + this.log('\n=== FINAL STATISTICS ==='); + this.log(`Total runs: ${this.runCount}`); + this.log(`Successful runs: ${this.successCount}`); + this.log(`Failed runs: ${this.failureCount}`); + this.log(`Success rate: ${this.runCount > 0 ? ((this.successCount / this.runCount) * 100).toFixed(1) : 0}%`); + this.log(`Total time: ${totalMinutes}m ${totalSeconds}s`); + this.log(`Average time per run: ${this.runCount > 0 ? (totalTime / this.runCount / 1000).toFixed(1) : 0}s`); + this.log(`Log file: ${this.logFile}`); + } + + async loop() { + this.log(`Starting test loop for: ${this.testFile} with profile: ${this.profile}`); + this.log(`Log file: ${this.logFile}`); + this.log(`Press Ctrl+C to stop the loop\n`); + + // Handle Ctrl+C gracefully + process.on('SIGINT', () => { + this.log('\n\nπŸ›‘ Loop interrupted by user'); + this.printStatistics(); + process.exit(0); + }); + + try { + while (true) { + const result = await this.runSingleTest(); + + if (!result.success) { + this.log('\nπŸ”΄ Test failed! Stopping loop...'); + this.printStatistics(); + process.exit(1); + } + + // Optional: Add a small delay between runs + if (this.options.delay) { + await new Promise(resolve => setTimeout(resolve, this.options.delay)); + } + + // Print periodic statistics + if (this.runCount % 10 === 0) { + const currentTime = Date.now() - this.startTime; + const minutes = Math.floor(currentTime / 60000); + const seconds = ((currentTime % 60000) / 1000).toFixed(1); + this.log(`πŸ“Š Progress: ${this.runCount} runs completed, ${this.successCount} successful (${minutes}m ${seconds}s elapsed)`); + } + } + } catch (error) { + this.log(`πŸ’₯ Unexpected error: ${error.message}`); + this.printStatistics(); + process.exit(1); + } + } +} + +// CLI handling +async function main() { + const args = process.argv.slice(2); + + if (args.length === 0) { + console.log('Usage: node loop-test.js [profile] [options]'); + console.log(''); + console.log('Arguments:'); + console.log(' test-file Path to the test file (e.g., step1.test)'); + console.log(' profile Browser profile (Chrome, Mobile, MobileSmall) [default: Chrome]'); + console.log(''); + console.log('Options:'); + console.log(' --delay Add delay between test runs in milliseconds'); + console.log(' --log Custom log file name'); + console.log(''); + console.log('Examples:'); + console.log(' node loop-test.js step1.test Chrome'); + console.log(' node loop-test.js step1.test Chrome --delay 1000'); + console.log(' node loop-test.js step1.test Chrome --log my-test-log.txt'); + console.log(''); + console.log('The script will run the test in a loop until it fails.'); + console.log('Press Ctrl+C to stop the loop manually.'); + process.exit(1); + } + + const testFile = args[0]; + const profile = args[1] || 'Chrome'; + + // Parse options + const options = {}; + const delayIndex = args.indexOf('--delay'); + if (delayIndex !== -1 && args[delayIndex + 1]) { + options.delay = parseInt(args[delayIndex + 1]); + } + + const logIndex = args.indexOf('--log'); + if (logIndex !== -1 && args[logIndex + 1]) { + options.logFile = args[logIndex + 1]; + } + + // Check if test file exists + if (!fs.existsSync(testFile)) { + console.error(`❌ Test file not found: ${testFile}`); + process.exit(1); + } + + const looper = new TestLooper(testFile, profile, options); + await looper.loop(); +} + +// Run if called directly +if (require.main === module) { + main().catch(console.error); +} + +module.exports = TestLooper; \ No newline at end of file diff --git a/loop-test.sh b/loop-test.sh new file mode 100644 index 0000000..31b2522 --- /dev/null +++ b/loop-test.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +echo "PlayWrong Test Loop Script" +echo "========================" +echo "" +echo "This script will run the PlayWrong test in a loop until it fails." +echo "Press Ctrl+C to stop the loop manually." +echo "" + +if [ $# -eq 0 ]; then + echo "Usage: ./loop-test.sh [profile] [options]" + echo "" + echo "Examples:" + echo " ./loop-test.sh step1.test Chrome" + echo " ./loop-test.sh step1.test Chrome --delay 1000" + echo " ./loop-test.sh step1.test Chrome --log my-test.log" + echo "" + exit 1 +fi + +echo "Starting test loop for: $1 with profile: $2" +echo "Log will be saved to a timestamped file" +echo "" + +node loop-test.js "$@" \ No newline at end of file diff --git a/src/executor.js b/src/executor.js index 1af80bb..d50b74a 100644 --- a/src/executor.js +++ b/src/executor.js @@ -43,13 +43,21 @@ class TestExecutor { await this.updateStatus({ type: 'initializing' }, false); } - for (const command of commands) { + for (let i = 0; i < commands.length; i++) { + const command = commands[i]; + // Update status before executing command if (!this.headless) { await this.updateStatus(command, false); } - await this.executeCommand(command); + const jumpCount = await this.executeCommand(command); + + // Handle jump commands + if (jumpCount > 0) { + console.log(`πŸ”„ Jumping ${jumpCount} commands`); + i += jumpCount; // Skip the specified number of commands + } // Update status after completing command if (!this.headless) { @@ -92,11 +100,21 @@ class TestExecutor { reducedMotion: 'reduce', // Force consistent viewport screen: profile.viewport, - deviceScaleFactor: 1 + deviceScaleFactor: 1, + // Allow popups to be opened + javaScriptEnabled: true, + // Handle popups properly + ignoreHTTPSErrors: true }); this.page = await this.context.newPage(); + // Handle popup events for follow command + this.page.on('popup', async (popup) => { + console.log('πŸ”„ Popup detected, adding to context'); + // The popup is automatically added to the context.pages() array + }); + // Create status window for headed mode AFTER main page so it appears on top if (!this.headless) { await this.createStatusWindow(); @@ -239,9 +257,17 @@ class TestExecutor { await this.extractToVariable(command); break; + case 'jumpIf': + return await this.jumpIf(command); + + case 'jumpIfNot': + return await this.jumpIfNot(command); + default: console.warn(`Unknown command type: ${command.type}`); } + + return 0; // No jump by default } async waitForElement(command) { @@ -922,15 +948,69 @@ class TestExecutor { async followToNewWindow() { console.log('πŸ”„ Switching to new tab...'); - // Get all current pages/tabs const pages = this.context.pages(); + console.log(`πŸ”„ Debug: Found ${pages.length} pages total`); + pages.forEach((page, index) => { + console.log(`πŸ”„ Page ${index}: ${page.url()}`); + }); - if (pages.length < 2) { - throw new Error('No additional tabs found to switch to'); + // Check if we already have multiple pages (popup already created) + if (pages.length >= 2) { + // Find the newest tab (not the current one) + let newTab = null; + const currentUrl = this.page.url(); + + // Look for a tab that's different from the current one + for (let i = pages.length - 1; i >= 0; i--) { + if (pages[i].url() !== currentUrl) { + newTab = pages[i]; + break; + } + } + + if (newTab) { + console.log(`πŸ”„ Found existing new tab: ${newTab.url()}`); + + // Wait for the tab to be ready + await newTab.waitForLoadState('networkidle'); + + // Switch to the new tab + this.page = newTab; + + console.log('πŸ”„ Switched to new tab'); + return; + } } - // Switch to the last (most recently opened) tab - const newTab = pages[pages.length - 1]; + // Fallback: Wait for a new tab to be created + const initialPageCount = pages.length; + let newTab = null; + let retryCount = 0; + const maxRetries = 20; // 10 seconds total (500ms * 20) + + while (retryCount < maxRetries) { + const currentPages = this.context.pages(); + + if (currentPages.length > initialPageCount) { + // New tab found, get the most recently opened one + newTab = currentPages[currentPages.length - 1]; + break; + } + + // Wait a bit before retrying + await this.page.waitForTimeout(500); + retryCount++; + + if (retryCount % 5 === 0) { + console.log(`πŸ”„ Still waiting for new tab... (${retryCount}/${maxRetries})`); + } + } + + if (!newTab) { + throw new Error('No additional tabs found to switch to after waiting'); + } + + console.log(`πŸ”„ New tab found: ${newTab.url()}`); // Wait for the tab to be ready await newTab.waitForLoadState('networkidle'); @@ -988,6 +1068,34 @@ class TestExecutor { console.log(`βœ… Variable "${variableName}" set to: "${value}"`); } + async jumpIf(command) { + const selector = this.buildSelector(command); + + try { + // Check if element exists (with a short timeout) + await this.page.locator(selector).first().waitFor({ timeout: 1000 }); + console.log(`πŸ”„ jumpIf: Element found, jumping ${command.jumpCount} commands`); + return command.jumpCount; + } catch (error) { + console.log(`πŸ”„ jumpIf: Element not found, continuing normally`); + return 0; + } + } + + async jumpIfNot(command) { + const selector = this.buildSelector(command); + + try { + // Check if element exists (with a short timeout) + await this.page.locator(selector).first().waitFor({ timeout: 1000 }); + console.log(`πŸ”„ jumpIfNot: Element found, continuing normally`); + return 0; + } catch (error) { + console.log(`πŸ”„ jumpIfNot: Element not found, jumping ${command.jumpCount} commands`); + return command.jumpCount; + } + } + formatCommandForOutput(command) { switch (command.type) { case 'use': @@ -1017,6 +1125,10 @@ class TestExecutor { return `switchToTab tabIndex=${command.tabIndex}`; case 'extract': return `extract ${this.formatSelector(command)} variableName="${command.variableName}"`; + case 'jumpIf': + return `jumpIf ${this.formatSelector(command)} jump=${command.jumpCount}`; + case 'jumpIfNot': + return `jumpIfNot ${this.formatSelector(command)} jump=${command.jumpCount}`; default: return `${command.type} ${JSON.stringify(command)}`; } diff --git a/src/linter.js b/src/linter.js index b509877..4397f3d 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', 'follow', 'switchToTab', 'extract']; + this.validCommands = ['use', 'open', 'wait', 'click', 'scroll', 'fill', 'break', 'sleep', 'dump', 'follow', 'switchToTab', 'extract', 'jumpIf', 'jumpIfNot']; // Valid HTML elements this.validElements = [ @@ -32,7 +32,9 @@ class TestLinter { 'dump': ['name'], 'follow': [], 'switchToTab': ['tabIndex'], - 'extract': ['element', 'attribute'] + 'extract': ['element', 'attribute'], + 'jumpIf': ['element', 'jump'], + 'jumpIfNot': ['element', 'jump'] }; // Initialize rules @@ -268,6 +270,14 @@ class TestLinter { continue; // This is valid } + if (line.cleaned.startsWith('jumpIf ')) { + continue; // This is valid + } + + if (line.cleaned.startsWith('jumpIfNot ')) { + continue; // This is valid + } + if (!this.validCommands.includes(command)) { this.addError(`Invalid command '${command}'. Valid commands: ${this.validCommands.join(', ')}`, line.lineNumber); } diff --git a/src/parser.js b/src/parser.js index cd7d318..13a103e 100644 --- a/src/parser.js +++ b/src/parser.js @@ -193,6 +193,52 @@ class TestParser { }; } + // Parse jumpIf command: jumpIf element=span childText="Server-Warenkorb" jump=4 + if (line.startsWith('jumpIf ')) { + const jumpMatch = line.match(/jump=(\d+)/); + if (jumpMatch) { + const jumpCount = parseInt(jumpMatch[1]); + const selectorPart = line.substring(7).replace(/\s+jump=\d+/, ''); // Remove 'jumpIf ' and jump=X + const params = this.parseParameters(selectorPart); + + return { + type: 'jumpIf', + jumpCount: jumpCount, + element: params.element, + name: params.name, + id: params.id, + class: params.class, + href: params.href, + htmlType: params.type, + child: params.child, + childText: params.childText + }; + } + } + + // Parse jumpIfNot command: jumpIfNot element=span childText="Server-Warenkorb" jump=4 + if (line.startsWith('jumpIfNot ')) { + const jumpMatch = line.match(/jump=(\d+)/); + if (jumpMatch) { + const jumpCount = parseInt(jumpMatch[1]); + const selectorPart = line.substring(10).replace(/\s+jump=\d+/, ''); // Remove 'jumpIfNot ' and jump=X + const params = this.parseParameters(selectorPart); + + return { + type: 'jumpIfNot', + jumpCount: jumpCount, + element: params.element, + name: params.name, + id: params.id, + class: params.class, + href: params.href, + htmlType: params.type, + child: params.child, + childText: params.childText + }; + } + } + // Parse switch to tab command: switch to tab 0 if (line.startsWith('switch to tab ')) { const match = line.match(/switch to tab (\d+)/); diff --git a/step1.test b/step1.test index 49db8f4..abc1ffc 100644 --- a/step1.test +++ b/step1.test @@ -31,7 +31,11 @@ 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" +sleep 2000 "anmelden click" +#dump "isServer" +jumpIfNot element=span childText="Server-Warenkorb lΓΆschen" jump=2 +click element=button childText="Weiter" +sleep 2000 "anmelden click" # Part 2 - Fill in the checkout form scroll element=span childText="Vorname"