diff --git a/.github/actions/setup-playwright/action.yml b/.github/actions/setup-playwright/action.yml new file mode 100644 index 0000000..c48baf1 --- /dev/null +++ b/.github/actions/setup-playwright/action.yml @@ -0,0 +1,43 @@ +name: Setup Python + Playwright +description: Common setup for all Playwright-based smoke tests +inputs: + python-version: + description: Version of Python to use + required: false + default: "3.11" + exclude_inputs: + description: Comma-separated list of inputs to exclude from masking + required: false + default: target_url,course_id + +runs: + using: composite + steps: + - name: Cache Python & Playwright dependencies + uses: actions/cache@v4 + with: + path: | + ~/.cache/pip + ~/.cache/ms-playwright + key: > + ${{ runner.os }}-py-${{ inputs.python-version }}-playwright-${{ + hashFiles('actions-hub/.github/actions/setup-playwright/requirements.txt') + }} + restore-keys: | + ${{ runner.os }}-py-${{ inputs.python-version }}-playwright + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ inputs.python-version }} + + - name: Install dependencies + run: | + pip install -r ./actions-hub/.github/actions/setup-playwright/requirements.txt + playwright install + shell: bash + + - name: Hide the inputs values to keep them private in the logs + uses: levibostian/action-hide-sensitive-inputs@v1 + with: + exclude_inputs: ${{ inputs.exclude_inputs }} diff --git a/.github/actions/setup-playwright/requirements.txt b/.github/actions/setup-playwright/requirements.txt new file mode 100644 index 0000000..801cd51 --- /dev/null +++ b/.github/actions/setup-playwright/requirements.txt @@ -0,0 +1 @@ +pytest-playwright diff --git a/.github/workflows/smoke-tests.yml b/.github/workflows/smoke-tests.yml new file mode 100644 index 0000000..6ce80d4 --- /dev/null +++ b/.github/workflows/smoke-tests.yml @@ -0,0 +1,171 @@ +name: Smoke Tests + +on: + workflow_call: + inputs: + run_test: + description: Select desired test + required: true + type: string + target_url: + description: Base URL for the application + required: true + type: string + user_email: + description: Test user email + required: true + type: string + user_password: + description: Test user password + required: true + type: string + course_id: + description: Course ID to test viewing the course page + required: true + type: string + target_plugins: + description: Desired plugins to validate + required: true + type: string + unit_id: + description: Unit ID to test complete-multiplechoice-unit + required: true + type: string + + +jobs: + check-heartbeat: + if: ${{ inputs.run_test == 'All' || inputs.run_test == 'check-heartbeat'}} + name: Check Heartbeat + runs-on: ubuntu-latest + + steps: + - name: Checkout actions-hub repo + uses: actions/checkout@v4 + with: + repository: nelc/actions-hub + ref: main + path: actions-hub + + - name: Setup Python and Playwright + uses: ./actions-hub/.github/actions/setup-playwright + + - name: Run heartbeat test + run: | + python actions-hub/scripts/smoke-tests/test_heartbeat.py \ + --base-url "${{ inputs.target_url }}" + + validate-plugins: + if: ${{ inputs.run_test == 'All' || inputs.run_test == 'validate-plugins'}} + name: Validate eox plugins + runs-on: ubuntu-latest + + steps: + - name: Checkout actions-hub repo + uses: actions/checkout@v4 + with: + repository: nelc/actions-hub + ref: main + path: actions-hub + + - name: Setup Python and Playwright + uses: ./actions-hub/.github/actions/setup-playwright + + - name: Validate eox plugins + run: | + python actions-hub/scripts/smoke-tests/test_eox_plugins.py \ + --base-url "${{ inputs.target_url }}" \ + --plugins ${{ inputs.target_plugins }} + + login-user: + if: ${{ inputs.run_test == 'All' || inputs.run_test == 'login-user'}} + name: Login User Test + runs-on: ubuntu-latest + + steps: + - name: Checkout actions-hub repo + uses: actions/checkout@v4 + with: + repository: nelc/actions-hub + ref: main + path: actions-hub + + - name: Setup Python and Playwright + uses: ./actions-hub/.github/actions/setup-playwright + + - name: Run login-user test + run: | + python actions-hub/scripts/smoke-tests/test_login_user.py \ + --base-url "${{ inputs.target_url }}" \ + --email "${{ inputs.user_email }}" \ + --password "${{ inputs.user_password }}" + + view-course: + if: ${{ inputs.run_test == 'All' || inputs.run_test == 'view-course'}} + name: View Course Page Test + runs-on: ubuntu-latest + + steps: + - name: Checkout actions-hub repo + uses: actions/checkout@v4 + with: + repository: nelc/actions-hub + ref: main + path: actions-hub + + - name: Setup Python and Playwright + uses: ./actions-hub/.github/actions/setup-playwright + + - name: Run view-course test + run: | + python actions-hub/scripts/smoke-tests/test_view_course.py \ + --base-url "${{ inputs.target_url }}" \ + --email "${{ inputs.user_email }}" \ + --password "${{ inputs.user_password }}" \ + --course-id "${{ inputs.course_id }}" + + complete-multiplechoice-unit: + if: ${{ inputs.run_test == 'All' || inputs.run_test == 'complete-multiplechoice-unit'}} + name: Complete multiplechoice unit test + runs-on: ubuntu-latest + + steps: + - name: Checkout actions-hub repo + uses: actions/checkout@v4 + with: + repository: nelc/actions-hub + ref: main + path: actions-hub + + - name: Setup Python and Playwright + uses: ./actions-hub/.github/actions/setup-playwright + + - name: Run complete-multiplechoice-unit test + run: | + python actions-hub/scripts/smoke-tests/test_complete_multiplechoice_unit.py \ + --base-url "${{ inputs.target_url }}" \ + --email "${{ inputs.user_email }}" \ + --password "${{ inputs.user_password }}" \ + --course-id "${{ inputs.course_id }}" \ + --unit-id "${{ inputs.unit_id }}" + + register-user: + if: ${{ inputs.run_test == 'All' || inputs.run_test == 'register-user'}} + name: Register user test + runs-on: ubuntu-latest + + steps: + - name: Checkout actions-hub repo + uses: actions/checkout@v4 + with: + repository: nelc/actions-hub + ref: main + path: actions-hub + + - name: Setup Python and Playwright + uses: ./actions-hub/.github/actions/setup-playwright + + - name: Run register-user test + run: | + python actions-hub/scripts/smoke-tests/test_register_user.py \ + --base-url "${{ inputs.target_url }}" diff --git a/scripts/smoke-tests/test_complete_multiplechoice_unit.py b/scripts/smoke-tests/test_complete_multiplechoice_unit.py new file mode 100644 index 0000000..b41f345 --- /dev/null +++ b/scripts/smoke-tests/test_complete_multiplechoice_unit.py @@ -0,0 +1,75 @@ +import argparse + +from playwright.sync_api import TimeoutError, sync_playwright +from utils import login_user + + +def test_complete_multiplechoice_unit(base_url: str, course_id: str, unit_id: str, email: str, password: str): + """ + Smoke test to verify that a multiple choice unit can be completed. + + Args: + base_url (str): The base URL of the platform. + course_id (str): The course ID to navigate into. + unit_id (str): The specific unit ID (usage key). + email (str): Login email. + password (str): Login password. + """ + unit_url = f"{base_url}/courses/{course_id}/jump_to_id/{unit_id}" + + with sync_playwright() as p: + browser = p.chromium.launch() + page = browser.new_page() + login_user(page, base_url, email, password) + page.goto(unit_url) + page.wait_for_load_state("networkidle") + + # Try to find the iframe that contains the unit content + iframe = page.frame_locator("#unit-iframe") + assert iframe is not None, "No iframe found containing unit content" + + # Get the first problem + problems = iframe.locator("div.problem") + assert problems.count() > 0, "No problems found" + problem = problems.first + + # Get problem choices + radios = problem.locator("input[type='radio']") + assert radios.count() > 0, "No multiple choice options found" + + # Get Submit button + submit_button = problem.locator("button.submit") + assert submit_button.is_visible(), "Submit button not found" + + for radio in radios.all(): + radio.click() + submit_button.click() + notification = problem.locator(".notification.success.notification-submit") + + try: + notification.wait_for(state="visible", timeout=5000) + break + except TimeoutError: + continue + + assert notification.is_visible(), "Success notification isn't visible" + + browser.close() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Smoke test: Complete a multiple choice unit") + parser.add_argument("--base-url", required=True, help="Base URL of the LMS") + parser.add_argument("--course-id", required=True, help="Course ID") + parser.add_argument("--unit-id", required=True, help="Unit ID (usage key)") + parser.add_argument("--email", required=True, help="User email for login") + parser.add_argument("--password", required=True, help="User password for login") + args = parser.parse_args() + + test_complete_multiplechoice_unit( + base_url=args.base_url, + course_id=args.course_id, + unit_id=args.unit_id, + email=args.email, + password=args.password, + ) diff --git a/scripts/smoke-tests/test_eox_plugins.py b/scripts/smoke-tests/test_eox_plugins.py new file mode 100644 index 0000000..cf1164c --- /dev/null +++ b/scripts/smoke-tests/test_eox_plugins.py @@ -0,0 +1,44 @@ +import argparse + +from playwright.sync_api import sync_playwright + + +def test_plugin_versions(base_url: str, plugins: list[str]) -> None: + """ + Smoke test to verify that plugin info endpoints return a valid JSON with version info. + + Args: + base_url (str): The base URL of the LMS. + plugins (list[str]): List of plugins to check. + """ + with sync_playwright() as p: + browser = p.chromium.launch() + page = browser.new_page() + + for plugin in plugins: + full_url = f"{base_url.rstrip('/')}/{plugin}/eox-info" + print(f"→ Checking {full_url}...") + + response = page.goto(full_url) + assert response is not None and response.ok, f"❌ Failed to load {full_url}" + + try: + json_data = response.json() + except Exception as e: + raise AssertionError(f"❌ Invalid JSON response from {full_url}: {e}") + + version = json_data.get("version") + assert version, f"❌ 'version' key not found in JSON from {full_url}" + + print(f"✅ {plugin} version: {version}") + + browser.close() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Smoke test: Validate eox plugins") + parser.add_argument("--base-url", required=True, help="Base LMS URL") + parser.add_argument("--plugins", nargs="+", required=True, help="List of plugin to check") + args = parser.parse_args() + + test_plugin_versions(base_url=args.base_url, plugins=args.plugins) diff --git a/scripts/smoke-tests/test_heartbeat.py b/scripts/smoke-tests/test_heartbeat.py new file mode 100644 index 0000000..1117362 --- /dev/null +++ b/scripts/smoke-tests/test_heartbeat.py @@ -0,0 +1,34 @@ +import argparse + +from playwright.sync_api import sync_playwright + + +def test_heartbeat(base_url: str): + """ + Smoke test to verify that the /heartbeat endpoint is reachable and returns HTTP 200. + + Args: + base_url (str): The base URL of the platform. + """ + with sync_playwright() as p: + browser = p.chromium.launch() + page = browser.new_page() + + response = page.goto(f"{base_url.rstrip('/')}/heartbeat") + + if not response or response.status != 200: + raise AssertionError( + f"❌ Heartbeat check failed. Status: {response.status if response else 'No response'}" + ) + + print(f"✅ Heartbeat responded with HTTP {response.status}") + + browser.close() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Smoke test: Heartbeat Endpoint") + parser.add_argument("--base-url", required=True, help="Base URL of the platform") + args = parser.parse_args() + + test_heartbeat(base_url=args.base_url) diff --git a/scripts/smoke-tests/test_login_user.py b/scripts/smoke-tests/test_login_user.py new file mode 100644 index 0000000..aac2854 --- /dev/null +++ b/scripts/smoke-tests/test_login_user.py @@ -0,0 +1,37 @@ +import argparse + +from playwright.sync_api import expect, sync_playwright +from utils import login_user + + +def test_login_user(base_url: str, email: str, password: str) -> None: + """ + Runs a login smoke test using Playwright against the specified base URL. + + Args: + base_url (str): Base URL of the target application. + email (str): Email of the test user. + password (str): Password of the test user. + + Raises: + AssertionError: If the login fails or the dashboard page is not reached. + """ + with sync_playwright() as p: + browser = p.chromium.launch() + page = browser.new_page() + + login_user(page, base_url, email, password) + + expect(page).to_have_url(f"{base_url}/dashboard") + + browser.close() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Run login smoke test using Playwright.") + parser.add_argument("--base-url", required=True, help="Base URL of the target application.") + parser.add_argument("--email", required=True, help="Login email.") + parser.add_argument("--password", required=True, help="Login password.") + args = parser.parse_args() + + test_login_user(base_url=args.base_url, email=args.email, password=args.password) diff --git a/scripts/smoke-tests/test_register_user.py b/scripts/smoke-tests/test_register_user.py new file mode 100644 index 0000000..69f120f --- /dev/null +++ b/scripts/smoke-tests/test_register_user.py @@ -0,0 +1,108 @@ +import argparse +import random +import string + +from playwright.sync_api import expect, sync_playwright + + +def random_string(length=6): + """ + Generate a random alphanumeric string of the given length. + + Args: + length (int): Length of the string to generate. Default is 6. + + Returns: + str: Random string composed of lowercase letters and digits. + """ + return "".join(random.choices(string.ascii_lowercase + string.digits, k=length)) + + +def fill_required_fields(page): + """ + Fills all required input and select fields within the registration form. + + It handles different input types (text, email, password) and selects the + first valid option for dropdown fields. + + Args: + page: A Playwright Page instance already loaded with the registration form. + """ + required_fields = page.locator(".required-fields > div") + + for field_index in range(required_fields.count()): + field = required_fields.nth(field_index) + + # Check if it's an input + if field.locator("input").count() > 0: + input_field = field.locator("input").first + input_name = input_field.get_attribute("name") + input_type = input_field.get_attribute("type") or "" + + if input_type == "email": + input_field.fill(f"testuser_{random_string()}@example.com") + elif input_type == "text" and input_name == "arabic_name": + input_field.fill("مستخدم اختبار") + elif input_type == "text" and input_name == "national_id": + input_field.fill(str(random.randint(4000000000, 9999999999))) + elif input_type == "text": + input_field.fill(f"Test{random_string()}") + elif input_type == "password": + input_field.fill("StrongPass123!") + elif input_type == "hidden": + continue + else: + input_field.fill("placeholder") + + # Check if it's a select dropdown + elif field.locator("select").count() > 0: + select_field = field.locator("select").first + options = select_field.locator("option") + + for option_index in range(options.count()): + value = options.nth(option_index).get_attribute("value") + if value and value != "": + select_field.select_option(value=value) + break + + +def test_register_user(base_url: str): + """ + Smoke test to register a new user on the Open edX platform. + + Navigates to the registration page, fills all required fields, + submits the form, and checks whether the registration was successful. + + Args: + base_url (str): The base URL of the LMS. + """ + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page() + page.goto(f"{base_url}/register") + + # Fill required fields + fill_required_fields(page) + + # Submit form + page.locator("#register-form button[type='submit']").click() + + # Wait for redirection or error + page.wait_for_load_state("networkidle") + error_box = page.locator("div.js-form-errors.status.submission-error") + + if error_box.is_visible(): + error_text = error_box.locator("ul.message-copy").inner_text() + raise AssertionError(f"❌ Registration failed: {error_text}") + + expect(page).to_have_url(f"{base_url}/dashboard") + + browser.close() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Smoke test: User registration") + parser.add_argument("--base-url", required=True, help="Base LMS URL") + args = parser.parse_args() + + test_register_user(base_url=args.base_url) diff --git a/scripts/smoke-tests/test_view_course.py b/scripts/smoke-tests/test_view_course.py new file mode 100644 index 0000000..683f7ee --- /dev/null +++ b/scripts/smoke-tests/test_view_course.py @@ -0,0 +1,47 @@ +import argparse + +from playwright.sync_api import expect, sync_playwright +from utils import login_user + + +def test_view_course(base_url: str, course_id: str, email: str, password: str): + """ + Smoke test to verify that a course page is accessible and loads key content. + + Args: + base_url (str): The base URL of the platform. + course_id (str): The course identifier. + email (str): The user email to log in. + password (str): The user password to log in. + """ + with sync_playwright() as p: + browser = p.chromium.launch() + page = browser.new_page() + + login_user(page, base_url, email, password) + + course_url = f"{base_url}/learning/course/{course_id}/home" + page.goto(course_url) + + expect(page).to_have_url(course_url) + expect(page.locator("ol#courseHome-outline li").first).to_be_visible() + assert page.locator("ol#courseHome-outline li").count() > 0 + assert page.locator("div#courseTabsNavigation .container-xl nav a").count() > 1 + + browser.close() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Smoke test: View Course Page") + parser.add_argument("--base-url", required=True, help="Base URL of the platform") + parser.add_argument("--course-id", required=True, help="Course ID to visit") + parser.add_argument("--email", required=True, help="User email for login") + parser.add_argument("--password", required=True, help="User password for login") + args = parser.parse_args() + + test_view_course( + base_url=args.base_url, + course_id=args.course_id, + email=args.email, + password=args.password, + ) diff --git a/scripts/smoke-tests/utils.py b/scripts/smoke-tests/utils.py new file mode 100644 index 0000000..9976e53 --- /dev/null +++ b/scripts/smoke-tests/utils.py @@ -0,0 +1,18 @@ + + +def login_user(page, base_url: str, email: str, password: str): + """ + Logs in a user using the login form. + + + Args: + page: Playwright Page object. + base_url (str): Base URL of the application. + email (str): User email. + password (str): User password. + """ + page.goto(f"{base_url}/login") + page.fill("input[name='email']", email) + page.fill("input[name='password']", password) + page.click("button[type='submit']") + page.wait_for_load_state("networkidle")