wiregui/tests/e2e/conftest.py
Stefano Bertelli 3bf6fabcff
Some checks failed
CI / test (push) Failing after 1m52s
CI / release (push) Has been skipped
CI / docker (push) Has been skipped
feat: IdP provisioning from YAML file + Playwright e2e tests
Add WG_IDP_CONFIG_FILE env var to seed OIDC/SAML identity providers
from a YAML file at startup, enabling GitOps and IaC workflows.
Providers are upserted by id (merge strategy preserves manual additions).

Convert all e2e tests from NiceGUI User fixture to Playwright async API
with --headed and --slowmo flags for visual debugging. Add full OIDC
login flow test against the mock-oidc service.
2026-03-31 14:23:31 -05:00

169 lines
5.5 KiB
Python

"""E2E test configuration — async Playwright browser tests against a running app."""
import os
import subprocess
import time
import pytest
import pytest_asyncio
from playwright.async_api import Browser, Page, async_playwright
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from sqlmodel import select
from wiregui.auth.passwords import hash_password
from wiregui.config import get_settings
from wiregui.db import async_session
from wiregui.models.configuration import Configuration
from wiregui.models.user import User
FAKE_SERVER_KEY = "SFake0ServerPubKey0000000000000000000000000w="
TEST_EMAIL = "e2e-test@example.com"
TEST_PASSWORD = "testpass123"
# Dedicated port so we don't conflict with a dev instance on 13000
TEST_APP_PORT = 13001
TEST_APP_BASE = f"http://localhost:{TEST_APP_PORT}"
_CHILD_TABLES = ("devices", "rules", "mfa_methods", "api_tokens", "oidc_connections")
def pytest_addoption(parser):
parser.addoption("--headed", action="store_true", default=False, help="Run browser in headed mode")
parser.addoption("--slowmo", type=int, default=0, help="Slow down Playwright actions by ms")
async def _cleanup_user_by_email(email: str):
"""Delete a user and all related objects by email."""
engine = create_async_engine(get_settings().database_url)
async with engine.begin() as conn:
row = (await conn.execute(
text("SELECT id FROM users WHERE email = :email"), {"email": email}
)).first()
if row:
uid = row[0]
for table in _CHILD_TABLES:
await conn.execute(text(f"DELETE FROM {table} WHERE user_id = :uid"), {"uid": uid}) # noqa: S608
await conn.execute(text("DELETE FROM users WHERE id = :uid"), {"uid": uid})
await engine.dispose()
async def _cleanup_test_user():
await _cleanup_user_by_email(TEST_EMAIL)
# ---------------------------------------------------------------------------
# App subprocess — shared across all e2e tests in the session
# ---------------------------------------------------------------------------
@pytest.fixture(scope="session")
def app_server():
"""Start WireGUI on TEST_APP_PORT for the entire test session."""
import httpx
env = os.environ.copy()
env["WG_LOG_TO_FILE"] = "false"
env["WG_PORT"] = str(TEST_APP_PORT)
env["WG_EXTERNAL_URL"] = TEST_APP_BASE
env.pop("PYTEST_CURRENT_TEST", None)
env.pop("NICEGUI_SCREEN_TEST_PORT", None)
proc = subprocess.Popen(
["uv", "run", "python", "-m", "wiregui.main"],
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
for _ in range(30):
try:
r = httpx.get(f"{TEST_APP_BASE}/api/health", timeout=1)
if r.status_code == 200:
break
except Exception:
pass
time.sleep(1)
else:
proc.kill()
out = proc.stdout.read().decode() if proc.stdout else ""
pytest.fail(f"App did not start in time. Output:\n{out}")
yield proc
proc.terminate()
proc.wait(timeout=10)
# ---------------------------------------------------------------------------
# Playwright browser — session-scoped, one browser for all tests
# ---------------------------------------------------------------------------
@pytest_asyncio.fixture(scope="session")
async def browser(request):
"""Launch a Playwright Chromium browser for the session."""
headed = request.config.getoption("--headed")
slowmo = request.config.getoption("--slowmo")
pw = await async_playwright().start()
br = await pw.chromium.launch(headless=not headed, slow_mo=slowmo)
yield br
await br.close()
await pw.stop()
@pytest_asyncio.fixture
async def page(browser: Browser):
"""Create a fresh browser context + page per test (isolated cookies/storage)."""
context = await browser.new_context()
pg = await context.new_page()
yield pg
await context.close()
# ---------------------------------------------------------------------------
# Test user fixture
# ---------------------------------------------------------------------------
@pytest_asyncio.fixture
async def test_user(app_server):
"""Create a test admin user, yield it, clean up after."""
await _cleanup_test_user()
async with async_session() as session:
config = (await session.execute(select(Configuration).limit(1))).scalar_one_or_none()
if config:
if not config.server_public_key:
config.server_public_key = FAKE_SERVER_KEY
session.add(config)
else:
config = Configuration(server_public_key=FAKE_SERVER_KEY)
session.add(config)
user = User(
email=TEST_EMAIL,
password_hash=hash_password(TEST_PASSWORD),
role="admin",
)
session.add(user)
await session.commit()
await session.refresh(user)
yield user
await _cleanup_test_user()
# ---------------------------------------------------------------------------
# Playwright helpers
# ---------------------------------------------------------------------------
async def login(page: Page, email: str = TEST_EMAIL, password: str = TEST_PASSWORD):
"""Fill the login form and submit."""
await page.goto(f"{TEST_APP_BASE}/login")
await page.wait_for_load_state("networkidle")
await page.locator("input[aria-label='Email']").fill(email)
await page.locator("input[aria-label='Password']").fill(password)
await page.get_by_role("button", name="Sign in", exact=True).click()