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
+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}/")