Skip to content

Commit 07e880f

Browse files
authored
Add Playwright e2e smoke tests + plug bugs that blocked them (#23)
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).
1 parent 2212aab commit 07e880f

11 files changed

Lines changed: 332 additions & 18 deletions

File tree

‎Makefile‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.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
1+
.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
22

33
# Install
44
install:
@@ -40,6 +40,9 @@ build:
4040
test:
4141
uv run pytest
4242

43+
test-e2e: ## Run end-to-end browser smoke tests (requires `make docker-up` + `make dev` and `uv run playwright install chromium`)
44+
uv run pytest -m e2e tests/e2e
45+
4346
lint: ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size
4447

4548
# Kept granular so pr.yml can run them in parallel.
@@ -66,17 +69,19 @@ doctor:
6669
uv run python -m simple_module_core
6770

6871
# Database migrations
72+
# All targets run from the repo root so alembic and `make dev-api` share the
73+
# same cwd (and therefore the same .env, SM_DATABASE_URL, and SQLite path).
6974
migrate: ## Run migrations to head
70-
cd host && uv run alembic upgrade head
75+
uv run --project host alembic -c host/alembic.ini upgrade head
7176

7277
migration: ## Create new migration (usage: make migration msg="add foo")
73-
cd host && uv run alembic revision --autogenerate -m "$(msg)"
78+
uv run --project host alembic -c host/alembic.ini revision --autogenerate -m "$(msg)"
7479

7580
downgrade: ## Downgrade one revision
76-
cd host && uv run alembic downgrade -1
81+
uv run --project host alembic -c host/alembic.ini downgrade -1
7782

7883
migration-history: ## Show migration history
79-
cd host && uv run alembic history --verbose
84+
uv run --project host alembic -c host/alembic.ini history --verbose
8085

8186
# Scaffolding
8287
new-module: ## Scaffold a new module (usage: make new-module name=orders)

‎conftest.py‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,27 @@ def _ensure_models_imported() -> list:
6464
return list(all_module_bases)
6565

6666

67+
@lru_cache(maxsize=1)
68+
def _alembic_head() -> str | None:
69+
"""Cached head revision — cannot change within a pytest run."""
70+
from simple_module_hosting._migrations import resolve_head_revision
71+
72+
return resolve_head_revision()
73+
74+
6775
async def _create_all_tables(engine) -> None:
68-
"""Create all module tables in a single connection."""
76+
"""Create all module tables in a single connection.
77+
78+
Also stamps the alembic_version table at head so the app's startup
79+
migration check (``check_migrations``) treats the test DB as current.
80+
Without the stamp the check would raise because ``create_all`` doesn't
81+
touch alembic_version.
82+
"""
83+
from sqlalchemy import text
84+
6985
bases = _ensure_models_imported()
86+
head = _alembic_head()
87+
7088
async with engine.begin() as conn:
7189

7290
def _sync_create_all(sync_conn):
@@ -75,6 +93,19 @@ def _sync_create_all(sync_conn):
7593

7694
await conn.run_sync(_sync_create_all)
7795

96+
if head:
97+
await conn.execute(
98+
text(
99+
"CREATE TABLE IF NOT EXISTS alembic_version "
100+
"(version_num VARCHAR(32) NOT NULL PRIMARY KEY)"
101+
)
102+
)
103+
await conn.execute(text("DELETE FROM alembic_version"))
104+
await conn.execute(
105+
text("INSERT INTO alembic_version (version_num) VALUES (:v)"),
106+
{"v": head},
107+
)
108+
78109

79110
@pytest.fixture
80111
async def db_session(db_state: DatabaseState) -> AsyncGenerator[AsyncSession, None]:

‎docs/e2e-testing.md‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# End-to-End Testing
2+
3+
The repo ships Playwright-driven smoke tests at
4+
[tests/e2e/test_smoke.py](../tests/e2e/test_smoke.py). Two tests drive a real
5+
Chromium browser through the core flows:
6+
7+
* **`test_login_and_browse_smoke`** — landing → Keycloak login → dashboard →
8+
products browse → logout. Minimal regression guard.
9+
* **`test_products_crud_smoke`** — same login + a full create / edit / delete
10+
round-trip against the products module.
11+
12+
End-to-end tests are gated behind the `e2e` pytest marker (declared in
13+
[pyproject.toml](../pyproject.toml)) and are **excluded from the default
14+
`make test` run**. They only execute under `make test-e2e`.
15+
16+
## Prerequisites
17+
18+
One-time setup on the machine that will run the tests:
19+
20+
```bash
21+
uv sync --all-packages
22+
uv run playwright install chromium
23+
```
24+
25+
Then bring up the full stack (in a separate terminal, leave it running):
26+
27+
```bash
28+
make docker-up # Keycloak + Postgres
29+
make migrate # apply Alembic migrations
30+
make dev # FastAPI on :8000 + Vite on :5173
31+
```
32+
33+
## Running
34+
35+
```bash
36+
make test-e2e
37+
```
38+
39+
Or directly:
40+
41+
```bash
42+
uv run pytest -m e2e tests/e2e
43+
```
44+
45+
## Configuration
46+
47+
The test reads three environment variables (all optional):
48+
49+
| Variable | Default | Notes |
50+
| -------------- | ------------------------- | ------------------------------------------------------------------ |
51+
| `E2E_BASE_URL` | `http://localhost:8000` | Where the FastAPI host is listening. |
52+
| `E2E_USERNAME` | `admin` | Keycloak username. The seeded `admin` user has role `admin`. |
53+
| `E2E_PASSWORD` | `admin` | Keycloak password for the above user. |
54+
55+
Seeded Keycloak users live in [keycloak/realm-export.json](../keycloak/realm-export.json).
56+
The defaults match the `admin`/`admin` user out of the box.
57+
58+
## What the smoke tests cover
59+
60+
**`test_login_and_browse_smoke`**
61+
62+
1. Landing page renders (`/`) with the "Get Started" CTA.
63+
2. Keycloak OIDC login round-trip.
64+
3. Dashboard (`/dashboard/`) renders — proves session cookie + AuthMiddleware +
65+
Inertia resolver + AuthenticatedLayout.
66+
4. Products browse (`/products/`) renders — proves module pages resolve.
67+
5. Logout returns the user to the public landing page.
68+
69+
**`test_products_crud_smoke`**
70+
71+
1. Login as admin.
72+
2. Create a timestamped product via the Create form.
73+
3. Edit its name and verify the new name appears in the list.
74+
4. Delete it through the confirm dialog and verify the row disappears.
75+
76+
The CRUD test relies on the Keycloak `simple-module-app` client shipping a
77+
`realm roles` protocol mapper that emits `realm_access.roles` into userinfo
78+
(see [keycloak/realm-export.json](../keycloak/realm-export.json)); without
79+
that, `RequiresPermission("products.create")` returns 403.
80+
81+
These are **not** pixel-perfect regression tests — the goal is to catch broad
82+
breakage in the auth + render + CRUD spine.
83+
84+
## Debugging
85+
86+
To see what the browser is doing, run headed with the Playwright trace
87+
viewer:
88+
89+
```bash
90+
uv run pytest -m e2e tests/e2e --headed --slowmo 250
91+
```
92+
93+
Add `--tracing on` to capture a trace for post-mortem inspection via
94+
`playwright show-trace`.

‎framework/hosting/simple_module_hosting/_migrations.py‎

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,20 @@
77
logger = logging.getLogger(__name__)
88

99

10+
def resolve_head_revision(alembic_ini_path: str = "host/alembic.ini") -> str | None:
11+
"""Return the current head revision string, or ``None`` if alembic
12+
isn't configured at ``alembic_ini_path`` or has no revisions."""
13+
from alembic.config import Config as AlembicConfig
14+
from alembic.script import ScriptDirectory
15+
from alembic.util.exc import CommandError
16+
17+
try:
18+
return ScriptDirectory.from_config(AlembicConfig(alembic_ini_path)).get_current_head()
19+
except (CommandError, FileNotFoundError) as exc:
20+
logger.debug("Alembic not available: %s", exc)
21+
return None
22+
23+
1024
async def check_migrations(engine, alembic_ini_path: str = "host/alembic.ini") -> dict:
1125
"""Check database migration state. Raises RuntimeError if not at head.
1226
@@ -15,7 +29,6 @@ async def check_migrations(engine, alembic_ini_path: str = "host/alembic.ini") -
1529
from alembic.config import Config as AlembicConfig
1630
from alembic.runtime.migration import MigrationContext
1731
from alembic.script import ScriptDirectory
18-
from alembic.util.exc import CommandError
1932

2033
_no_migrations = {
2134
"current_revision": None,
@@ -24,16 +37,10 @@ async def check_migrations(engine, alembic_ini_path: str = "host/alembic.ini") -
2437
"pending_count": 0,
2538
}
2639

27-
try:
28-
alembic_cfg = AlembicConfig(alembic_ini_path)
29-
script = ScriptDirectory.from_config(alembic_cfg)
30-
head = script.get_current_head()
31-
except (CommandError, FileNotFoundError) as exc:
32-
logger.debug("Alembic not available: %s — skipping migration check", exc)
33-
return _no_migrations
34-
40+
head = resolve_head_revision(alembic_ini_path)
3541
if head is None:
3642
return _no_migrations
43+
script = ScriptDirectory.from_config(AlembicConfig(alembic_ini_path))
3744

3845
async with engine.connect() as conn:
3946

‎framework/hosting/tests/test_scaffolding_host.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from __future__ import annotations
44

5+
import re
6+
57
import pytest
68

79

@@ -58,6 +60,13 @@ async def test_write_manifest_emits_json_and_ts(self, tmp_path):
5860
assert "import.meta.glob" in ts
5961
assert "Products" in ts
6062
assert "AUTO-GENERATED" in ts or "auto-generated" in ts.lower()
63+
# Glob patterns must be relative to output_dir — Vite treats
64+
# leading-slash paths as project-root-relative and silently matches
65+
# nothing for FS-absolute paths.
66+
for match in re.findall(r'import\.meta\.glob<PageModule>\("([^"]+)"\)', ts):
67+
assert match.startswith(("./", "../")), (
68+
f"glob pattern {match!r} must be relative, not absolute"
69+
)
6170

6271
css_text = css.read_text(encoding="utf-8")
6372
assert "AUTO-GENERATED" in css_text or "auto-generated" in css_text.lower()

‎host/alembic.ini‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
[alembic]
2-
script_location = migrations
2+
# Resolve script_location relative to this ini file, not the invocation cwd,
3+
# so `alembic -c host/alembic.ini ...` works from the repo root.
4+
script_location = %(here)s/migrations
35
sqlalchemy.url =
46

57
[loggers]

‎keycloak/realm-export.json‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,22 @@
2020
"webOrigins": ["http://localhost:8000"],
2121
"standardFlowEnabled": true,
2222
"directAccessGrantsEnabled": true,
23-
"defaultClientScopes": ["openid", "profile", "email", "roles"]
23+
"defaultClientScopes": ["openid", "profile", "email", "roles"],
24+
"protocolMappers": [
25+
{
26+
"name": "realm roles",
27+
"protocol": "openid-connect",
28+
"protocolMapper": "oidc-usermodel-realm-role-mapper",
29+
"config": {
30+
"claim.name": "realm_access.roles",
31+
"jsonType.label": "String",
32+
"multivalued": "true",
33+
"userinfo.token.claim": "true",
34+
"id.token.claim": "true",
35+
"access.token.claim": "true"
36+
}
37+
}
38+
]
2439
}
2540
],
2641
"users": [

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,6 @@ exclude = ["framework/hosting/simple_module_hosting/templates/**"]
7070

7171
[tool.pytest.ini_options]
7272
asyncio_mode = "auto"
73-
testpaths = ["framework/core/tests", "framework/db/tests", "framework/hosting/tests", "framework/testing/tests", "modules/auth/tests", "modules/dashboard/tests", "modules/products/tests", "tests/integration"]
73+
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"]
7474
markers = ["e2e: end-to-end tests requiring live services (Keycloak, browser)"]
7575
addopts = "-m 'not e2e'"

‎tests/e2e/__init__.py‎

Whitespace-only changes.

‎tests/e2e/conftest.py‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""Fixtures for end-to-end browser tests.
2+
3+
These tests drive a real Chromium browser against a running stack
4+
(`make docker-up` + `make dev`). They are gated by the ``e2e`` pytest marker
5+
(declared in ``pyproject.toml``) and excluded from the default suite.
6+
7+
Env vars:
8+
E2E_BASE_URL — where the FastAPI host is listening (default: http://localhost:8000)
9+
E2E_USERNAME — Keycloak username (default: admin — seeded in keycloak/realm-export.json)
10+
E2E_PASSWORD — Keycloak password (default: admin)
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import os
16+
17+
import pytest
18+
19+
20+
@pytest.fixture(scope="session")
21+
def base_url() -> str:
22+
return os.environ.get("E2E_BASE_URL", "http://localhost:8000")
23+
24+
25+
@pytest.fixture(scope="session")
26+
def e2e_username() -> str:
27+
return os.environ.get("E2E_USERNAME", "admin")
28+
29+
30+
@pytest.fixture(scope="session")
31+
def e2e_password() -> str:
32+
return os.environ.get("E2E_PASSWORD", "admin")
33+
34+
35+
@pytest.fixture(scope="session")
36+
def browser_context_args(browser_context_args, base_url):
37+
"""Override pytest-playwright's context args to set base_url.
38+
39+
With this in place, ``page.goto("/")`` resolves relative to E2E_BASE_URL.
40+
"""
41+
return {**browser_context_args, "base_url": base_url}

0 commit comments

Comments
 (0)