Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 5 additions & 0 deletions API/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
110 changes: 110 additions & 0 deletions ui_tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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)
39 changes: 39 additions & 0 deletions ui_tests/start_test_server.py
Original file line number Diff line number Diff line change
@@ -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,
)
66 changes: 66 additions & 0 deletions ui_tests/test_ui_smoke.py
Original file line number Diff line number Diff line change
@@ -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 <script> tags before #main and the footer are fully parsed.
- No SPA route tests (/#AddCase, /#Home, /#Config) — those require jQuery
$.load() to complete and are flaky in headless CI.
"""
import re

import requests
from playwright.sync_api import Page, expect


# ---------------------------------------------------------------------------
# Pure API tests (no browser required)
# ---------------------------------------------------------------------------

def test_health_endpoint(live_server: str):
"""Flask /health must return {"status": "ok"}."""
r = requests.get(f"{live_server}/health", timeout=5)
assert r.status_code == 200
assert r.json().get("status") == "ok"


def test_session_api(live_server: str):
"""Flask /getSession must return a JSON object with a 'session' key."""
r = requests.get(f"{live_server}/getSession", timeout=5)
assert r.status_code == 200
assert "session" in r.json()


# ---------------------------------------------------------------------------
# Browser tests (Playwright)
# ---------------------------------------------------------------------------

def test_load_app(page: Page, live_server: str):
"""
The root URL must serve index.html with the correct page title.

wait_until='commit' returns the instant Flask starts sending bytes.
to_have_title then polls until the <title> tag is parsed (fast, in <head>).
"""
page.goto(live_server, wait_until="commit")
expect(page).to_have_title(re.compile(r"MUIO\s*5\.5"), timeout=15000)


def test_static_footer(page: Page, live_server: str):
"""
The footer copyright text must be present in the DOM.

'MUIO ver.5.5' is hardcoded in index.html (line 143) — no JS needed.
It sits after ~20 synchronous <script> tags so we allow 60 s for the
browser to finish downloading and executing all blocking scripts.
to_be_attached (not to_be_visible) because the footer may be off-screen.
"""
page.goto(live_server, wait_until="commit")
expect(
page.get_by_text("MUIO ver.5.5", exact=False).first
).to_be_attached(timeout=60000)
Loading