diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f57873ee..5d1251ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,3 +63,42 @@ jobs: - name: Run tests run: pytest --cov=API --cov-report=term-missing --cov-fail-under=5 + + ui-test: + name: UI E2E (Playwright) + runs-on: ubuntu-latest + needs: lint + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Fix requirements encoding + run: | + python - <<'EOF' + import pathlib + data = pathlib.Path("requirements.txt").read_bytes() + if b"\x00" in data: + text = data.decode("utf-16") + pathlib.Path("requirements.txt").write_text(text, encoding="utf-8") + print("Converted requirements.txt from UTF-16 to UTF-8") + else: + print("requirements.txt encoding OK") + EOF + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Install Playwright + run: | + pip install "pytest>=7" pytest-playwright requests + playwright install chromium + + - name: Run UI Tests + run: pytest ui_tests/ -v diff --git a/API/app.py b/API/app.py index 3651fbfa..de7267c4 100644 --- a/API/app.py +++ b/API/app.py @@ -168,6 +168,11 @@ def setSession(): return jsonify('No selected parameters!'), 404 +@app.route("/health", methods=['GET']) +def health_check(): + return jsonify({"status": "ok"}), 200 + + if __name__ == '__main__': # if __name__ == 'app': #potrebno radi module js importa u index.html ES6 modules diff --git a/README.md b/README.md index b43b2412..2646ef43 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,23 @@ pip install -r requirements.txt pip install -r requirements-build-win.txt ``` +### Running UI Tests Locally (Playwright E2E) + +Because the Playwright dependencies are intentionally kept out of the main `requirements.txt` to keep the production bundle lightweight, you will need to install them manually when developing locally: + +```bash +# 1. Install testing dependencies +pip install "pytest>=7" pytest-playwright requests + +# 2. Install Playwright browser binaries +playwright install chromium + +# 3. Run the UI test suite +pytest ui_tests/ -v +``` + +The E2E test suite automatically spawns an isolated Flask server instance on port `5003` (to avoid conflicting with standard instances) and waits for the application to be fully ready before it executes DOM checks. + ## Project Boundaries This repository is downstream and separately managed from upstream: diff --git a/ui_tests/conftest.py b/ui_tests/conftest.py new file mode 100644 index 00000000..34aa84a0 --- /dev/null +++ b/ui_tests/conftest.py @@ -0,0 +1,110 @@ +""" +conftest.py — pytest fixture that starts the MUIOGO Flask server for Playwright E2E tests. + +Strategy: Run Waitress in a daemon thread *inside* the pytest process. + +Why not subprocess? + subprocess + Flask dev server (app.run) hangs serving static files on Python 3.12 Windows. + +Why Waitress (not Flask dev server)? + - Proven to work: it's how the app runs in production. + - Threaded properly: 16 threads prevent exhaustion from Playwright's burst of concurrent + JS/CSS asset requests when loading index.html. + +Why daemon thread (not subprocess)? + - Avoids all cross-platform CWD / sys.path / env-var inheritance issues. + - The thread is killed automatically when the pytest session ends. +""" +import os +import sys +import time +import threading + +import pytest +import requests + +# --------------------------------------------------------------------------- +# Path constants +# --------------------------------------------------------------------------- +_HERE = os.path.dirname(os.path.abspath(__file__)) # ui_tests/ +_PROJECT_ROOT = os.path.dirname(_HERE) # MUIOGO/ +_API_DIR = os.path.join(_PROJECT_ROOT, "API") # MUIOGO/API/ + +TEST_PORT = 5003 +BASE_URL = f"http://127.0.0.1:{TEST_PORT}" + + +def _run_waitress(app) -> None: + """Target function for the daemon thread: serve with Waitress.""" + from waitress import serve + serve( + app, + host="127.0.0.1", + port=TEST_PORT, + threads=16, # handles burst of parallel asset requests from Playwright + channel_timeout=10, # release idle connections quickly between test sessions + ) + + +@pytest.fixture(scope="session") +def live_server(): + """ + Start the Flask app in a Waitress daemon thread on port 5003. + + The fixture: + 1. Configures the environment (HEROKU_DEPLOY=0 → local file sessions, no PostgreSQL). + 2. Adds API/ to sys.path and changes CWD to project root — mirrors how the app + is normally launched, so Config.WEBAPP_PATH resolves to the correct WebAPP/ dir. + 3. Imports the Flask `app` object and hands it to Waitress running in a daemon thread. + 4. Polls /health for up to 15 seconds before yielding to the tests. + 5. Restores CWD on teardown (the daemon thread is killed automatically). + """ + # 1. Configure environment BEFORE importing the app so Config reads correct values. + os.environ["HEROKU_DEPLOY"] = "0" + os.environ["PORT"] = str(TEST_PORT) + + # 2. Add API/ to sys.path so `from app import app` and its internal imports work. + if _API_DIR not in sys.path: + sys.path.insert(0, _API_DIR) + + # 3. Change CWD to project root — same as when the app is launched normally. + original_cwd = os.getcwd() + os.chdir(_PROJECT_ROOT) + + try: + import mimetypes + mimetypes.add_type("application/javascript", ".js") + + # Import the Flask application (app-level Config is read here). + from app import app # noqa: E402 (import after path manipulation) + + thread = threading.Thread( + target=_run_waitress, + args=(app,), + daemon=True, # killed automatically when pytest exits + name="muiogo-test-server", + ) + thread.start() + + # 4. Poll /health until the server is ready (max 15 s). + ready = False + for _ in range(30): + try: + r = requests.get(f"{BASE_URL}/health", timeout=1) + if r.status_code == 200: + ready = True + break + except requests.exceptions.RequestException: + pass + time.sleep(0.5) + + if not ready: + raise RuntimeError( + f"Flask test server at {BASE_URL} did not start within 15 seconds." + ) + + yield BASE_URL + + finally: + # 5. Restore CWD for safety (daemon thread dies with the process). + os.chdir(original_cwd) diff --git a/ui_tests/start_test_server.py b/ui_tests/start_test_server.py new file mode 100644 index 00000000..92464c01 --- /dev/null +++ b/ui_tests/start_test_server.py @@ -0,0 +1,39 @@ +""" +Test-specific Flask server entrypoint for Playwright E2E tests. + +Uses Flask's built-in threaded WSGI server (not Waitress) to reliably handle +the burst of concurrent asset requests that Playwright fires when loading +index.html — jQuery, Wijmo, SmartAdmin, Plotly etc load in parallel. + +Waitress defaults to 4 threads which gets saturated on Windows causing all +subsequent requests to hang. Flask's threaded=True uses a new thread per +connection which correctly handles concurrent browser asset loading. +""" +import os +import sys +import mimetypes + +# Resolve paths relative to this file +_ui_tests_dir = os.path.dirname(os.path.abspath(__file__)) +_project_root = os.path.dirname(_ui_tests_dir) +_api_dir = os.path.join(_project_root, "API") + +# API-internal imports (Config, Routes, etc.) use non-package style +sys.path.insert(0, _api_dir) + +# Ensure CWD is the project root so Flask finds WebAPP/ for templates/static +os.chdir(_project_root) + +from app import app # noqa: E402 (after path manipulation) + +mimetypes.add_type("application/javascript", ".js") + +port = int(os.environ.get("PORT", 5003)) + +app.run( + host="127.0.0.1", + port=port, + threaded=True, # one thread per connection — handles burst asset loads + use_reloader=False, # never auto-restart inside a test subprocess + debug=False, +) diff --git a/ui_tests/test_ui_smoke.py b/ui_tests/test_ui_smoke.py new file mode 100644 index 00000000..1040aed3 --- /dev/null +++ b/ui_tests/test_ui_smoke.py @@ -0,0 +1,66 @@ +""" +test_ui_smoke.py — Playwright smoke tests for the MUIOGO frontend. + +Scope: verify that Flask serves the app and core UI elements are present. +These tests are intentionally minimal and environment-agnostic. + +Design rules: + - wait_until="commit" on every page.goto() — returns the moment Flask starts + sending the response without waiting for CDN resources (MathJax etc.). + - Element assertions use generous timeouts because index.html loads ~20 + synchronous