"""Pytest configuration and fixtures for E2E tests.""" import ast import logging import pytest import os from pathlib import Path from dotenv import load_dotenv # Load .env file from test_e2e directory load_dotenv(Path(__file__).parent / ".env") logger = logging.getLogger(__name__) @pytest.fixture(scope="session") def base_url(): """Base URL for the LMS.""" return os.getenv("LMS_BASE_URL", "http://localhost:8000") @pytest.fixture(scope="session") def studio_base_url(): """Base URL for Studio.""" return os.getenv("STUDIO_BASE_URL", "http://localhost:8001") @pytest.fixture(scope="session") def authn_base_url(): """Base URL for Authn MFE.""" return os.getenv("AUTHN_BASE_URL", "http://localhost:1999") @pytest.fixture(scope="session") def learner_dashboard_base_url(): """Base URL for Learner Dashboard MFE.""" return os.getenv("LEARNER_DASHBOARD_BASE_URL", "http://localhost:1996") @pytest.fixture(scope="session") def studio_mfe_base_url(): """Base URL for Studio MFE.""" return os.getenv("STUDIO_MFE_BASE_URL", "http://localhost:2001") @pytest.fixture(scope="session") def tenant_domain(tenant_config): """Tenant domain for LMS (e.g., mondaytest.local.openedx.io).""" return f"{tenant_config['tenant_name']}.local.openedx.io" @pytest.fixture(scope="session") def tenant_apps_domain(tenant_config): """Tenant apps domain for MFEs (e.g., mondaytest.apps.local.openedx.io).""" return f"{tenant_config['tenant_name']}.apps.local.openedx.io" @pytest.fixture(scope="session") def admin_auth(): """Admin credentials for API authentication (superuser).""" return { "username": os.getenv("API_ADMIN_USERNAME", "admin"), "password": os.getenv("API_ADMIN_PASSWORD", "admin123"), } @pytest.fixture(scope="session") def tenant_config(): """Tenant configuration for the test.""" return { "tenant_name": os.getenv("TENANT_NAME", "mondaytest"), "platform_name": os.getenv("PLATFORM_NAME", "Monday Test Learning"), "theme_name": os.getenv("THEME_NAME", "indigo"), "org_filter": [os.getenv("ORG_FILTER", "monday")], } @pytest.fixture(scope="session") def admin_config(): """Admin user configuration.""" tenant_name = os.getenv("TENANT_NAME", "mondaytest") return { "tenant_name": tenant_name, "username": os.getenv("ADMIN_USERNAME", f"{tenant_name}_admin"), "email": os.getenv("ADMIN_EMAIL", f"{tenant_name}_admin@example.com"), "password": os.getenv("ADMIN_PASSWORD", "admin123"), "org_name": os.getenv("ORG_FILTER", "monday"), } @pytest.fixture(scope="session") def course_config(): """Course configuration.""" return { "org": os.getenv("COURSE_ORG", "course"), "number": os.getenv("COURSE_NUMBER", "101"), "run": os.getenv("COURSE_RUN", "2026"), "name": os.getenv("COURSE_NAME", "E2E Test Course"), } @pytest.fixture(scope="function") def report_dir(): """Directory for test reports.""" report_path = Path(__file__).parent / "reports" report_path.mkdir(exist_ok=True) return report_path @pytest.fixture(scope="function") def screenshot_dir(): """Directory for screenshots.""" screenshot_path = Path(__file__).parent / "screenshots" screenshot_path.mkdir(exist_ok=True) return screenshot_path @pytest.fixture(scope="function") def browser_context_args(browser_context_args): """Browser context arguments for Playwright.""" return { **browser_context_args, "viewport": {"width": 1920, "height": 1080}, "record_video_dir": str(Path(__file__).parent / "videos"), } @pytest.fixture(scope="session") def browser_type_launch_args(browser_type_launch_args): """Launch args to ignore certificate errors in dev.""" return { **browser_type_launch_args, "args": [ "--disable-web-security", "--ignore-certificate-errors", "--allow-insecure-localhost", "--disable-site-isolation-trials", ], } @pytest.fixture(scope="function") def context(context, tenant_config): """Create browser context with proper headers for tenant detection.""" tenant_name = tenant_config.get("tenant_name", "mondaytest") tenant_host = f"{tenant_name}.local.openedx.io" # Add extra HTTP headers for tenant detection context.set_default_timeout(60000) return context # --------------------------------------------------------------------------- # Auto-cleanup fixtures — run once before the test session starts # --------------------------------------------------------------------------- @pytest.fixture(scope="session", autouse=True) def auto_cleanup_tenants(): """ Automatically delete ALL tenants before the test session starts. This ensures a clean slate — no stale tenant configs interfere with tests. Run with: pytest --co -q # to see what would run without executing Skip with: pytest --no-cleanup """ if os.getenv("SKIP_AUTO_CLEANUP", "").lower() in ("1", "true", "yes"): logger.info("Skipping auto-cleanup (SKIP_AUTO_CLEANUP=1)") return import subprocess import requests from dotenv import load_dotenv # Re-load .env in case this runs in a different import context load_dotenv(Path(__file__).parent / ".env") lms_base_url = os.getenv("LMS_BASE_URL", "http://localhost:8000").rstrip("/") admin_user = os.getenv("API_ADMIN_USERNAME", "admin") admin_pass = os.getenv("API_ADMIN_PASSWORD", "admin123") auth = (admin_user, admin_pass) # List and delete all tenants try: list_url = f"{lms_base_url}/api/tenant/v1/list" resp = requests.get(list_url, auth=auth, timeout=30) resp.raise_for_status() tenants = resp.json().get("tenants", []) logger.info(f"[Auto-cleanup] Found {len(tenants)} tenant(s): {[t['tenant_name'] for t in tenants]}") for tenant in tenants: name = tenant["tenant_name"] delete_url = f"{lms_base_url}/api/tenant/v1/delete/{name}" try: r = requests.delete(delete_url, auth=auth, timeout=30) r.raise_for_status() logger.info(f"[Auto-cleanup] Deleted tenant: {name}") except Exception as e: logger.warning(f"[Auto-cleanup] Could not delete tenant '{name}': {e}") except Exception as e: logger.warning(f"[Auto-cleanup] Could not list/delete tenants: {e}") @pytest.fixture(scope="session", autouse=True) def auto_cleanup_admins(): """ Automatically delete all admin users (username ending with _admin) created by the tenant API, after tenants are deleted. """ if os.getenv("SKIP_AUTO_CLEANUP", "").lower() in ("1", "true", "yes"): logger.info("Skipping auto-cleanup admins (SKIP_AUTO_CLEANUP=1)") return import subprocess # Resolve tutor executable as a list: e.g. ['C:\\...\\tutor.exe', 'dev'] env_tutor = os.getenv("TUTOR_CMD", "").strip() if env_tutor: tutor_cmd = env_tutor.split() else: project_root = Path(__file__).resolve().parent.parent.parent venv_tutor = project_root / ".venv" / "Scripts" / "tutor.exe" if venv_tutor.exists(): tutor_cmd = [str(venv_tutor), "dev"] else: tutor_cmd = ["tutor", "dev"] def run_tutor(args, timeout=120): return subprocess.run(args, capture_output=True, text=True, timeout=timeout) # List admin users via LMS Django shell try: list_cmd = tutor_cmd + [ "exec", "-T", "lms", "python", "manage.py", "lms", "shell", "-c", "from django.contrib.auth import get_user_model; " "User = get_user_model(); " "admins = list(User.objects.filter(username__endswith='_admin').values_list('username', flat=True)); " "print(repr(admins))", ] result = run_tutor(list_cmd, timeout=60) output = result.stdout + result.stderr if result.returncode != 0: stderr_lower = result.stderr.lower() if "is not running" in stderr_lower or ("service" in stderr_lower and "not" in stderr_lower): logger.warning( "[Auto-cleanup] LMS is not running. Skipping admin cleanup.\n" f"Start with: {' '.join(tutor_cmd)} start" ) else: logger.warning(f"[Auto-cleanup] Could not list admins: {result.stderr}") return admins = [] for line in output.strip().split("\n"): stripped = line.strip() if stripped.startswith("[") and stripped.endswith("]"): admins = ast.literal_eval(stripped) break logger.info(f"[Auto-cleanup] Found {len(admins)} admin user(s): {admins}") for username in admins: escaped_user = username.replace("'", "''") delete_cmd = tutor_cmd + [ "exec", "-T", "lms", "python", "manage.py", "lms", "shell", "-c", ( "from django.db import connection; " "cursor = connection.cursor(); " "cursor.execute('SET FOREIGN_KEY_CHECKS = 0'); " "cursor.execute('DELETE FROM auth_user WHERE username = %s', ['" + escaped_user + "']); " "cursor.execute('SET FOREIGN_KEY_CHECKS = 1'); " "print('DELETED_" + escaped_user + "')" ), ] try: dr = run_tutor(delete_cmd, timeout=60) out = dr.stdout + dr.stderr marker = f"DELETED_{username}" if dr.returncode == 0 and marker in out: logger.info(f"[Auto-cleanup] Deleted admin: {username}") elif "FOREIGN_KEY_CHECKS" in dr.stderr and "Error" in dr.stderr: logger.warning(f"[Auto-cleanup] Failed to delete '{username}': {dr.stderr.strip()[:200]}") else: logger.info(f"[Auto-cleanup] Admin '{username}' not found (already deleted)") except subprocess.TimeoutExpired: logger.warning(f"[Auto-cleanup] Timeout deleting admin: {username}") except Exception as e: logger.warning(f"[Auto-cleanup] Error deleting '{username}': {e}") except Exception as e: logger.warning(f"[Auto-cleanup] Admin cleanup failed: {e}") @pytest.fixture(scope="session", autouse=True) def auto_cleanup_courses(): """ Automatically delete ALL courses before the test session starts. Uses 'tutor dev exec -T cms python manage.py cms shell' with modulestore API for each course found in CourseOverview. Run with: pytest --co -q # to see what would run without executing Skip with: pytest --no-cleanup """ if os.getenv("SKIP_AUTO_CLEANUP", "").lower() in ("1", "true", "yes"): logger.info("Skipping auto-cleanup courses (SKIP_AUTO_CLEANUP=1)") return import subprocess # Resolve tutor executable as a list: e.g. ['C:\\...\\tutor.exe', 'dev'] env_tutor = os.getenv("TUTOR_CMD", "").strip() if env_tutor: tutor_cmd = env_tutor.split() else: project_root = Path(__file__).resolve().parent.parent.parent venv_tutor = project_root / ".venv" / "Scripts" / "tutor.exe" if venv_tutor.exists(): tutor_cmd = [str(venv_tutor), "dev"] else: tutor_cmd = ["tutor", "dev"] # fallback to PATH def run_tutor(args, timeout=120): return subprocess.run(args, capture_output=True, text=True, timeout=timeout) # List all course keys try: list_cmd = tutor_cmd + [ "exec", "lms", "python", "manage.py", "lms", "shell", "-c", "from openedx.core.djangoapps.content.course_overviews.models import CourseOverview; " "print('\\n'.join([str(c.id) for c in CourseOverview.objects.all()]))", ] result = run_tutor(list_cmd, timeout=60) # Course data may be in stdout or stderr (Django logs to stderr) output = result.stdout + result.stderr if result.returncode != 0: stderr_lower = result.stderr.lower() if "is not running" in stderr_lower or ("service" in stderr_lower and "not" in stderr_lower): logger.warning( "[Auto-cleanup] LMS is not running. Skipping course cleanup.\n" f"Start with: {' '.join(tutor_cmd)} start" ) else: logger.warning(f"[Auto-cleanup] Could not list courses (rc={result.returncode}): {result.stderr}") return # Filter out noise lines — only keep course-v1: lines courses = [ line.strip() for line in output.strip().split("\n") if line.strip().startswith("course-v1:") ] logger.info(f"[Auto-cleanup] Found {len(courses)} course(s): {courses}") for course_key in courses: # Escape single quotes in course_key for shell escaped_key = course_key.replace("'", "'\\''") delete_cmd = tutor_cmd + [ "exec", "-T", "cms", "python", "manage.py", "cms", "shell", "-c", ( f"from xmodule.modulestore.django import modulestore; " f"from opaque_keys.edx.keys import CourseKey; " f"from openedx.core.djangoapps.content.course_overviews.models import CourseOverview; " f"store = modulestore(); " f"key = CourseKey.from_string('{escaped_key}'); " f"store.delete_course(key, None); " # Also explicitly delete CourseOverview to avoid orphaned cache rows # that cause courses to still appear in Studio UI after modulestore deletion f"CourseOverview.objects.filter(id=key).delete(); " f"print(f'DELETED: ' + str(key))" ), ] try: dr = run_tutor(delete_cmd, timeout=120) output = dr.stdout + dr.stderr if dr.returncode == 0 and f"DELETED: {course_key}" in output: logger.info(f"[Auto-cleanup] Deleted course: {course_key}") else: err_lower = dr.stderr.lower() if "not found" in err_lower or "does not exist" in err_lower or "does not have" in err_lower: logger.info(f"[Auto-cleanup] Course already gone: {course_key}") else: logger.warning(f"[Auto-cleanup] Failed to delete '{course_key}': {dr.stderr.strip()[:200]}") except subprocess.TimeoutExpired: logger.warning(f"[Auto-cleanup] Timeout deleting course: {course_key}") except Exception as e: logger.warning(f"[Auto-cleanup] Error deleting '{course_key}': {e}") # Also clear Meilisearch course discovery indexes so courses don't appear # on the /courses page even after database deletion try: import urllib.request import json # Get master key from Django settings key_cmd = tutor_cmd + [ "exec", "lms", "python", "-c", "from django.conf import settings; k=getattr(settings,'MEILISEARCH_MASTER_KEY',None); print(k or '')", ] kr = run_tutor(key_cmd, timeout=30) lines = [l.strip() for l in kr.stdout.splitlines() if l.strip()] master_key = lines[-1] if lines else None if not master_key: logger.warning("[Auto-cleanup] Could not get Meilisearch master key") return host = "http://127.0.0.1:7700" # default accessible from host headers = {"Authorization": f"Bearer {master_key}", "Content-Type": "application/json"} # List all indexes req = urllib.request.Request(f"{host}/indexes", headers=headers) with urllib.request.urlopen(req, timeout=15) as resp: data = json.loads(resp.read()) indexes = data.get("results", []) course_indexes = [idx["uid"] for idx in indexes if "course" in idx["uid"].lower()] for idx_uid in course_indexes: try: # Get doc count req2 = urllib.request.Request(f"{host}/indexes/{idx_uid}/stats", headers=headers) with urllib.request.urlopen(req2, timeout=15) as resp2: stats = json.loads(resp2.read()) doc_count = stats.get("numberOfDocuments", 0) # Delete all docs req3 = urllib.request.Request( f"{host}/indexes/{idx_uid}/documents", headers=headers, method="DELETE" ) with urllib.request.urlopen(req3, timeout=15) as resp3: task = json.loads(resp3.read()) logger.info(f"[Auto-cleanup] Meilisearch cleaned: {idx_uid} ({doc_count} docs, taskUid={task.get('taskUid')})") except Exception as me: logger.warning(f"[Auto-cleanup] Meilisearch cleanup failed for '{idx_uid}': {me}") except Exception as me_err: logger.warning(f"[Auto-cleanup] Meilisearch cleanup failed: {me_err}") except Exception as e: logger.warning(f"[Auto-cleanup] Course cleanup failed: {e}")