605 lines
23 KiB
Python
605 lines
23 KiB
Python
"""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
|