diff --git a/README.md b/README.md index 0e478e8..50a5785 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,12 @@ To execute the unit test suite: 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) - `pytest --cov=. --cov-report=term-missing` reports **95%** overall coverage across the project. diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index c17ad01..f1f3238 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -1,3 +1,4 @@ +import os import subprocess import time from typing import Generator @@ -5,23 +6,58 @@ from typing import Generator import pytest from playwright.sync_api import Browser, Page, Playwright, sync_playwright +import httpx + # Use a different port for the test server to avoid conflicts TEST_PORT = 8001 BASE_URL = f"http://localhost:{TEST_PORT}" -@pytest.fixture(scope="function") +@pytest.fixture(scope="session", autouse=True) def live_server() -> Generator[str, None, None]: """Launch a live test server in a separate process.""" process = subprocess.Popen( - ["uvicorn", "main:app", f"--port={TEST_PORT}"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + [ + "uvicorn", + "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 - process.terminate() - process.wait() + + 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.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") @@ -45,5 +81,7 @@ def browser( def page(browser: Browser, live_server: str) -> Generator[Page, None, None]: """Provide a new page for each test.""" page = browser.new_page(base_url=live_server) + page.goto("/") + page.wait_for_load_state("networkidle") yield page page.close() diff --git a/tests/e2e/test_consumption.py b/tests/e2e/test_consumption.py index 7afcfb2..1303e71 100644 --- a/tests/e2e/test_consumption.py +++ b/tests/e2e/test_consumption.py @@ -6,8 +6,8 @@ from playwright.sync_api import Page, expect def test_consumption_form_loads(page: Page): """Verify the consumption form page loads correctly.""" page.goto("/ui/consumption") - expect(page).to_have_title("CalMiner Consumption") - expect(page.locator("h1")).to_have_text("Consumption") + expect(page).to_have_title("Consumption · CalMiner") + expect(page.locator("h2:has-text('Add Consumption Record')")).to_be_visible() def test_create_consumption_item(page: Page): @@ -25,8 +25,8 @@ def test_create_consumption_item(page: Page): # Create a consumption item. consumption_desc = "Diesel for generators" - page.select_option("select[name='scenario_id']", label=scenario_name) - page.fill("input[name='description']", consumption_desc) + page.select_option("#consumption-form-scenario", label=scenario_name) + page.fill("textarea[name='description']", consumption_desc) page.fill("input[name='amount']", "5000") page.click("button[type='submit']") @@ -35,8 +35,14 @@ def test_create_consumption_item(page: Page): assert response_info.value.status == 201 # 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. - expect(page.locator("#consumption-feedback") - ).to_have_text("Consumption record saved.") + expect(page.locator("#consumption-feedback")).to_have_text( + "Consumption record saved." + ) diff --git a/tests/e2e/test_costs.py b/tests/e2e/test_costs.py index b66c931..6e52b3b 100644 --- a/tests/e2e/test_costs.py +++ b/tests/e2e/test_costs.py @@ -6,8 +6,8 @@ from playwright.sync_api import Page, expect def test_costs_form_loads(page: Page): """Verify the costs form page loads correctly.""" page.goto("/ui/costs") - expect(page).to_have_title("CalMiner Costs") - expect(page.locator("h1")).to_have_text("Costs") + expect(page).to_have_title("Costs · CalMiner") + expect(page.locator("h2:has-text('Add CAPEX Entry')")).to_be_visible() 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. capex_desc = "Initial drilling equipment" - page.select_option("select[name='scenario_id']", label=scenario_name) - page.fill("input[name='description']", capex_desc) - page.fill("input[name='amount']", "150000") + page.select_option("#capex-form-scenario", label=scenario_name) + page.fill("#capex-form-description", capex_desc) + page.fill("#capex-form-amount", "150000") page.click("#capex-form button[type='submit']") 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. opex_desc = "Monthly fuel costs" - page.select_option("select[name='scenario_id']", label=scenario_name) - page.fill("input[name='description']", opex_desc) - page.fill("input[name='amount']", "25000") + page.select_option("#opex-form-scenario", label=scenario_name) + page.fill("#opex-form-description", opex_desc) + page.fill("#opex-form-amount", "25000") page.click("#opex-form button[type='submit']") 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 # Verify the new items appear in their respective tables. - expect(page.locator( - f"#capex-table tr:has-text('{capex_desc}')")).to_be_visible() - expect(page.locator( - f"#opex-table tr:has-text('{opex_desc}')")).to_be_visible() + page.select_option("#costs-scenario-filter", label=scenario_name) + expect( + page.locator("#capex-table-body tr").filter(has_text=capex_desc) + ).to_be_visible() + expect( + page.locator("#opex-table-body tr").filter(has_text=opex_desc) + ).to_be_visible() # Verify the feedback messages. expect(page.locator("#capex-feedback") diff --git a/tests/e2e/test_dashboard.py b/tests/e2e/test_dashboard.py index d198d22..198b04d 100644 --- a/tests/e2e/test_dashboard.py +++ b/tests/e2e/test_dashboard.py @@ -3,16 +3,15 @@ from playwright.sync_api import Page, expect def test_dashboard_loads_and_has_title(page: Page): """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): """Check that the summary metrics panel is visible.""" - summary_panel = page.locator("section.panel h2:has-text('Summary Metrics')") - expect(summary_panel).to_be_visible() + expect(page.locator("h2:has-text('Operations Overview')")).to_be_visible() def test_dashboard_renders_cost_chart(page: Page): """Ensure the scenario cost chart canvas is present.""" - cost_chart = page.locator("#scenario-cost-chart") - expect(cost_chart).to_be_visible() + expect(page.locator("#cost-chart")).to_be_attached() + expect(page.locator("#cost-chart-empty")).to_be_visible() diff --git a/tests/e2e/test_equipment.py b/tests/e2e/test_equipment.py index 53e2707..5e0c4f3 100644 --- a/tests/e2e/test_equipment.py +++ b/tests/e2e/test_equipment.py @@ -6,8 +6,8 @@ from playwright.sync_api import Page, expect def test_equipment_form_loads(page: Page): """Verify the equipment form page loads correctly.""" page.goto("/ui/equipment") - expect(page).to_have_title("CalMiner Equipment") - expect(page.locator("h1")).to_have_text("Equipment") + expect(page).to_have_title("Equipment · CalMiner") + expect(page.locator("h2:has-text('Add Equipment')")).to_be_visible() def test_create_equipment_item(page: Page): @@ -26,9 +26,9 @@ def test_create_equipment_item(page: Page): # Create an equipment item. equipment_name = "Haul Truck HT-05" equipment_desc = "Primary haul truck for ore transport." - page.select_option("select[name='scenario_id']", label=scenario_name) - page.fill("input[name='name']", equipment_name) - page.fill("textarea[name='description']", equipment_desc) + page.select_option("#equipment-form-scenario", label=scenario_name) + page.fill("#equipment-form-name", equipment_name) + page.fill("#equipment-form-description", equipment_desc) page.click("button[type='submit']") 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 # 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. expect(page.locator("#equipment-feedback") diff --git a/tests/e2e/test_maintenance.py b/tests/e2e/test_maintenance.py index ae05a4a..08dc77c 100644 --- a/tests/e2e/test_maintenance.py +++ b/tests/e2e/test_maintenance.py @@ -6,8 +6,8 @@ from playwright.sync_api import Page, expect def test_maintenance_form_loads(page: Page): """Verify the maintenance form page loads correctly.""" page.goto("/ui/maintenance") - expect(page).to_have_title("CalMiner Maintenance") - expect(page.locator("h1")).to_have_text("Maintenance") + expect(page).to_have_title("Maintenance · CalMiner") + expect(page.locator("h2:has-text('Add Maintenance Entry')")).to_be_visible() def test_create_maintenance_item(page: Page): @@ -22,8 +22,8 @@ def test_create_maintenance_item(page: Page): page.goto("/ui/equipment") equipment_name = f"Excavator EX-12 {uuid4()}" - page.select_option("select[name='scenario_id']", label=scenario_name) - page.fill("input[name='name']", equipment_name) + page.select_option("#equipment-form-scenario", label=scenario_name) + page.fill("#equipment-form-name", equipment_name) page.click("button[type='submit']") with page.expect_response("**/api/equipment/"): pass @@ -33,11 +33,11 @@ def test_create_maintenance_item(page: Page): # Create a maintenance item. maintenance_desc = "Scheduled engine overhaul" - page.select_option("select[name='scenario_id']", label=scenario_name) - page.select_option("select[name='equipment_id']", label=equipment_name) - page.fill("input[name='maintenance_date']", "2025-12-01") - page.fill("textarea[name='description']", maintenance_desc) - page.fill("input[name='cost']", "12000") + page.select_option("#maintenance-form-scenario", label=scenario_name) + page.select_option("#maintenance-form-equipment", label=equipment_name) + page.fill("#maintenance-form-date", "2025-12-01") + page.fill("#maintenance-form-description", maintenance_desc) + page.fill("#maintenance-form-cost", "12000") page.click("button[type='submit']") 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 # 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. expect(page.locator("#maintenance-feedback") diff --git a/tests/e2e/test_production.py b/tests/e2e/test_production.py index 794c987..09c98bb 100644 --- a/tests/e2e/test_production.py +++ b/tests/e2e/test_production.py @@ -6,8 +6,8 @@ from playwright.sync_api import Page, expect def test_production_form_loads(page: Page): """Verify the production form page loads correctly.""" page.goto("/ui/production") - expect(page).to_have_title("CalMiner Production") - expect(page.locator("h1")).to_have_text("Production") + expect(page).to_have_title("Production · CalMiner") + expect(page.locator("h2:has-text('Add Production Output')")).to_be_visible() def test_create_production_item(page: Page): @@ -25,9 +25,9 @@ def test_create_production_item(page: Page): # Create a production item. production_desc = "Ore extracted - Grade A" - page.select_option("select[name='scenario_id']", label=scenario_name) - page.fill("input[name='description']", production_desc) - page.fill("input[name='amount']", "1500") + page.select_option("#production-form-scenario", label=scenario_name) + page.fill("#production-form-description", production_desc) + page.fill("#production-form-amount", "1500") page.click("button[type='submit']") 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 # 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. expect(page.locator("#production-feedback") diff --git a/tests/e2e/test_reporting.py b/tests/e2e/test_reporting.py index 5769a8e..04cee12 100644 --- a/tests/e2e/test_reporting.py +++ b/tests/e2e/test_reporting.py @@ -3,6 +3,7 @@ from playwright.sync_api import Page, expect def test_reporting_view_loads(page: Page): """Verify the reporting view page loads correctly.""" - page.click("a[href='/ui/reporting']") - expect(page).to_have_url("/ui/reporting") - expect(page.locator("h2:has-text('Reporting')")).to_be_visible() + page.get_by_role("link", name="Reporting").click() + expect(page).to_have_url("http://localhost:8001/ui/reporting") + expect(page).to_have_title("Reporting · CalMiner") + expect(page.locator("h2:has-text('Scenario KPI Summary')")).to_be_visible() diff --git a/tests/e2e/test_scenarios.py b/tests/e2e/test_scenarios.py index a0cedaa..0f3a419 100644 --- a/tests/e2e/test_scenarios.py +++ b/tests/e2e/test_scenarios.py @@ -9,7 +9,7 @@ def test_scenario_form_loads(page: Page): expect(page).to_have_url( "http://localhost:8001/ui/scenarios" ) # 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): @@ -20,7 +20,7 @@ def test_create_new_scenario(page: Page): scenario_desc = "A scenario created during an end-to-end test." 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. with page.expect_response("**/api/scenarios/") as response_info: diff --git a/tests/e2e/test_smoke.py b/tests/e2e/test_smoke.py index 5c4c4e2..a601dcc 100644 --- a/tests/e2e/test_smoke.py +++ b/tests/e2e/test_smoke.py @@ -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. UI_ROUTES = [ - ("/", "CalMiner Dashboard", "Dashboard"), - ("/ui/dashboard", "CalMiner Dashboard", "Dashboard"), - ("/ui/scenarios", "CalMiner Scenarios", "Scenarios"), - ("/ui/parameters", "CalMiner Parameters", "Parameters"), - ("/ui/costs", "CalMiner Costs", "Costs"), - ("/ui/consumption", "CalMiner Consumption", "Consumption"), - ("/ui/production", "CalMiner Production", "Production"), - ("/ui/equipment", "CalMiner Equipment", "Equipment"), - ("/ui/maintenance", "CalMiner Maintenance", "Maintenance"), - ("/ui/simulations", "CalMiner Simulations", "Simulations"), - ("/ui/reporting", "CalMiner Reporting", "Reporting"), + ("/", "Dashboard · CalMiner", "Operations Overview"), + ("/ui/dashboard", "Dashboard · CalMiner", "Operations Overview"), + ("/ui/scenarios", "Scenario Management · CalMiner", "Create a New Scenario"), + ("/ui/parameters", "Process Parameters · CalMiner", "Scenario Parameters"), + ("/ui/costs", "Costs · CalMiner", "Cost Overview"), + ("/ui/consumption", "Consumption · CalMiner", "Consumption Tracking"), + ("/ui/production", "Production · CalMiner", "Production Output"), + ("/ui/equipment", "Equipment · CalMiner", "Equipment Inventory"), + ("/ui/maintenance", "Maintenance · CalMiner", "Maintenance Schedule"), + ("/ui/simulations", "Simulations · CalMiner", "Monte Carlo Simulations"), + ("/ui/reporting", "Reporting · CalMiner", "Scenario KPI Summary"), ] -@pytest.mark.usefixtures("live_server") @pytest.mark.parametrize("url, title, heading", UI_ROUTES) 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."""