Adding e2e testing

This commit is contained in:
2026-06-20 10:43:08 -04:00
parent 10ab215396
commit 5e33c1d47b
14 changed files with 506 additions and 1 deletions
+4
View File
@@ -85,3 +85,7 @@ coverage.xml
*.cover
.hypothesis/
.pytest_cache/
# Playwright artifacts
playwright-report/
test-results/
+14
View File
@@ -208,6 +208,20 @@ Or in Docker:
docker compose exec api pytest --cov=app --cov-report=term-missing
```
### End-to-End Testing
Browser e2e tests use pytest plus Playwright. The recommended path is the containerized runner, which joins the same Compose network as the app and opens the web service at `http://web`.
```bash
docker compose -f docker-compose.yml -f docker-compose.e2e.yml up -d db api web
docker compose -f docker-compose.yml -f docker-compose.e2e.yml run --rm e2e
```
Authenticated browser tests are skipped unless `E2E_ADMIN_USERNAME` and `E2E_ADMIN_PASSWORD` are supplied. See [`tests/e2e/README.md`](./tests/e2e/README.md) for credential examples, host-run instructions, and guidance for adding specs.
The e2e Compose override uses a separate MySQL container and volume, so tests do not use the normal dev/prod database configured in `.env`. It is still a real MySQL database, but isolated for e2e.
E2e reports are written to `test-results/e2e-report.html` and `test-results/e2e-junit.xml`.
## Additional Features
### Email Notifications
+79
View File
@@ -0,0 +1,79 @@
services:
db:
container_name: ppr_e2e_db
build:
context: .
dockerfile: tests/e2e/mysql.Dockerfile
image: pprdev-e2e-db
environment:
MYSQL_ROOT_PASSWORD: e2e_root_password
MYSQL_DATABASE: ppr_e2e
MYSQL_USER: ppr_e2e
MYSQL_PASSWORD: ppr_e2e_password
volumes:
- ppr_e2e_mysql_data:/var/lib/mysql
api:
container_name: ppr_e2e_api
ports:
- "${E2E_API_PORT_EXTERNAL:-18002}:8000"
environment:
DB_HOST: db
DB_PORT: 3306
DB_NAME: ppr_e2e
DB_USER: ppr_e2e
DB_PASSWORD: ppr_e2e_password
BASE_URL: http://web
MAIL_HOST: 127.0.0.1
MAIL_PORT: 1
MAIL_USERNAME: e2e
MAIL_PASSWORD: e2e
MAIL_FROM: e2e@example.com
MAIL_FROM_NAME: PPR E2E
web:
container_name: ppr_e2e_web
ports:
- "${E2E_WEB_PORT_EXTERNAL:-18055}:80"
environment:
BASE_URL: ""
command: >
sh -c "if [ -z \"$${BASE_URL}\" ]; then API_BASE='/api/v1'; else API_BASE=\"$${BASE_URL}/api/v1\"; fi;
printf 'window.PPR_CONFIG = { apiBase: \"%s\" };' \"$${API_BASE}\" > /usr/share/nginx/html/config.js;
nginx -g 'daemon off;'"
e2e:
build:
context: .
dockerfile: tests/e2e/Dockerfile
working_dir: /workspace
volumes:
- .:/workspace
environment:
E2E_BASE_URL: http://web
E2E_ADMIN_USERNAME: ${E2E_ADMIN_USERNAME:-}
E2E_ADMIN_PASSWORD: ${E2E_ADMIN_PASSWORD:-}
E2E_HEALTH_URL: http://api:8000/health
E2E_ARTIFACT_UID: ${E2E_ARTIFACT_UID:-1000}
E2E_ARTIFACT_GID: ${E2E_ARTIFACT_GID:-1000}
PYTHONDONTWRITEBYTECODE: "1"
depends_on:
- web
networks:
- public_network
command: >
bash -lc "mkdir -p test-results &&
python tests/e2e/wait_for_web.py &&
pytest tests/e2e
--browser chromium
--tracing=retain-on-failure
--screenshot=only-on-failure
--junitxml=test-results/e2e-junit.xml
--html=test-results/e2e-report.html
--self-contained-html;
status=$$?;
chown -R \"$${E2E_ARTIFACT_UID}:$${E2E_ARTIFACT_GID}\" test-results || true;
exit $$status"
volumes:
ppr_e2e_mysql_data:
+6
View File
@@ -0,0 +1,6 @@
FROM mcr.microsoft.com/playwright/python:v1.45.0-jammy
WORKDIR /workspace
COPY tests/e2e/requirements.txt /tmp/e2e-requirements.txt
RUN pip install --no-cache-dir -r /tmp/e2e-requirements.txt
+90
View File
@@ -0,0 +1,90 @@
# End-to-End Tests
The e2e suite uses pytest plus Playwright. The preferred path is the containerized runner, which joins the same Docker Compose network as the web app and opens `http://web`.
## Containerized Run
```bash
docker compose -f docker-compose.yml -f docker-compose.e2e.yml up -d db api web
docker compose -f docker-compose.yml -f docker-compose.e2e.yml run --rm e2e
```
The e2e override sets `E2E_BASE_URL=http://web`, makes the web container generate relative API config for browser-side requests, and forces the API container to use an e2e-only Compose database.
The e2e database is a real MySQL server, but it is isolated from the normal dev database:
- DB container: `ppr_e2e_db`
- API container: `ppr_e2e_api`
- Web container: `ppr_e2e_web`
- Database name: `ppr_e2e`
- Volume: `pprdev_ppr_e2e_mysql_data`
The e2e DB image is plain `mysql:8.0`, so the API should see a fresh empty database and create schema through Alembic migrations instead of stamping an older `db-init` schema.
Authenticated tests are skipped unless credentials are supplied:
```bash
E2E_ADMIN_USERNAME=admin \
E2E_ADMIN_PASSWORD=admin123 \
docker compose -f docker-compose.yml -f docker-compose.e2e.yml run --rm e2e
```
## Rebuild The Test Image
```bash
docker compose -f docker-compose.yml -f docker-compose.e2e.yml build e2e
```
## Test Evidence
Containerized runs write evidence to `test-results/`:
- `test-results/e2e-report.html` for a human-readable report
- `test-results/e2e-junit.xml` for CI systems
- Playwright traces and screenshots on failures
If the report files are not owned by your host user, pass your UID/GID:
```bash
E2E_ARTIFACT_UID=$(id -u) \
E2E_ARTIFACT_GID=$(id -g) \
docker compose -f docker-compose.yml -f docker-compose.e2e.yml run --rm e2e
```
## Host Run
Running on the host is still supported for quick debugging if Python and Playwright are installed locally.
First-time host setup:
```bash
python -m venv .venv
source .venv/bin/activate
pip install -r backend/requirements.txt
pip install -r tests/e2e/requirements.txt
python -m playwright install --with-deps chromium
```
Run against a host-exposed web port:
```bash
E2E_BASE_URL=http://localhost:8082 pytest tests/e2e
```
Run authenticated host tests:
```bash
E2E_BASE_URL=http://localhost:8082 \
E2E_ADMIN_USERNAME=admin \
E2E_ADMIN_PASSWORD=admin123 \
pytest tests/e2e
```
## Adding Tests
Put browser specs in `tests/e2e/test_*.py`. Start with user-visible behavior and stable selectors:
- Navigate through the same URLs users open.
- Prefer roles and labels, such as `get_by_role()` and `get_by_label()`.
- Use API setup only when a test needs specific records to exist.
- Keep specs independent so they can run in any order.
+27
View File
@@ -0,0 +1,27 @@
import os
from playwright.sync_api import expect
BASE_URL = os.getenv("E2E_BASE_URL", "http://localhost:8082").rstrip("/")
ADMIN_USERNAME = os.getenv("E2E_ADMIN_USERNAME")
ADMIN_PASSWORD = os.getenv("E2E_ADMIN_PASSWORD")
def app_url(path):
return f"{BASE_URL}/{path.lstrip('/')}"
def login_as_admin(page):
if page.locator("#login-username").is_visible():
page.locator("#login-username").fill(ADMIN_USERNAME)
page.locator("#login-password").fill(ADMIN_PASSWORD)
page.locator("#login-btn").click()
expect(page.locator("#current-user")).to_have_text(ADMIN_USERNAME)
if page.get_by_role("heading", name="Login").count() > 0:
expect(page.get_by_role("heading", name="Login")).to_be_hidden()
def skip_without_admin_credentials(pytest):
if not ADMIN_USERNAME or not ADMIN_PASSWORD:
pytest.skip("Set E2E_ADMIN_USERNAME and E2E_ADMIN_PASSWORD to run authenticated e2e tests")
+1
View File
@@ -0,0 +1 @@
FROM mysql:8.0
+3
View File
@@ -0,0 +1,3 @@
playwright==1.45.0
pytest-playwright>=0.5,<0.6
pytest-html>=4,<5
+73
View File
@@ -0,0 +1,73 @@
import re
import pytest
from playwright.sync_api import expect
from helpers import app_url, login_as_admin, skip_without_admin_credentials
def open_admin_dropdown(page):
page.locator("#adminDropdownBtn").click()
admin_menu = page.locator("#adminDropdownMenu")
expect(admin_menu).to_have_class(re.compile("active"))
return admin_menu
def open_admin_page(page):
skip_without_admin_credentials(pytest)
page.goto(app_url("/admin"))
expect(page).to_have_title(re.compile("PPR Admin Interface"))
expect(page.get_by_role("heading", name=re.compile("Swansea Tower"))).to_be_visible()
login_as_admin(page)
def test_actions_and_admin_dropdowns_toggle_exclusively(page):
open_admin_page(page)
actions_menu = page.locator("#actionsDropdownMenu")
admin_menu = page.locator("#adminDropdownMenu")
expect(actions_menu).not_to_have_class(re.compile("active"))
expect(admin_menu).not_to_have_class(re.compile("active"))
page.locator("#actionsDropdownBtn").click()
expect(actions_menu).to_have_class(re.compile("active"))
expect(admin_menu).not_to_have_class(re.compile("active"))
expect(actions_menu.get_by_role("link", name=re.compile("New PPR"))).to_be_visible()
expect(actions_menu.get_by_role("link", name=re.compile("Book Out"))).to_be_visible()
expect(actions_menu.get_by_role("link", name=re.compile("Book In"))).to_be_visible()
expect(actions_menu.get_by_role("link", name=re.compile("Overflight"))).to_be_visible()
admin_menu = open_admin_dropdown(page)
expect(actions_menu).not_to_have_class(re.compile("active"))
expect(admin_menu.get_by_role("link", name=re.compile("Admin View"))).to_be_visible()
expect(admin_menu.get_by_role("link", name=re.compile("ATC View"))).to_be_visible()
expect(admin_menu.get_by_role("link", name=re.compile("Reports"))).to_be_visible()
expect(admin_menu.get_by_role("link", name=re.compile("Drone Requests"))).to_be_visible()
expect(admin_menu.get_by_role("link", name=re.compile("Journal Log"))).to_be_visible()
page.locator(".container").click()
expect(admin_menu).not_to_have_class(re.compile("active"))
def test_admin_menu_links_navigate_to_expected_pages(page):
open_admin_page(page)
menu_expectations = [
("Admin View", re.compile(r"/admin$"), re.compile("PPR Admin Interface"), "title"),
("ATC View", re.compile(r"/atc$"), re.compile("ATC Management Interface"), "title"),
("Reports", re.compile(r"/reports$"), re.compile("PPR Reports"), "heading"),
("Drone Requests", re.compile(r"/drone-requests$"), re.compile("Drone Flight Requests"), "heading"),
("Journal Log", re.compile(r"/journal$"), re.compile("Journal Log"), "heading"),
]
for label, url_pattern, expected_text, assertion_type in menu_expectations:
page.goto(app_url("/admin"))
login_as_admin(page)
admin_menu = open_admin_dropdown(page)
admin_menu.get_by_role("link", name=re.compile(label)).click()
expect(page).to_have_url(url_pattern)
if assertion_type == "title":
expect(page).to_have_title(expected_text)
else:
expect(page.get_by_role("heading", name=expected_text)).to_be_visible()
+64
View File
@@ -0,0 +1,64 @@
import re
import json
from datetime import datetime
import pytest
from playwright.sync_api import expect
from helpers import app_url, login_as_admin, skip_without_admin_credentials
def drone_payload(operator_name):
return {
"operator_name": operator_name,
"operator_id": "E2E-OP",
"flyer_name": "E2E Remote Pilot",
"flyer_id": "E2E-FLYER",
"email": "drone-e2e@example.com",
"phone": "0123456789",
"flight_date": "2026-06-21",
"estimated_takeoff_time": "10:00",
"estimated_completion_time": "10:30",
"estimated_takeoff_at": "2026-06-21T10:00:00",
"estimated_completion_at": "2026-06-21T10:30:00",
"maximum_elevation_ft_amsl": 200,
"location_description": "E2E north apron survey",
"location_latitude": 51.623389,
"location_longitude": -4.069231,
"location_inside_frz": "yes",
"notes": "Created by Playwright e2e",
}
def test_drone_requests_requires_login_and_loads_queue(page):
skip_without_admin_credentials(pytest)
page.goto(app_url("/drone-requests"))
expect(page).to_have_title(re.compile("Drone Flight Requests"))
expect(page.get_by_role("heading", name="Drone Flight Requests")).to_be_visible()
expect(page.get_by_role("heading", name="Login")).to_be_visible()
login_as_admin(page)
expect(page.locator("#request-list-body")).not_to_contain_text("Loading requests...")
expect(page.locator("#request-count")).to_have_text(re.compile(r"\d+"))
def test_public_drone_request_appears_in_tower_queue(page):
skip_without_admin_credentials(pytest)
operator_name = f"E2E Rotor Ops {datetime.utcnow().strftime('%H%M%S%f')}"
create_response = page.request.post(
app_url("/api/v1/drone-requests/public"),
data=json.dumps(drone_payload(operator_name)),
headers={"Content-Type": "application/json"},
)
assert create_response.ok, create_response.text()
created = create_response.json()
page.goto(app_url("/drone-requests"))
login_as_admin(page)
expect(page.locator("#request-list-body")).to_contain_text(created["reference_number"])
expect(page.locator("#request-list-body")).to_contain_text(operator_name)
+56
View File
@@ -0,0 +1,56 @@
from datetime import datetime
import pytest
from playwright.sync_api import expect
from helpers import app_url, login_as_admin, skip_without_admin_credentials
def local_flight_row(page, registration):
return page.locator("#local-flights-table-body tr").filter(has_text=registration)
def confirm_timestamp(page, button_text):
expect(page.locator("#timestampModal")).to_be_visible()
expect(page.locator("#timestamp-submit-btn")).to_contain_text(button_text)
page.locator("#timestamp-submit-btn").click()
expect(page.locator("#timestampModal")).to_be_hidden()
def test_operator_books_out_local_flight_records_touch_and_go_and_lands(page):
skip_without_admin_credentials(pytest)
registration = f"GE2E{datetime.utcnow().strftime('%H%M%S')}"
page.goto(app_url("/admin"))
login_as_admin(page)
page.locator("#actionsDropdownBtn").click()
page.locator("#actionsDropdownMenu").get_by_role("link", name="🛫 Book Out (L)").click()
expect(page.locator("#localFlightModal")).to_be_visible()
page.locator("#local_registration").fill(registration)
page.locator("#local_type").fill("PA28")
page.locator("#local_pob").fill("1")
page.locator("#local_flight_type").select_option("LOCAL")
page.locator("#local_duration").fill("45")
page.locator("#local_notes").fill("E2E local flight lifecycle")
page.locator("#local-flight-form").get_by_role("button", name="🛫 Book Out").click()
expect(page.locator("#localFlightModal")).to_be_hidden()
row = local_flight_row(page, registration)
expect(row).to_be_visible()
expect(row).to_contain_text("GROUND")
row.get_by_role("button", name="TAKE OFF").click()
confirm_timestamp(page, "Confirm Takeoff")
expect(row).to_contain_text("LOCAL")
row.get_by_role("button", name="T&G").click()
expect(page.locator("#circuitModal")).to_be_visible()
page.locator("#circuit-form").get_by_role("button", name="Record Circuit").click()
expect(page.locator("#circuitModal")).to_be_hidden()
expect(row.locator("td").nth(7)).to_have_text("1")
row.get_by_role("button", name="LAND").click()
confirm_timestamp(page, "Confirm Landing")
expect(local_flight_row(page, registration)).to_have_count(0)
+26
View File
@@ -0,0 +1,26 @@
import os
import re
from playwright.sync_api import expect
BASE_URL = os.getenv("E2E_BASE_URL", "http://localhost:8082").rstrip("/")
def app_url(path):
return f"{BASE_URL}/{path.lstrip('/')}"
def test_flight_information_display_loads(page):
page.goto(app_url("/"))
expect(page).to_have_title(re.compile("Swansea Airport - Arrivals & Departures"))
expect(page.get_by_role("heading", name="Flight Information")).to_be_visible()
def test_public_ppr_form_loads(page):
page.goto(app_url("/ppr.html"))
expect(page).to_have_title(re.compile("Swansea PPR"))
expect(page.get_by_role("heading", name=re.compile("PPR Request"))).to_be_visible()
expect(page.get_by_role("button", name="Submit PPR Request")).to_be_visible()
+38
View File
@@ -0,0 +1,38 @@
import os
from datetime import datetime
from playwright.sync_api import expect
BASE_URL = os.getenv("E2E_BASE_URL", "http://localhost:8082").rstrip("/")
def app_url(path):
return f"{BASE_URL}/{path.lstrip('/')}"
def test_public_ppr_form_submits_successfully(page):
unique_registration = f"GE{datetime.utcnow().strftime('%H%M')}"
page.goto(app_url("/ppr.html"))
page.locator("#ac_reg").fill(unique_registration)
page.locator("#ac_type").fill("PA28")
page.locator("#ac_call").fill(unique_registration)
page.locator("#captain").fill("E2E Test Pilot")
page.locator("#in_from").fill("EGLL")
page.locator("#eta-date").fill("2026-06-21")
page.locator("#eta-time").select_option("10:00")
page.locator("#pob_in").fill("2")
page.locator("#fuel").select_option("100LL")
page.locator("#out_to").fill("EGFF")
page.locator("#etd-date").fill("2026-06-21")
page.locator("#etd-time").select_option("12:00")
page.locator("#pob_out").fill("2")
page.locator("#phone").fill("0123456789")
page.locator("#notes").fill("Submitted by Playwright e2e")
page.get_by_role("button", name="Submit PPR Request").click()
expect(page.locator("#success-message")).to_be_visible()
expect(page.locator("#success-message")).to_contain_text("PPR Request Submitted")
+24
View File
@@ -0,0 +1,24 @@
import os
import time
import urllib.request
base_url = os.getenv("E2E_BASE_URL", "http://web").rstrip("/")
health_url = os.getenv("E2E_HEALTH_URL", f"{base_url}/")
deadline = time.time() + int(os.getenv("E2E_WEB_TIMEOUT_SECONDS", "120"))
last_error = None
while time.time() < deadline:
try:
with urllib.request.urlopen(health_url, timeout=5) as response:
if response.status < 500:
break
except Exception as exc:
last_error = exc
time.sleep(2)
else:
raise SystemExit(f"Timed out waiting for {health_url}: {last_error}")
with urllib.request.urlopen(f"{base_url}/", timeout=5) as response:
if response.status >= 500:
raise SystemExit(f"Web returned HTTP {response.status} at {base_url}/")