2493 lines
99 KiB
Python
2493 lines
99 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Flask HTML-Formular Test Automator - Frontend-Fokussierter Browser-Tester
|
|
=========================================================================
|
|
|
|
Automatisierte Tests für HTML-Formulare durch echte Browser-Interaktionen.
|
|
Testet die tatsächliche Benutzeroberfläche mit JavaScript-Validierungen,
|
|
dynamischen Elementen und realistischen User-Interaktionen.
|
|
|
|
Autor: Till Tomczak
|
|
Version: 1.0
|
|
Mercedes-Benz Projektarbeit MYP
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import time
|
|
import random
|
|
import re
|
|
from pathlib import Path
|
|
from datetime import datetime
|
|
from typing import Dict, List, Optional, Any, Tuple
|
|
from dataclasses import dataclass, asdict
|
|
from enum import Enum
|
|
|
|
# Browser-Automation
|
|
try:
|
|
from playwright.async_api import async_playwright, Page, Browser, BrowserContext
|
|
PLAYWRIGHT_AVAILABLE = True
|
|
except ImportError:
|
|
PLAYWRIGHT_AVAILABLE = False
|
|
print("⚠️ Playwright nicht verfügbar. Installiere mit: pip install playwright")
|
|
# Fallback-Klassen für Type Hints
|
|
class Page: pass
|
|
class Browser: pass
|
|
class BrowserContext: pass
|
|
|
|
# Test-Daten-Generierung
|
|
try:
|
|
from faker import Faker
|
|
FAKER_AVAILABLE = True
|
|
except ImportError:
|
|
FAKER_AVAILABLE = False
|
|
print("⚠️ Faker nicht verfügbar. Installiere mit: pip install faker")
|
|
# Fallback-Klasse
|
|
class Faker:
|
|
def __init__(self, locale='de_DE'): pass
|
|
def email(self): return "test@example.com"
|
|
def name(self): return "Test User"
|
|
def first_name(self): return "Test"
|
|
def last_name(self): return "User"
|
|
def company(self): return "Test Company"
|
|
def city(self): return "Test City"
|
|
def street_address(self): return "Test Street 123"
|
|
def phone_number(self): return "+49 123 4567890"
|
|
def url(self): return "https://example.com"
|
|
def date(self): return "2024-06-18"
|
|
def text(self, max_nb_chars=200): return "Test text"
|
|
|
|
# HTML-Parsing
|
|
try:
|
|
from bs4 import BeautifulSoup
|
|
BS4_AVAILABLE = True
|
|
except ImportError:
|
|
BS4_AVAILABLE = False
|
|
print("⚠️ BeautifulSoup nicht verfügbar. Installiere mit: pip install beautifulsoup4")
|
|
# Fallback-Klasse
|
|
class BeautifulSoup:
|
|
def __init__(self, *args, **kwargs): pass
|
|
|
|
# Rich Console für schöne Ausgabe
|
|
try:
|
|
from rich.console import Console
|
|
from rich.table import Table
|
|
from rich.progress import Progress
|
|
from rich.panel import Panel
|
|
from rich.text import Text
|
|
RICH_AVAILABLE = True
|
|
console = Console()
|
|
except ImportError:
|
|
RICH_AVAILABLE = False
|
|
console = None
|
|
# Fallback-Klassen
|
|
class Console:
|
|
def print(self, *args, **kwargs): print(*args)
|
|
class Table:
|
|
def __init__(self, *args, **kwargs): pass
|
|
def add_column(self, *args, **kwargs): pass
|
|
def add_row(self, *args, **kwargs): pass
|
|
|
|
|
|
class TestStatus(Enum):
|
|
"""Status-Enumerierung für Tests"""
|
|
PASSED = "✅ BESTANDEN"
|
|
FAILED = "❌ FEHLGESCHLAGEN"
|
|
WARNING = "⚠️ WARNUNG"
|
|
SKIPPED = "⏭️ ÜBERSPRUNGEN"
|
|
|
|
|
|
@dataclass
|
|
class FormField:
|
|
"""Repräsentiert ein Formular-Feld mit allen Attributen"""
|
|
name: str
|
|
field_type: str
|
|
selector: str
|
|
label: str = ""
|
|
placeholder: str = ""
|
|
required: bool = False
|
|
pattern: str = ""
|
|
min_length: int = 0
|
|
max_length: int = 0
|
|
min_value: str = ""
|
|
max_value: str = ""
|
|
options: List[str] = None
|
|
validation_message: str = ""
|
|
|
|
def __post_init__(self):
|
|
if self.options is None:
|
|
self.options = []
|
|
|
|
|
|
@dataclass
|
|
class FormStructure:
|
|
"""Repräsentiert die Struktur eines HTML-Formulars"""
|
|
selector: str
|
|
action: str
|
|
method: str
|
|
fields: List[FormField]
|
|
submit_button: str = ""
|
|
is_multi_step: bool = False
|
|
csrf_token_field: str = ""
|
|
enctype: str = "application/x-www-form-urlencoded"
|
|
|
|
|
|
@dataclass
|
|
class TestResult:
|
|
"""Ergebnis eines Formular-Tests"""
|
|
form_selector: str
|
|
test_type: str
|
|
status: TestStatus
|
|
message: str
|
|
screenshot_path: str = ""
|
|
execution_time: float = 0.0
|
|
details: Dict[str, Any] = None
|
|
|
|
def __post_init__(self):
|
|
if self.details is None:
|
|
self.details = {}
|
|
|
|
|
|
class FrontendTestDataGenerator:
|
|
"""
|
|
Generiert realistische Test-Daten für HTML-Formulare basierend auf
|
|
Feld-Attributen und semantischen Hinweisen.
|
|
"""
|
|
|
|
def __init__(self, locale: str = 'de_DE'):
|
|
self.fake = Faker(locale) if FAKER_AVAILABLE else None
|
|
|
|
# Mapping von Input-Types zu Generatoren
|
|
self.type_generators = {
|
|
'email': self._generate_email,
|
|
'password': self._generate_password,
|
|
'tel': self._generate_phone,
|
|
'url': self._generate_url,
|
|
'date': self._generate_date,
|
|
'datetime-local': self._generate_datetime,
|
|
'time': self._generate_time,
|
|
'number': self._generate_number,
|
|
'range': self._generate_range,
|
|
'color': self._generate_color,
|
|
'text': self._generate_text,
|
|
'textarea': self._generate_text
|
|
}
|
|
|
|
# Pattern-basierte Generatoren
|
|
self.pattern_generators = {
|
|
r'[0-9]{5}': lambda: str(random.randint(10000, 99999)), # PLZ
|
|
r'[0-9]{3,4}': lambda: str(random.randint(100, 9999)), # Kurze Zahlen
|
|
r'[A-Za-z]+': lambda: self.fake.word() if self.fake else "TestWort",
|
|
r'[A-Z]{2,3}': lambda: ''.join(random.choices('ABCDEFGHIJKLMNOPQRSTUVWXYZ', k=3))
|
|
}
|
|
|
|
def generate_for_field(self, field: FormField, scenario: str = 'valid') -> str:
|
|
"""
|
|
Generiert Test-Daten für ein spezifisches Feld.
|
|
|
|
Args:
|
|
field: Das Formular-Feld
|
|
scenario: 'valid', 'invalid', 'edge_case', 'empty'
|
|
"""
|
|
if scenario == 'empty':
|
|
return ""
|
|
|
|
# Erst Pattern prüfen
|
|
if field.pattern and scenario == 'valid':
|
|
for pattern, generator in self.pattern_generators.items():
|
|
if re.match(pattern, field.pattern):
|
|
return generator()
|
|
|
|
# Dann Type-basierten Generator verwenden
|
|
generator = self.type_generators.get(field.field_type, self._generate_text)
|
|
|
|
if scenario == 'valid':
|
|
return generator(field)
|
|
elif scenario == 'invalid':
|
|
return self._generate_invalid_data(field)
|
|
elif scenario == 'edge_case':
|
|
return self._generate_edge_case(field)
|
|
|
|
return generator(field)
|
|
|
|
def _generate_email(self, field: FormField = None) -> str:
|
|
if self.fake:
|
|
return self.fake.email()
|
|
return f"test{random.randint(1000, 9999)}@example.com"
|
|
|
|
def _generate_password(self, field: FormField = None) -> str:
|
|
"""Generiert sichere Passwörter"""
|
|
if field and field.min_length > 0:
|
|
length = max(field.min_length, 8)
|
|
else:
|
|
length = 12
|
|
|
|
chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*"
|
|
return ''.join(random.choices(chars, k=length))
|
|
|
|
def _generate_phone(self, field: FormField = None) -> str:
|
|
if self.fake:
|
|
return self.fake.phone_number()
|
|
return f"+49 {random.randint(100, 999)} {random.randint(1000000, 9999999)}"
|
|
|
|
def _generate_url(self, field: FormField = None) -> str:
|
|
if self.fake:
|
|
return self.fake.url()
|
|
return f"https://example{random.randint(1, 100)}.com"
|
|
|
|
def _generate_date(self, field: FormField = None) -> str:
|
|
if self.fake:
|
|
return self.fake.date()
|
|
return "2024-06-18"
|
|
|
|
def _generate_datetime(self, field: FormField = None) -> str:
|
|
return f"{self._generate_date()}T{self._generate_time()}"
|
|
|
|
def _generate_time(self, field: FormField = None) -> str:
|
|
return f"{random.randint(0, 23):02d}:{random.randint(0, 59):02d}"
|
|
|
|
def _generate_number(self, field: FormField) -> str:
|
|
if field.min_value and field.max_value:
|
|
try:
|
|
min_val = int(field.min_value)
|
|
max_val = int(field.max_value)
|
|
return str(random.randint(min_val, max_val))
|
|
except ValueError:
|
|
pass
|
|
return str(random.randint(1, 1000))
|
|
|
|
def _generate_range(self, field: FormField) -> str:
|
|
return self._generate_number(field)
|
|
|
|
def _generate_color(self, field: FormField = None) -> str:
|
|
return f"#{random.randint(0, 0xFFFFFF):06x}"
|
|
|
|
def _generate_text(self, field: FormField) -> str:
|
|
"""Generiert Text basierend auf Feld-Eigenschaften"""
|
|
|
|
# Intelligente Generierung basierend auf Namen/Labels
|
|
field_name_lower = field.name.lower()
|
|
label_lower = field.label.lower() if field.label else ""
|
|
|
|
if any(keyword in field_name_lower + label_lower for keyword in ['name', 'vorname', 'nachname', 'firma']):
|
|
if self.fake:
|
|
if 'vorname' in field_name_lower or 'first' in field_name_lower:
|
|
return self.fake.first_name()
|
|
elif 'nachname' in field_name_lower or 'last' in field_name_lower:
|
|
return self.fake.last_name()
|
|
elif 'firma' in field_name_lower or 'company' in field_name_lower:
|
|
return self.fake.company()
|
|
else:
|
|
return self.fake.name()
|
|
|
|
if any(keyword in field_name_lower + label_lower for keyword in ['stadt', 'city', 'ort']):
|
|
return self.fake.city() if self.fake else "München"
|
|
|
|
if any(keyword in field_name_lower + label_lower for keyword in ['straße', 'adresse', 'address']):
|
|
return self.fake.street_address() if self.fake else "Teststraße 123"
|
|
|
|
# Standard-Text basierend auf Länge
|
|
if field.max_length > 0:
|
|
if field.max_length <= 20:
|
|
return "Kurzer Test"
|
|
elif field.max_length <= 100:
|
|
return "Dies ist ein mittellanger Testtext für das Formular."
|
|
else:
|
|
return self.fake.text(max_nb_chars=field.max_length) if self.fake else "Langer Testtext " * 10
|
|
|
|
return "Test Eingabe"
|
|
|
|
def _generate_invalid_data(self, field: FormField) -> str:
|
|
"""Generiert bewusst ungültige Daten für Validierungstests"""
|
|
|
|
if field.field_type == 'email':
|
|
return "ungueltige-email"
|
|
elif field.field_type == 'url':
|
|
return "keine-url"
|
|
elif field.field_type == 'number':
|
|
return "nicht-numerisch"
|
|
elif field.pattern:
|
|
return "passt-nicht-zum-pattern"
|
|
elif field.max_length > 0:
|
|
return "x" * (field.max_length + 10) # Zu lang
|
|
|
|
return "🚫 Ungültige Eingabe ñäöü"
|
|
|
|
def _generate_edge_case(self, field: FormField) -> str:
|
|
"""Generiert Edge-Cases für robuste Tests"""
|
|
|
|
edge_cases = [
|
|
"' OR 1=1 --", # SQL Injection
|
|
"<script>alert('xss')</script>", # XSS
|
|
"../../etc/passwd", # Path Traversal
|
|
"NULL\x00", # Null Byte
|
|
"ñäöüßÀÁÂÃÄÅ", # Unicode
|
|
"👨💻🚀💻", # Emojis
|
|
"\n\r\t", # Whitespace
|
|
" ", # Nur Leerzeichen
|
|
]
|
|
|
|
return random.choice(edge_cases)
|
|
|
|
|
|
class FormInteractionEngine:
|
|
"""
|
|
Simuliert realistische Benutzer-Interaktionen mit HTML-Formularen.
|
|
Verwendet menschliche Timing-Muster und natürliche Mausbewegungen.
|
|
"""
|
|
|
|
def __init__(self, page: Page):
|
|
self.page = page
|
|
self.human_delay_min = 50 # Minimale Tipp-Verzögerung (ms)
|
|
self.human_delay_max = 150 # Maximale Tipp-Verzögerung (ms)
|
|
|
|
async def fill_form_like_human(self, form: FormStructure, test_data: Dict[str, str]) -> List[TestResult]:
|
|
"""
|
|
Füllt ein Formular wie ein echter Mensch aus.
|
|
|
|
Args:
|
|
form: Die Formular-Struktur
|
|
test_data: Die einzutragenden Daten
|
|
"""
|
|
results = []
|
|
|
|
try:
|
|
# Formular in Sicht scrollen
|
|
await self.page.locator(form.selector).scroll_into_view_if_needed()
|
|
await self._human_pause()
|
|
|
|
for field in form.fields:
|
|
if field.name in test_data:
|
|
result = await self._fill_field_humanlike(field, test_data[field.name])
|
|
results.append(result)
|
|
|
|
return results
|
|
|
|
except Exception as e:
|
|
return [TestResult(
|
|
form_selector=form.selector,
|
|
test_type="form_filling",
|
|
status=TestStatus.FAILED,
|
|
message=f"Fehler beim Ausfüllen: {str(e)}"
|
|
)]
|
|
|
|
async def _fill_field_humanlike(self, field: FormField, value: str) -> TestResult:
|
|
"""Füllt ein einzelnes Feld mit menschlichem Verhalten"""
|
|
|
|
try:
|
|
field_element = self.page.locator(field.selector)
|
|
|
|
# Warten bis Feld sichtbar ist
|
|
await field_element.wait_for(state="visible", timeout=5000)
|
|
|
|
# Zu Feld scrollen
|
|
await field_element.scroll_into_view_if_needed()
|
|
await self._human_pause(100, 300)
|
|
|
|
# Feld anklicken (nicht nur fokussieren)
|
|
await field_element.click()
|
|
await self._human_pause()
|
|
|
|
# Existierenden Inhalt löschen
|
|
await self.page.keyboard.press("Control+a")
|
|
await self._human_pause(50, 100)
|
|
|
|
# Spezielle Behandlung nach Feld-Typ
|
|
if field.field_type in ['select', 'select-one']:
|
|
await self._select_option_humanlike(field_element, value)
|
|
elif field.field_type == 'checkbox':
|
|
if value.lower() in ['true', '1', 'on', 'yes']:
|
|
await field_element.check()
|
|
elif field.field_type == 'radio':
|
|
await field_element.check()
|
|
elif field.field_type == 'file':
|
|
await field_element.set_input_files(value)
|
|
else:
|
|
# Text-Input mit menschlicher Geschwindigkeit
|
|
await self._type_like_human(value)
|
|
|
|
# Kurze Pause nach Eingabe
|
|
await self._human_pause()
|
|
|
|
# Tab zur nächsten Feld (menschliches Verhalten)
|
|
await self.page.keyboard.press("Tab")
|
|
|
|
return TestResult(
|
|
form_selector=field.selector,
|
|
test_type="field_filling",
|
|
status=TestStatus.PASSED,
|
|
message=f"Feld '{field.name}' erfolgreich ausgefüllt"
|
|
)
|
|
|
|
except Exception as e:
|
|
return TestResult(
|
|
form_selector=field.selector,
|
|
test_type="field_filling",
|
|
status=TestStatus.FAILED,
|
|
message=f"Fehler bei Feld '{field.name}': {str(e)}"
|
|
)
|
|
|
|
async def _select_option_humanlike(self, select_element, value: str):
|
|
"""Wählt Option in Select-Element wie ein Mensch"""
|
|
|
|
# Dropdown öffnen
|
|
await select_element.click()
|
|
await self._human_pause(200, 400)
|
|
|
|
# Option suchen und klicken
|
|
option_selector = f"option[value='{value}'], option:has-text('{value}')"
|
|
try:
|
|
await self.page.click(option_selector)
|
|
except:
|
|
# Fallback: erste Option wählen
|
|
await self.page.click("option:first-child")
|
|
|
|
async def _type_like_human(self, text: str):
|
|
"""Tippt Text mit menschlicher Geschwindigkeit und gelegentlichen Fehlern"""
|
|
|
|
for char in text:
|
|
# Gelegentliche "Tippfehler" und Korrekturen (5% Chance)
|
|
if random.random() < 0.05 and char.isalpha():
|
|
# Falscher Buchstabe
|
|
wrong_char = random.choice('abcdefghijklmnopqrstuvwxyz')
|
|
await self.page.keyboard.type(wrong_char, delay=self._random_delay())
|
|
await self._human_pause(100, 200)
|
|
|
|
# Korrigieren
|
|
await self.page.keyboard.press("Backspace")
|
|
await self._human_pause(50, 150)
|
|
|
|
# Richtiger Buchstabe
|
|
await self.page.keyboard.type(char, delay=self._random_delay())
|
|
|
|
# Gelegentliche längere Pausen (Nachdenken)
|
|
if random.random() < 0.1:
|
|
await self._human_pause(500, 1000)
|
|
|
|
async def test_form_validations(self, form: FormStructure) -> List[TestResult]:
|
|
"""
|
|
Testet alle visuellen Validierungen eines Formulars.
|
|
"""
|
|
results = []
|
|
|
|
# 1. Teste Required-Feld-Validierungen
|
|
required_test = await self._test_required_fields(form)
|
|
results.extend(required_test)
|
|
|
|
# 2. Teste Pattern-Validierungen
|
|
pattern_test = await self._test_pattern_validations(form)
|
|
results.extend(pattern_test)
|
|
|
|
# 3. Teste Längen-Validierungen
|
|
length_test = await self._test_length_validations(form)
|
|
results.extend(length_test)
|
|
|
|
return results
|
|
|
|
async def _test_required_fields(self, form: FormStructure) -> List[TestResult]:
|
|
"""Testet Required-Feld-Validierungen"""
|
|
results = []
|
|
|
|
try:
|
|
# Versuche zu submitten ohne Required-Felder
|
|
if form.submit_button:
|
|
await self.page.click(form.submit_button)
|
|
await self._human_pause(500, 1000)
|
|
|
|
# Prüfe auf Validierungsmeldungen
|
|
for field in form.fields:
|
|
if field.required:
|
|
validation_visible = await self._check_validation_message_visible(field)
|
|
|
|
status = TestStatus.PASSED if validation_visible else TestStatus.FAILED
|
|
message = "Required-Validierung sichtbar" if validation_visible else "Keine Required-Validierung gefunden"
|
|
|
|
results.append(TestResult(
|
|
form_selector=field.selector,
|
|
test_type="required_validation",
|
|
status=status,
|
|
message=message
|
|
))
|
|
|
|
except Exception as e:
|
|
results.append(TestResult(
|
|
form_selector=form.selector,
|
|
test_type="required_validation",
|
|
status=TestStatus.FAILED,
|
|
message=f"Fehler bei Required-Test: {str(e)}"
|
|
))
|
|
|
|
return results
|
|
|
|
async def _test_pattern_validations(self, form: FormStructure) -> List[TestResult]:
|
|
"""Testet Pattern-basierte Validierungen"""
|
|
results = []
|
|
|
|
for field in form.fields:
|
|
if field.pattern:
|
|
try:
|
|
# Ungültiges Pattern eingeben
|
|
await self.page.fill(field.selector, "ungültiges-pattern")
|
|
await self.page.keyboard.press("Tab") # Trigger Validation
|
|
await self._human_pause(300, 500)
|
|
|
|
# Prüfe Validierungsmeldung
|
|
validation_visible = await self._check_validation_message_visible(field)
|
|
|
|
status = TestStatus.PASSED if validation_visible else TestStatus.FAILED
|
|
message = f"Pattern-Validierung für '{field.pattern}' funktioniert" if validation_visible else f"Pattern-Validierung für '{field.pattern}' fehlt"
|
|
|
|
results.append(TestResult(
|
|
form_selector=field.selector,
|
|
test_type="pattern_validation",
|
|
status=status,
|
|
message=message
|
|
))
|
|
|
|
except Exception as e:
|
|
results.append(TestResult(
|
|
form_selector=field.selector,
|
|
test_type="pattern_validation",
|
|
status=TestStatus.FAILED,
|
|
message=f"Fehler bei Pattern-Test: {str(e)}"
|
|
))
|
|
|
|
return results
|
|
|
|
async def _test_length_validations(self, form: FormStructure) -> List[TestResult]:
|
|
"""Testet Längen-Validierungen"""
|
|
results = []
|
|
|
|
for field in form.fields:
|
|
if field.max_length > 0:
|
|
try:
|
|
# Zu langen Text eingeben
|
|
long_text = "x" * (field.max_length + 10)
|
|
await self.page.fill(field.selector, long_text)
|
|
await self.page.keyboard.press("Tab")
|
|
await self._human_pause(300, 500)
|
|
|
|
# Prüfe ob Text abgeschnitten wurde oder Validierung angezeigt wird
|
|
actual_value = await self.page.input_value(field.selector)
|
|
validation_visible = await self._check_validation_message_visible(field)
|
|
|
|
if len(actual_value) <= field.max_length or validation_visible:
|
|
status = TestStatus.PASSED
|
|
message = f"Längen-Validierung (max: {field.max_length}) funktioniert"
|
|
else:
|
|
status = TestStatus.FAILED
|
|
message = f"Längen-Validierung (max: {field.max_length}) nicht wirksam"
|
|
|
|
results.append(TestResult(
|
|
form_selector=field.selector,
|
|
test_type="length_validation",
|
|
status=status,
|
|
message=message
|
|
))
|
|
|
|
except Exception as e:
|
|
results.append(TestResult(
|
|
form_selector=field.selector,
|
|
test_type="length_validation",
|
|
status=TestStatus.FAILED,
|
|
message=f"Fehler bei Längen-Test: {str(e)}"
|
|
))
|
|
|
|
return results
|
|
|
|
async def _check_validation_message_visible(self, field: FormField) -> bool:
|
|
"""Prüft ob eine Validierungsmeldung sichtbar ist"""
|
|
|
|
# Verschiedene Selektoren für Validierungsmeldungen
|
|
validation_selectors = [
|
|
f"{field.selector}:invalid",
|
|
f"{field.selector} + .error",
|
|
f"{field.selector} + .invalid-feedback",
|
|
f"{field.selector} ~ .error-message",
|
|
".field-error",
|
|
".validation-error",
|
|
"[role='alert']",
|
|
".alert-danger",
|
|
".text-danger"
|
|
]
|
|
|
|
for selector in validation_selectors:
|
|
try:
|
|
element = self.page.locator(selector)
|
|
if await element.count() > 0 and await element.is_visible():
|
|
return True
|
|
except:
|
|
continue
|
|
|
|
# Prüfe auch HTML5-Validierungsmeldungen
|
|
try:
|
|
validation_message = await self.page.evaluate(f"""
|
|
document.querySelector('{field.selector}').validationMessage
|
|
""")
|
|
return bool(validation_message)
|
|
except:
|
|
pass
|
|
|
|
return False
|
|
|
|
async def test_dynamic_form_behavior(self, form: FormStructure) -> List[TestResult]:
|
|
"""
|
|
Testet dynamisches Formular-Verhalten wie Conditional Fields.
|
|
"""
|
|
results = []
|
|
|
|
# Teste JavaScript-basierte Feld-Anzeige/Verstecken
|
|
initial_field_count = len([f for f in form.fields if await self.page.locator(f.selector).is_visible()])
|
|
|
|
# Interagiere mit Feldern und prüfe Änderungen
|
|
for field in form.fields:
|
|
if field.field_type in ['select', 'radio', 'checkbox']:
|
|
try:
|
|
# Zustand vor Änderung
|
|
visible_fields_before = await self._get_visible_field_count(form)
|
|
|
|
# Feld ändern
|
|
if field.field_type == 'checkbox':
|
|
await self.page.locator(field.selector).check()
|
|
elif field.options:
|
|
await self.page.select_option(field.selector, field.options[0])
|
|
|
|
await self._human_pause(500, 1000) # Warten auf JavaScript
|
|
|
|
# Zustand nach Änderung
|
|
visible_fields_after = await self._get_visible_field_count(form)
|
|
|
|
if visible_fields_before != visible_fields_after:
|
|
results.append(TestResult(
|
|
form_selector=field.selector,
|
|
test_type="dynamic_behavior",
|
|
status=TestStatus.PASSED,
|
|
message=f"Dynamisches Verhalten erkannt: {visible_fields_before} → {visible_fields_after} Felder"
|
|
))
|
|
|
|
except Exception as e:
|
|
results.append(TestResult(
|
|
form_selector=field.selector,
|
|
test_type="dynamic_behavior",
|
|
status=TestStatus.FAILED,
|
|
message=f"Fehler bei Dynamic-Test: {str(e)}"
|
|
))
|
|
|
|
return results
|
|
|
|
async def _get_visible_field_count(self, form: FormStructure) -> int:
|
|
"""Zählt sichtbare Felder in einem Formular"""
|
|
count = 0
|
|
for field in form.fields:
|
|
try:
|
|
if await self.page.locator(field.selector).is_visible():
|
|
count += 1
|
|
except:
|
|
pass
|
|
return count
|
|
|
|
async def _human_pause(self, min_ms: int = 100, max_ms: int = 300):
|
|
"""Simuliert menschliche Pausen"""
|
|
delay = random.randint(min_ms, max_ms)
|
|
await asyncio.sleep(delay / 1000)
|
|
|
|
def _random_delay(self) -> int:
|
|
"""Zufällige Tipp-Verzögerung"""
|
|
return random.randint(self.human_delay_min, self.human_delay_max)
|
|
|
|
|
|
class VisualFormValidator:
|
|
"""
|
|
Validiert visuelle Aspekte von Formularen wie Accessibility,
|
|
Responsive-Design und Benutzerfreundlichkeit.
|
|
"""
|
|
|
|
def __init__(self, page: Page):
|
|
self.page = page
|
|
|
|
async def check_form_accessibility(self, form: FormStructure) -> List[TestResult]:
|
|
"""
|
|
Prüft Accessibility-Aspekte eines Formulars.
|
|
"""
|
|
results = []
|
|
|
|
# 1. Label-Zuordnung prüfen
|
|
label_results = await self._check_label_associations(form)
|
|
results.extend(label_results)
|
|
|
|
# 2. ARIA-Attribute prüfen
|
|
aria_results = await self._check_aria_attributes(form)
|
|
results.extend(aria_results)
|
|
|
|
# 3. Keyboard-Navigation testen
|
|
keyboard_results = await self._test_keyboard_navigation(form)
|
|
results.extend(keyboard_results)
|
|
|
|
# 4. Kontrast prüfen
|
|
contrast_results = await self._check_color_contrast(form)
|
|
results.extend(contrast_results)
|
|
|
|
return results
|
|
|
|
async def _check_label_associations(self, form: FormStructure) -> List[TestResult]:
|
|
"""Prüft ob alle Formular-Felder korrekte Labels haben"""
|
|
results = []
|
|
|
|
for field in form.fields:
|
|
try:
|
|
# Verschiedene Label-Zuordnungsmethoden prüfen
|
|
has_label = await self.page.evaluate(f"""
|
|
(() => {{
|
|
const field = document.querySelector('{field.selector}');
|
|
if (!field) return false;
|
|
|
|
// 1. Explizites Label mit for-Attribut
|
|
const labelByFor = document.querySelector(`label[for="${{field.id}}"]`);
|
|
if (labelByFor) return true;
|
|
|
|
// 2. Umschließendes Label
|
|
const parentLabel = field.closest('label');
|
|
if (parentLabel) return true;
|
|
|
|
// 3. ARIA-Label
|
|
if (field.getAttribute('aria-label')) return true;
|
|
|
|
// 4. ARIA-Labelledby
|
|
if (field.getAttribute('aria-labelledby')) return true;
|
|
|
|
return false;
|
|
}})()
|
|
""")
|
|
|
|
status = TestStatus.PASSED if has_label else TestStatus.FAILED
|
|
message = "Label korrekt zugeordnet" if has_label else "Kein Label gefunden"
|
|
|
|
results.append(TestResult(
|
|
form_selector=field.selector,
|
|
test_type="accessibility_labels",
|
|
status=status,
|
|
message=message
|
|
))
|
|
|
|
except Exception as e:
|
|
results.append(TestResult(
|
|
form_selector=field.selector,
|
|
test_type="accessibility_labels",
|
|
status=TestStatus.FAILED,
|
|
message=f"Fehler bei Label-Prüfung: {str(e)}"
|
|
))
|
|
|
|
return results
|
|
|
|
async def _check_aria_attributes(self, form: FormStructure) -> List[TestResult]:
|
|
"""Prüft ARIA-Attribute für bessere Accessibility"""
|
|
results = []
|
|
|
|
for field in form.fields:
|
|
try:
|
|
aria_info = await self.page.evaluate(f"""
|
|
(() => {{
|
|
const field = document.querySelector('{field.selector}');
|
|
if (!field) return {{}};
|
|
|
|
return {{
|
|
hasAriaLabel: !!field.getAttribute('aria-label'),
|
|
hasAriaLabelledBy: !!field.getAttribute('aria-labelledby'),
|
|
hasAriaDescribedBy: !!field.getAttribute('aria-describedby'),
|
|
hasAriaRequired: !!field.getAttribute('aria-required'),
|
|
hasAriaInvalid: !!field.getAttribute('aria-invalid'),
|
|
role: field.getAttribute('role')
|
|
}};
|
|
}})()
|
|
""")
|
|
|
|
# Bewerte ARIA-Vollständigkeit
|
|
aria_score = sum([
|
|
aria_info.get('hasAriaLabel', False),
|
|
aria_info.get('hasAriaLabelledBy', False),
|
|
aria_info.get('hasAriaDescribedBy', False),
|
|
aria_info.get('hasAriaRequired', False) if field.required else True,
|
|
True # Basis-Punkt
|
|
])
|
|
|
|
if aria_score >= 3:
|
|
status = TestStatus.PASSED
|
|
message = "ARIA-Attribute ausreichend vorhanden"
|
|
elif aria_score >= 2:
|
|
status = TestStatus.WARNING
|
|
message = "ARIA-Attribute teilweise vorhanden"
|
|
else:
|
|
status = TestStatus.FAILED
|
|
message = "ARIA-Attribute unzureichend"
|
|
|
|
results.append(TestResult(
|
|
form_selector=field.selector,
|
|
test_type="accessibility_aria",
|
|
status=status,
|
|
message=message,
|
|
details=aria_info
|
|
))
|
|
|
|
except Exception as e:
|
|
results.append(TestResult(
|
|
form_selector=field.selector,
|
|
test_type="accessibility_aria",
|
|
status=TestStatus.FAILED,
|
|
message=f"Fehler bei ARIA-Prüfung: {str(e)}"
|
|
))
|
|
|
|
return results
|
|
|
|
async def _test_keyboard_navigation(self, form: FormStructure) -> List[TestResult]:
|
|
"""Testet Keyboard-Navigation durch das Formular"""
|
|
results = []
|
|
|
|
try:
|
|
# Zum ersten Feld navigieren
|
|
if form.fields:
|
|
first_field = form.fields[0]
|
|
await self.page.locator(first_field.selector).focus()
|
|
|
|
# Durch alle Felder tabben
|
|
focusable_count = 0
|
|
for i, field in enumerate(form.fields):
|
|
try:
|
|
# Tab-Taste drücken
|
|
if i > 0:
|
|
await self.page.keyboard.press("Tab")
|
|
await asyncio.sleep(0.1)
|
|
|
|
# Prüfen ob Feld fokussiert ist
|
|
is_focused = await self.page.evaluate(f"""
|
|
document.activeElement === document.querySelector('{field.selector}')
|
|
""")
|
|
|
|
if is_focused:
|
|
focusable_count += 1
|
|
|
|
except Exception:
|
|
pass
|
|
|
|
# Bewertung
|
|
expected_focusable = len([f for f in form.fields if f.field_type not in ['hidden']])
|
|
focus_ratio = focusable_count / expected_focusable if expected_focusable > 0 else 0
|
|
|
|
if focus_ratio >= 0.8:
|
|
status = TestStatus.PASSED
|
|
message = f"Keyboard-Navigation funktioniert ({focusable_count}/{expected_focusable} Felder erreichbar)"
|
|
elif focus_ratio >= 0.5:
|
|
status = TestStatus.WARNING
|
|
message = f"Keyboard-Navigation teilweise funktionsfähig ({focusable_count}/{expected_focusable} Felder)"
|
|
else:
|
|
status = TestStatus.FAILED
|
|
message = f"Keyboard-Navigation problematisch ({focusable_count}/{expected_focusable} Felder)"
|
|
|
|
results.append(TestResult(
|
|
form_selector=form.selector,
|
|
test_type="accessibility_keyboard",
|
|
status=status,
|
|
message=message
|
|
))
|
|
|
|
except Exception as e:
|
|
results.append(TestResult(
|
|
form_selector=form.selector,
|
|
test_type="accessibility_keyboard",
|
|
status=TestStatus.FAILED,
|
|
message=f"Fehler bei Keyboard-Test: {str(e)}"
|
|
))
|
|
|
|
return results
|
|
|
|
async def _check_color_contrast(self, form: FormStructure) -> List[TestResult]:
|
|
"""Prüft Farbkontrast für bessere Lesbarkeit"""
|
|
results = []
|
|
|
|
for field in form.fields:
|
|
try:
|
|
# Hole CSS-Eigenschaften
|
|
styles = await self.page.evaluate(f"""
|
|
(() => {{
|
|
const field = document.querySelector('{field.selector}');
|
|
if (!field) return null;
|
|
|
|
const computed = getComputedStyle(field);
|
|
return {{
|
|
color: computed.color,
|
|
backgroundColor: computed.backgroundColor,
|
|
borderColor: computed.borderColor
|
|
}};
|
|
}})()
|
|
""")
|
|
|
|
if styles:
|
|
# Vereinfachte Kontrast-Bewertung
|
|
# (Eine echte Implementierung würde WCAG-Kontrast-Algorithmus verwenden)
|
|
has_good_contrast = True # Placeholder
|
|
|
|
status = TestStatus.PASSED if has_good_contrast else TestStatus.WARNING
|
|
message = "Farbkontrast ausreichend" if has_good_contrast else "Farbkontrast möglicherweise unzureichend"
|
|
|
|
results.append(TestResult(
|
|
form_selector=field.selector,
|
|
test_type="accessibility_contrast",
|
|
status=status,
|
|
message=message,
|
|
details=styles
|
|
))
|
|
|
|
except Exception as e:
|
|
results.append(TestResult(
|
|
form_selector=field.selector,
|
|
test_type="accessibility_contrast",
|
|
status=TestStatus.FAILED,
|
|
message=f"Fehler bei Kontrast-Prüfung: {str(e)}"
|
|
))
|
|
|
|
return results
|
|
|
|
async def validate_error_display(self, form: FormStructure) -> List[TestResult]:
|
|
"""
|
|
Prüft wie Fehlermeldungen angezeigt werden.
|
|
"""
|
|
results = []
|
|
|
|
# Trigger Validierungsfehler
|
|
try:
|
|
# Submit ohne ausgefüllte Required-Felder
|
|
if form.submit_button:
|
|
await self.page.click(form.submit_button)
|
|
await asyncio.sleep(1) # Warten auf Validierung
|
|
|
|
# Prüfe Fehler-Display-Eigenschaften
|
|
error_display_quality = await self._analyze_error_display()
|
|
|
|
results.append(TestResult(
|
|
form_selector=form.selector,
|
|
test_type="error_display",
|
|
status=error_display_quality['status'],
|
|
message=error_display_quality['message'],
|
|
details=error_display_quality['details']
|
|
))
|
|
|
|
except Exception as e:
|
|
results.append(TestResult(
|
|
form_selector=form.selector,
|
|
test_type="error_display",
|
|
status=TestStatus.FAILED,
|
|
message=f"Fehler bei Error-Display-Test: {str(e)}"
|
|
))
|
|
|
|
return results
|
|
|
|
async def _analyze_error_display(self) -> Dict[str, Any]:
|
|
"""Analysiert die Qualität der Fehlerdarstellung"""
|
|
|
|
error_analysis = await self.page.evaluate("""
|
|
(() => {
|
|
const errorElements = document.querySelectorAll(
|
|
'.error, .invalid-feedback, .text-danger, [role="alert"], .field-error'
|
|
);
|
|
|
|
let visibleErrors = 0;
|
|
let totalErrors = errorElements.length;
|
|
let errorTexts = [];
|
|
|
|
errorElements.forEach(el => {
|
|
const isVisible = el.offsetWidth > 0 && el.offsetHeight > 0;
|
|
if (isVisible) {
|
|
visibleErrors++;
|
|
errorTexts.push(el.textContent.trim());
|
|
}
|
|
});
|
|
|
|
return {
|
|
totalErrors,
|
|
visibleErrors,
|
|
errorTexts,
|
|
hasErrors: totalErrors > 0
|
|
};
|
|
})()
|
|
""")
|
|
|
|
if error_analysis['hasErrors']:
|
|
if error_analysis['visibleErrors'] > 0:
|
|
status = TestStatus.PASSED
|
|
message = f"Fehlermeldungen werden korrekt angezeigt ({error_analysis['visibleErrors']} sichtbar)"
|
|
else:
|
|
status = TestStatus.FAILED
|
|
message = "Fehlermeldungen vorhanden aber nicht sichtbar"
|
|
else:
|
|
status = TestStatus.WARNING
|
|
message = "Keine Fehlermeldungen gefunden (möglicherweise keine Validierung aktiv)"
|
|
|
|
return {
|
|
'status': status,
|
|
'message': message,
|
|
'details': error_analysis
|
|
}
|
|
|
|
async def test_responsive_behavior(self, form: FormStructure, viewports: List[Dict] = None) -> List[TestResult]:
|
|
"""
|
|
Testet Formular auf verschiedenen Bildschirmgrößen.
|
|
"""
|
|
if not viewports:
|
|
viewports = [
|
|
{'width': 375, 'height': 667, 'name': 'iPhone SE'},
|
|
{'width': 768, 'height': 1024, 'name': 'iPad'},
|
|
{'width': 1920, 'height': 1080, 'name': 'Desktop'}
|
|
]
|
|
|
|
results = []
|
|
original_viewport = self.page.viewport_size
|
|
|
|
for viewport in viewports:
|
|
try:
|
|
# Viewport setzen
|
|
await self.page.set_viewport_size(width=viewport['width'], height=viewport['height'])
|
|
await asyncio.sleep(0.5) # Layout stabilisieren
|
|
|
|
# Formular-Sichtbarkeit prüfen
|
|
form_visible = await self.page.locator(form.selector).is_visible()
|
|
|
|
# Feld-Zugänglichkeit prüfen
|
|
accessible_fields = 0
|
|
for field in form.fields:
|
|
try:
|
|
field_element = self.page.locator(field.selector)
|
|
if await field_element.is_visible():
|
|
# Prüfe ob Feld bedienbar ist
|
|
bounding_box = await field_element.bounding_box()
|
|
if bounding_box and bounding_box['width'] > 0 and bounding_box['height'] > 0:
|
|
accessible_fields += 1
|
|
except:
|
|
pass
|
|
|
|
# Bewertung
|
|
accessibility_ratio = accessible_fields / len(form.fields) if form.fields else 0
|
|
|
|
if form_visible and accessibility_ratio >= 0.9:
|
|
status = TestStatus.PASSED
|
|
message = f"Responsive Design funktioniert auf {viewport['name']}"
|
|
elif form_visible and accessibility_ratio >= 0.7:
|
|
status = TestStatus.WARNING
|
|
message = f"Responsive Design teilweise funktional auf {viewport['name']}"
|
|
else:
|
|
status = TestStatus.FAILED
|
|
message = f"Responsive Design problematisch auf {viewport['name']}"
|
|
|
|
results.append(TestResult(
|
|
form_selector=form.selector,
|
|
test_type=f"responsive_{viewport['name'].lower().replace(' ', '_')}",
|
|
status=status,
|
|
message=message,
|
|
details={
|
|
'viewport': viewport,
|
|
'form_visible': form_visible,
|
|
'accessible_fields': accessible_fields,
|
|
'total_fields': len(form.fields),
|
|
'accessibility_ratio': accessibility_ratio
|
|
}
|
|
))
|
|
|
|
except Exception as e:
|
|
results.append(TestResult(
|
|
form_selector=form.selector,
|
|
test_type=f"responsive_{viewport['name'].lower().replace(' ', '_')}",
|
|
status=TestStatus.FAILED,
|
|
message=f"Fehler bei Responsive-Test auf {viewport['name']}: {str(e)}"
|
|
))
|
|
|
|
# Ursprüngliches Viewport wiederherstellen
|
|
if original_viewport:
|
|
await self.page.set_viewport_size(
|
|
width=original_viewport['width'],
|
|
height=original_viewport['height']
|
|
)
|
|
|
|
return results
|
|
|
|
|
|
class VisualTestReporter:
|
|
"""
|
|
Erstellt umfassende Reports mit Screenshots, Videos und visuellen Beweisen.
|
|
"""
|
|
|
|
def __init__(self, output_dir: Path = None):
|
|
self.output_dir = output_dir or Path("form_test_reports")
|
|
self.output_dir.mkdir(exist_ok=True)
|
|
|
|
self.screenshots_dir = self.output_dir / "screenshots"
|
|
self.videos_dir = self.output_dir / "videos"
|
|
self.reports_dir = self.output_dir / "reports"
|
|
|
|
for dir_path in [self.screenshots_dir, self.videos_dir, self.reports_dir]:
|
|
dir_path.mkdir(exist_ok=True)
|
|
|
|
async def capture_form_states(self, page: Page, form_selector: str, test_name: str) -> Dict[str, str]:
|
|
"""
|
|
Dokumentiert verschiedene Formular-Zustände visuell.
|
|
"""
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
base_name = f"{test_name}_{timestamp}"
|
|
|
|
screenshots = {}
|
|
|
|
try:
|
|
# 1. Initial State
|
|
initial_path = self.screenshots_dir / f"{base_name}_initial.png"
|
|
await page.screenshot(path=str(initial_path), full_page=True)
|
|
screenshots['initial'] = str(initial_path)
|
|
|
|
# 2. Form in Focus
|
|
form_element = page.locator(form_selector)
|
|
await form_element.scroll_into_view_if_needed()
|
|
await asyncio.sleep(0.5)
|
|
|
|
form_focus_path = self.screenshots_dir / f"{base_name}_form_focus.png"
|
|
await page.screenshot(path=str(form_focus_path), full_page=True)
|
|
screenshots['form_focus'] = str(form_focus_path)
|
|
|
|
# 3. Form Element Only (wenn möglich)
|
|
try:
|
|
form_only_path = self.screenshots_dir / f"{base_name}_form_only.png"
|
|
await form_element.screenshot(path=str(form_only_path))
|
|
screenshots['form_only'] = str(form_only_path)
|
|
except:
|
|
pass
|
|
|
|
return screenshots
|
|
|
|
except Exception as e:
|
|
print(f"Fehler beim Screenshot-Capturing: {e}")
|
|
return {}
|
|
|
|
async def capture_validation_errors(self, page: Page, test_name: str) -> str:
|
|
"""
|
|
Erstellt Screenshots von Validierungsfehlern.
|
|
"""
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
error_screenshot_path = self.screenshots_dir / f"{test_name}_validation_errors_{timestamp}.png"
|
|
|
|
try:
|
|
await page.screenshot(path=str(error_screenshot_path), full_page=True)
|
|
return str(error_screenshot_path)
|
|
except Exception as e:
|
|
print(f"Fehler beim Error-Screenshot: {e}")
|
|
return ""
|
|
|
|
async def start_video_recording(self, page: Page, test_name: str):
|
|
"""Startet Video-Aufzeichnung (falls unterstützt)"""
|
|
try:
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
video_path = self.videos_dir / f"{test_name}_{timestamp}.webm"
|
|
|
|
# Playwright Video-Recording aktivieren (context-basiert)
|
|
# Dies muss beim Context-Setup konfiguriert werden
|
|
return str(video_path)
|
|
except Exception as e:
|
|
print(f"Video-Recording nicht verfügbar: {e}")
|
|
return None
|
|
|
|
def generate_visual_report(self, test_results: List[TestResult], report_name: str = "form_test_report") -> str:
|
|
"""
|
|
Generiert einen HTML-Report mit visuellen Beweisen.
|
|
"""
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
report_path = self.reports_dir / f"{report_name}_{timestamp}.html"
|
|
|
|
# Gruppiere Ergebnisse nach Form
|
|
forms_results = {}
|
|
for result in test_results:
|
|
form_id = result.form_selector
|
|
if form_id not in forms_results:
|
|
forms_results[form_id] = []
|
|
forms_results[form_id].append(result)
|
|
|
|
# HTML-Report generieren
|
|
html_content = self._generate_html_report(forms_results, report_name)
|
|
|
|
try:
|
|
with open(report_path, 'w', encoding='utf-8') as f:
|
|
f.write(html_content)
|
|
|
|
print(f"📊 HTML-Report erstellt: {report_path}")
|
|
return str(report_path)
|
|
|
|
except Exception as e:
|
|
print(f"Fehler beim Report-Generieren: {e}")
|
|
return ""
|
|
|
|
def _generate_html_report(self, forms_results: Dict[str, List[TestResult]], report_name: str) -> str:
|
|
"""Generiert HTML-Inhalt für den Report"""
|
|
|
|
# Statistiken berechnen
|
|
total_tests = sum(len(results) for results in forms_results.values())
|
|
passed_tests = sum(1 for results in forms_results.values() for r in results if r.status == TestStatus.PASSED)
|
|
failed_tests = sum(1 for results in forms_results.values() for r in results if r.status == TestStatus.FAILED)
|
|
warning_tests = sum(1 for results in forms_results.values() for r in results if r.status == TestStatus.WARNING)
|
|
|
|
success_rate = (passed_tests / total_tests * 100) if total_tests > 0 else 0
|
|
|
|
html = f"""
|
|
<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>{report_name} - Formular Test Report</title>
|
|
<style>
|
|
body {{
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
line-height: 1.6;
|
|
margin: 0;
|
|
padding: 20px;
|
|
background-color: #f5f5f5;
|
|
}}
|
|
.container {{
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
background: white;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
overflow: hidden;
|
|
}}
|
|
.header {{
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
color: white;
|
|
padding: 30px;
|
|
text-align: center;
|
|
}}
|
|
.stats {{
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 20px;
|
|
padding: 30px;
|
|
background: #f8f9fa;
|
|
}}
|
|
.stat-card {{
|
|
background: white;
|
|
padding: 20px;
|
|
border-radius: 8px;
|
|
text-align: center;
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
}}
|
|
.stat-number {{
|
|
font-size: 2.5em;
|
|
font-weight: bold;
|
|
margin-bottom: 10px;
|
|
}}
|
|
.passed {{ color: #28a745; }}
|
|
.failed {{ color: #dc3545; }}
|
|
.warning {{ color: #ffc107; }}
|
|
.content {{
|
|
padding: 30px;
|
|
}}
|
|
.form-section {{
|
|
margin-bottom: 40px;
|
|
border: 1px solid #dee2e6;
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
}}
|
|
.form-header {{
|
|
background: #e9ecef;
|
|
padding: 15px 20px;
|
|
border-bottom: 1px solid #dee2e6;
|
|
}}
|
|
.test-results {{
|
|
padding: 20px;
|
|
}}
|
|
.test-item {{
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 10px 0;
|
|
border-bottom: 1px solid #f1f3f4;
|
|
}}
|
|
.test-item:last-child {{
|
|
border-bottom: none;
|
|
}}
|
|
.test-status {{
|
|
width: 150px;
|
|
font-weight: bold;
|
|
}}
|
|
.test-message {{
|
|
flex: 1;
|
|
}}
|
|
.test-details {{
|
|
background: #f8f9fa;
|
|
padding: 10px;
|
|
margin-top: 10px;
|
|
border-radius: 4px;
|
|
font-family: monospace;
|
|
font-size: 0.9em;
|
|
}}
|
|
.screenshot {{
|
|
max-width: 100%;
|
|
border-radius: 4px;
|
|
margin: 10px 0;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
|
}}
|
|
.footer {{
|
|
background: #f8f9fa;
|
|
padding: 20px;
|
|
text-align: center;
|
|
color: #6c757d;
|
|
border-top: 1px solid #dee2e6;
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="header">
|
|
<h1>🧪 Formular Test Report</h1>
|
|
<h2>{report_name}</h2>
|
|
<p>Generiert am {datetime.now().strftime('%d.%m.%Y um %H:%M:%S')}</p>
|
|
</div>
|
|
|
|
<div class="stats">
|
|
<div class="stat-card">
|
|
<div class="stat-number">{total_tests}</div>
|
|
<div>Gesamt Tests</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-number passed">{passed_tests}</div>
|
|
<div>Bestanden</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-number failed">{failed_tests}</div>
|
|
<div>Fehlgeschlagen</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-number warning">{warning_tests}</div>
|
|
<div>Warnungen</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-number" style="color: {'#28a745' if success_rate >= 80 else '#ffc107' if success_rate >= 60 else '#dc3545'}">{success_rate:.1f}%</div>
|
|
<div>Erfolgsrate</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="content">
|
|
"""
|
|
|
|
# Formular-spezifische Ergebnisse
|
|
for form_selector, results in forms_results.items():
|
|
html += f"""
|
|
<div class="form-section">
|
|
<div class="form-header">
|
|
<h3>📝 Formular: {form_selector}</h3>
|
|
<p>{len(results)} Tests durchgeführt</p>
|
|
</div>
|
|
<div class="test-results">
|
|
"""
|
|
|
|
for result in results:
|
|
status_class = result.status.value.split()[0].lower().replace('✅', 'passed').replace('❌', 'failed').replace('⚠️', 'warning')
|
|
|
|
html += f"""
|
|
<div class="test-item">
|
|
<div class="test-status {status_class}">{result.status.value}</div>
|
|
<div class="test-message">
|
|
<strong>{result.test_type.replace('_', ' ').title()}</strong><br>
|
|
{result.message}
|
|
{f'<small> ({result.execution_time:.2f}s)</small>' if result.execution_time > 0 else ''}
|
|
</div>
|
|
</div>
|
|
"""
|
|
|
|
# Details hinzufügen wenn vorhanden
|
|
if result.details:
|
|
html += f"""
|
|
<div class="test-details">
|
|
<strong>Details:</strong><br>
|
|
<pre>{json.dumps(result.details, indent=2, ensure_ascii=False)}</pre>
|
|
</div>
|
|
"""
|
|
|
|
# Screenshot hinzufügen wenn vorhanden
|
|
if result.screenshot_path and Path(result.screenshot_path).exists():
|
|
rel_path = Path(result.screenshot_path).relative_to(self.output_dir)
|
|
html += f"""
|
|
<div>
|
|
<img src="{rel_path}" alt="Screenshot für {result.test_type}" class="screenshot">
|
|
</div>
|
|
"""
|
|
|
|
html += """
|
|
</div>
|
|
</div>
|
|
"""
|
|
|
|
html += f"""
|
|
</div>
|
|
|
|
<div class="footer">
|
|
<p>🚀 Generiert mit Flask HTML-Formular Test Automator</p>
|
|
<p>Mercedes-Benz Projektarbeit MYP - Till Tomczak</p>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
return html
|
|
|
|
def generate_json_report(self, test_results: List[TestResult], report_name: str = "form_test_report") -> str:
|
|
"""Generiert einen JSON-Report für maschinelle Weiterverarbeitung"""
|
|
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
json_path = self.reports_dir / f"{report_name}_{timestamp}.json"
|
|
|
|
# Konvertiere Results zu Dict
|
|
results_data = []
|
|
for result in test_results:
|
|
result_dict = asdict(result)
|
|
result_dict['status'] = result.status.value # Enum zu String
|
|
results_data.append(result_dict)
|
|
|
|
report_data = {
|
|
'report_name': report_name,
|
|
'generated_at': datetime.now().isoformat(),
|
|
'summary': {
|
|
'total_tests': len(test_results),
|
|
'passed': len([r for r in test_results if r.status == TestStatus.PASSED]),
|
|
'failed': len([r for r in test_results if r.status == TestStatus.FAILED]),
|
|
'warnings': len([r for r in test_results if r.status == TestStatus.WARNING]),
|
|
'skipped': len([r for r in test_results if r.status == TestStatus.SKIPPED])
|
|
},
|
|
'test_results': results_data
|
|
}
|
|
|
|
try:
|
|
with open(json_path, 'w', encoding='utf-8') as f:
|
|
json.dump(report_data, f, indent=2, ensure_ascii=False)
|
|
|
|
print(f"📋 JSON-Report erstellt: {json_path}")
|
|
return str(json_path)
|
|
|
|
except Exception as e:
|
|
print(f"Fehler beim JSON-Report: {e}")
|
|
return ""
|
|
|
|
|
|
class HTMLFormTestAutomator:
|
|
"""
|
|
Hauptklasse für automatisierte HTML-Formular-Tests.
|
|
Koordiniert alle Test-Komponenten und Browser-Interaktionen.
|
|
"""
|
|
|
|
def __init__(self, base_url: str, browser: str = 'chromium', headless: bool = True):
|
|
self.base_url = base_url.rstrip('/')
|
|
self.browser_type = browser
|
|
self.headless = headless
|
|
|
|
# Komponenten
|
|
self.data_generator = FrontendTestDataGenerator()
|
|
self.reporter = VisualTestReporter()
|
|
|
|
# Browser-Instanzen (werden bei Bedarf initialisiert)
|
|
self.playwright = None
|
|
self.browser = None
|
|
self.context = None
|
|
self.page = None
|
|
|
|
# Test-Ergebnisse
|
|
self.test_results: List[TestResult] = []
|
|
|
|
# Validiere Voraussetzungen
|
|
if not PLAYWRIGHT_AVAILABLE:
|
|
raise ImportError("Playwright ist erforderlich. Installiere mit: pip install playwright")
|
|
|
|
async def __aenter__(self):
|
|
"""Async Context Manager - Setup"""
|
|
await self._setup_browser()
|
|
return self
|
|
|
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
"""Async Context Manager - Cleanup"""
|
|
await self._cleanup_browser()
|
|
|
|
async def _setup_browser(self):
|
|
"""Initialisiert Browser und Kontext"""
|
|
try:
|
|
self.playwright = await async_playwright().start()
|
|
|
|
# Browser starten
|
|
if self.browser_type == 'chromium':
|
|
self.browser = await self.playwright.chromium.launch(headless=self.headless)
|
|
elif self.browser_type == 'firefox':
|
|
self.browser = await self.playwright.firefox.launch(headless=self.headless)
|
|
elif self.browser_type == 'webkit':
|
|
self.browser = await self.playwright.webkit.launch(headless=self.headless)
|
|
else:
|
|
raise ValueError(f"Unbekannter Browser: {self.browser_type}")
|
|
|
|
# Kontext erstellen mit optimalen Einstellungen
|
|
self.context = await self.browser.new_context(
|
|
viewport={'width': 1280, 'height': 720},
|
|
ignore_https_errors=True, # Für lokale Entwicklung
|
|
record_video_dir=str(self.reporter.videos_dir) if not self.headless else None
|
|
)
|
|
|
|
# Seite erstellen
|
|
self.page = await self.context.new_page()
|
|
|
|
# Event-Handler für besseres Debugging
|
|
self.page.on("pageerror", lambda error: print(f"🚨 Browser-Fehler: {error}"))
|
|
self.page.on("console", lambda msg: print(f"🔍 Console: {msg.text}"))
|
|
|
|
print(f"✅ Browser {self.browser_type} gestartet (headless: {self.headless})")
|
|
|
|
except Exception as e:
|
|
await self._cleanup_browser()
|
|
raise Exception(f"Browser-Setup fehlgeschlagen: {str(e)}")
|
|
|
|
async def _cleanup_browser(self):
|
|
"""Räumt Browser-Ressourcen auf"""
|
|
try:
|
|
if self.page:
|
|
await self.page.close()
|
|
if self.context:
|
|
await self.context.close()
|
|
if self.browser:
|
|
await self.browser.close()
|
|
if self.playwright:
|
|
await self.playwright.stop()
|
|
except Exception as e:
|
|
print(f"⚠️ Cleanup-Fehler: {e}")
|
|
|
|
async def scan_for_forms(self, page_url: str) -> List[FormStructure]:
|
|
"""
|
|
Öffnet eine Seite und findet alle HTML-Formulare.
|
|
|
|
Args:
|
|
page_url: Relative oder absolute URL zur Seite
|
|
"""
|
|
full_url = page_url if page_url.startswith('http') else f"{self.base_url}{page_url}"
|
|
|
|
try:
|
|
print(f"🔍 Scanne Formulare auf: {full_url}")
|
|
|
|
# Seite laden
|
|
response = await self.page.goto(full_url, wait_until="networkidle")
|
|
if response.status >= 400:
|
|
print(f"⚠️ HTTP {response.status}: {full_url}")
|
|
|
|
# Warten auf JavaScript und dynamische Inhalte
|
|
await asyncio.sleep(2)
|
|
|
|
# Formulare finden und analysieren
|
|
forms_data = await self.page.evaluate("""
|
|
() => {
|
|
const forms = Array.from(document.querySelectorAll('form'));
|
|
|
|
return forms.map((form, index) => {
|
|
const formId = form.id || form.className || `form-${index}`;
|
|
const selector = form.id ? `#${form.id}` :
|
|
form.className ? `.${form.className.split(' ')[0]}` :
|
|
`form:nth-of-type(${index + 1})`;
|
|
|
|
// Felder analysieren
|
|
const fields = Array.from(form.querySelectorAll(
|
|
'input, textarea, select, [contenteditable="true"]'
|
|
)).map(field => {
|
|
const label = field.labels?.[0]?.textContent ||
|
|
field.getAttribute('aria-label') ||
|
|
field.previousElementSibling?.textContent ||
|
|
field.placeholder ||
|
|
field.name ||
|
|
field.id;
|
|
|
|
const options = field.tagName === 'SELECT' ?
|
|
Array.from(field.options).map(opt => opt.value) : [];
|
|
|
|
return {
|
|
name: field.name || field.id || `field-${Math.random().toString(36).substr(2, 9)}`,
|
|
field_type: field.type || field.tagName.toLowerCase(),
|
|
selector: field.id ? `#${field.id}` :
|
|
field.name ? `[name="${field.name}"]` :
|
|
field.className ? `.${field.className.split(' ')[0]}` :
|
|
`${field.tagName.toLowerCase()}:nth-of-type(${Array.from(form.querySelectorAll(field.tagName)).indexOf(field) + 1})`,
|
|
label: label?.trim() || '',
|
|
placeholder: field.placeholder || '',
|
|
required: field.required,
|
|
pattern: field.pattern || '',
|
|
min_length: parseInt(field.minLength) || 0,
|
|
max_length: parseInt(field.maxLength) || 0,
|
|
min_value: field.min || '',
|
|
max_value: field.max || '',
|
|
options: options
|
|
};
|
|
});
|
|
|
|
// Submit-Button finden
|
|
const submitButton = form.querySelector('[type="submit"], button[type="submit"], button:not([type])');
|
|
const submitSelector = submitButton ?
|
|
(submitButton.id ? `#${submitButton.id}` :
|
|
submitButton.className ? `.${submitButton.className.split(' ')[0]}` :
|
|
'[type="submit"]') : '';
|
|
|
|
// CSRF-Token finden
|
|
const csrfField = form.querySelector('[name*="csrf"], [name*="token"], [name="_token"]');
|
|
const csrfSelector = csrfField ? `[name="${csrfField.name}"]` : '';
|
|
|
|
return {
|
|
selector: selector,
|
|
action: form.action || window.location.href,
|
|
method: form.method || 'POST',
|
|
fields: fields,
|
|
submit_button: submitSelector,
|
|
is_multi_step: form.querySelectorAll('.step, .wizard-step, [data-step]').length > 1,
|
|
csrf_token_field: csrfSelector,
|
|
enctype: form.enctype || 'application/x-www-form-urlencoded'
|
|
};
|
|
});
|
|
}
|
|
""")
|
|
|
|
# Konvertiere zu FormStructure-Objekten
|
|
forms = []
|
|
for form_data in forms_data:
|
|
fields = [FormField(**field_data) for field_data in form_data['fields']]
|
|
form = FormStructure(
|
|
selector=form_data['selector'],
|
|
action=form_data['action'],
|
|
method=form_data['method'],
|
|
fields=fields,
|
|
submit_button=form_data['submit_button'],
|
|
is_multi_step=form_data['is_multi_step'],
|
|
csrf_token_field=form_data['csrf_token_field'],
|
|
enctype=form_data['enctype']
|
|
)
|
|
forms.append(form)
|
|
|
|
print(f"✅ {len(forms)} Formulare gefunden")
|
|
for i, form in enumerate(forms):
|
|
print(f" 📋 Form {i+1}: {form.selector} ({len(form.fields)} Felder)")
|
|
|
|
return forms
|
|
|
|
except Exception as e:
|
|
print(f"❌ Fehler beim Formular-Scan: {str(e)}")
|
|
return []
|
|
|
|
async def test_all_forms_on_page(self, page_url: str, test_scenarios: List[str] = None) -> List[TestResult]:
|
|
"""
|
|
Testet alle Formulare auf einer Seite mit verschiedenen Szenarien.
|
|
|
|
Args:
|
|
page_url: URL der zu testenden Seite
|
|
test_scenarios: Liste der Test-Szenarien ['valid', 'invalid', 'edge_cases', 'accessibility']
|
|
"""
|
|
if test_scenarios is None:
|
|
test_scenarios = ['valid', 'invalid', 'accessibility']
|
|
|
|
all_results = []
|
|
|
|
# Formulare finden
|
|
forms = await self.scan_for_forms(page_url)
|
|
|
|
if not forms:
|
|
return [TestResult(
|
|
form_selector=page_url,
|
|
test_type="form_discovery",
|
|
status=TestStatus.WARNING,
|
|
message="Keine Formulare auf der Seite gefunden"
|
|
)]
|
|
|
|
# Jedes Formular testen
|
|
for form in forms:
|
|
print(f"\n🧪 Teste Formular: {form.selector}")
|
|
|
|
form_results = await self.test_form_comprehensive(form, test_scenarios)
|
|
all_results.extend(form_results)
|
|
|
|
self.test_results.extend(all_results)
|
|
return all_results
|
|
|
|
async def test_form_comprehensive(self, form: FormStructure, test_scenarios: List[str]) -> List[TestResult]:
|
|
"""
|
|
Führt umfassende Tests für ein einzelnes Formular durch.
|
|
"""
|
|
results = []
|
|
|
|
# Test-Engines initialisieren
|
|
interaction_engine = FormInteractionEngine(self.page)
|
|
visual_validator = VisualFormValidator(self.page)
|
|
|
|
# Screenshots für Dokumentation
|
|
screenshots = await self.reporter.capture_form_states(
|
|
self.page, form.selector, f"form_test_{form.selector.replace('#', '').replace('.', '')}"
|
|
)
|
|
|
|
try:
|
|
# 1. Gültige Daten testen
|
|
if 'valid' in test_scenarios:
|
|
print(" ✅ Teste gültige Eingaben...")
|
|
valid_results = await self._test_valid_form_submission(form, interaction_engine)
|
|
results.extend(valid_results)
|
|
|
|
# 2. Ungültige Daten testen
|
|
if 'invalid' in test_scenarios:
|
|
print(" ❌ Teste ungültige Eingaben...")
|
|
invalid_results = await self._test_invalid_form_submission(form, interaction_engine)
|
|
results.extend(invalid_results)
|
|
|
|
# 3. Edge Cases testen
|
|
if 'edge_cases' in test_scenarios:
|
|
print(" 🔍 Teste Edge Cases...")
|
|
edge_results = await self._test_edge_cases(form, interaction_engine)
|
|
results.extend(edge_results)
|
|
|
|
# 4. Accessibility testen
|
|
if 'accessibility' in test_scenarios:
|
|
print(" ♿ Teste Accessibility...")
|
|
accessibility_results = await visual_validator.check_form_accessibility(form)
|
|
results.extend(accessibility_results)
|
|
|
|
# 5. Validierungen testen
|
|
if 'validations' in test_scenarios:
|
|
print(" 🔧 Teste Validierungen...")
|
|
validation_results = await interaction_engine.test_form_validations(form)
|
|
results.extend(validation_results)
|
|
|
|
# 6. Responsive Design testen
|
|
if 'responsive' in test_scenarios:
|
|
print(" 📱 Teste Responsive Design...")
|
|
responsive_results = await visual_validator.test_responsive_behavior(form)
|
|
results.extend(responsive_results)
|
|
|
|
# 7. Dynamisches Verhalten testen
|
|
if 'dynamic' in test_scenarios:
|
|
print(" ⚡ Teste dynamisches Verhalten...")
|
|
dynamic_results = await interaction_engine.test_dynamic_form_behavior(form)
|
|
results.extend(dynamic_results)
|
|
|
|
# 8. Error Display testen
|
|
if 'error_display' in test_scenarios:
|
|
print(" 🚨 Teste Error Display...")
|
|
error_results = await visual_validator.validate_error_display(form)
|
|
results.extend(error_results)
|
|
|
|
except Exception as e:
|
|
results.append(TestResult(
|
|
form_selector=form.selector,
|
|
test_type="comprehensive_test",
|
|
status=TestStatus.FAILED,
|
|
message=f"Fehler beim umfassenden Test: {str(e)}"
|
|
))
|
|
|
|
# Screenshots zu Ergebnissen hinzufügen
|
|
for result in results:
|
|
if not result.screenshot_path and screenshots.get('form_focus'):
|
|
result.screenshot_path = screenshots['form_focus']
|
|
|
|
return results
|
|
|
|
async def _test_valid_form_submission(self, form: FormStructure, interaction_engine: FormInteractionEngine) -> List[TestResult]:
|
|
"""Testet Formular mit gültigen Daten"""
|
|
|
|
# Gültige Test-Daten generieren
|
|
test_data = {}
|
|
for field in form.fields:
|
|
if field.field_type not in ['submit', 'button', 'hidden']:
|
|
test_data[field.name] = self.data_generator.generate_for_field(field, 'valid')
|
|
|
|
start_time = time.time()
|
|
|
|
# Formular ausfüllen
|
|
fill_results = await interaction_engine.fill_form_like_human(form, test_data)
|
|
|
|
# Versuche zu submitten
|
|
try:
|
|
if form.submit_button:
|
|
await self.page.click(form.submit_button)
|
|
await asyncio.sleep(2) # Warten auf Response
|
|
|
|
# Prüfe Erfolg (Page-Change, Success-Message, etc.)
|
|
success = await self._check_submission_success()
|
|
|
|
execution_time = time.time() - start_time
|
|
|
|
if success:
|
|
fill_results.append(TestResult(
|
|
form_selector=form.selector,
|
|
test_type="valid_submission",
|
|
status=TestStatus.PASSED,
|
|
message="Gültige Formular-Submission erfolgreich",
|
|
execution_time=execution_time,
|
|
details={'test_data': test_data}
|
|
))
|
|
else:
|
|
fill_results.append(TestResult(
|
|
form_selector=form.selector,
|
|
test_type="valid_submission",
|
|
status=TestStatus.FAILED,
|
|
message="Gültige Submission wurde nicht akzeptiert",
|
|
execution_time=execution_time,
|
|
details={'test_data': test_data}
|
|
))
|
|
|
|
except Exception as e:
|
|
fill_results.append(TestResult(
|
|
form_selector=form.selector,
|
|
test_type="valid_submission",
|
|
status=TestStatus.FAILED,
|
|
message=f"Fehler bei gültiger Submission: {str(e)}",
|
|
execution_time=time.time() - start_time
|
|
))
|
|
|
|
return fill_results
|
|
|
|
async def _test_invalid_form_submission(self, form: FormStructure, interaction_engine: FormInteractionEngine) -> List[TestResult]:
|
|
"""Testet Formular mit ungültigen Daten"""
|
|
|
|
# Ungültige Test-Daten generieren
|
|
test_data = {}
|
|
for field in form.fields:
|
|
if field.field_type not in ['submit', 'button', 'hidden']:
|
|
test_data[field.name] = self.data_generator.generate_for_field(field, 'invalid')
|
|
|
|
start_time = time.time()
|
|
|
|
# Formular ausfüllen
|
|
fill_results = await interaction_engine.fill_form_like_human(form, test_data)
|
|
|
|
# Versuche zu submitten
|
|
try:
|
|
if form.submit_button:
|
|
await self.page.click(form.submit_button)
|
|
await asyncio.sleep(2)
|
|
|
|
# Prüfe ob Validierung greift (Submission sollte fehlschlagen)
|
|
validation_triggered = await self._check_validation_triggered()
|
|
|
|
execution_time = time.time() - start_time
|
|
|
|
if validation_triggered:
|
|
fill_results.append(TestResult(
|
|
form_selector=form.selector,
|
|
test_type="invalid_submission",
|
|
status=TestStatus.PASSED,
|
|
message="Ungültige Daten wurden korrekt abgewiesen",
|
|
execution_time=execution_time,
|
|
details={'test_data': test_data}
|
|
))
|
|
else:
|
|
fill_results.append(TestResult(
|
|
form_selector=form.selector,
|
|
test_type="invalid_submission",
|
|
status=TestStatus.FAILED,
|
|
message="Ungültige Daten wurden fälschlicherweise akzeptiert",
|
|
execution_time=execution_time,
|
|
details={'test_data': test_data}
|
|
))
|
|
|
|
except Exception as e:
|
|
fill_results.append(TestResult(
|
|
form_selector=form.selector,
|
|
test_type="invalid_submission",
|
|
status=TestStatus.FAILED,
|
|
message=f"Fehler bei ungültiger Submission: {str(e)}",
|
|
execution_time=time.time() - start_time
|
|
))
|
|
|
|
return fill_results
|
|
|
|
async def _test_edge_cases(self, form: FormStructure, interaction_engine: FormInteractionEngine) -> List[TestResult]:
|
|
"""Testet Edge Cases wie XSS, SQL Injection, etc."""
|
|
|
|
results = []
|
|
edge_test_cases = [
|
|
"xss_injection",
|
|
"sql_injection",
|
|
"unicode_characters",
|
|
"extremely_long_input",
|
|
"null_bytes",
|
|
"whitespace_only"
|
|
]
|
|
|
|
for test_case in edge_test_cases:
|
|
# Edge-Case Test-Daten generieren
|
|
test_data = {}
|
|
for field in form.fields:
|
|
if field.field_type not in ['submit', 'button', 'hidden']:
|
|
test_data[field.name] = self.data_generator.generate_for_field(field, 'edge_case')
|
|
|
|
try:
|
|
start_time = time.time()
|
|
|
|
# Formular ausfüllen
|
|
await interaction_engine.fill_form_like_human(form, test_data)
|
|
|
|
# Submit versuchen
|
|
if form.submit_button:
|
|
await self.page.click(form.submit_button)
|
|
await asyncio.sleep(1)
|
|
|
|
# Prüfe ob System stabil bleibt
|
|
page_crashed = await self._check_page_stability()
|
|
|
|
execution_time = time.time() - start_time
|
|
|
|
if not page_crashed:
|
|
results.append(TestResult(
|
|
form_selector=form.selector,
|
|
test_type=f"edge_case_{test_case}",
|
|
status=TestStatus.PASSED,
|
|
message=f"Edge Case '{test_case}' korrekt behandelt",
|
|
execution_time=execution_time,
|
|
details={'test_data': test_data}
|
|
))
|
|
else:
|
|
results.append(TestResult(
|
|
form_selector=form.selector,
|
|
test_type=f"edge_case_{test_case}",
|
|
status=TestStatus.FAILED,
|
|
message=f"Edge Case '{test_case}' verursacht Probleme",
|
|
execution_time=execution_time,
|
|
details={'test_data': test_data}
|
|
))
|
|
|
|
except Exception as e:
|
|
results.append(TestResult(
|
|
form_selector=form.selector,
|
|
test_type=f"edge_case_{test_case}",
|
|
status=TestStatus.FAILED,
|
|
message=f"Fehler bei Edge Case '{test_case}': {str(e)}"
|
|
))
|
|
|
|
return results
|
|
|
|
async def _check_submission_success(self) -> bool:
|
|
"""Prüft ob eine Formular-Submission erfolgreich war"""
|
|
|
|
try:
|
|
# Warten auf Navigation oder Änderungen
|
|
await asyncio.sleep(1)
|
|
|
|
success_indicators = await self.page.evaluate("""
|
|
() => {
|
|
// Verschiedene Erfolgs-Indikatoren prüfen
|
|
const successElements = document.querySelectorAll(
|
|
'.success, .alert-success, .message-success, .notification-success, ' +
|
|
'[class*="success"], [role="alert"][class*="success"]'
|
|
);
|
|
|
|
const errorElements = document.querySelectorAll(
|
|
'.error, .alert-error, .alert-danger, .message-error, ' +
|
|
'[class*="error"], [class*="danger"], [role="alert"][class*="error"]'
|
|
);
|
|
|
|
const urlChanged = window.location.href !== document.referrer;
|
|
|
|
return {
|
|
hasSuccessMessage: successElements.length > 0,
|
|
hasErrorMessage: errorElements.length > 0,
|
|
urlChanged: urlChanged,
|
|
currentUrl: window.location.href
|
|
};
|
|
}
|
|
""")
|
|
|
|
# Success wenn: Success-Message ODER URL-Änderung UND keine Error-Message
|
|
return ((success_indicators['hasSuccessMessage'] or success_indicators['urlChanged'])
|
|
and not success_indicators['hasErrorMessage'])
|
|
|
|
except Exception:
|
|
return False
|
|
|
|
async def _check_validation_triggered(self) -> bool:
|
|
"""Prüft ob Client-Side-Validierung ausgelöst wurde"""
|
|
|
|
try:
|
|
validation_info = await self.page.evaluate("""
|
|
() => {
|
|
// HTML5 Validierung prüfen
|
|
const invalidFields = document.querySelectorAll(':invalid');
|
|
|
|
// Custom Validierungs-Elemente
|
|
const errorElements = document.querySelectorAll(
|
|
'.error, .invalid-feedback, .field-error, .validation-error, ' +
|
|
'[role="alert"], .alert-danger, .text-danger'
|
|
);
|
|
|
|
// Prüfe ob Submit verhindert wurde (URL ändert sich nicht)
|
|
const formElements = document.querySelectorAll('form');
|
|
|
|
return {
|
|
invalidFieldsCount: invalidFields.length,
|
|
errorElementsCount: errorElements.length,
|
|
hasValidationMessages: errorElements.length > 0
|
|
};
|
|
}
|
|
""")
|
|
|
|
return (validation_info['invalidFieldsCount'] > 0 or
|
|
validation_info['hasValidationMessages'])
|
|
|
|
except Exception:
|
|
return False
|
|
|
|
async def _check_page_stability(self) -> bool:
|
|
"""Prüft ob die Seite nach Edge-Case-Input stabil bleibt"""
|
|
|
|
try:
|
|
# Warte kurz und prüfe dann
|
|
await asyncio.sleep(0.5)
|
|
|
|
# Prüfe ob Page noch reagiert
|
|
title = await self.page.title()
|
|
|
|
# Prüfe auf JavaScript-Fehler
|
|
errors = await self.page.evaluate("""
|
|
() => {
|
|
return window.testErrors || [];
|
|
}
|
|
""")
|
|
|
|
# Stabil wenn Title abgerufen werden kann und keine kritischen Fehler
|
|
return title is not None and len(errors) == 0
|
|
|
|
except Exception:
|
|
return False # Page crashed
|
|
|
|
async def test_form(self, url: str, form_selector: str, test_data: Dict[str, str] = None,
|
|
test_scenarios: List[str] = None) -> List[TestResult]:
|
|
"""
|
|
Testet ein spezifisches Formular mit benutzerdefinierten Daten.
|
|
|
|
Args:
|
|
url: URL der Seite mit dem Formular
|
|
form_selector: CSS-Selektor für das Formular
|
|
test_data: Benutzerdefinierte Test-Daten
|
|
test_scenarios: Test-Szenarien ['valid', 'invalid', 'edge_cases']
|
|
"""
|
|
if test_scenarios is None:
|
|
test_scenarios = ['valid', 'invalid']
|
|
|
|
# Seite laden
|
|
full_url = url if url.startswith('http') else f"{self.base_url}{url}"
|
|
await self.page.goto(full_url, wait_until="networkidle")
|
|
|
|
# Formular finden
|
|
forms = await self.scan_for_forms(url)
|
|
target_form = None
|
|
|
|
for form in forms:
|
|
if form.selector == form_selector:
|
|
target_form = form
|
|
break
|
|
|
|
if not target_form:
|
|
return [TestResult(
|
|
form_selector=form_selector,
|
|
test_type="form_discovery",
|
|
status=TestStatus.FAILED,
|
|
message=f"Formular mit Selektor '{form_selector}' nicht gefunden"
|
|
)]
|
|
|
|
# Mit benutzerdefinierten Daten überschreiben falls vorhanden
|
|
if test_data:
|
|
interaction_engine = FormInteractionEngine(self.page)
|
|
|
|
start_time = time.time()
|
|
results = await interaction_engine.fill_form_like_human(target_form, test_data)
|
|
|
|
# Submit
|
|
if target_form.submit_button:
|
|
await self.page.click(target_form.submit_button)
|
|
await asyncio.sleep(2)
|
|
|
|
success = await self._check_submission_success()
|
|
execution_time = time.time() - start_time
|
|
|
|
results.append(TestResult(
|
|
form_selector=form_selector,
|
|
test_type="custom_data_submission",
|
|
status=TestStatus.PASSED if success else TestStatus.FAILED,
|
|
message="Custom Test-Daten submission " + ("erfolgreich" if success else "fehlgeschlagen"),
|
|
execution_time=execution_time,
|
|
details={'test_data': test_data}
|
|
))
|
|
|
|
self.test_results.extend(results)
|
|
return results
|
|
|
|
# Standard-Tests durchführen
|
|
return await self.test_form_comprehensive(target_form, test_scenarios)
|
|
|
|
async def test_multi_step_form(self, start_url: str, steps: List[str]) -> List[TestResult]:
|
|
"""
|
|
Testet mehrstufige Formulare.
|
|
|
|
Args:
|
|
start_url: URL des ersten Schritts
|
|
steps: Liste der Schritt-Selektoren oder -URLs
|
|
"""
|
|
results = []
|
|
|
|
try:
|
|
# Erster Schritt
|
|
full_url = start_url if start_url.startswith('http') else f"{self.base_url}{start_url}"
|
|
await self.page.goto(full_url, wait_until="networkidle")
|
|
|
|
current_step = 1
|
|
|
|
for step_identifier in steps:
|
|
print(f"🔄 Teste Multi-Step Schritt {current_step}: {step_identifier}")
|
|
|
|
# Formulare im aktuellen Schritt finden
|
|
forms = await self.scan_for_forms(self.page.url)
|
|
|
|
if forms:
|
|
# Erstes Formular im Schritt testen
|
|
step_form = forms[0]
|
|
|
|
# Test-Daten generieren
|
|
test_data = {}
|
|
for field in step_form.fields:
|
|
if field.field_type not in ['submit', 'button', 'hidden']:
|
|
test_data[field.name] = self.data_generator.generate_for_field(field, 'valid')
|
|
|
|
# Schritt ausfüllen
|
|
interaction_engine = FormInteractionEngine(self.page)
|
|
fill_results = await interaction_engine.fill_form_like_human(step_form, test_data)
|
|
results.extend(fill_results)
|
|
|
|
# Zum nächsten Schritt
|
|
if step_form.submit_button:
|
|
await self.page.click(step_form.submit_button)
|
|
await asyncio.sleep(2)
|
|
|
|
# Prüfe ob nächster Schritt erreicht
|
|
if current_step < len(steps):
|
|
# Warte auf Navigation/Update
|
|
await asyncio.sleep(1)
|
|
|
|
results.append(TestResult(
|
|
form_selector=step_form.selector,
|
|
test_type=f"multi_step_navigation_{current_step}",
|
|
status=TestStatus.PASSED,
|
|
message=f"Navigation zu Schritt {current_step + 1} erfolgreich",
|
|
details={'step': current_step, 'test_data': test_data}
|
|
))
|
|
else:
|
|
# Finaler Schritt - prüfe Erfolg
|
|
success = await self._check_submission_success()
|
|
|
|
results.append(TestResult(
|
|
form_selector=step_form.selector,
|
|
test_type="multi_step_completion",
|
|
status=TestStatus.PASSED if success else TestStatus.FAILED,
|
|
message="Multi-Step Formular " + ("erfolgreich abgeschlossen" if success else "Abschluss fehlgeschlagen"),
|
|
details={'total_steps': len(steps)}
|
|
))
|
|
|
|
current_step += 1
|
|
|
|
except Exception as e:
|
|
results.append(TestResult(
|
|
form_selector="multi_step_form",
|
|
test_type="multi_step_error",
|
|
status=TestStatus.FAILED,
|
|
message=f"Fehler bei Multi-Step Test: {str(e)}"
|
|
))
|
|
|
|
self.test_results.extend(results)
|
|
return results
|
|
|
|
async def test_form_on_devices(self, url: str, devices: List[str] = None) -> List[TestResult]:
|
|
"""
|
|
Testet Formulare auf verschiedenen Geräte-Viewports.
|
|
|
|
Args:
|
|
url: URL der zu testenden Seite
|
|
devices: Liste der Geräte-Namen ['iPhone 12', 'iPad', 'Desktop 1920x1080']
|
|
"""
|
|
if devices is None:
|
|
devices = ['iPhone SE', 'iPad', 'Desktop 1920x1080']
|
|
|
|
device_viewports = {
|
|
'iPhone SE': {'width': 375, 'height': 667},
|
|
'iPhone 12': {'width': 390, 'height': 844},
|
|
'iPad': {'width': 768, 'height': 1024},
|
|
'Desktop 1920x1080': {'width': 1920, 'height': 1080},
|
|
'Desktop 1280x720': {'width': 1280, 'height': 720}
|
|
}
|
|
|
|
all_results = []
|
|
original_viewport = self.page.viewport_size
|
|
|
|
for device in devices:
|
|
if device in device_viewports:
|
|
viewport = device_viewports[device]
|
|
|
|
print(f"📱 Teste auf {device} ({viewport['width']}x{viewport['height']})")
|
|
|
|
# Viewport setzen
|
|
await self.page.set_viewport_size(width=viewport['width'], height=viewport['height'])
|
|
await asyncio.sleep(1)
|
|
|
|
# Seite neu laden für korrekte Responsive-Darstellung
|
|
full_url = url if url.startswith('http') else f"{self.base_url}{url}"
|
|
await self.page.goto(full_url, wait_until="networkidle")
|
|
|
|
# Formulare testen
|
|
forms = await self.scan_for_forms(url)
|
|
|
|
for form in forms:
|
|
visual_validator = VisualFormValidator(self.page)
|
|
responsive_results = await visual_validator.test_responsive_behavior(
|
|
form, [{'width': viewport['width'], 'height': viewport['height'], 'name': device}]
|
|
)
|
|
|
|
# Screenshots für Device-Tests
|
|
device_screenshots = await self.reporter.capture_form_states(
|
|
self.page, form.selector, f"device_test_{device.replace(' ', '_').lower()}"
|
|
)
|
|
|
|
for result in responsive_results:
|
|
if device_screenshots.get('form_focus'):
|
|
result.screenshot_path = device_screenshots['form_focus']
|
|
|
|
all_results.extend(responsive_results)
|
|
|
|
# Ursprüngliches Viewport wiederherstellen
|
|
if original_viewport:
|
|
await self.page.set_viewport_size(
|
|
width=original_viewport['width'],
|
|
height=original_viewport['height']
|
|
)
|
|
|
|
self.test_results.extend(all_results)
|
|
return all_results
|
|
|
|
def generate_report(self, report_name: str = "form_test_report") -> str:
|
|
"""
|
|
Generiert einen umfassenden Test-Report.
|
|
|
|
Args:
|
|
report_name: Name des Reports
|
|
|
|
Returns:
|
|
Pfad zum generierten HTML-Report
|
|
"""
|
|
if not self.test_results:
|
|
print("⚠️ Keine Test-Ergebnisse vorhanden")
|
|
return ""
|
|
|
|
# HTML-Report generieren
|
|
html_report_path = self.reporter.generate_visual_report(self.test_results, report_name)
|
|
|
|
# JSON-Report für maschinelle Verarbeitung
|
|
json_report_path = self.reporter.generate_json_report(self.test_results, report_name)
|
|
|
|
# Zusammenfassung in Console
|
|
self._print_test_summary()
|
|
|
|
return html_report_path
|
|
|
|
def _print_test_summary(self):
|
|
"""Druckt eine Zusammenfassung der Test-Ergebnisse"""
|
|
|
|
if not self.test_results:
|
|
return
|
|
|
|
total = len(self.test_results)
|
|
passed = len([r for r in self.test_results if r.status == TestStatus.PASSED])
|
|
failed = len([r for r in self.test_results if r.status == TestStatus.FAILED])
|
|
warnings = len([r for r in self.test_results if r.status == TestStatus.WARNING])
|
|
skipped = len([r for r in self.test_results if r.status == TestStatus.SKIPPED])
|
|
|
|
success_rate = (passed / total * 100) if total > 0 else 0
|
|
|
|
if RICH_AVAILABLE and console:
|
|
# Rich Table für schöne Ausgabe
|
|
table = Table(title="🧪 Formular Test Zusammenfassung")
|
|
table.add_column("Metrik", style="bold")
|
|
table.add_column("Anzahl", justify="right")
|
|
table.add_column("Prozent", justify="right")
|
|
|
|
table.add_row("Gesamt Tests", str(total), "100.0%")
|
|
table.add_row("✅ Bestanden", str(passed), f"{passed/total*100:.1f}%", style="green")
|
|
table.add_row("❌ Fehlgeschlagen", str(failed), f"{failed/total*100:.1f}%", style="red")
|
|
table.add_row("⚠️ Warnungen", str(warnings), f"{warnings/total*100:.1f}%", style="yellow")
|
|
table.add_row("⏭️ Übersprungen", str(skipped), f"{skipped/total*100:.1f}%", style="blue")
|
|
table.add_row("", "", "", style="dim")
|
|
table.add_row("🎯 Erfolgsrate", f"{success_rate:.1f}%", "",
|
|
style="green" if success_rate >= 80 else "yellow" if success_rate >= 60 else "red")
|
|
|
|
console.print("\n")
|
|
console.print(table)
|
|
console.print("\n")
|
|
|
|
# Status-basierte Ausgabe
|
|
if success_rate >= 90:
|
|
console.print("🎉 Exzellente Test-Ergebnisse!", style="bold green")
|
|
elif success_rate >= 80:
|
|
console.print("✅ Gute Test-Ergebnisse!", style="bold yellow")
|
|
elif success_rate >= 60:
|
|
console.print("⚠️ Verbesserungsbedarf bei den Formularen", style="bold orange")
|
|
else:
|
|
console.print("❌ Kritische Probleme gefunden!", style="bold red")
|
|
else:
|
|
# Einfache Text-Ausgabe
|
|
print(f"""
|
|
{'='*60}
|
|
🧪 FORMULAR TEST ZUSAMMENFASSUNG
|
|
{'='*60}
|
|
|
|
📊 STATISTIKEN:
|
|
Gesamt Tests: {total}
|
|
✅ Bestanden: {passed} ({passed/total*100:.1f}%)
|
|
❌ Fehlgeschlagen: {failed} ({failed/total*100:.1f}%)
|
|
⚠️ Warnungen: {warnings} ({warnings/total*100:.1f}%)
|
|
⏭️ Übersprungen: {skipped} ({skipped/total*100:.1f}%)
|
|
|
|
🎯 ERFOLGSRATE: {success_rate:.1f}%
|
|
|
|
{'='*60}
|
|
""")
|
|
|
|
|
|
# CLI-Interface für den Form Test Automator
|
|
async def main():
|
|
"""Haupt-CLI-Interface für das Tool"""
|
|
|
|
import argparse
|
|
|
|
parser = argparse.ArgumentParser(
|
|
description="Flask HTML-Formular Test Automator",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="""
|
|
BEISPIELE:
|
|
# Alle Formulare auf einer Seite testen
|
|
python form_test_automator.py --url http://localhost:5000/register --test-all
|
|
|
|
# Spezifisches Formular testen
|
|
python form_test_automator.py --url http://localhost:5000/contact --form "#contact-form"
|
|
|
|
# Multi-Step Formular testen
|
|
python form_test_automator.py --url http://localhost:5000/signup --multi-step --steps "step1,step2,step3"
|
|
|
|
# Responsive Testing
|
|
python form_test_automator.py --url http://localhost:5000/order --responsive --devices "iPhone 12,iPad,Desktop"
|
|
|
|
# Umfassende Tests mit allen Szenarien
|
|
python form_test_automator.py --url http://localhost:5000/application --comprehensive
|
|
"""
|
|
)
|
|
|
|
parser.add_argument('--url', required=True, help='Basis-URL der Flask-Anwendung')
|
|
parser.add_argument('--form', help='CSS-Selektor für spezifisches Formular')
|
|
parser.add_argument('--test-all', action='store_true', help='Teste alle Formulare auf der Seite')
|
|
parser.add_argument('--multi-step', action='store_true', help='Multi-Step Formular testen')
|
|
parser.add_argument('--steps', help='Komma-getrennte Liste der Schritte (für Multi-Step)')
|
|
parser.add_argument('--responsive', action='store_true', help='Responsive Design testen')
|
|
parser.add_argument('--devices', help='Komma-getrennte Liste der Test-Devices')
|
|
parser.add_argument('--comprehensive', action='store_true', help='Umfassende Tests mit allen Szenarien')
|
|
parser.add_argument('--scenarios', help='Komma-getrennte Test-Szenarien (valid,invalid,edge_cases,accessibility,etc.)')
|
|
parser.add_argument('--browser', choices=['chromium', 'firefox', 'webkit'], default='chromium', help='Browser-Engine')
|
|
parser.add_argument('--headless', action='store_true', default=True, help='Headless-Modus (Standard: True)')
|
|
parser.add_argument('--headed', action='store_true', help='Browser-Fenster anzeigen (Gegenteil von headless)')
|
|
parser.add_argument('--output', help='Ausgabeverzeichnis für Reports')
|
|
parser.add_argument('--report-name', default='form_test_report', help='Name des generierten Reports')
|
|
|
|
args = parser.parse_args()
|
|
|
|
# Headless-Modus bestimmen
|
|
headless = args.headless and not args.headed
|
|
|
|
# Output-Verzeichnis
|
|
output_dir = Path(args.output) if args.output else None
|
|
|
|
# Test-Szenarien
|
|
if args.comprehensive:
|
|
test_scenarios = ['valid', 'invalid', 'edge_cases', 'accessibility', 'validations', 'responsive', 'dynamic', 'error_display']
|
|
elif args.scenarios:
|
|
test_scenarios = [s.strip() for s in args.scenarios.split(',')]
|
|
else:
|
|
test_scenarios = ['valid', 'invalid', 'accessibility']
|
|
|
|
print(f"""
|
|
{'='*70}
|
|
🧪 FLASK HTML-FORMULAR TEST AUTOMATOR
|
|
{'='*70}
|
|
|
|
🎯 Target URL: {args.url}
|
|
🌐 Browser: {args.browser} ({'headless' if headless else 'headed'})
|
|
📋 Test-Szenarien: {', '.join(test_scenarios)}
|
|
📁 Output: {output_dir or 'form_test_reports/'}
|
|
|
|
{'='*70}
|
|
""")
|
|
|
|
try:
|
|
# Test Automator initialisieren
|
|
async with HTMLFormTestAutomator(
|
|
base_url=args.url,
|
|
browser=args.browser,
|
|
headless=headless
|
|
) as automator:
|
|
|
|
if output_dir:
|
|
automator.reporter.output_dir = output_dir
|
|
|
|
if args.test_all:
|
|
# Alle Formulare auf der Seite testen
|
|
results = await automator.test_all_forms_on_page('/', test_scenarios)
|
|
|
|
elif args.form:
|
|
# Spezifisches Formular testen
|
|
results = await automator.test_form('/', args.form, test_scenarios=test_scenarios)
|
|
|
|
elif args.multi_step and args.steps:
|
|
# Multi-Step Formular testen
|
|
steps = [s.strip() for s in args.steps.split(',')]
|
|
results = await automator.test_multi_step_form('/', steps)
|
|
|
|
elif args.responsive:
|
|
# Responsive Testing
|
|
devices = ['iPhone 12', 'iPad', 'Desktop 1920x1080']
|
|
if args.devices:
|
|
devices = [d.strip() for d in args.devices.split(',')]
|
|
|
|
results = await automator.test_form_on_devices('/', devices)
|
|
|
|
else:
|
|
# Standard: Alle Formulare mit Standard-Szenarien
|
|
results = await automator.test_all_forms_on_page('/', test_scenarios)
|
|
|
|
# Report generieren
|
|
report_path = automator.generate_report(args.report_name)
|
|
|
|
if report_path:
|
|
print(f"\n🎉 Test abgeschlossen! Report verfügbar: {report_path}")
|
|
else:
|
|
print("\n⚠️ Test abgeschlossen, aber Report-Generierung fehlgeschlagen")
|
|
|
|
except KeyboardInterrupt:
|
|
print("\n⚠️ Test durch Benutzer abgebrochen")
|
|
except Exception as e:
|
|
print(f"\n❌ Fehler beim Test: {str(e)}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if not PLAYWRIGHT_AVAILABLE:
|
|
print("""
|
|
❌ PLAYWRIGHT NICHT VERFÜGBAR
|
|
|
|
Installiere Playwright mit:
|
|
pip install playwright
|
|
playwright install chromium
|
|
|
|
Oder installiere alle Dependencies:
|
|
pip install playwright faker beautifulsoup4 rich
|
|
playwright install
|
|
""")
|
|
exit(1)
|
|
|
|
asyncio.run(main()) |