2026-03-31 14:23:31 -05:00
|
|
|
"""E2E test configuration — async Playwright browser tests against a running app."""
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
import subprocess
|
|
|
|
|
import time
|
2026-03-30 22:26:15 -05:00
|
|
|
|
|
|
|
|
import pytest
|
2026-03-31 14:23:31 -05:00
|
|
|
import pytest_asyncio
|
|
|
|
|
from playwright.async_api import Browser, Page, async_playwright
|
2026-03-30 23:11:58 -05:00
|
|
|
from sqlalchemy import text
|
|
|
|
|
from sqlalchemy.ext.asyncio import create_async_engine
|
2026-03-30 22:26:15 -05:00
|
|
|
from sqlmodel import select
|
|
|
|
|
|
|
|
|
|
from wiregui.auth.passwords import hash_password
|
2026-03-30 23:11:58 -05:00
|
|
|
from wiregui.config import get_settings
|
2026-03-30 22:26:15 -05:00
|
|
|
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"
|
|
|
|
|
|
2026-03-31 14:23:31 -05:00
|
|
|
# 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}"
|
|
|
|
|
|
2026-03-30 23:11:58 -05:00
|
|
|
_CHILD_TABLES = ("devices", "rules", "mfa_methods", "api_tokens", "oidc_connections")
|
|
|
|
|
|
|
|
|
|
|
2026-03-31 14:23:31 -05:00
|
|
|
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."""
|
2026-03-30 23:11:58 -05:00
|
|
|
engine = create_async_engine(get_settings().database_url)
|
|
|
|
|
async with engine.begin() as conn:
|
|
|
|
|
row = (await conn.execute(
|
2026-03-31 14:23:31 -05:00
|
|
|
text("SELECT id FROM users WHERE email = :email"), {"email": email}
|
2026-03-30 23:11:58 -05:00
|
|
|
)).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()
|
2026-03-30 22:26:15 -05:00
|
|
|
|
|
|
|
|
|
2026-03-31 14:23:31 -05:00
|
|
|
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."""
|
2026-03-30 23:11:58 -05:00
|
|
|
await _cleanup_test_user()
|
2026-03-30 22:26:15 -05:00
|
|
|
|
2026-03-30 23:11:58 -05:00
|
|
|
async with async_session() as session:
|
2026-03-30 22:26:15 -05:00
|
|
|
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
|
|
|
|
|
|
2026-03-30 23:11:58 -05:00
|
|
|
await _cleanup_test_user()
|
2026-03-31 14:23:31 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# 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()
|