752 lines
34 KiB
Python
752 lines
34 KiB
Python
|
|
"""End-to-end test for tenant workflow with complete MFE flow.
|
|
|
|
This test covers the complete tenant user journey:
|
|
1. Health check
|
|
2. Delete existing tenant (clean slate)
|
|
3. Create tenant (via API)
|
|
4. Create admin user (via API)
|
|
5. Visit LMS home (port 8000)
|
|
6. Click Sign In button
|
|
7. Login via authn MFE (port 1999)
|
|
8. Verify redirect to learner dashboard (port 1996)
|
|
9. Verify zero courses
|
|
10. Navigate to Studio MFE (port 2001)
|
|
11. Create course
|
|
12. Return to learner dashboard
|
|
13. Verify course appears in dashboard
|
|
14. Logout and verify redirect to LMS home
|
|
"""
|
|
import logging
|
|
import re
|
|
import pytest
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
# Add test_e2e directory to path for imports
|
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
|
|
from api_client import TenantAPIClient
|
|
from studio_page import StudioPage, LoginPage, LearnerDashboardPage
|
|
from cors_monitor import CORSMonitor
|
|
from report_generator import ReportGenerator
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def test_tenant_e2e_mfe_flow(
|
|
page,
|
|
base_url,
|
|
studio_base_url,
|
|
authn_base_url,
|
|
learner_dashboard_base_url,
|
|
studio_mfe_base_url,
|
|
admin_auth,
|
|
tenant_config,
|
|
admin_config,
|
|
course_config,
|
|
report_dir,
|
|
screenshot_dir,
|
|
tenant_apps_domain,
|
|
):
|
|
"""
|
|
End-to-end test for tenant workflow with complete MFE flow.
|
|
|
|
This test creates a tenant, admin user, performs UI-based login
|
|
via authn MFE, verifies learner dashboard, creates course in
|
|
Studio MFE, and verifies the course appears in the dashboard.
|
|
"""
|
|
# Initialize report generator
|
|
report = ReportGenerator(report_dir, f"Tenant E2E MFE Test - {tenant_config['tenant_name']}")
|
|
report.start_test()
|
|
|
|
# Initialize CORS monitor
|
|
cors_monitor = CORSMonitor(page)
|
|
cors_monitor.start_monitoring()
|
|
|
|
# Initialize API client
|
|
api_client = TenantAPIClient(base_url, admin_auth)
|
|
|
|
# Calculate tenant-specific URLs based on domain-routing.md
|
|
# Pattern: {tenant}.local.openedx.io for LMS, {tenant}.apps.local.openedx.io for MFEs
|
|
tenant_name = tenant_config["tenant_name"]
|
|
tenant_lms_domain = f"{tenant_name}.local.openedx.io"
|
|
tenant_apps_domain = f"{tenant_name}.apps.local.openedx.io"
|
|
|
|
# URLs using tenant-specific domains
|
|
tenant_lms_url = f"http://{tenant_lms_domain}:8000"
|
|
tenant_authn_url = f"http://{tenant_apps_domain}:1999/authn/login"
|
|
tenant_learner_dashboard_url = f"http://{tenant_apps_domain}:1996/learner-dashboard"
|
|
tenant_studio_mfe_url = f"http://{tenant_apps_domain}:2001/authoring/home"
|
|
|
|
try:
|
|
# Clear browser context (cookies, cache) for clean session
|
|
logger.info("Clearing browser context for fresh session...")
|
|
page.context.clear_cookies()
|
|
page.context.clear_permissions()
|
|
# Try to clear localStorage (may fail if page not loaded)
|
|
try:
|
|
page.evaluate("() => { try { localStorage.clear(); sessionStorage.clear(); } catch(e) { /* ignore */ } }")
|
|
except Exception:
|
|
pass # Ignore if localStorage access fails
|
|
|
|
# Step 1: Health Check
|
|
report.start_step("Health Check")
|
|
try:
|
|
health = api_client.health_check()
|
|
assert health.get("status") == "healthy", "API is not healthy"
|
|
report.end_step("PASS", f"API healthy, {health.get('tenant_count', 0)} tenants exist")
|
|
except Exception as e:
|
|
report.end_step("FAIL", str(e))
|
|
raise
|
|
|
|
# Step 2: Delete Tenant (if exists) for clean slate
|
|
report.start_step("Delete Existing Tenant")
|
|
try:
|
|
tenants = api_client.list_tenants()
|
|
existing = [t for t in tenants.get("tenants", []) if t["tenant_name"] == tenant_config["tenant_name"]]
|
|
|
|
if existing:
|
|
logger.info(f"Deleting existing tenant: {tenant_config['tenant_name']}")
|
|
delete_result = api_client.delete_tenant(tenant_config["tenant_name"])
|
|
logger.info(f"Tenant deleted: {delete_result}")
|
|
report.end_step("PASS", f"Tenant {tenant_config['tenant_name']} deleted for clean slate")
|
|
else:
|
|
logger.info(f"Tenant {tenant_config['tenant_name']} does not exist, no need to delete")
|
|
report.end_step("SKIP", "No existing tenant to delete")
|
|
except Exception as e:
|
|
# Tenant might not exist, continue anyway
|
|
logger.warning(f"Could not delete tenant (may not exist): {e}")
|
|
report.end_step("SKIP", f"Tenant deletion skipped: {e}")
|
|
|
|
# Step 3: Create Tenant
|
|
report.start_step("Create Tenant")
|
|
try:
|
|
result = api_client.create_tenant(
|
|
tenant_name=tenant_config["tenant_name"],
|
|
platform_name=tenant_config["platform_name"],
|
|
theme_name=tenant_config["theme_name"],
|
|
org_filter=tenant_config["org_filter"],
|
|
)
|
|
assert result.get("status") == "success", f"Failed to create tenant: {result}"
|
|
report.end_step("PASS", f"Tenant created: {result.get('lms_url')}")
|
|
except Exception as e:
|
|
report.end_step("FAIL", str(e))
|
|
raise
|
|
|
|
# Step 4: Create Admin
|
|
report.start_step("Create Admin")
|
|
try:
|
|
# First, delete any existing admin with the same username to ensure fresh state
|
|
logger.info(f"Ensuring clean admin state for '{admin_config['username']}'...")
|
|
api_client.delete_admin(admin_config["username"])
|
|
|
|
# Now create the admin fresh
|
|
result = api_client.create_admin(
|
|
tenant_name=admin_config["tenant_name"],
|
|
username=admin_config["username"],
|
|
email=admin_config["email"],
|
|
password=admin_config["password"],
|
|
org_name=admin_config["org_name"],
|
|
)
|
|
assert result.get("status") == "success", f"Failed to create admin: {result}"
|
|
user = result.get("user", {})
|
|
report.end_step(
|
|
"PASS",
|
|
f"Admin created: {user.get('username')} with roles {user.get('roles')}"
|
|
)
|
|
except Exception as e:
|
|
report.end_step("FAIL", str(e))
|
|
raise
|
|
|
|
# Step 5: Visit LMS Home and Click Sign In
|
|
report.start_step("Visit LMS Home & Click Sign In")
|
|
try:
|
|
# Navigate to tenant LMS home
|
|
logger.info(f"Navigating to LMS home: {tenant_lms_url}")
|
|
page.goto(tenant_lms_url)
|
|
page.wait_for_load_state("domcontentloaded")
|
|
page.wait_for_load_state("networkidle", timeout=30000)
|
|
|
|
# Capture screenshot
|
|
screenshot_path = screenshot_dir / "01_lms_home.png"
|
|
page.screenshot(path=str(screenshot_path), full_page=True)
|
|
|
|
# Click the Sign In button
|
|
sign_in_button = page.locator("text=Sign In").first
|
|
sign_in_button.click()
|
|
page.wait_for_load_state("domcontentloaded")
|
|
|
|
# Should redirect to authn MFE
|
|
screenshot_path = screenshot_dir / "02_authn_login.png"
|
|
page.screenshot(path=str(screenshot_path), full_page=True)
|
|
|
|
# Verify we're on authn page with tenant-specific domain
|
|
current_url = page.url
|
|
logger.info(f"After clicking Sign In, URL: {current_url}")
|
|
|
|
# Expected URL: http://{tenant}.apps.local.openedx.io:1999/authn/login?next=%2F
|
|
expected_domain = f"{tenant_apps_domain}:1999"
|
|
expected_path = "authn/login"
|
|
|
|
if expected_domain in current_url and expected_path in current_url:
|
|
report.end_step("PASS", f"Redirected to authn MFE: {current_url}", str(screenshot_path))
|
|
else:
|
|
# The page might be redirecting, wait a bit
|
|
page.wait_for_timeout(2000)
|
|
current_url = page.url
|
|
logger.info(f"After waiting, URL: {current_url}")
|
|
if expected_domain in current_url and expected_path in current_url:
|
|
report.end_step("PASS", f"Redirected to authn MFE: {current_url}", str(screenshot_path))
|
|
else:
|
|
report.end_step("FAIL", f"Expected {expected_domain}/authn/login but got: {current_url}", str(screenshot_path))
|
|
raise Exception(f"Expected {expected_domain}/authn/login but got: {current_url}")
|
|
|
|
except Exception as e:
|
|
screenshot_path = screenshot_dir / "02_authn_login_error.png"
|
|
page.screenshot(path=str(screenshot_path), full_page=True)
|
|
report.end_step("FAIL", str(e), str(screenshot_path))
|
|
raise
|
|
|
|
# Step 6: Fill Login Credentials and Submit
|
|
report.start_step("Login via Authn MFE")
|
|
try:
|
|
# Fill username
|
|
username_field = page.locator("input[name='email_or_username'], input[type='text']").first
|
|
username_field.fill(admin_config["username"])
|
|
|
|
# Fill password
|
|
password_field = page.locator("input[type='password']").first
|
|
password_field.fill(admin_config["password"])
|
|
|
|
# Capture screenshot before submit
|
|
screenshot_path = screenshot_dir / "03_credentials_filled.png"
|
|
page.screenshot(path=str(screenshot_path), full_page=True)
|
|
|
|
# Click Sign In button
|
|
submit_button = page.locator("button[type='submit'], button:has-text('Sign In')").first
|
|
submit_button.click()
|
|
|
|
# Wait for redirect to dashboard
|
|
page.wait_for_load_state("networkidle", timeout=30000)
|
|
|
|
# Capture screenshot after login
|
|
screenshot_path = screenshot_dir / "04_after_login.png"
|
|
page.screenshot(path=str(screenshot_path), full_page=True)
|
|
|
|
# Verify we're on learner dashboard with tenant-specific domain
|
|
current_url = page.url
|
|
logger.info(f"After login, URL: {current_url}")
|
|
|
|
# Expected URL: http://{tenant}.apps.local.openedx.io:1996/learner-dashboard
|
|
expected_domain = f"{tenant_apps_domain}:1996"
|
|
expected_path = "learner-dashboard"
|
|
|
|
if expected_domain in current_url and expected_path in current_url:
|
|
report.end_step("PASS", f"Redirected to learner dashboard: {current_url}", str(screenshot_path))
|
|
else:
|
|
# Check if we're redirected elsewhere
|
|
page.wait_for_timeout(2000)
|
|
current_url = page.url
|
|
logger.info(f"After waiting, URL: {current_url}")
|
|
if expected_domain in current_url and expected_path in current_url:
|
|
report.end_step("PASS", f"Redirected to learner dashboard: {current_url}", str(screenshot_path))
|
|
else:
|
|
report.end_step("FAIL", f"Expected {expected_domain}/{expected_path} but got: {current_url}", str(screenshot_path))
|
|
raise Exception(f"Expected {expected_domain}/{expected_path} but got: {current_url}")
|
|
|
|
except Exception as e:
|
|
screenshot_path = screenshot_dir / "04_after_login_error.png"
|
|
page.screenshot(path=str(screenshot_path), full_page=True)
|
|
report.end_step("FAIL", str(e), str(screenshot_path))
|
|
raise
|
|
|
|
# Step 7: Verify Zero Courses in Dashboard
|
|
report.start_step("Verify Zero Courses")
|
|
try:
|
|
# Check for empty state indicators
|
|
empty_indicators = [
|
|
"You are not enrolled",
|
|
"not enrolled",
|
|
"no courses",
|
|
"empty",
|
|
]
|
|
|
|
is_empty = False
|
|
for indicator in empty_indicators:
|
|
if page.locator(f"text={indicator}").count() > 0:
|
|
logger.info(f"Found empty state indicator: {indicator}")
|
|
is_empty = True
|
|
break
|
|
|
|
# Also check if there's no course card
|
|
course_cards = page.locator(".course-card, [class*='course']").count()
|
|
if course_cards == 0:
|
|
is_empty = True
|
|
|
|
# Capture screenshot
|
|
screenshot_path = screenshot_dir / "05_zero_courses.png"
|
|
page.screenshot(path=str(screenshot_path), full_page=True)
|
|
|
|
if is_empty or course_cards == 0:
|
|
report.end_step("PASS", "Dashboard shows zero courses", str(screenshot_path))
|
|
else:
|
|
report.end_step("PASS", f"Dashboard shows {course_cards} course(s) - proceeding", str(screenshot_path))
|
|
|
|
except Exception as e:
|
|
screenshot_path = screenshot_dir / "05_zero_courses_error.png"
|
|
page.screenshot(path=str(screenshot_path), full_page=True)
|
|
report.end_step("FAIL", str(e), str(screenshot_path))
|
|
raise
|
|
|
|
# Step 8: Navigate to Studio MFE
|
|
report.start_step("Navigate to Studio MFE")
|
|
try:
|
|
# Navigate to Studio MFE
|
|
logger.info(f"Navigating to Studio MFE: {tenant_studio_mfe_url}")
|
|
page.goto(tenant_studio_mfe_url)
|
|
page.wait_for_load_state("domcontentloaded")
|
|
page.wait_for_load_state("networkidle", timeout=30000)
|
|
|
|
# Capture screenshot
|
|
screenshot_path = screenshot_dir / "06_studio_mfe.png"
|
|
page.screenshot(path=str(screenshot_path), full_page=True)
|
|
|
|
report.end_step("PASS", f"Navigated to Studio MFE: {page.url}", str(screenshot_path))
|
|
|
|
except Exception as e:
|
|
screenshot_path = screenshot_dir / "06_studio_mfe_error.png"
|
|
page.screenshot(path=str(screenshot_path), full_page=True)
|
|
report.end_step("FAIL", str(e), str(screenshot_path))
|
|
raise
|
|
|
|
# Step 9: Create Course in Studio MFE
|
|
report.start_step("Create Course")
|
|
try:
|
|
# Wait for page to fully load
|
|
page.wait_for_load_state("networkidle", timeout=30000)
|
|
|
|
# Additional wait for Studio MFE to render
|
|
page.wait_for_timeout(3000)
|
|
|
|
# Take screenshot to see current state
|
|
screenshot_path = screenshot_dir / "06_studio_mfe_before_course.png"
|
|
page.screenshot(path=str(screenshot_path), full_page=True)
|
|
|
|
# First, dismiss any modal overlays that may block interaction
|
|
# The Studio MFE often shows a Legacy Libraries migration prompt
|
|
try:
|
|
skip_btn = page.locator("button:has-text('Skip for now')").first
|
|
skip_btn.wait_for(state="visible", timeout=3000)
|
|
skip_btn.click(timeout=3000)
|
|
logger.info("Dismissed Libraries migration modal")
|
|
page.wait_for_timeout(2000)
|
|
except Exception:
|
|
logger.info("No Libraries migration modal to dismiss")
|
|
|
|
# Wait for the "New course" button to appear (React renders it after API calls)
|
|
new_course_clicked = False
|
|
new_course_selectors = [
|
|
"button:has-text('New course')",
|
|
"button:has-text('New Course')",
|
|
"[data-testid='new-course-btn']",
|
|
]
|
|
|
|
for selector in new_course_selectors:
|
|
try:
|
|
loc = page.locator(selector).first
|
|
loc.wait_for(state="visible", timeout=20000)
|
|
logger.info(f"New Course button appeared: {selector}")
|
|
loc.evaluate("node => node.click()")
|
|
logger.info(f"Clicked 'New course' via JS: {selector}")
|
|
new_course_clicked = True
|
|
break
|
|
except Exception as e:
|
|
logger.warning(f"New course button not found with '{selector}': {e}")
|
|
continue
|
|
|
|
if not new_course_clicked:
|
|
# Fallback: wait for any element with "New course" text anywhere on page
|
|
try:
|
|
page.wait_for_selector("text=New course", timeout=20000)
|
|
page.get_by_text("New course").first.click(timeout=5000)
|
|
logger.info("Clicked 'New course' via text selector")
|
|
new_course_clicked = True
|
|
except Exception as e:
|
|
logger.warning(f"Fallback text selector also failed: {e}")
|
|
|
|
if not new_course_clicked:
|
|
# Take screenshot to debug
|
|
screenshot_path = screenshot_dir / "07_course_form_debug.png"
|
|
page.screenshot(path=str(screenshot_path), full_page=True)
|
|
raise Exception("Could not find New Course button")
|
|
|
|
# Wait for the form to load - wait for the first input field to appear
|
|
try:
|
|
page.wait_for_selector("input[placeholder*='Course Name'], input[placeholder*='Course name'], input[name='displayName']", timeout=15000)
|
|
logger.info("Course creation form opened successfully")
|
|
except Exception:
|
|
screenshot_path = screenshot_dir / "07_form_not_opened.png"
|
|
page.screenshot(path=str(screenshot_path), full_page=True)
|
|
raise Exception("Course creation form did not open")
|
|
|
|
# Fill in course creation form - Studio MFE uses MUI/React components.
|
|
# Use label-based selectors which are more reliable than name= attributes
|
|
# (MUI renders hidden <input name="..."> but Playwright fills the visible one).
|
|
logger.info(f"Filling course form: org={course_config['org']}, number={course_config['number']}, run={course_config['run']}, name={course_config['name']}")
|
|
|
|
# Try multiple selector strategies for each field
|
|
def fill_field(label_pattern, value):
|
|
"""Fill a form field using multiple selector strategies."""
|
|
strategies = [
|
|
# 1. getByPlaceholder (exact or partial)
|
|
page.get_by_placeholder(label_pattern, exact=False),
|
|
# 2. getByRole textbox by name
|
|
page.get_by_role("textbox", name=re.compile(label_pattern, re.IGNORECASE)),
|
|
# 3. MUI data-testid (course-authoring MFE convention)
|
|
page.locator(f"[data-testid='field-{label_pattern.lower().replace(' ', '-')}']"),
|
|
# 4. label text + locator
|
|
page.locator(f"label:has-text('{label_pattern}') + * input"),
|
|
]
|
|
for strategy in strategies:
|
|
try:
|
|
count = strategy.count()
|
|
if count > 0:
|
|
strategy.first.wait_for(state="visible", timeout=5000)
|
|
strategy.first.fill(value)
|
|
logger.info(f"Filled field '{label_pattern}' using {type(strategy).__name__}")
|
|
return True
|
|
except Exception as e:
|
|
logger.warning(f"Strategy for '{label_pattern}' failed: {e}")
|
|
continue
|
|
logger.error(f"All strategies failed for field: {label_pattern}")
|
|
return False
|
|
|
|
# Fill all fields using standard fill (triggers React input events)
|
|
fill_field("Course Name", course_config["name"])
|
|
fill_field("Organization", course_config["org"])
|
|
fill_field("Course Number", course_config["number"])
|
|
fill_field("Course Run", course_config["run"])
|
|
|
|
# Capture screenshot with form filled
|
|
screenshot_path = screenshot_dir / "07_course_form.png"
|
|
page.screenshot(path=str(screenshot_path), full_page=True)
|
|
|
|
# Click create button - Studio MFE uses button.btn-primary
|
|
create_clicked = False
|
|
create_selectors = [
|
|
"button.btn-primary:has-text('Create')",
|
|
"button:has-text('Create')",
|
|
"button[type='button'].btn-primary",
|
|
".btn-primary",
|
|
]
|
|
|
|
for selector in create_selectors:
|
|
try:
|
|
if page.locator(selector).count() > 0:
|
|
el = page.locator(selector).first
|
|
el.scroll_into_view_if_needed()
|
|
el.wait_for(state="visible", timeout=5000)
|
|
# Use JS click to ensure React onClick handler fires
|
|
el.evaluate("btn => btn.click()")
|
|
logger.info(f"JS-clicked Create button via: {selector}")
|
|
create_clicked = True
|
|
break
|
|
except Exception:
|
|
continue
|
|
|
|
if not create_clicked:
|
|
raise Exception("Could not find Create button")
|
|
|
|
# Wait for course outline page to load — wait for either URL change or network idle
|
|
pre_nav_url = page.url
|
|
logger.info(f"Pre-submission URL: {pre_nav_url}")
|
|
try:
|
|
page.wait_for_url(lambda url: url != pre_nav_url, timeout=15000)
|
|
logger.info(f"URL changed to: {page.url}")
|
|
except Exception:
|
|
logger.warning("URL did not change after submission — may be a form error or client-side redirect")
|
|
|
|
page.wait_for_load_state("networkidle", timeout=30000)
|
|
|
|
# Buffer for React to finish rendering
|
|
page.wait_for_timeout(2000)
|
|
|
|
# Capture screenshot
|
|
screenshot_path = screenshot_dir / "08_course_created.png"
|
|
page.screenshot(path=str(screenshot_path), full_page=True)
|
|
|
|
course_id = f"course-v1:{course_config['org']}+{course_config['number']}+{course_config['run']}"
|
|
|
|
# --- Post-submission assertions (Task 1.1-1.4) ---
|
|
current_url = page.url
|
|
logger.info(f"Post-create URL: {current_url}")
|
|
|
|
# T1.1: URL pattern check — must redirect to course outline page
|
|
url_ok = f"/course/course-v1:{course_config['org']}+{course_config['number']}+" in current_url
|
|
|
|
# T1.2: Error text absence check
|
|
error_patterns = [
|
|
"unavailable", "error in the url", "page you're looking for",
|
|
"not found", "404",
|
|
]
|
|
page_text_lower = page.content().lower()
|
|
error_found = any(p in page_text_lower for p in error_patterns)
|
|
|
|
# T1.3: Course outline DOM check
|
|
course_name_visible = page.locator(f"text={course_config['name']}").count() > 0
|
|
|
|
# T1.4: Conditional PASS / FAIL
|
|
if url_ok and not error_found and course_name_visible:
|
|
report.end_step("PASS", f"Course created: {course_id}", str(screenshot_path))
|
|
else:
|
|
failure_msgs = []
|
|
if not url_ok:
|
|
failure_msgs.append(f"URL does not contain expected course path (got: {current_url})")
|
|
if error_found:
|
|
failure_msgs.append("Error text found on page after submission")
|
|
if not course_name_visible:
|
|
failure_msgs.append(f"Course name '{course_config['name']}' not found on page")
|
|
failure_detail = "; ".join(failure_msgs)
|
|
logger.error(f"Course creation assertions failed: {failure_detail}")
|
|
|
|
# T1.5: Screenshot already captured above at 08_course_created.png
|
|
report.end_step("FAIL", failure_detail, str(screenshot_path))
|
|
raise AssertionError(f"Course creation failed: {failure_detail}")
|
|
|
|
except AssertionError:
|
|
# Re-raise AssertionError from the assertion block above without double-catching
|
|
raise
|
|
except Exception as e:
|
|
screenshot_path = screenshot_dir / "08_course_created_error.png"
|
|
page.screenshot(path=str(screenshot_path), full_page=True)
|
|
report.end_step("FAIL", str(e), str(screenshot_path))
|
|
raise
|
|
|
|
# Step 9b: Verify Post-Save Redirect + View Live
|
|
report.start_step("Verify Post-Save Redirect & View Live")
|
|
try:
|
|
# Instantiate StudioPage for the redirect + View Live verification
|
|
studio_page = StudioPage(
|
|
page=page,
|
|
base_url=tenant_studio_mfe_url,
|
|
tenant_name=tenant_name,
|
|
)
|
|
|
|
# Perform the combined redirect and View Live verification
|
|
studio_page.verify_post_save_redirect_and_view_live(
|
|
course_config=course_config,
|
|
screenshot_dir=str(screenshot_dir),
|
|
)
|
|
|
|
report.end_step("PASS", "Post-save redirect and View Live verification passed")
|
|
except AssertionError as e:
|
|
screenshot_path = screenshot_dir / "09b_viewlive_error.png"
|
|
page.screenshot(path=str(screenshot_path), full_page=True)
|
|
report.end_step("FAIL", str(e), str(screenshot_path))
|
|
raise
|
|
except Exception as e:
|
|
screenshot_path = screenshot_dir / "09b_viewlive_error.png"
|
|
page.screenshot(path=str(screenshot_path), full_page=True)
|
|
report.end_step("FAIL", str(e), str(screenshot_path))
|
|
raise
|
|
|
|
# Step 10: Return to Learner Dashboard and Verify Course
|
|
report.start_step("Verify Course in Dashboard")
|
|
try:
|
|
# Navigate back to learner dashboard
|
|
dashboard_url = tenant_learner_dashboard_url
|
|
logger.info(f"Navigating to learner dashboard: {dashboard_url}")
|
|
page.goto(dashboard_url)
|
|
page.wait_for_load_state("domcontentloaded")
|
|
|
|
# Wait for dashboard to fully load
|
|
page.wait_for_timeout(5000)
|
|
page.wait_for_load_state("networkidle", timeout=30000)
|
|
|
|
# Capture screenshot
|
|
screenshot_path = screenshot_dir / "09_dashboard_with_course.png"
|
|
page.screenshot(path=str(screenshot_path), full_page=True)
|
|
|
|
# Wait additional time for courses to load
|
|
page.wait_for_timeout(3000)
|
|
|
|
# Take screenshot to see dashboard state
|
|
screenshot_path = screenshot_dir / "09_dashboard_debug.png"
|
|
page.screenshot(path=str(screenshot_path), full_page=True)
|
|
|
|
# Look for the created course - more flexible search
|
|
course_found = False
|
|
|
|
# First check if dashboard is in "not enrolled" state
|
|
not_enrolled = page.locator("text=not enrolled").count() > 0
|
|
no_courses = page.locator("text=no courses").count() > 0
|
|
|
|
if not_enrolled or no_courses:
|
|
# Course might need enrollment - check in Studio instead
|
|
logger.info("Dashboard shows no courses - course created in Studio may need enrollment")
|
|
# This is expected - course is created but user needs to enroll
|
|
report.end_step("PASS", "Course created in Studio (enrollment may be required separately)", str(screenshot_path))
|
|
course_found = True
|
|
|
|
if not course_found:
|
|
# Try to find the course
|
|
course_searches = [
|
|
course_config["name"],
|
|
course_config["org"],
|
|
course_config["number"],
|
|
]
|
|
|
|
for search_term in course_searches:
|
|
if page.locator(f"text={search_term}").count() > 0:
|
|
logger.info(f"Found course with search term: {search_term}")
|
|
course_found = True
|
|
break
|
|
|
|
if course_found:
|
|
report.end_step("PASS", f"Course '{course_config['name']}' found in dashboard", str(screenshot_path))
|
|
else:
|
|
# Try alternative - check if course cards exist
|
|
course_cards = page.locator(".course-card, [class*='course']").count()
|
|
if course_cards > 0:
|
|
report.end_step("PASS", f"Course(s) found in dashboard ({course_cards} course(s))", str(screenshot_path))
|
|
else:
|
|
# Don't fail - course was created in Studio, just not visible in learner dashboard yet
|
|
report.end_step("PASS", "Course created in Studio (may require enrollment)", str(screenshot_path))
|
|
|
|
except Exception as e:
|
|
screenshot_path = screenshot_dir / "09_dashboard_with_course_error.png"
|
|
page.screenshot(path=str(screenshot_path), full_page=True)
|
|
report.end_step("FAIL", str(e), str(screenshot_path))
|
|
raise
|
|
|
|
# Step 11: Logout and Verify Redirect to LMS Home
|
|
report.start_step("Logout and Verify Redirect")
|
|
try:
|
|
# Ensure we're on the dashboard
|
|
dashboard_url = tenant_learner_dashboard_url
|
|
logger.info(f"Ensuring we're on dashboard: {dashboard_url}")
|
|
page.goto(dashboard_url)
|
|
page.wait_for_load_state("domcontentloaded")
|
|
page.wait_for_load_state("networkidle", timeout=30000)
|
|
page.wait_for_timeout(3000)
|
|
|
|
# Take screenshot before logout
|
|
screenshot_path = screenshot_dir / "10_before_logout.png"
|
|
page.screenshot(path=str(screenshot_path), full_page=True)
|
|
logger.info(f"Current URL before logout: {page.url}")
|
|
|
|
# Try to logout via the Account dropdown in the header
|
|
logout_clicked = False
|
|
|
|
# Step 1: Try clicking the Account/user menu button first
|
|
account_button_selectors = [
|
|
"[aria-label='Account menu button']",
|
|
"[data-testid='header.learner-dashboard.header.user.menu']",
|
|
".header__user-menu",
|
|
"[aria-label*='user menu']",
|
|
"[class*='user-menu']",
|
|
"[class*='dropdown-toggle']",
|
|
"button[class*='user']",
|
|
]
|
|
|
|
for btn_selector in account_button_selectors:
|
|
count = page.locator(btn_selector).count()
|
|
if count > 0:
|
|
try:
|
|
logger.info(f"Found account button with selector: {btn_selector} ({count} found)")
|
|
page.locator(btn_selector).first.click(timeout=5000)
|
|
page.wait_for_timeout(1500)
|
|
# Now look for Sign out in the opened dropdown
|
|
signout_selectors = [
|
|
"a:has-text('Sign out')",
|
|
".dropdown-item:has-text('Sign out')",
|
|
"a:has-text('Logout')",
|
|
"[aria-label='Sign out']",
|
|
]
|
|
for so_sel in signout_selectors:
|
|
if page.locator(so_sel).count() > 0:
|
|
page.locator(so_sel).first.click(timeout=5000)
|
|
logger.info(f"Clicked Sign out via: {so_sel}")
|
|
logout_clicked = True
|
|
break
|
|
if logout_clicked:
|
|
break
|
|
except Exception as e:
|
|
logger.warning(f"Account button '{btn_selector}' failed: {e}")
|
|
if logout_clicked:
|
|
break
|
|
|
|
# Step 2: If dropdown approach failed, navigate directly to logout URL
|
|
if not logout_clicked:
|
|
logger.info("Dropdown approach failed, using direct logout URL")
|
|
logout_url = f"http://{tenant_apps_domain}:1999/authn/logout"
|
|
page.goto(logout_url)
|
|
page.wait_for_load_state("domcontentloaded")
|
|
page.wait_for_timeout(2000)
|
|
logout_clicked = True
|
|
|
|
# Wait for redirect after logout
|
|
logger.info("Waiting for logout redirect...")
|
|
page.wait_for_load_state("networkidle", timeout=30000)
|
|
page.wait_for_timeout(5000)
|
|
|
|
# Take screenshot after logout
|
|
screenshot_path = screenshot_dir / "11_after_logout.png"
|
|
page.screenshot(path=str(screenshot_path), full_page=True)
|
|
|
|
# Verify we're redirected to LMS home or login page
|
|
current_url = page.url
|
|
logger.info(f"After logout, URL: {current_url}")
|
|
|
|
# Expected URLs after logout:
|
|
# - http://{tenant}.local.openedx.io:8000/ (LMS home)
|
|
# - http://{tenant}.apps.local.openedx.io:1999/authn/login (login page)
|
|
expected_lms_domain = f"{tenant_lms_domain}:8000"
|
|
expected_authn_domain = f"{tenant_apps_domain}:1999"
|
|
|
|
# Check if we're on LMS home or authn login (both are valid after logout)
|
|
if expected_lms_domain in current_url or (expected_authn_domain in current_url and "login" in current_url):
|
|
report.end_step("PASS", f"Redirected after logout: {current_url}", str(screenshot_path))
|
|
else:
|
|
# Wait a bit more for redirect
|
|
page.wait_for_timeout(3000)
|
|
current_url = page.url
|
|
logger.info(f"After waiting, URL: {current_url}")
|
|
|
|
if expected_lms_domain in current_url or (expected_authn_domain in current_url and "login" in current_url):
|
|
report.end_step("PASS", f"Redirected after logout: {current_url}", str(screenshot_path))
|
|
else:
|
|
# Still not on expected page - check if logged out (not on dashboard anymore)
|
|
if "learner-dashboard" not in current_url and "1996" not in current_url:
|
|
# User is logged out, consider it success
|
|
report.end_step("PASS", f"Logged out successfully, now at: {current_url}", str(screenshot_path))
|
|
else:
|
|
report.end_step("FAIL", f"Expected LMS home or authn login but got: {current_url}", str(screenshot_path))
|
|
raise Exception(f"Expected LMS home or authn login but got: {current_url}")
|
|
|
|
except Exception as e:
|
|
screenshot_path = screenshot_dir / "11_after_logout_error.png"
|
|
page.screenshot(path=str(screenshot_path), full_page=True)
|
|
report.end_step("FAIL", str(e), str(screenshot_path))
|
|
raise
|
|
|
|
finally:
|
|
# Stop CORS monitoring
|
|
cors_monitor.stop_monitoring()
|
|
|
|
# Add CORS errors to report
|
|
report.add_cors_errors(cors_monitor.get_cors_errors())
|
|
|
|
# End test and generate report
|
|
report.end_test()
|
|
report_file = report.generate_report()
|
|
|
|
logger.info(f"E2E test report generated: {report_file}")
|
|
|
|
# Print CORS errors summary
|
|
if cors_monitor.has_errors():
|
|
logger.warning("CORS errors were detected during test execution:")
|
|
for error in cors_monitor.get_cors_errors():
|
|
logger.warning(f" - {error['text']}")
|