| """ |
| E2E UI Test Configuration with Pytest and Playwright. |
| |
| This module provides pytest fixtures for Playwright browser automation |
| including browser context, page, and base URL configuration. |
| """ |
|
|
| import os |
| import shutil |
| import sys |
| from datetime import datetime |
|
|
| import pytest |
| from playwright.sync_api import BrowserContext as SyncBrowserContext |
|
|
| |
| try: |
| import allure |
| ALLURE_AVAILABLE = True |
| except ImportError: |
| ALLURE_AVAILABLE = False |
|
|
| def is_ci_environment(): |
| """Detect if running in CI environment.""" |
| return os.getenv("CI") == "true" or os.getenv("GITHUB_ACTIONS") == "true" or os.getenv("GITLAB_CI") == "true" |
|
|
| |
| from .fixtures import auth_fixtures |
| from .fixtures import database_fixtures |
| from .fixtures import api_fixtures |
| from .fixtures import test_data_factory |
|
|
| |
| from .fixtures.auth_fixtures import authenticated_page, authenticated_page_api, test_user, authenticated_user |
| from .fixtures.database_fixtures import db_session |
| from .fixtures.api_fixtures import setup_test_user, setup_test_project, api_client, api_base_url, test_user_data |
|
|
|
|
| @pytest.fixture(scope="session") |
| def worker_id(): |
| """ |
| Provide worker_id for pytest-xdist compatibility. |
| |
| Returns 'master' when not running under xdist (single worker mode). |
| |
| Yields: |
| str: Worker ID ('master' for single worker, 'gw0', 'gw1', etc. for xdist) |
| """ |
| return "master" |
|
|
|
|
| def pytest_configure(config): |
| """ |
| Pytest configuration hook. |
| |
| Register custom markers and configure CI-only retries. |
| """ |
| |
| config.addinivalue_line( |
| "markers", "e2e: mark test as end-to-end UI test" |
| ) |
|
|
| |
| if is_ci_environment(): |
| |
| if "--reruns" not in sys.argv and "-r" not in sys.argv: |
| reruns = os.getenv("PYTEST_RERUNS", "2") |
| sys.argv.extend(["--reruns", reruns]) |
| print(f"\nCI environment: Enabled {reruns} retries on failure") |
| else: |
| print("\nLocal development: Test retries disabled (fast feedback)") |
|
|
|
|
| @pytest.fixture(scope="session") |
| def browser_type_launch_args(browser_type_launch_args): |
| """ |
| Configure browser launch arguments. |
| |
| Args: |
| browser_type_launch_args: Default launch arguments from pytest-playwright |
| |
| Returns: |
| Updated launch arguments with headless mode |
| """ |
| return { |
| **browser_type_launch_args, |
| "headless": True, |
| } |
|
|
|
|
| @pytest.fixture(scope="session") |
| def browser_context_args(browser_context_args): |
| """ |
| Configure browser context arguments with CI-aware video recording. |
| |
| Args: |
| browser_context_args: Default context arguments from pytest-playwright |
| |
| Returns: |
| Updated context arguments with accept downloads, bypass CSP, and conditional video recording |
| """ |
| context_args = { |
| **browser_context_args, |
| "accept_downloads": True, |
| "bypass_csp": True, |
| "ignore_https_errors": True, |
| } |
|
|
| |
| if is_ci_environment(): |
| video_dir = "backend/tests/e2e_ui/artifacts/videos" |
| os.makedirs(video_dir, exist_ok=True) |
| context_args["record_video_dir"] = video_dir |
|
|
| return context_args |
|
|
|
|
| @pytest.fixture(scope="session", autouse=True) |
| def clean_allure_results(): |
| """ |
| Clean Allure results directory before test run. |
| |
| Prevents old test results from polluting current run. |
| |
| Yields: |
| None: Allows test session to proceed |
| """ |
| if ALLURE_AVAILABLE: |
| allure_dir = "allure-results" |
| if os.path.exists(allure_dir): |
| shutil.rmtree(allure_dir) |
| yield |
| |
|
|
|
|
| @pytest.fixture(scope="session") |
| def base_url(): |
| """ |
| Base URL for E2E UI tests. |
| |
| Uses port 3001 to avoid conflict with dev frontend (port 3000). |
| |
| Returns: |
| str: Base URL for test application |
| """ |
| return "http://localhost:3001" |
|
|
|
|
| @pytest.fixture(scope="function") |
| def page(browser, base_url): |
| """ |
| Create a new page with base URL. |
| |
| Args: |
| browser: Playwright browser fixture (session-scoped) |
| base_url: Base URL fixture |
| |
| Yields: |
| Page: Playwright page object |
| """ |
| |
| context = browser.new_context() |
| page = context.new_page() |
|
|
| |
| page.goto(base_url) |
|
|
| yield page |
|
|
| |
| page.close() |
| context.close() |
|
|
|
|
| @pytest.fixture(scope="function") |
| def screenshot_page(page, request): |
| """ |
| Capture screenshot on test failure. |
| |
| Args: |
| page: Playwright page fixture |
| request: Pytest request node |
| |
| Returns: |
| Page: Same page object for chaining |
| """ |
| yield page |
|
|
| |
| if request.node.rep_call.failed: |
| screenshot_path = f"screenshots/{request.node.name}.png" |
| page.screenshot(path=screenshot_path) |
| print(f"\nScreenshot saved: {screenshot_path}") |
|
|
|
|
| @pytest.fixture(scope="function") |
| def video_page(browser, base_url, request): |
| """ |
| Capture video on test failure. |
| |
| Args: |
| browser: Playwright browser fixture |
| base_url: Base URL fixture |
| request: Pytest request node |
| |
| Yields: |
| Page: Page object with video recording enabled |
| """ |
| context = browser.new_context(record_video_dir="videos/") |
| page = context.new_page() |
|
|
| yield page |
|
|
| |
| if request.node.rep_call.failed: |
| video_path = page.video.path() |
| print(f"\nVideo saved: {video_path}") |
|
|
| page.close() |
| context.close() |
|
|
|
|
| @pytest.fixture(autouse=True) |
| def track_page_for_screenshots(request): |
| """ |
| Track page object for automatic screenshot capture on test failure. |
| |
| This autouse fixture stores a reference to the page object in the |
| test node, allowing the pytest_runtest_makereport hook to capture |
| screenshots when tests fail. |
| |
| Args: |
| request: Pytest request object |
| |
| Yields: |
| None: Allows test to execute |
| """ |
| |
| if request.node.get_closest_marker('no_browser'): |
| yield |
| return |
|
|
| |
| if hasattr(request, "funcargs"): |
| page = request.funcargs.get("page") or request.funcargs.get("authenticated_page") or request.funcargs.get("authenticated_page_api") |
| if page and hasattr(request, "node"): |
| request.node._page = page |
| yield |
|
|
|
|
| |
| @pytest.hookimpl(tryfirst=True, hookwrapper=True) |
| def pytest_runtest_makereport(item, call): |
| """ |
| Pytest hook to capture test results for screenshot/video capture. |
| |
| Automatically captures screenshots on test failure and saves them |
| to artifacts/screenshots/ with descriptive filenames including timestamp |
| and test name for easy debugging in CI and local development. |
| |
| Args: |
| item: Pytest test item |
| call: Pytest call info |
| |
| Returns: |
| Test report with outcome information |
| """ |
| outcome = yield |
| rep = outcome.get_result() |
|
|
| |
| setattr(item, "rep_" + rep.when, rep) |
|
|
| |
| if rep.when == "call" and rep.failed: |
| |
| page = getattr(item, "_page", None) |
| if page is None: |
| |
| if hasattr(item, "funcargs"): |
| page = item.funcargs.get("page") or item.funcargs.get("authenticated_page") |
|
|
| if page is not None: |
| |
| screenshot_dir = "backend/tests/e2e_ui/artifacts/screenshots" |
| os.makedirs(screenshot_dir, exist_ok=True) |
|
|
| |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| test_name = item.name.replace("::", "_").replace("/", "_")[:100] |
| screenshot_path = f"{screenshot_dir}/{timestamp}_{test_name}.png" |
|
|
| |
| page.screenshot(path=screenshot_path, full_page=True) |
| print(f"\nScreenshot saved: {screenshot_path}") |
|
|
| |
| if ALLURE_AVAILABLE: |
| try: |
| allure.attach.file( |
| screenshot_path, |
| name=f"Screenshot: {item.name}", |
| attachment_type=allure.attachment_type.PNG |
| ) |
| except Exception as e: |
| print(f"Failed to attach screenshot to Allure: {e}") |
|
|
| |
| if is_ci_environment(): |
| video_path = page.video.path() |
| if video_path and os.path.exists(video_path): |
| |
| video_timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| video_test_name = item.name.replace("::", "_").replace("/", "_")[:100] |
| named_video_path = f"backend/tests/e2e_ui/artifacts/videos/{video_timestamp}_{video_test_name}.webm" |
| os.rename(video_path, named_video_path) |
| print(f"\nVideo saved: {named_video_path}") |
|
|
| |
| if ALLURE_AVAILABLE: |
| try: |
| allure.attach.file( |
| named_video_path, |
| name=f"Video: {item.name}", |
| attachment_type=allure.attachment_type.WEBM |
| ) |
| except Exception as e: |
| print(f"Failed to attach video to Allure: {e}") |
|
|
|
|
| |
| |
| |
|
|
| |
| try: |
| |
| import pytest_html |
| PYTEST_HTML_AVAILABLE = True |
| except ImportError: |
| PYTEST_HTML_AVAILABLE = False |
|
|
|
|
| if PYTEST_HTML_AVAILABLE: |
| def pytest_html_results_summary(prefix, summary, postfix): |
| """ |
| Add custom content to pytest HTML report summary. |
| |
| Args: |
| prefix: List of HTML elements to insert before summary |
| summary: Summary data |
| postfix: List of HTML elements to insert after summary |
| """ |
| prefix.extend([ |
| "<h2>Atom E2E UI Test Report</h2>", |
| "<p>Generated on: {}</p>".format( |
| datetime.now().strftime('%Y-%m-%d %H:%M:%S') |
| ), |
| ]) |
|
|
|
|
| def pytest_html_results_table_row(report, cells): |
| """ |
| Add screenshot link to failed test rows in HTML report. |
| |
| Args: |
| report: Pytest test report |
| cells: List of table cells for this test row |
| """ |
| if report.failed: |
| |
| screenshot_dir = "backend/tests/e2e_ui/artifacts/screenshots" |
| test_name = report.nodeid.replace("::", "_").replace("/", "_")[:100] |
|
|
| |
| if os.path.exists(screenshot_dir): |
| for filename in sorted(os.listdir(screenshot_dir), reverse=True): |
| if test_name in filename and filename.endswith(".png"): |
| screenshot_path = os.path.join(screenshot_dir, filename) |
| |
| cells.append( |
| f'<td><a href="{screenshot_path}">Screenshot</a></td>' |
| ) |
| break |
|
|
|
|
| def pytest_html_results_table_header(cells): |
| """ |
| Add screenshot column header to HTML report. |
| |
| Args: |
| cells: List of table header cells |
| """ |
| cells.append("<th>Screenshot</th>") |
|
|