From 24f2c1fe6a6e61dc0dc15c9f7c6b76d6afe558c6 Mon Sep 17 00:00:00 2001 From: Tejas Shasane Date: Thu, 8 Oct 2026 17:36:30 +0530 Subject: [PATCH] Add CAPTCHA automation beta flow --- .gitignore | 3 + pytest.ini | 6 + tests/browser.py | 11 +- tests/captcha_solver.py | 353 +++++++++++++++++++++++++++++ tests/helpers.py | 227 ++++++++++++++++--- tests/locators.py | 4 +- tests/test_captcha_login_stress.py | 124 ++++++++++ 7 files changed, 698 insertions(+), 30 deletions(-) create mode 100644 tests/captcha_solver.py create mode 100644 tests/test_captcha_login_stress.py diff --git a/.gitignore b/.gitignore index 81f7099..e5fd208 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ reports/logs/* reports/html/* !reports/html/.gitkeep + +# CAPTCHA stress-test screenshots and CSV reports +reports/captcha_login_stress/ diff --git a/pytest.ini b/pytest.ini index e984976..4c3f267 100644 --- a/pytest.ini +++ b/pytest.ini @@ -12,3 +12,9 @@ markers = reports: report download coverage order_book: order book coverage trade_book: trade book coverage + captcha: CAPTCHA login stress test + +filterwarnings = + ignore::DeprecationWarning:easyocr.* + ignore::DeprecationWarning:torch.* + ignore::UserWarning:torch.* diff --git a/tests/browser.py b/tests/browser.py index f692ec3..01b50ae 100644 --- a/tests/browser.py +++ b/tests/browser.py @@ -1,4 +1,7 @@ +import os + from selenium import webdriver +from selenium.webdriver.chrome.service import Service def create_chrome_driver(): @@ -10,5 +13,11 @@ def create_chrome_driver(): options.add_argument("--disable-features=CalculateNativeWinOcclusion") options.add_argument("--disable-hang-monitor") options.add_argument("--disable-popup-blocking") + options.add_argument("--disable-background-networking") + options.add_argument("--disable-component-update") + options.add_argument("--disable-sync") + options.add_argument("--disable-default-apps") + options.add_experimental_option("excludeSwitches", ["enable-logging"]) - return webdriver.Chrome(options=options) + service = Service(log_output=os.devnull) + return webdriver.Chrome(service=service, options=options) diff --git a/tests/captcha_solver.py b/tests/captcha_solver.py new file mode 100644 index 0000000..6d81fbe --- /dev/null +++ b/tests/captcha_solver.py @@ -0,0 +1,353 @@ +""" +CAPTCHA Solver for PXIL Login using EasyOCR. +Extracts base64 from input's CSS background-image, runs EasyOCR OCR, fills the input. +""" +import re +import base64 +import time +from typing import Optional + +from PIL import Image +import cv2 +import numpy as np +from selenium.webdriver.common.by import By +from selenium.webdriver.remote.webdriver import WebDriver +from selenium.webdriver.remote.webelement import WebElement +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC +from selenium.common.exceptions import StaleElementReferenceException, TimeoutException + + +# Initialize EasyOCR once (module level) +# First run downloads models, then works offline +try: + import easyocr + _EASYOCR_READER = easyocr.Reader(['en'], gpu=False) +except ImportError: + _EASYOCR_READER = None + + +# Character confusion correction map for common OCR errors +CONFUSION_MAP = { + '0': 'O', 'O': '0', 'D': '0', + '1': 'I', 'I': '1', 'L': '1', 'l': '1', + '5': 'S', 'S': '5', + '8': 'B', 'B': '8', + '2': 'Z', 'Z': '2', + '6': 'G', 'G': '6', + '9': 'g', 'g': '9', + 'Q': 'O', + 'U': 'V', 'V': 'U', + 'n': 'h', 'h': 'n', + 'r': 'f', 'f': 'r', + 'c': 'e', 'e': 'c', + 'o': '0', '0': 'o', + 's': '5', '5': 's', + 'z': '2', '2': 'z', + 'i': '1', '1': 'i', + 'l': '1', '1': 'l', +} + + +def find_visible_captcha_input(driver: WebDriver) -> WebElement: + """Find the visible captcha input element (name='captcha').""" + wait = WebDriverWait(driver, 15) + inputs = wait.until(EC.presence_of_all_elements_located((By.NAME, "captcha"))) + for inp in inputs: + if inp.is_displayed(): + return inp + raise RuntimeError("No visible captcha input found") + + +def extract_captcha_base64(captcha_input: WebElement) -> bytes: + """Extract base64 image data from input's style attribute. Returns raw JPEG bytes.""" + style = captcha_input.get_attribute("style") or "" + match = re.search(r'background:\s*url\(["\']?data:image/[^;]+;base64,([^"\')]+)["\']?\)', style) + if not match: + raise RuntimeError(f"No base64 background-image found in style: {style[:200]}") + + b64_data = match.group(1) + if not b64_data: + raise RuntimeError("Base64 data is empty (captcha not loaded yet)") + + b64_data += "=" * ((4 - len(b64_data) % 4) % 4) # Fix padding + return base64.b64decode(b64_data) + + +def wait_for_captcha_load(driver: WebDriver, captcha_input: WebElement, timeout: int = 15) -> bool: + """Wait for captcha base64 to be populated with actual image data.""" + end_time = time.time() + timeout + while time.time() < end_time: + try: + style = captcha_input.get_attribute("style") or "" + match = re.search(r'background:\s*url\(["\']?data:image/[^;]+;base64,([^"\')]+)["\']?\)', style) + if match and match.group(1): + return True + except StaleElementReferenceException: + captcha_input = find_visible_captcha_input(driver) + time.sleep(0.3) + return False + + +def preprocess_captcha_image(image_bytes: bytes) -> Image.Image: + """Preprocess image for better OCR accuracy - gentle preprocessing.""" + nparr = np.frombuffer(image_bytes, np.uint8) + img = cv2.imdecode(nparr, cv2.IMREAD_COLOR) # Load as color (BGR) + + if img is None: + raise RuntimeError("Failed to decode image bytes") + + # Convert BGR to RGB + img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + + # Convert to grayscale + gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY) + + # Simple threshold - keep it simple + _, gray = cv2.threshold(gray, 128, 255, cv2.THRESH_BINARY) + + # Light denoise + gray = cv2.fastNlMeansDenoising(gray, h=3) + + # Very light morphological close - only connect broken parts + kernel = np.ones((1, 1), np.uint8) + gray = cv2.morphologyEx(gray, cv2.MORPH_CLOSE, kernel) + + # Resize 6x for better recognition (larger for 6-char captcha) + gray = cv2.resize(gray, None, fx=6, fy=6, interpolation=cv2.INTER_CUBIC) + + # Convert back to RGB (3-channel) for EasyOCR + rgb = cv2.cvtColor(gray, cv2.COLOR_GRAY2RGB) + + return Image.fromarray(rgb) + + +def apply_confusion_corrections(text: str) -> str: + """Apply character confusion corrections based on common OCR errors.""" + corrected = [] + for ch in text: + corrected.append(CONFUSION_MAP.get(ch, ch)) + return ''.join(corrected) + + +def ocr_captcha(image: Image.Image) -> str: + """Run OCR on preprocessed captcha image using EasyOCR.""" + if _EASYOCR_READER is None: + raise RuntimeError("EasyOCR not installed. Run: pip install easyocr") + + img_array = np.array(image) + + # EasyOCR with alphanumeric allowlist for 6-char captcha + results = _EASYOCR_READER.readtext( + img_array, + detail=0, + allowlist='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789', + width_ths=0.5, + paragraph=False + ) + + # Combine all detected text + result = ''.join(results) + + # Clean result - keep only alphanumeric, max 6 chars + cleaned = ''.join(filter(str.isalnum, result))[:6] + + print(f" DEBUG OCR: result='{result}', cleaned='{cleaned}', len={len(cleaned)}") + return cleaned + + +def find_refresh_button(driver: WebDriver, captcha_input: WebElement) -> Optional[WebElement]: + """Find captcha refresh button near the captcha input.""" + parent = captcha_input.find_element(By.XPATH, "..") + for sibling in parent.find_elements(By.XPATH, "*"): + onclick = (sibling.get_attribute("onclick") or "").lower() + class_attr = (sibling.get_attribute("class") or "").lower() + id_attr = (sibling.get_attribute("id") or "").lower() + if any(kw in onclick for kw in ["refresh", "captcha", "reload", "newcaptcha"]): + return sibling + if any(kw in class_attr for kw in ["refresh", "captcha-refresh", "reload"]): + return sibling + if any(kw in id_attr for kw in ["refresh", "captcha", "reload"]): + return sibling + + # Fallback: search globally for refresh-like elements + try: + candidates = driver.find_elements(By.XPATH, "//*[self::a or self::button or self::img or self::span][contains(translate(@onclick, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'refresh') or contains(translate(@class, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'refresh') or contains(translate(@title, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'refresh')]") + for cand in candidates: + if cand.is_displayed(): + return cand + except: + pass + return None + + +def wait_for_new_captcha(driver: WebDriver, captcha_input: WebElement, timeout: int = 10) -> bool: + """Wait for captcha background-image to change (new challenge loaded).""" + old_style = captcha_input.get_attribute("style") or "" + end_time = time.time() + timeout + while time.time() < end_time: + try: + new_style = captcha_input.get_attribute("style") or "" + if new_style != old_style and "data:image" in new_style: + return True + except StaleElementReferenceException: + captcha_input = find_visible_captcha_input(driver) + old_style = captcha_input.get_attribute("style") or "" + time.sleep(0.3) + return False + + +def solve_and_fill_captcha(driver: WebDriver, max_retries: int = 5, attempt_callback=None) -> bool: + """ + Main entry point: solve captcha and fill input. + Returns True if filled successfully, False if max retries exceeded. + Each retry gets a fresh captcha via refresh button. + """ + for attempt in range(1, max_retries + 1): + captcha_text = "" + try: + captcha_input = find_visible_captcha_input(driver) + + # Wait for captcha base64 to be populated (async load) + if not wait_for_captcha_load(driver, captcha_input): + print(f" [Attempt {attempt}] Captcha failed to load in time, retrying...") + if attempt < max_retries: + refresh_btn = find_refresh_button(driver, captcha_input) + if refresh_btn: + refresh_btn.click() + wait_for_new_captcha(driver, captcha_input) + if attempt_callback: + attempt_callback(attempt, "", "", "not_loaded") + continue + + image_bytes = extract_captcha_base64(captcha_input) + pil_image = preprocess_captcha_image(image_bytes) + captcha_text = ocr_captcha(pil_image) + + if not captcha_text or len(captcha_text) < 6: + print(f" [Attempt {attempt}] OCR weak result: '{captcha_text}' (len={len(captcha_text)}), retrying...") + if attempt < max_retries: + refresh_btn = find_refresh_button(driver, captcha_input) + if refresh_btn: + refresh_btn.click() + wait_for_new_captcha(driver, captcha_input) + if attempt_callback: + attempt_callback(attempt, captcha_text, "", "ocr_failed") + continue + + # Apply confusion corrections to get best variant + corrected = apply_confusion_corrections(captcha_text) + + # Generate case variants for case-sensitive captcha (mixed case + numbers) + variants = [ + captcha_text, # as-is (from OCR) + corrected, # with confusion corrections + captcha_text.upper(), # all uppercase + captcha_text.capitalize(), # first letter uppercase + captcha_text.title(), # each word capitalized + captcha_text.swapcase(), # swap case + ] + + # Add variations with first char variations + if len(captcha_text) >= 1: + variants.append(captcha_text[0].upper() + captcha_text[1:]) + variants.append(captcha_text[0].lower() + captcha_text[1:]) + if len(captcha_text) >= 2: + variants.append(captcha_text[:2].upper() + captcha_text[2:]) + variants.append(captcha_text[:2].lower() + captcha_text[2:]) + if len(captcha_text) >= 3: + variants.append(captcha_text[:3].upper() + captcha_text[3:]) + variants.append(captcha_text[:3].lower() + captcha_text[3:]) + + # Remove duplicates while preserving order, keep only 6-char variants + seen = set() + unique_variants = [] + for v in variants: + if v not in seen and len(v) == 6: + seen.add(v) + unique_variants.append(v) + variants = unique_variants + + if attempt_callback: + screenshot_path = attempt_callback(attempt, captcha_text, "", "before_entry") or "" + + success = False + # Try ALL variants before giving up on this attempt + for variant in variants: + if len(variant) == 6: + print(f" [Attempt {attempt}] Trying variant: '{variant}'") + captcha_input.clear() + captcha_input.send_keys(variant) + success = True + if attempt_callback: + attempt_callback(attempt, captcha_text, variant, "filled") + break + + if not success: + print(f" [Attempt {attempt}] No valid 6-char variant found for '{captcha_text}', retrying...") + if attempt < max_retries: + refresh_btn = find_refresh_button(driver, captcha_input) + if refresh_btn: + refresh_btn.click() + wait_for_new_captcha(driver, captcha_input) + continue + + # Click submit + submit_btn = WebDriverWait(driver, 10).until( + EC.element_to_be_clickable((By.XPATH, "//button[@value='Login']")) + ) + submit_btn.click() + + # Wait a bit for response + time.sleep(2) + + # Check for invalid captcha error using the exact xpath + try: + # First handle any alerts that might be open + try: + alert = driver.switch_to.alert + alert_text = alert.text + print(f" DEBUG: Alert detected after submit: {alert_text}") + if "logout" in alert_text.lower() or "session" in alert_text.lower() or "previous" in alert_text.lower(): + alert.accept() + print(" DEBUG: Accepted session/logout alert") + time.sleep(2) + except: + pass + + invalid_captcha_elem = WebDriverWait(driver, 3).until( + EC.visibility_of_element_located(( + By.XPATH, + "//div[@id='error']//strong[contains(normalize-space(), 'Invalid Captcha')]", + )) + ) + print(f" [Attempt {attempt}] Invalid captcha detected: {invalid_captcha_elem.text}") + if attempt < max_retries: + refresh_btn = find_refresh_button(driver, captcha_input) + if refresh_btn: + refresh_btn.click() + wait_for_new_captcha(driver, captcha_input) + continue + except TimeoutException: + # No invalid captcha error - likely success + print(f" [Attempt {attempt}] No invalid captcha error detected, assuming success") + return True + except Exception as e: + # Handle other exceptions like NoSuchElementException + print(f" [Attempt {attempt}] Error checking invalid captcha: {type(e).__name__}: {e}") + if attempt < max_retries: + refresh_btn = find_refresh_button(driver, captcha_input) + if refresh_btn: + refresh_btn.click() + wait_for_new_captcha(driver, captcha_input) + continue + + except Exception as e: + print(f" [Attempt {attempt}] Error: {e}") + if attempt < max_retries: + refresh_btn = find_refresh_button(driver, captcha_input) + if refresh_btn: + refresh_btn.click() + wait_for_new_captcha(driver, captcha_input) + + return False diff --git a/tests/helpers.py b/tests/helpers.py index 4259378..c7a0eda 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -57,43 +57,216 @@ def enter_pressed(): return pressed -def login(driver, username, password): +def check_captcha_error(driver) -> str: + """Check if there's a CAPTCHA error message on the page.""" + try: + # First handle any alerts + try: + alert = driver.switch_to.alert + alert_text = alert.text + print(f" DEBUG: Alert detected in check_captcha_error: {alert_text}") + if "logout" in alert_text.lower() or "session" in alert_text.lower() or "previous" in alert_text.lower(): + alert.accept() + print(" DEBUG: Accepted session/logout alert") + time.sleep(2) + except: + pass + + error_selectors = [ + "//div[@id='error']//strong[normalize-space()='Invalid Captcha']", + "//div[@id='error']//span[contains(@class,'help-block')]//strong[contains(text(),'Invalid')]", + "//*[contains(@class,'error') and contains(text(),'Invalid Captcha')]", + ] + for selector in error_selectors: + elements = driver.find_elements(By.XPATH, selector) + for el in elements: + if el.is_displayed() and el.text.strip(): + return el.text.strip() + except Exception as e: + print(f" DEBUG: check_captcha_error exception: {e}") + return "" + + +def check_login_success(driver) -> bool: + """Check if login was successful using multiple indicators.""" + try: + # Handle any pending alerts first + try: + alert = driver.switch_to.alert + alert_text = alert.text + print(f" DEBUG: Alert detected: {alert_text}") + if "logout" in alert_text.lower() or "session" in alert_text.lower() or "previous" in alert_text.lower(): + alert.accept() + print(" DEBUG: Accepted session/logout alert") + time.sleep(2) + except: + pass + + # Wait a bit for page to load after submit + time.sleep(3) + + # Print current URL and page title for debugging + print(f" DEBUG: Current URL: {driver.current_url}") + print(f" DEBUG: Page title: {driver.title}") + + # Multiple dashboard indicators + dashboard_indicators = [ + "//span[normalize-space()='TRADING MAINTENANCE']", + "//a[contains(@href, 'tradingMaintenance')]", + "//span[contains(text(), 'Dashboard')]", + "//h1[contains(text(), 'Dashboard')]", + "//div[contains(@class, 'dashboard')]", + "//a[contains(text(), 'Trading')]", + "//div[contains(@class, 'navbar')]//span[contains(text(), 'Trading')]", + ] + + wait = WebDriverWait(driver, 15) + for indicator in dashboard_indicators: + try: + wait.until(EC.visibility_of_element_located((By.XPATH, indicator))) + print(f" DEBUG: Found dashboard indicator: {indicator}") + return True + except: + continue + + # URL-based check + if "home" in driver.current_url.lower() or "dashboard" in driver.current_url.lower(): + print(" DEBUG: Login success detected via URL") + return True + + return False + except Exception as e: + print(f" DEBUG: check_login_success failed: {e}") + return False + + +def login( + driver, + username, + password, + max_captcha_retries=5, + captcha_metrics=None, + max_login_duration_seconds=1200, +): wait = WebDriverWait(driver, 20) - # username - userid_field = wait.until( - EC.visibility_of_element_located((By.XPATH, Locator.userid)) - ) - userid_field.send_keys(username) + # --- AUTOMATED CAPTCHA SOLVING WITH RETRY ON FAILURE --- + from tests.captcha_solver import solve_and_fill_captcha, find_refresh_button, wait_for_new_captcha + + login_deadline = time.monotonic() + max_login_duration_seconds + captcha_attempt = 0 + while True: + captcha_attempt += 1 - # password - password_field = driver.find_element(By.XPATH, Locator.password) - password_field.send_keys(password) + # Refreshing after a failed CAPTCHA clears the login form, so refill + # both credentials before every automatic or manual retry. + userid_field = wait.until( + EC.visibility_of_element_located((By.XPATH, Locator.userid)) + ) + userid_field.clear() + userid_field.send_keys(username) - print(f"Solve captcha within {LOGIN_CAPTCHA_WAIT_SECONDS} seconds...") + password_field = wait.until( + EC.visibility_of_element_located((By.XPATH, Locator.password)) + ) + password_field.clear() + password_field.send_keys(password) - # wait for manual captcha solve - time.sleep(LOGIN_CAPTCHA_WAIT_SECONDS) + if captcha_metrics is not None: + captcha_metrics["tries"] = captcha_attempt + captcha_metrics["max_retries"] = max_captcha_retries + print(f"Solving CAPTCHA (attempt {captcha_attempt})...") - # click submit - submit_btn = wait.until( - EC.element_to_be_clickable((By.XPATH, Locator.submitbtn)) - ) - submit_btn.click() + # Once automatic solving has run for the configured duration, ask for + # the CAPTCHA value in a browser prompt and continue the same login flow. + if time.monotonic() >= login_deadline: + print("Automatic CAPTCHA retries timed out; waiting for manual CAPTCHA entry.") + manual_value = driver.execute_script( + "return window.prompt('Automatic CAPTCHA solving timed out. Enter the CAPTCHA shown on the page:');" + ) + if not manual_value: + raise RuntimeError("Manual CAPTCHA entry was cancelled") - time.sleep(LOGIN_POST_SUBMIT_WAIT_SECONDS) + captcha_input = wait.until( + EC.visibility_of_element_located((By.NAME, "captcha")) + ) + captcha_input.clear() + captcha_input.send_keys(manual_value.strip()) + wait.until(EC.element_to_be_clickable((By.XPATH, Locator.submitbtn))).click() + time.sleep(LOGIN_POST_SUBMIT_WAIT_SECONDS) + accept_browser_alert_if_present(driver) + handle_transient_popups(driver) - accept_browser_alert_if_present(driver) + captcha_error = check_captcha_error(driver) + if captcha_error: + print(f" Manual CAPTCHA rejected: {captcha_error}") + continue - # wait for dashboard - wait.until( - EC.visibility_of_element_located((By.XPATH, Locator.dashboard_marker)) - ) - - close_tac_expiry_modal_if_present(driver) - - print("Login successful") + if check_login_success(driver): + print("Login successful after manual CAPTCHA entry") + if captcha_metrics is not None: + captcha_metrics["correct_attempt"] = captcha_attempt + captcha_metrics.setdefault("captcha_attempts", []).append({ + "attempt": captcha_attempt, + "captcha_text": "manual", + "variant": manual_value.strip(), + "status": "manual", + "screenshot": "", + "error": "", + }) + close_tac_expiry_modal_if_present(driver) + return True + continue + + def attempt_callback(attempt_num, captcha_text, variant, status, screenshot_path="", error=""): + if captcha_metrics is not None: + captcha_metrics.setdefault("captcha_attempts", []).append({ + "attempt": attempt_num, + "captcha_text": captcha_text, + "variant": variant, + "status": status, + "screenshot": screenshot_path, + "error": error + }) + + if not solve_and_fill_captcha(driver, max_retries=1, attempt_callback=attempt_callback): + print(" Automatic CAPTCHA solve failed; retrying until the login deadline.") + driver.refresh() + time.sleep(3) + continue + + # solve_and_fill_captcha already clicks submit, so just wait and check result + time.sleep(LOGIN_POST_SUBMIT_WAIT_SECONDS) + + accept_browser_alert_if_present(driver) + + # Handle any popups that appear + handle_transient_popups(driver) + + # Check for CAPTCHA error (invalid captcha message) + captcha_error = check_captcha_error(driver) + if captcha_error: + print(f" CAPTCHA validation failed: {captcha_error}") + # Reload the page to get a fresh CAPTCHA and keep retrying until + # the overall login deadline is reached. + print(f" Reloading page to get fresh CAPTCHA (attempt {captcha_attempt + 1})...") + driver.refresh() + time.sleep(3) + continue + + # Check for successful login - use improved detection + if check_login_success(driver): + print("Login successful") + if captcha_metrics is not None: + captcha_metrics["correct_attempt"] = captcha_attempt + close_tac_expiry_modal_if_present(driver) + return True + + # If we get here, something else is wrong + print(" Login not confirmed, retrying...") + driver.refresh() + time.sleep(3) def accept_browser_alert_if_present(driver, timeout=5): diff --git a/tests/locators.py b/tests/locators.py index 5ce4763..5f4dedd 100644 --- a/tests/locators.py +++ b/tests/locators.py @@ -4,11 +4,11 @@ class Locator(object): userid="//input[@placeholder='User ID']" password="//input[@placeholder='Password']" submitbtn="//button[@value='Login']" - dashboard_marker="//span[normalize-space()='TRADING MAINTENANCE']" + dashboard_marker="//*[contains(normalize-space(), 'TRADING MAINTENANCE')]" tacexpiryclosebtn="//div[contains(@class,'modal') and contains(@style,'display: block')]//button[contains(@class,'close')]" usermenutoggle="//li[contains(@class,'dropdown') and contains(@class,'navbar-user')]//a[contains(@class,'dropdown-toggle') and @data-toggle='dropdown']" logoutlink="//li[contains(@class,'dropdown') and contains(@class,'navbar-user')]//ul[contains(@class,'dropdown-menu')]//a[@ng-click='logout()' and normalize-space()='Log Out']" - tradingmaintenance="//span[normalize-space()='TRADING MAINTENANCE']" + tradingmaintenance="//*[contains(normalize-space(), 'TRADING MAINTENANCE')]" tradingsession="//a[normalize-space()='Trading Session Master']" tokengeneration="//a[@href='#/tradingMaintenance/generateToken' or normalize-space()='Token Generation']" tokengenerator = "//a[@href='#/tradingMaintenance/tokenGenerator']" diff --git a/tests/test_captcha_login_stress.py b/tests/test_captcha_login_stress.py new file mode 100644 index 0000000..ad50783 --- /dev/null +++ b/tests/test_captcha_login_stress.py @@ -0,0 +1,124 @@ +import csv +import os +import time +from datetime import datetime + +import pytest +from selenium.webdriver.common.by import By +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC + +from tests.browser import create_chrome_driver +from tests.config_reader import load_config +from tests.helpers import login, logout +from tests.locators import Locator + + +LOGIN_ATTEMPTS = 50 +MAX_CAPTCHA_RETRIES = 7 + + +@pytest.mark.captcha +def test_captcha_login_stress(): + """Run bounded EasyOCR login attempts and retain auditable CAPTCHA evidence.""" + config = load_config() + output_dir = os.path.abspath(os.path.join("reports", "captcha_login_stress")) + os.makedirs(output_dir, exist_ok=True) + started = datetime.now().strftime("%Y%m%d_%H%M%S") + csv_path = os.path.join(output_dir, f"captcha_login_{started}.csv") + rows = [] + passed = 0 + failed = 0 + + def record(row): + rows.append(row) + + for login_number in range(1, LOGIN_ATTEMPTS + 1): + login_started = datetime.now().isoformat(timespec="seconds") + captcha_rows = [] + + driver = create_chrome_driver() + try: + driver.get(config["url"]) + + login_status = "FAILED" + logout_status = "NOT_ATTEMPTED" + error_text = "" + captcha_metrics = {"captcha_attempts": []} + try: + login( + driver, + config["login"]["username"], + config["login"]["password"], + max_captcha_retries=MAX_CAPTCHA_RETRIES, + captcha_metrics=captcha_metrics, + max_login_duration_seconds=1200, + ) + login_status = "PASSED" + passed += 1 + except Exception as exc: + failed += 1 + error_text = f"{type(exc).__name__}: {exc}" + try: + driver.save_screenshot(os.path.join(output_dir, f"login_{login_number:03d}_failure.png")) + except Exception: + pass + finally: + # Explicitly log out after a successful authenticated flow, + # then close the browser session. + if login_status == "PASSED": + try: + logout_status = "PASSED" if logout(driver) else "FAILED" + except Exception as exc: + logout_status = "FAILED" + print(f"Logout error: {type(exc).__name__}: {exc}") + + try: + driver.quit() + except Exception: + pass + + captcha_rows = captcha_metrics.get("captcha_attempts", []) or [{ + "attempt": 0, "captcha_text": "", "variant": "", + "status": "no_captcha_record", "screenshot": "", "error": error_text, + }] + for item in captcha_rows: + record({ + "login_attempt": login_number, + "started_at": login_started, + "login_status": login_status, + "logout_status": logout_status, + "captcha_attempt": item["attempt"], + "attempted_captcha": item["variant"], + "ocr_captcha": item["captcha_text"], + "screenshot_path": item["screenshot"], + "captcha_status": item["status"], + "captcha_entered_count": sum( + 1 for captcha_row in captcha_rows if captcha_row["variant"] + ), + "error": item["error"] or error_text, + "attempts_to_result": len(captcha_rows), + "passed_count": passed, + "failed_count": failed, + }) + finally: + # Clean up the driver after all attempts + try: + driver.quit() + except Exception: + pass + + fieldnames = [ + "login_attempt", "started_at", "login_status", "captcha_attempt", + "logout_status", + "attempted_captcha", "ocr_captcha", "screenshot_path", "captcha_status", + "captcha_entered_count", "error", "attempts_to_result", "passed_count", + "failed_count", + ] + with open(csv_path, "w", newline="", encoding="utf-8") as report: + writer = csv.DictWriter(report, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(rows) + + print(f"CAPTCHA stress report: {csv_path}") + print(f"Passed: {passed}; Failed: {failed}; Total: {LOGIN_ATTEMPTS}")