Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand Down
33 changes: 32 additions & 1 deletion conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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]:
Expand Down
94 changes: 94 additions & 0 deletions docs/e2e-testing.md
Original file line number Diff line number Diff line change
@@ -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`.
25 changes: 16 additions & 9 deletions framework/hosting/simple_module_hosting/_migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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,
Expand All @@ -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:

Expand Down
9 changes: 9 additions & 0 deletions framework/hosting/tests/test_scaffolding_host.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from __future__ import annotations

import re

import pytest


Expand Down Expand Up @@ -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<PageModule>\("([^"]+)"\)', 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()
Expand Down
4 changes: 3 additions & 1 deletion host/alembic.ini
Original file line number Diff line number Diff line change
@@ -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]
Expand Down
17 changes: 16 additions & 1 deletion keycloak/realm-export.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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'"
Empty file added tests/e2e/__init__.py
Empty file.
41 changes: 41 additions & 0 deletions tests/e2e/conftest.py
Original file line number Diff line number Diff line change
@@ -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}
Loading
Loading