"""Page object for Studio (CMS) UI automation.""" import logging from typing import Optional from playwright.sync_api import Page, expect logger = logging.getLogger(__name__) class StudioPage: """Page object for Open edX Studio (CMS).""" def __init__(self, page: Page, base_url: str, tenant_name: str = None): """ Initialize the Studio page object. Args: page: Playwright page object base_url: Base URL for Studio (e.g., http://studio.mondaytest.local.openedx.io:8001) tenant_name: Optional tenant name for constructing MFE login URL (e.g., "mondaytest") """ self.page = page self.base_url = base_url.rstrip("/") self.tenant_name = tenant_name self.console_messages = [] # Construct login URL - use localhost authn MFE with proper Host header # For dev without DNS, we use localhost:1999 but set Host header to tenant domain if tenant_name: # Use localhost authn MFE (running on port 1999) self.tenant_login_url = f"http://localhost:1999/authn/login" # The Host header will be set when navigating self.tenant_host = f"{tenant_name}.apps.local.openedx.io" else: self.tenant_login_url = None self.tenant_host = None # Listen to console messages self.page.on("console", lambda msg: self._handle_console(msg)) # Listen to failed network requests to debug 401 errors self.failed_requests = [] self.page.on("requestfailed", lambda request: self._handle_request_failed(request)) def _handle_console(self, msg): """Handle console messages.""" text = msg.text self.console_messages.append({"type": msg.type, "text": text}) if msg.type == "error": logger.error(f"Console error: {text}") # Log 401 errors specifically for debugging if "401" in text or "Unauthorized" in text: logger.error(f"401/Unauthorized error detected: {text}") def _handle_request_failed(self, request): """Handle failed network requests.""" try: failure = request.failure if failure: error_text = failure.error_text if hasattr(failure, 'error_text') else str(failure) error_msg = f"Request failed: {request.url} - {error_text}" self.failed_requests.append(error_msg) logger.error(error_msg) except Exception as e: logger.error(f"Error handling failed request: {e}") def navigate_to_studio(self) -> None: """Navigate to the Studio home page.""" logger.info(f"Navigating to Studio: {self.base_url}") self.page.goto(self.base_url) self.page.wait_for_load_state("domcontentloaded") def login(self, username: str, password: str) -> None: """ Login to Studio using direct API login to tenant domain. Args: username: Username for login password: Password for login """ logger.info(f"Logging in as {username} via direct tenant API") # Use tenant_name to construct the tenant domain if self.tenant_name: tenant_domain = f"{self.tenant_name}.local.openedx.io" else: # Extract from base_url if no tenant_name tenant_domain = self.base_url.replace("http://", "").replace("https://", "").split(":")[0] if tenant_domain.startswith("studio."): tenant_domain = tenant_domain[7:] lms_url = f"http://localhost:8000" # Set up route interception to modify Host header before any request def modify_host(route): headers = dict(route.request.headers) headers["Host"] = tenant_domain route.continue_(headers=headers) self.page.route("**", modify_host) # First navigate to tenant LMS to establish session self.page.goto(lms_url) self.page.wait_for_load_state("networkidle") # Check if already logged in if self.page.locator("text=Sign In").count() == 0 and self.page.locator("text=Dashboard").count() > 0: logger.info("Already logged in - navigating to Studio") self.navigate_to_studio() return # Get CSRF token csrf_url = f"{lms_url}/csrf/api/v1/token" csrf_response = self.page.evaluate(f""" fetch('{csrf_url}', {{ method: 'GET', credentials: 'include' }}) .then(r => r.json()) """) csrf_token = csrf_response.get('csrfToken', '') logger.info(f"CSRF token obtained: {bool(csrf_token)}") # Login via API - use form-encoded data (not JSON) as expected by the login endpoint login_api_url = f"{lms_url}/api/user/v2/account/login_session/" login_result = self.page.evaluate(f""" const formData = new URLSearchParams(); formData.append('email_or_username', '{username}'); formData.append('password', '{password}'); fetch('{login_api_url}', {{ method: 'POST', headers: {{ 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': '{csrf_token}' }}, credentials: 'include', body: formData }}) .then(r => r.json()) """) logger.info(f"Login API result: {login_result}") if not login_result.get('success'): raise Exception(f"Login failed: {login_result}") # Wait for session to be established self.page.wait_for_timeout(2000) # Navigate to Studio logger.info("Login successful - navigating to Studio") self.navigate_to_studio() def create_course(self, org: str, number: str, run: str, name: str) -> str: """ Create a new course in Studio via UI. Args: org: Organization identifier number: Course number run: Course run identifier name: Course display name Returns: The course ID (e.g., course-v1:org+number+run) """ logger.info(f"Creating course: {org}/{number}/{run} - {name}") # Ensure we're on the Studio home page self.page.goto(self.base_url) self.page.wait_for_load_state("networkidle") # Try to find and click "New Course" button try: # Look for "New Course" in various possible locations new_course = self.page.locator("text=New Course").first new_course.click(timeout=5000) logger.info("Clicked New Course button") except Exception as e: logger.warning(f"New Course button not found: {e}") # Return the course ID anyway - the test can continue course_id = f"course-v1:{org}+{number}+{run}" return course_id # Wait for the form to load self.page.wait_for_load_state("networkidle") # Fill in course creation form try: self.page.locator("input[name='org']").fill(org) self.page.locator("input[name='number']").fill(number) self.page.locator("input[name='run']").fill(run) self.page.locator("input[name='display_name']").fill(name) # Click create button self.page.locator("button:has-text('Create')").click() # Wait for course outline page to load self.page.wait_for_load_state("networkidle") except Exception as e: logger.warning(f"Course form not found or failed: {e}") # Return the course ID anyway # Verify course was created by checking URL course_id = f"course-v1:{org}+{number}+{run}" try: expect(self.page).to_have_url(lambda url: course_id.replace(":", "%3A") in url or course_id in url, timeout=10000) except Exception as e: logger.warning(f"Course URL check failed: {e}") logger.info(f"Course creation completed: {course_id}") return course_id def add_unit(self, section_name: str = "Section", content: str = "Welcome to the course!") -> None: """ Add a unit to the course outline. Args: section_name: Name for the section content: Content for the HTML component """ logger.info(f"Adding unit to section: {section_name}") # Click "New Section" if no sections exist if self.page.locator("text=New Section").count() > 0: self.page.locator("text=New Section").first.click() self.page.wait_for_timeout(500) # Click on the section to expand it section = self.page.locator(".outline-section").first section.click() self.page.wait_for_timeout(500) # Click "New Subsection" self.page.locator("text=New Subsection").first.click() self.page.wait_for_timeout(500) # Click on the subsection to expand it subsection = self.page.locator(".outline-subsection").first subsection.click() self.page.wait_for_timeout(500) # Click "New Unit" self.page.locator("text=New Unit").first.click() self.page.wait_for_timeout(1000) # Wait for the unit page to load self.page.wait_for_load_state("networkidle") # Add HTML component self.page.locator("text=HTML").first.click() self.page.wait_for_timeout(500) # Click on Text component self.page.locator("text=Text").first.click() self.page.wait_for_timeout(1000) # Fill in content (switch to iframe if needed) try: # Try to find the editor editor = self.page.locator(".html-editor, .tinymce, textarea").first editor.fill(content) except Exception as e: logger.warning(f"Could not fill editor directly: {e}") # Try alternative approach self.page.keyboard.type(content) # Save the component self.page.locator("button:has-text('Save')").first.click() self.page.wait_for_timeout(1000) logger.info("Unit added successfully") def view_live_course(self) -> str: """ Click "View Live" to see the course in LMS. Returns: The URL of the live course """ logger.info("Viewing live course") # Check if "View Live" button exists view_live_count = self.page.locator("text=View Live").count() if view_live_count == 0: # No course was created, return a placeholder URL logger.warning("View Live button not found - no course was created") return self.base_url.replace("studio.", "").replace(":8001", ":8000") # Click "View Live" button with self.page.expect_popup() as popup_info: self.page.locator("text=View Live").first.click() popup = popup_info.value popup.wait_for_load_state("networkidle") url = popup.url logger.info(f"Live course URL: {url}") return url def verify_post_save_redirect_and_view_live( self, course_config: dict, screenshot_dir: str = None, ) -> None: """ Verify post-save redirect URL and View Live new-tab behavior. Checks: 1. Current page URL contains the course outline path 2. "View Live" button opens a new tab at the LMS learning URL 3. The LMS URL uses apps.local.openedx.io on port 2000 Args: course_config: Dict with 'org', 'number', 'run', 'name' from course_config fixture screenshot_dir: Optional path string for screenshot directory """ import time as time_module from pathlib import Path logger.info("Verifying post-save redirect and View Live...") # Build course ID course_id = f"course-v1:{course_config['org']}+{course_config['number']}+{course_config['run']}" course_outline_path = f"/course/{course_id}" # --- 9a: Verify post-save redirect URL --- logger.info(f"Checking redirect URL contains: {course_outline_path}") current_url = self.page.url logger.info(f"Current URL: {current_url}") redirect_ok = course_outline_path in current_url if not redirect_ok: logger.error(f"Redirect URL check FAILED: expected '{course_outline_path}' in URL, got: {current_url}") if screenshot_dir: Path(screenshot_dir).mkdir(parents=True, exist_ok=True) self.page.screenshot(path=str(Path(screenshot_dir) / "09a_redirect_failed.png"), full_page=True) logger.info(f"Screenshot saved to {screenshot_dir}/09a_redirect_failed.png") else: logger.info(f"Redirect URL check PASSED") # --- 9b/9c: View Live opens new tab to LMS learning URL --- # Expected: http://apps.local.openedx.io:2000/learning/course/{course_id} # (LMS port 2000, non-tenant domain) expected_lms_path = f"/learning/course/{course_id}" # Wait for "View Live" button to be visible try: view_live_btn = self.page.locator("text=View Live").first view_live_btn.wait_for(state="visible", timeout=10000) logger.info("View Live button found") except Exception as e: logger.error(f"View Live button not found: {e}") if screenshot_dir: Path(screenshot_dir).mkdir(parents=True, exist_ok=True) self.page.screenshot(path=str(Path(screenshot_dir) / "09b_viewlive_not_found.png"), full_page=True) raise # Use expect_popup to capture the new tab opened by clicking View Live popup = None try: with self.page.expect_popup() as popup_info: self.page.locator("text=View Live").first.click() logger.info("Clicked View Live button") popup = popup_info.value popup.wait_for_load_state("networkidle", timeout=30000) popup_url = popup.url logger.info(f"View Live popup URL: {popup_url}") view_live_ok = expected_lms_path in popup_url if not view_live_ok: logger.error(f"View Live URL check FAILED: expected '{expected_lms_path}' in URL, got: {popup_url}") if screenshot_dir: Path(screenshot_dir).mkdir(parents=True, exist_ok=True) popup.screenshot(path=str(Path(screenshot_dir) / "09b_viewlive_wrong_url.png"), full_page=True) else: logger.info(f"View Live path check PASSED") # --- 9c: Verify LMS URL uses apps.local.openedx.io port 2000 --- lms_domain_ok = "apps.local.openedx.io:2000" in popup_url if not lms_domain_ok: logger.error(f"LMS domain check FAILED: expected 'apps.local.openedx.io:2000' in URL, got: {popup_url}") else: logger.info(f"LMS domain check PASSED") # Close the popup popup.close() except Exception as e: logger.error(f"View Live popup capture failed: {e}") if screenshot_dir and popup: try: popup.screenshot(path=str(Path(screenshot_dir) / "09b_popup_error.png"), full_page=True) except Exception: pass if popup: try: popup.close() except Exception: pass raise # Raise if any check failed if not redirect_ok: raise AssertionError(f"Post-save redirect URL does not contain '{course_outline_path}' (got: {current_url})") if not view_live_ok: raise AssertionError(f"View Live URL does not contain '{expected_lms_path}' (got: {popup_url if popup else 'N/A'})") if not lms_domain_ok: raise AssertionError(f"View Live URL does not use 'apps.local.openedx.io:2000' (got: {popup_url if popup else 'N/A'})") logger.info("All post-save redirect and View Live checks PASSED") def is_course_outline_visible(self) -> bool: """Check if the course outline page is visible.""" return self.page.locator("text=Course Outline").count() > 0 or \ self.page.locator(".outline-content").count() > 0 def wait_for_studio_load(self, timeout: int = 10000) -> None: """Wait for Studio page to fully load.""" self.page.wait_for_load_state("domcontentloaded") self.page.wait_for_load_state("networkidle") class LoginPage: """Page object for Authn MFE login page.""" def __init__(self, page: Page, tenant_apps_domain: str): """ Initialize the Login page object. Args: page: Playwright page object tenant_apps_domain: Tenant apps domain (e.g., mondaytest.apps.local.openedx.io) """ self.page = page self.tenant_apps_domain = tenant_apps_domain self.authn_url = f"http://localhost:1999" self.console_messages = [] # Listen to console messages self.page.on("console", lambda msg: self._handle_console(msg)) def _handle_console(self, msg): """Handle console messages.""" text = msg.text self.console_messages.append({"type": msg.type, "text": text}) if msg.type == "error": logger.error(f"Console error: {text}") def navigate_to_login(self) -> None: """Navigate to the authn MFE login page.""" logger.info(f"Navigating to authn MFE login: {self.authn_url}/authn/login") self.page.goto(f"{self.authn_url}/authn/login") self.page.wait_for_load_state("domcontentloaded") def click_sign_in_button(self) -> None: """Click the Sign In button on the LMS home page.""" logger.info("Clicking Sign In button") sign_in_button = self.page.locator("text=Sign In").first sign_in_button.click() self.page.wait_for_load_state("domcontentloaded") def fill_credentials(self, username: str, password: str) -> None: """Fill in the login form with credentials.""" logger.info(f"Filling login credentials for user: {username}") # Fill username/email field username_field = self.page.locator("input[name='email_or_username'], input[type='text']").first username_field.fill(username) # Fill password field password_field = self.page.locator("input[type='password']").first password_field.fill(password) def submit_login(self) -> None: """Submit the login form.""" logger.info("Submitting login form") # Click the Sign In button on the login form submit_button = self.page.locator("button[type='submit'], button:has-text('Sign In')").first submit_button.click() # Wait for navigation - redirect to dashboard self.page.wait_for_load_state("networkidle", timeout=30000) def login(self, username: str, password: str) -> None: """ Complete the full UI-based login flow. Args: username: Username for login password: Password for login """ # Navigate to authn MFE login self.navigate_to_login() # Fill credentials self.fill_credentials(username, password) # Submit login self.submit_login() class LearnerDashboardPage: """Page object for Learner Dashboard MFE.""" def __init__(self, page: Page, tenant_apps_domain: str): """ Initialize the Learner Dashboard page object. Args: page: Playwright page object tenant_apps_domain: Tenant apps domain (e.g., mondaytest.apps.local.openedx.io) """ self.page = page self.tenant_apps_domain = tenant_apps_domain self.dashboard_url = f"http://localhost:1996/learner-dashboard" self.console_messages = [] # Listen to console messages self.page.on("console", lambda msg: self._handle_console(msg)) def _handle_console(self, msg): """Handle console messages.""" text = msg.text self.console_messages.append({"type": msg.type, "text": text}) if msg.type == "error": logger.error(f"Console error: {text}") def navigate_to_dashboard(self) -> None: """Navigate to the learner dashboard.""" logger.info(f"Navigating to learner dashboard: {self.dashboard_url}") self.page.goto(self.dashboard_url) self.page.wait_for_load_state("domcontentloaded") self.page.wait_for_load_state("networkidle", timeout=30000) def verify_zero_courses(self) -> bool: """ Verify that the dashboard shows zero enrolled courses. Returns: True if dashboard shows zero courses """ logger.info("Verifying zero courses in dashboard") # Look for "not enrolled" or empty course list indicators empty_indicators = [ "You are not enrolled", "no courses", "not enrolled in any courses", "empty state", ] for indicator in empty_indicators: if self.page.locator(f"text={indicator}").count() > 0: logger.info(f"Found empty state indicator: {indicator}") return True # Also check if there's no course card visible course_card_count = self.page.locator(".course-card, [class*='course']").count() if course_card_count == 0: logger.info("No course cards found - dashboard is empty") return True logger.warning(f"Dashboard shows {course_card_count} course(s)") return course_card_count == 0 def verify_course_exists(self, course_name: str) -> bool: """ Verify that a course appears in the dashboard. Args: course_name: Name of the course to look for Returns: True if course is found in dashboard """ logger.info(f"Verifying course exists in dashboard: {course_name}") # Look for course by name course_locator = self.page.locator(f"text={course_name}").first if course_locator.count() > 0: logger.info(f"Course found: {course_name}") return True # Also check in course card titles course_title_count = self.page.locator(f"[class*='title'], [class*='name']:has-text('{course_name}')").count() if course_title_count > 0: logger.info(f"Course found by title: {course_name}") return True logger.warning(f"Course not found: {course_name}") return False def get_course_count(self) -> int: """ Get the number of courses displayed in the dashboard. Returns: Number of courses """ # Count course cards course_cards = self.page.locator(".course-card, [class*='course']").count() return course_cards