From 8a7fcde133f328ac0ab951dda55853f84a680ac2 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 14:00:55 +0200 Subject: [PATCH] Add Playwright e2e smoke tests + plug bugs that blocked them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two Chromium-driven smoke tests under tests/e2e/: * test_login_and_browse_smoke — landing → Keycloak login → dashboard → products browse → logout. Minimal regression guard that proves auth and page rendering work end to end. * test_products_crud_smoke — full create → edit → delete loop against the live products module. Both are gated by the existing e2e pytest marker (excluded from `make test`, runnable via the new `make test-e2e`). Along the way, fixes four pre-existing issues that blocked the tests from running: * Keycloak: add a realm-roles protocol mapper so `realm_access.roles` reaches userinfo; without it, `RequiresPermission(products.create)` always 403s even for the seeded admin user. * alembic.ini: resolve `script_location` with `%(here)s/` so alembic works from the repo root (needed for the Makefile change below). * Makefile: run migrate / migration / downgrade / migration-history from the repo root against `host/alembic.ini`, matching how `dev-api` runs — so both target the same SQLite file under relative `SM_DATABASE_URL`. * conftest._create_all_tables: stamp alembic_version at head after `create_all` so the app's startup migration check accepts the test DB. This surfaced once the alembic.ini fix made the check actually run (previously the bad script_location made it silently no-op). Extracts `resolve_head_revision()` into `_migrations.py` so `check_migrations` and the test fixture share one implementation; cached at module level in conftest since head can't change during a pytest run. Adds docs/e2e-testing.md with prerequisites, run commands, and configuration env vars. Adds a regression assertion in test_scaffolding_host.py that all emitted glob patterns are relative (Vite 8 treats leading-slash paths as project-root-relative, not FS-absolute; upstream manifest.py already respects this, the test just guards against regressions). --- Makefile | 15 ++- conftest.py | 33 +++++- docs/e2e-testing.md | 94 +++++++++++++++ .../simple_module_hosting/_migrations.py | 25 ++-- .../hosting/tests/test_scaffolding_host.py | 9 ++ host/alembic.ini | 4 +- keycloak/realm-export.json | 17 ++- pyproject.toml | 2 +- tests/e2e/__init__.py | 0 tests/e2e/conftest.py | 41 +++++++ tests/e2e/test_smoke.py | 110 ++++++++++++++++++ 11 files changed, 332 insertions(+), 18 deletions(-) create mode 100644 docs/e2e-testing.md create mode 100644 tests/e2e/__init__.py create mode 100644 tests/e2e/conftest.py create mode 100644 tests/e2e/test_smoke.py diff --git a/Makefile b/Makefile index 290e043f..dabbb045 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install install-py install-js dev dev-api dev-ui build test lint doctor migrate migration downgrade migration-history docker-up docker-down kill new-module gen-pages sync-module-deps ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size +.PHONY: install install-py install-js dev dev-api dev-ui build test test-e2e lint doctor migrate migration downgrade migration-history docker-up docker-down kill new-module gen-pages sync-module-deps ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size # Install install: @@ -40,6 +40,9 @@ build: test: uv run pytest +test-e2e: ## Run end-to-end browser smoke tests (requires `make docker-up` + `make dev` and `uv run playwright install chromium`) + uv run pytest -m e2e tests/e2e + lint: ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size # Kept granular so pr.yml can run them in parallel. @@ -66,17 +69,19 @@ doctor: uv run python -m simple_module_core # Database migrations +# All targets run from the repo root so alembic and `make dev-api` share the +# same cwd (and therefore the same .env, SM_DATABASE_URL, and SQLite path). migrate: ## Run migrations to head - cd host && uv run alembic upgrade head + uv run --project host alembic -c host/alembic.ini upgrade head migration: ## Create new migration (usage: make migration msg="add foo") - cd host && uv run alembic revision --autogenerate -m "$(msg)" + uv run --project host alembic -c host/alembic.ini revision --autogenerate -m "$(msg)" downgrade: ## Downgrade one revision - cd host && uv run alembic downgrade -1 + uv run --project host alembic -c host/alembic.ini downgrade -1 migration-history: ## Show migration history - cd host && uv run alembic history --verbose + uv run --project host alembic -c host/alembic.ini history --verbose # Scaffolding new-module: ## Scaffold a new module (usage: make new-module name=orders) diff --git a/conftest.py b/conftest.py index c0adfb17..2ed6be8e 100644 --- a/conftest.py +++ b/conftest.py @@ -64,9 +64,27 @@ def _ensure_models_imported() -> list: return list(all_module_bases) +@lru_cache(maxsize=1) +def _alembic_head() -> str | None: + """Cached head revision — cannot change within a pytest run.""" + from simple_module_hosting._migrations import resolve_head_revision + + return resolve_head_revision() + + async def _create_all_tables(engine) -> None: - """Create all module tables in a single connection.""" + """Create all module tables in a single connection. + + Also stamps the alembic_version table at head so the app's startup + migration check (``check_migrations``) treats the test DB as current. + Without the stamp the check would raise because ``create_all`` doesn't + touch alembic_version. + """ + from sqlalchemy import text + bases = _ensure_models_imported() + head = _alembic_head() + async with engine.begin() as conn: def _sync_create_all(sync_conn): @@ -75,6 +93,19 @@ def _sync_create_all(sync_conn): await conn.run_sync(_sync_create_all) + if head: + await conn.execute( + text( + "CREATE TABLE IF NOT EXISTS alembic_version " + "(version_num VARCHAR(32) NOT NULL PRIMARY KEY)" + ) + ) + await conn.execute(text("DELETE FROM alembic_version")) + await conn.execute( + text("INSERT INTO alembic_version (version_num) VALUES (:v)"), + {"v": head}, + ) + @pytest.fixture async def db_session(db_state: DatabaseState) -> AsyncGenerator[AsyncSession, None]: diff --git a/docs/e2e-testing.md b/docs/e2e-testing.md new file mode 100644 index 00000000..cba5b951 --- /dev/null +++ b/docs/e2e-testing.md @@ -0,0 +1,94 @@ +# End-to-End Testing + +The repo ships Playwright-driven smoke tests at +[tests/e2e/test_smoke.py](../tests/e2e/test_smoke.py). Two tests drive a real +Chromium browser through the core flows: + +* **`test_login_and_browse_smoke`** — landing → Keycloak login → dashboard → + products browse → logout. Minimal regression guard. +* **`test_products_crud_smoke`** — same login + a full create / edit / delete + round-trip against the products module. + +End-to-end tests are gated behind the `e2e` pytest marker (declared in +[pyproject.toml](../pyproject.toml)) and are **excluded from the default +`make test` run**. They only execute under `make test-e2e`. + +## Prerequisites + +One-time setup on the machine that will run the tests: + +```bash +uv sync --all-packages +uv run playwright install chromium +``` + +Then bring up the full stack (in a separate terminal, leave it running): + +```bash +make docker-up # Keycloak + Postgres +make migrate # apply Alembic migrations +make dev # FastAPI on :8000 + Vite on :5173 +``` + +## Running + +```bash +make test-e2e +``` + +Or directly: + +```bash +uv run pytest -m e2e tests/e2e +``` + +## Configuration + +The test reads three environment variables (all optional): + +| Variable | Default | Notes | +| -------------- | ------------------------- | ------------------------------------------------------------------ | +| `E2E_BASE_URL` | `http://localhost:8000` | Where the FastAPI host is listening. | +| `E2E_USERNAME` | `admin` | Keycloak username. The seeded `admin` user has role `admin`. | +| `E2E_PASSWORD` | `admin` | Keycloak password for the above user. | + +Seeded Keycloak users live in [keycloak/realm-export.json](../keycloak/realm-export.json). +The defaults match the `admin`/`admin` user out of the box. + +## What the smoke tests cover + +**`test_login_and_browse_smoke`** + +1. Landing page renders (`/`) with the "Get Started" CTA. +2. Keycloak OIDC login round-trip. +3. Dashboard (`/dashboard/`) renders — proves session cookie + AuthMiddleware + + Inertia resolver + AuthenticatedLayout. +4. Products browse (`/products/`) renders — proves module pages resolve. +5. Logout returns the user to the public landing page. + +**`test_products_crud_smoke`** + +1. Login as admin. +2. Create a timestamped product via the Create form. +3. Edit its name and verify the new name appears in the list. +4. Delete it through the confirm dialog and verify the row disappears. + +The CRUD test relies on the Keycloak `simple-module-app` client shipping a +`realm roles` protocol mapper that emits `realm_access.roles` into userinfo +(see [keycloak/realm-export.json](../keycloak/realm-export.json)); without +that, `RequiresPermission("products.create")` returns 403. + +These are **not** pixel-perfect regression tests — the goal is to catch broad +breakage in the auth + render + CRUD spine. + +## Debugging + +To see what the browser is doing, run headed with the Playwright trace +viewer: + +```bash +uv run pytest -m e2e tests/e2e --headed --slowmo 250 +``` + +Add `--tracing on` to capture a trace for post-mortem inspection via +`playwright show-trace`. diff --git a/framework/hosting/simple_module_hosting/_migrations.py b/framework/hosting/simple_module_hosting/_migrations.py index 692d4d77..6291baf4 100644 --- a/framework/hosting/simple_module_hosting/_migrations.py +++ b/framework/hosting/simple_module_hosting/_migrations.py @@ -7,6 +7,20 @@ logger = logging.getLogger(__name__) +def resolve_head_revision(alembic_ini_path: str = "host/alembic.ini") -> str | None: + """Return the current head revision string, or ``None`` if alembic + isn't configured at ``alembic_ini_path`` or has no revisions.""" + from alembic.config import Config as AlembicConfig + from alembic.script import ScriptDirectory + from alembic.util.exc import CommandError + + try: + return ScriptDirectory.from_config(AlembicConfig(alembic_ini_path)).get_current_head() + except (CommandError, FileNotFoundError) as exc: + logger.debug("Alembic not available: %s", exc) + return None + + async def check_migrations(engine, alembic_ini_path: str = "host/alembic.ini") -> dict: """Check database migration state. Raises RuntimeError if not at head. @@ -15,7 +29,6 @@ async def check_migrations(engine, alembic_ini_path: str = "host/alembic.ini") - from alembic.config import Config as AlembicConfig from alembic.runtime.migration import MigrationContext from alembic.script import ScriptDirectory - from alembic.util.exc import CommandError _no_migrations = { "current_revision": None, @@ -24,16 +37,10 @@ async def check_migrations(engine, alembic_ini_path: str = "host/alembic.ini") - "pending_count": 0, } - try: - alembic_cfg = AlembicConfig(alembic_ini_path) - script = ScriptDirectory.from_config(alembic_cfg) - head = script.get_current_head() - except (CommandError, FileNotFoundError) as exc: - logger.debug("Alembic not available: %s — skipping migration check", exc) - return _no_migrations - + head = resolve_head_revision(alembic_ini_path) if head is None: return _no_migrations + script = ScriptDirectory.from_config(AlembicConfig(alembic_ini_path)) async with engine.connect() as conn: diff --git a/framework/hosting/tests/test_scaffolding_host.py b/framework/hosting/tests/test_scaffolding_host.py index ed93e547..7d61aa2c 100644 --- a/framework/hosting/tests/test_scaffolding_host.py +++ b/framework/hosting/tests/test_scaffolding_host.py @@ -2,6 +2,8 @@ from __future__ import annotations +import re + import pytest @@ -58,6 +60,13 @@ async def test_write_manifest_emits_json_and_ts(self, tmp_path): assert "import.meta.glob" in ts assert "Products" in ts assert "AUTO-GENERATED" in ts or "auto-generated" in ts.lower() + # Glob patterns must be relative to output_dir — Vite treats + # leading-slash paths as project-root-relative and silently matches + # nothing for FS-absolute paths. + for match in re.findall(r'import\.meta\.glob\("([^"]+)"\)', ts): + assert match.startswith(("./", "../")), ( + f"glob pattern {match!r} must be relative, not absolute" + ) css_text = css.read_text(encoding="utf-8") assert "AUTO-GENERATED" in css_text or "auto-generated" in css_text.lower() diff --git a/host/alembic.ini b/host/alembic.ini index a6410a6f..d26cf323 100644 --- a/host/alembic.ini +++ b/host/alembic.ini @@ -1,5 +1,7 @@ [alembic] -script_location = migrations +# Resolve script_location relative to this ini file, not the invocation cwd, +# so `alembic -c host/alembic.ini ...` works from the repo root. +script_location = %(here)s/migrations sqlalchemy.url = [loggers] diff --git a/keycloak/realm-export.json b/keycloak/realm-export.json index 92281d90..b4e29797 100644 --- a/keycloak/realm-export.json +++ b/keycloak/realm-export.json @@ -20,7 +20,22 @@ "webOrigins": ["http://localhost:8000"], "standardFlowEnabled": true, "directAccessGrantsEnabled": true, - "defaultClientScopes": ["openid", "profile", "email", "roles"] + "defaultClientScopes": ["openid", "profile", "email", "roles"], + "protocolMappers": [ + { + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "config": { + "claim.name": "realm_access.roles", + "jsonType.label": "String", + "multivalued": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true" + } + } + ] } ], "users": [ diff --git a/pyproject.toml b/pyproject.toml index 97ed814e..38cb0197 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,6 +70,6 @@ exclude = ["framework/hosting/simple_module_hosting/templates/**"] [tool.pytest.ini_options] asyncio_mode = "auto" -testpaths = ["framework/core/tests", "framework/db/tests", "framework/hosting/tests", "framework/testing/tests", "modules/auth/tests", "modules/dashboard/tests", "modules/products/tests", "tests/integration"] +testpaths = ["framework/core/tests", "framework/db/tests", "framework/hosting/tests", "framework/testing/tests", "modules/auth/tests", "modules/dashboard/tests", "modules/products/tests", "tests/integration", "tests/e2e"] markers = ["e2e: end-to-end tests requiring live services (Keycloak, browser)"] addopts = "-m 'not e2e'" diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py new file mode 100644 index 00000000..e1eac65b --- /dev/null +++ b/tests/e2e/conftest.py @@ -0,0 +1,41 @@ +"""Fixtures for end-to-end browser tests. + +These tests drive a real Chromium browser against a running stack +(`make docker-up` + `make dev`). They are gated by the ``e2e`` pytest marker +(declared in ``pyproject.toml``) and excluded from the default suite. + +Env vars: + E2E_BASE_URL — where the FastAPI host is listening (default: http://localhost:8000) + E2E_USERNAME — Keycloak username (default: admin — seeded in keycloak/realm-export.json) + E2E_PASSWORD — Keycloak password (default: admin) +""" + +from __future__ import annotations + +import os + +import pytest + + +@pytest.fixture(scope="session") +def base_url() -> str: + return os.environ.get("E2E_BASE_URL", "http://localhost:8000") + + +@pytest.fixture(scope="session") +def e2e_username() -> str: + return os.environ.get("E2E_USERNAME", "admin") + + +@pytest.fixture(scope="session") +def e2e_password() -> str: + return os.environ.get("E2E_PASSWORD", "admin") + + +@pytest.fixture(scope="session") +def browser_context_args(browser_context_args, base_url): + """Override pytest-playwright's context args to set base_url. + + With this in place, ``page.goto("/")`` resolves relative to E2E_BASE_URL. + """ + return {**browser_context_args, "base_url": base_url} diff --git a/tests/e2e/test_smoke.py b/tests/e2e/test_smoke.py new file mode 100644 index 00000000..8805753d --- /dev/null +++ b/tests/e2e/test_smoke.py @@ -0,0 +1,110 @@ +"""End-to-end UI smoke tests. + +Two browser-driven happy-path tests, both gated by the ``e2e`` marker: + +* :func:`test_login_and_browse_smoke` — landing → Keycloak login → + dashboard → products browse → logout. Minimal regression guard that + proves auth and page rendering work end to end. + +* :func:`test_products_crud_smoke` — builds on the browse smoke with + the full create → edit → delete loop. Requires the Keycloak client + to emit ``realm_access.roles`` in userinfo so ``RequiresPermission`` + lets the admin user through. + +Requires a live stack. See docs/e2e-testing.md for setup. +""" + +from __future__ import annotations + +import re +import time + +import pytest +from playwright.sync_api import Page, expect + +_PRODUCTS_URL = re.compile(r"/products/?$") + +pytestmark = pytest.mark.e2e + + +def _login(page: Page, username: str, password: str) -> None: + """From landing, click Get Started and complete the Keycloak form.""" + # Landing renders a nav "Get Started" and a hero "Get Started"; either works. + page.get_by_role("link", name="Get Started").first.click() + page.locator("#username").fill(username) + page.locator("#password").fill(password) + page.locator("#kc-login").click() + + +def _login_and_land_on_dashboard(page: Page, username: str, password: str) -> None: + """Shared setup: open landing, log in, wait for dashboard.""" + page.goto("/") + expect(page.get_by_role("heading", name="Modular Monolith")).to_be_visible() + _login(page, username, password) + page.wait_for_url("**/dashboard/**", timeout=15_000) + expect(page.get_by_role("heading", name="Dashboard")).to_be_visible() + + +def test_login_and_browse_smoke( + page: Page, + e2e_username: str, + e2e_password: str, +) -> None: + _login_and_land_on_dashboard(page, e2e_username, e2e_password) + # Welcome card — anchor on the CardTitle text which is unique on this page. + expect(page.get_by_text("Welcome", exact=True)).to_be_visible() + + # Navigate directly: sidebar link is hidden below the lg breakpoint. + page.goto("/products") + expect(page.get_by_role("heading", name="Products")).to_be_visible() + + page.goto("/auth/logout") + page.goto("/") + expect(page.get_by_role("heading", name="Modular Monolith")).to_be_visible() + + +def test_products_crud_smoke( + page: Page, + e2e_username: str, + e2e_password: str, +) -> None: + """Full create → edit → delete loop against the live Products module. + + Requires the admin user's session to carry ``realm_access.roles`` + (set via the realm-export.json protocol mapper) so the + ``products.create`` / ``.edit`` / ``.delete`` permission gates allow + the calls. + """ + _login_and_land_on_dashboard(page, e2e_username, e2e_password) + + # Millisecond-timestamped name keeps the test idempotent across reruns, + # even if a prior run crashed before cleanup. + product_name = f"Smoke Test Widget {int(time.time() * 1000)}" + edited_name = f"{product_name} edited" + + page.goto("/products/create") + expect(page.get_by_role("heading", name="Create Product")).to_be_visible() + page.locator("#name").fill(product_name) + page.locator("#description").fill("e2e smoke test") + page.locator("#price").fill("9.99") + page.get_by_role("button", name="Create Product").click() + + page.wait_for_url(_PRODUCTS_URL, timeout=15_000) + created_row = page.get_by_role("row").filter(has_text=product_name) + expect(created_row).to_be_visible(timeout=10_000) + + created_row.locator("a[href*='/edit']").click() + page.wait_for_url("**/edit", timeout=10_000) + page.locator("#name").fill(edited_name) + page.get_by_role("button", name="Save Changes").click() + + page.wait_for_url(_PRODUCTS_URL, timeout=15_000) + edited_row = page.get_by_role("row").filter(has_text=edited_name) + expect(edited_row).to_be_visible(timeout=10_000) + + # Row has an edit link + a single