feat: Enhance end-to-end testing framework with improved server setup and UI validation

This commit is contained in:
2025-10-21 09:04:06 +02:00
parent f020d276bc
commit 9114b584c2
11 changed files with 138 additions and 71 deletions

View File

@@ -107,6 +107,12 @@ To execute the unit test suite:
pytest pytest
``` ```
### End-to-End Tests
- Playwright-based E2E tests rely on a session-scoped `live_server` fixture that auto-starts the FastAPI app on `http://localhost:8001`, so no per-test `@pytest.mark.usefixtures("live_server")` annotations are required.
- The fixture now polls `[http://localhost:8001](http://localhost:8001)` until it responds (up to ~30s), ensuring the uvicorn subprocess is ready before Playwright starts navigation, then preloads `/` and waits for a `networkidle` state so sidebar navigation and global assets are ready for each test.
- Latest run (`pytest tests/e2e/` on 2025-10-21) passes end-to-end smoke and form coverage after aligning form selectors, titles, and the live server startup behaviour.
### Coverage Snapshot (2025-10-20) ### Coverage Snapshot (2025-10-20)
- `pytest --cov=. --cov-report=term-missing` reports **95%** overall coverage across the project. - `pytest --cov=. --cov-report=term-missing` reports **95%** overall coverage across the project.

View File

@@ -1,3 +1,4 @@
import os
import subprocess import subprocess
import time import time
from typing import Generator from typing import Generator
@@ -5,23 +6,58 @@ from typing import Generator
import pytest import pytest
from playwright.sync_api import Browser, Page, Playwright, sync_playwright from playwright.sync_api import Browser, Page, Playwright, sync_playwright
import httpx
# Use a different port for the test server to avoid conflicts # Use a different port for the test server to avoid conflicts
TEST_PORT = 8001 TEST_PORT = 8001
BASE_URL = f"http://localhost:{TEST_PORT}" BASE_URL = f"http://localhost:{TEST_PORT}"
@pytest.fixture(scope="function") @pytest.fixture(scope="session", autouse=True)
def live_server() -> Generator[str, None, None]: def live_server() -> Generator[str, None, None]:
"""Launch a live test server in a separate process.""" """Launch a live test server in a separate process."""
process = subprocess.Popen( process = subprocess.Popen(
["uvicorn", "main:app", f"--port={TEST_PORT}"], [
stdout=subprocess.PIPE, "uvicorn",
stderr=subprocess.PIPE, "main:app",
"--host",
"127.0.0.1",
f"--port={TEST_PORT}",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=os.environ.copy(),
) )
time.sleep(2) # Give the server a moment to start
yield BASE_URL deadline = time.perf_counter() + 30
last_error: Exception | None = None
while time.perf_counter() < deadline:
if process.poll() is not None:
raise RuntimeError("uvicorn server exited before becoming ready")
try:
response = httpx.get(BASE_URL, timeout=1.0)
if response.status_code < 500:
break
except Exception as exc: # noqa: BLE001
last_error = exc
time.sleep(0.5)
else:
process.terminate() process.terminate()
process.wait() process.wait(timeout=5)
raise TimeoutError(
"Timed out waiting for uvicorn test server to start"
) from last_error
try:
yield BASE_URL
finally:
if process.poll() is None:
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=5)
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
@@ -45,5 +81,7 @@ def browser(
def page(browser: Browser, live_server: str) -> Generator[Page, None, None]: def page(browser: Browser, live_server: str) -> Generator[Page, None, None]:
"""Provide a new page for each test.""" """Provide a new page for each test."""
page = browser.new_page(base_url=live_server) page = browser.new_page(base_url=live_server)
page.goto("/")
page.wait_for_load_state("networkidle")
yield page yield page
page.close() page.close()

View File

@@ -6,8 +6,8 @@ from playwright.sync_api import Page, expect
def test_consumption_form_loads(page: Page): def test_consumption_form_loads(page: Page):
"""Verify the consumption form page loads correctly.""" """Verify the consumption form page loads correctly."""
page.goto("/ui/consumption") page.goto("/ui/consumption")
expect(page).to_have_title("CalMiner Consumption") expect(page).to_have_title("Consumption · CalMiner")
expect(page.locator("h1")).to_have_text("Consumption") expect(page.locator("h2:has-text('Add Consumption Record')")).to_be_visible()
def test_create_consumption_item(page: Page): def test_create_consumption_item(page: Page):
@@ -25,8 +25,8 @@ def test_create_consumption_item(page: Page):
# Create a consumption item. # Create a consumption item.
consumption_desc = "Diesel for generators" consumption_desc = "Diesel for generators"
page.select_option("select[name='scenario_id']", label=scenario_name) page.select_option("#consumption-form-scenario", label=scenario_name)
page.fill("input[name='description']", consumption_desc) page.fill("textarea[name='description']", consumption_desc)
page.fill("input[name='amount']", "5000") page.fill("input[name='amount']", "5000")
page.click("button[type='submit']") page.click("button[type='submit']")
@@ -35,8 +35,14 @@ def test_create_consumption_item(page: Page):
assert response_info.value.status == 201 assert response_info.value.status == 201
# Verify the new item appears in the table. # Verify the new item appears in the table.
expect(page.locator(f"tr:has-text('{consumption_desc}')")).to_be_visible() page.select_option("#consumption-scenario-filter", label=scenario_name)
expect(
page.locator("#consumption-table-body tr").filter(
has_text=consumption_desc
)
).to_be_visible()
# Verify the feedback message. # Verify the feedback message.
expect(page.locator("#consumption-feedback") expect(page.locator("#consumption-feedback")).to_have_text(
).to_have_text("Consumption record saved.") "Consumption record saved."
)

View File

@@ -6,8 +6,8 @@ from playwright.sync_api import Page, expect
def test_costs_form_loads(page: Page): def test_costs_form_loads(page: Page):
"""Verify the costs form page loads correctly.""" """Verify the costs form page loads correctly."""
page.goto("/ui/costs") page.goto("/ui/costs")
expect(page).to_have_title("CalMiner Costs") expect(page).to_have_title("Costs · CalMiner")
expect(page.locator("h1")).to_have_text("Costs") expect(page.locator("h2:has-text('Add CAPEX Entry')")).to_be_visible()
def test_create_capex_and_opex_items(page: Page): def test_create_capex_and_opex_items(page: Page):
@@ -25,9 +25,9 @@ def test_create_capex_and_opex_items(page: Page):
# Create a CAPEX item. # Create a CAPEX item.
capex_desc = "Initial drilling equipment" capex_desc = "Initial drilling equipment"
page.select_option("select[name='scenario_id']", label=scenario_name) page.select_option("#capex-form-scenario", label=scenario_name)
page.fill("input[name='description']", capex_desc) page.fill("#capex-form-description", capex_desc)
page.fill("input[name='amount']", "150000") page.fill("#capex-form-amount", "150000")
page.click("#capex-form button[type='submit']") page.click("#capex-form button[type='submit']")
with page.expect_response("**/api/costs/capex") as response_info: with page.expect_response("**/api/costs/capex") as response_info:
@@ -36,9 +36,9 @@ def test_create_capex_and_opex_items(page: Page):
# Create an OPEX item. # Create an OPEX item.
opex_desc = "Monthly fuel costs" opex_desc = "Monthly fuel costs"
page.select_option("select[name='scenario_id']", label=scenario_name) page.select_option("#opex-form-scenario", label=scenario_name)
page.fill("input[name='description']", opex_desc) page.fill("#opex-form-description", opex_desc)
page.fill("input[name='amount']", "25000") page.fill("#opex-form-amount", "25000")
page.click("#opex-form button[type='submit']") page.click("#opex-form button[type='submit']")
with page.expect_response("**/api/costs/opex") as response_info: with page.expect_response("**/api/costs/opex") as response_info:
@@ -46,10 +46,13 @@ def test_create_capex_and_opex_items(page: Page):
assert response_info.value.status == 200 assert response_info.value.status == 200
# Verify the new items appear in their respective tables. # Verify the new items appear in their respective tables.
expect(page.locator( page.select_option("#costs-scenario-filter", label=scenario_name)
f"#capex-table tr:has-text('{capex_desc}')")).to_be_visible() expect(
expect(page.locator( page.locator("#capex-table-body tr").filter(has_text=capex_desc)
f"#opex-table tr:has-text('{opex_desc}')")).to_be_visible() ).to_be_visible()
expect(
page.locator("#opex-table-body tr").filter(has_text=opex_desc)
).to_be_visible()
# Verify the feedback messages. # Verify the feedback messages.
expect(page.locator("#capex-feedback") expect(page.locator("#capex-feedback")

View File

@@ -3,16 +3,15 @@ from playwright.sync_api import Page, expect
def test_dashboard_loads_and_has_title(page: Page): def test_dashboard_loads_and_has_title(page: Page):
"""Verify the dashboard page loads and the title is correct.""" """Verify the dashboard page loads and the title is correct."""
expect(page).to_have_title("CalMiner Dashboard") expect(page).to_have_title("Dashboard · CalMiner")
def test_dashboard_shows_summary_metrics_panel(page: Page): def test_dashboard_shows_summary_metrics_panel(page: Page):
"""Check that the summary metrics panel is visible.""" """Check that the summary metrics panel is visible."""
summary_panel = page.locator("section.panel h2:has-text('Summary Metrics')") expect(page.locator("h2:has-text('Operations Overview')")).to_be_visible()
expect(summary_panel).to_be_visible()
def test_dashboard_renders_cost_chart(page: Page): def test_dashboard_renders_cost_chart(page: Page):
"""Ensure the scenario cost chart canvas is present.""" """Ensure the scenario cost chart canvas is present."""
cost_chart = page.locator("#scenario-cost-chart") expect(page.locator("#cost-chart")).to_be_attached()
expect(cost_chart).to_be_visible() expect(page.locator("#cost-chart-empty")).to_be_visible()

View File

@@ -6,8 +6,8 @@ from playwright.sync_api import Page, expect
def test_equipment_form_loads(page: Page): def test_equipment_form_loads(page: Page):
"""Verify the equipment form page loads correctly.""" """Verify the equipment form page loads correctly."""
page.goto("/ui/equipment") page.goto("/ui/equipment")
expect(page).to_have_title("CalMiner Equipment") expect(page).to_have_title("Equipment · CalMiner")
expect(page.locator("h1")).to_have_text("Equipment") expect(page.locator("h2:has-text('Add Equipment')")).to_be_visible()
def test_create_equipment_item(page: Page): def test_create_equipment_item(page: Page):
@@ -26,9 +26,9 @@ def test_create_equipment_item(page: Page):
# Create an equipment item. # Create an equipment item.
equipment_name = "Haul Truck HT-05" equipment_name = "Haul Truck HT-05"
equipment_desc = "Primary haul truck for ore transport." equipment_desc = "Primary haul truck for ore transport."
page.select_option("select[name='scenario_id']", label=scenario_name) page.select_option("#equipment-form-scenario", label=scenario_name)
page.fill("input[name='name']", equipment_name) page.fill("#equipment-form-name", equipment_name)
page.fill("textarea[name='description']", equipment_desc) page.fill("#equipment-form-description", equipment_desc)
page.click("button[type='submit']") page.click("button[type='submit']")
with page.expect_response("**/api/equipment/") as response_info: with page.expect_response("**/api/equipment/") as response_info:
@@ -36,7 +36,12 @@ def test_create_equipment_item(page: Page):
assert response_info.value.status == 200 assert response_info.value.status == 200
# Verify the new item appears in the table. # Verify the new item appears in the table.
expect(page.locator(f"tr:has-text('{equipment_name}')")).to_be_visible() page.select_option("#equipment-scenario-filter", label=scenario_name)
expect(
page.locator("#equipment-table-body tr").filter(
has_text=equipment_name
)
).to_be_visible()
# Verify the feedback message. # Verify the feedback message.
expect(page.locator("#equipment-feedback") expect(page.locator("#equipment-feedback")

View File

@@ -6,8 +6,8 @@ from playwright.sync_api import Page, expect
def test_maintenance_form_loads(page: Page): def test_maintenance_form_loads(page: Page):
"""Verify the maintenance form page loads correctly.""" """Verify the maintenance form page loads correctly."""
page.goto("/ui/maintenance") page.goto("/ui/maintenance")
expect(page).to_have_title("CalMiner Maintenance") expect(page).to_have_title("Maintenance · CalMiner")
expect(page.locator("h1")).to_have_text("Maintenance") expect(page.locator("h2:has-text('Add Maintenance Entry')")).to_be_visible()
def test_create_maintenance_item(page: Page): def test_create_maintenance_item(page: Page):
@@ -22,8 +22,8 @@ def test_create_maintenance_item(page: Page):
page.goto("/ui/equipment") page.goto("/ui/equipment")
equipment_name = f"Excavator EX-12 {uuid4()}" equipment_name = f"Excavator EX-12 {uuid4()}"
page.select_option("select[name='scenario_id']", label=scenario_name) page.select_option("#equipment-form-scenario", label=scenario_name)
page.fill("input[name='name']", equipment_name) page.fill("#equipment-form-name", equipment_name)
page.click("button[type='submit']") page.click("button[type='submit']")
with page.expect_response("**/api/equipment/"): with page.expect_response("**/api/equipment/"):
pass pass
@@ -33,11 +33,11 @@ def test_create_maintenance_item(page: Page):
# Create a maintenance item. # Create a maintenance item.
maintenance_desc = "Scheduled engine overhaul" maintenance_desc = "Scheduled engine overhaul"
page.select_option("select[name='scenario_id']", label=scenario_name) page.select_option("#maintenance-form-scenario", label=scenario_name)
page.select_option("select[name='equipment_id']", label=equipment_name) page.select_option("#maintenance-form-equipment", label=equipment_name)
page.fill("input[name='maintenance_date']", "2025-12-01") page.fill("#maintenance-form-date", "2025-12-01")
page.fill("textarea[name='description']", maintenance_desc) page.fill("#maintenance-form-description", maintenance_desc)
page.fill("input[name='cost']", "12000") page.fill("#maintenance-form-cost", "12000")
page.click("button[type='submit']") page.click("button[type='submit']")
with page.expect_response("**/api/maintenance/") as response_info: with page.expect_response("**/api/maintenance/") as response_info:
@@ -45,7 +45,12 @@ def test_create_maintenance_item(page: Page):
assert response_info.value.status == 201 assert response_info.value.status == 201
# Verify the new item appears in the table. # Verify the new item appears in the table.
expect(page.locator(f"tr:has-text('{maintenance_desc}')")).to_be_visible() page.select_option("#maintenance-scenario-filter", label=scenario_name)
expect(
page.locator("#maintenance-table-body tr").filter(
has_text=maintenance_desc
)
).to_be_visible()
# Verify the feedback message. # Verify the feedback message.
expect(page.locator("#maintenance-feedback") expect(page.locator("#maintenance-feedback")

View File

@@ -6,8 +6,8 @@ from playwright.sync_api import Page, expect
def test_production_form_loads(page: Page): def test_production_form_loads(page: Page):
"""Verify the production form page loads correctly.""" """Verify the production form page loads correctly."""
page.goto("/ui/production") page.goto("/ui/production")
expect(page).to_have_title("CalMiner Production") expect(page).to_have_title("Production · CalMiner")
expect(page.locator("h1")).to_have_text("Production") expect(page.locator("h2:has-text('Add Production Output')")).to_be_visible()
def test_create_production_item(page: Page): def test_create_production_item(page: Page):
@@ -25,9 +25,9 @@ def test_create_production_item(page: Page):
# Create a production item. # Create a production item.
production_desc = "Ore extracted - Grade A" production_desc = "Ore extracted - Grade A"
page.select_option("select[name='scenario_id']", label=scenario_name) page.select_option("#production-form-scenario", label=scenario_name)
page.fill("input[name='description']", production_desc) page.fill("#production-form-description", production_desc)
page.fill("input[name='amount']", "1500") page.fill("#production-form-amount", "1500")
page.click("button[type='submit']") page.click("button[type='submit']")
with page.expect_response("**/api/production/") as response_info: with page.expect_response("**/api/production/") as response_info:
@@ -35,7 +35,12 @@ def test_create_production_item(page: Page):
assert response_info.value.status == 201 assert response_info.value.status == 201
# Verify the new item appears in the table. # Verify the new item appears in the table.
expect(page.locator(f"tr:has-text('{production_desc}')")).to_be_visible() page.select_option("#production-scenario-filter", label=scenario_name)
expect(
page.locator("#production-table-body tr").filter(
has_text=production_desc
)
).to_be_visible()
# Verify the feedback message. # Verify the feedback message.
expect(page.locator("#production-feedback") expect(page.locator("#production-feedback")

View File

@@ -3,6 +3,7 @@ from playwright.sync_api import Page, expect
def test_reporting_view_loads(page: Page): def test_reporting_view_loads(page: Page):
"""Verify the reporting view page loads correctly.""" """Verify the reporting view page loads correctly."""
page.click("a[href='/ui/reporting']") page.get_by_role("link", name="Reporting").click()
expect(page).to_have_url("/ui/reporting") expect(page).to_have_url("http://localhost:8001/ui/reporting")
expect(page.locator("h2:has-text('Reporting')")).to_be_visible() expect(page).to_have_title("Reporting · CalMiner")
expect(page.locator("h2:has-text('Scenario KPI Summary')")).to_be_visible()

View File

@@ -9,7 +9,7 @@ def test_scenario_form_loads(page: Page):
expect(page).to_have_url( expect(page).to_have_url(
"http://localhost:8001/ui/scenarios" "http://localhost:8001/ui/scenarios"
) # Updated port ) # Updated port
expect(page.locator("h2:has-text('Create New Scenario')")).to_be_visible() expect(page.locator("h2:has-text('Create a New Scenario')")).to_be_visible()
def test_create_new_scenario(page: Page): def test_create_new_scenario(page: Page):
@@ -20,7 +20,7 @@ def test_create_new_scenario(page: Page):
scenario_desc = "A scenario created during an end-to-end test." scenario_desc = "A scenario created during an end-to-end test."
page.fill("input[name='name']", scenario_name) page.fill("input[name='name']", scenario_name)
page.fill("textarea[name='description']", scenario_desc) page.fill("input[name='description']", scenario_desc)
# Expect a network response from the POST request after clicking the submit button. # Expect a network response from the POST request after clicking the submit button.
with page.expect_response("**/api/scenarios/") as response_info: with page.expect_response("**/api/scenarios/") as response_info:

View File

@@ -3,21 +3,20 @@ from playwright.sync_api import Page, expect
# A list of UI routes to check, with their URL, expected title, and a key heading text. # A list of UI routes to check, with their URL, expected title, and a key heading text.
UI_ROUTES = [ UI_ROUTES = [
("/", "CalMiner Dashboard", "Dashboard"), ("/", "Dashboard · CalMiner", "Operations Overview"),
("/ui/dashboard", "CalMiner Dashboard", "Dashboard"), ("/ui/dashboard", "Dashboard · CalMiner", "Operations Overview"),
("/ui/scenarios", "CalMiner Scenarios", "Scenarios"), ("/ui/scenarios", "Scenario Management · CalMiner", "Create a New Scenario"),
("/ui/parameters", "CalMiner Parameters", "Parameters"), ("/ui/parameters", "Process Parameters · CalMiner", "Scenario Parameters"),
("/ui/costs", "CalMiner Costs", "Costs"), ("/ui/costs", "Costs · CalMiner", "Cost Overview"),
("/ui/consumption", "CalMiner Consumption", "Consumption"), ("/ui/consumption", "Consumption · CalMiner", "Consumption Tracking"),
("/ui/production", "CalMiner Production", "Production"), ("/ui/production", "Production · CalMiner", "Production Output"),
("/ui/equipment", "CalMiner Equipment", "Equipment"), ("/ui/equipment", "Equipment · CalMiner", "Equipment Inventory"),
("/ui/maintenance", "CalMiner Maintenance", "Maintenance"), ("/ui/maintenance", "Maintenance · CalMiner", "Maintenance Schedule"),
("/ui/simulations", "CalMiner Simulations", "Simulations"), ("/ui/simulations", "Simulations · CalMiner", "Monte Carlo Simulations"),
("/ui/reporting", "CalMiner Reporting", "Reporting"), ("/ui/reporting", "Reporting · CalMiner", "Scenario KPI Summary"),
] ]
@pytest.mark.usefixtures("live_server")
@pytest.mark.parametrize("url, title, heading", UI_ROUTES) @pytest.mark.parametrize("url, title, heading", UI_ROUTES)
def test_ui_pages_load_correctly(page: Page, url: str, title: str, heading: str): def test_ui_pages_load_correctly(page: Page, url: str, title: str, heading: str):
"""Verify that all UI pages load with the correct title and a visible heading.""" """Verify that all UI pages load with the correct title and a visible heading."""