Files
plugins/openedx-tenant-api/test_e2e/studio_page.py
DamarKusumo 2b7027e37d Add openedx-tenant-api plugin
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 08:20:57 +07:00

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