test: add Playwright browser tests and document test loop

- add real browser test for overview and options pages
- document engineering learnings in AGENTS.md
- commit NiceGUI header layout fix
- limit options initial expirations for faster first render
This commit is contained in:
Bu5hm4nn
2026-03-23 23:11:38 +01:00
parent 199ecb933f
commit d51fa05d5a
6 changed files with 109 additions and 18 deletions

3
.gitignore vendored
View File

@@ -6,3 +6,6 @@ config/secrets.yaml
data/cache/ data/cache/
.idea/ .idea/
.vscode/ .vscode/
.worktrees/
tests/artifacts/
secrets/

52
AGENTS.md Normal file
View File

@@ -0,0 +1,52 @@
# AGENTS.md
## Engineering Rules
1. **Always close the test loop.**
- Do not stop after code changes.
- Run the app locally.
- Run real tests against the running app.
- For UI work, prefer real browser tests (Playwright) over assumptions.
- Verify the exact route/page that changed.
2. **Local Docker first, deploy later.**
- Use local Docker/OrbStack for fast feedback.
- Only deploy after local behavior is verified.
3. **Browser-visible behavior beats log-only confidence.**
- A route returning HTML is not sufficient.
- Confirm page content renders and no runtime/500 error is visible.
4. **When using parallel worktrees, preserve shared domain models.**
- New feature branches must not silently remove compatibility types used elsewhere.
- In this project, `LombardPortfolio` is still required by strategy/core modules.
5. **Do not claim a feature is live unless the UI is wired to it.**
- Backend service existence is not enough.
- The rendered page must actually consume the live data path.
## Project Learnings
### NiceGUI layout constraint
- `ui.header()` must be a top-level page layout element.
- Do not nest `ui.header()` inside `ui.column()` or other containers.
### Options page performance lesson
- Loading all expiries/chains before first paint can make the page appear broken.
- Prefer rendering fast, then loading data incrementally.
### FastAPI/NiceGUI integration lesson
- NiceGUI page handlers should not assume `request.app.state.*` is always the right access path for shared services.
- Use an explicit runtime/service registry if needed.
### Docker development lesson
- Avoid mounting the whole repo over `/app` when the image contains required runtime scripts.
- Mount narrower paths in dev (`./app`, `./config`) to preserve image-managed files like entrypoints.
## Validation Checklist for UI Changes
- [ ] Local Docker stack starts cleanly
- [ ] `/health` returns OK
- [ ] Changed page opens in browser automation
- [ ] No visible 500/runtime error
- [ ] Screenshot artifact captured
- [ ] Relevant logs checked

View File

@@ -183,25 +183,26 @@ def strategy_metrics(strategy_name: str, scenario_pct: int) -> dict[str, Any]:
def dashboard_page(title: str, subtitle: str, current: str) -> Iterator[ui.column]: def dashboard_page(title: str, subtitle: str, current: str) -> Iterator[ui.column]:
ui.colors(primary="#0f172a", secondary="#1e293b", accent="#0ea5e9") ui.colors(primary="#0f172a", secondary="#1e293b", accent="#0ea5e9")
with ui.column().classes("mx-auto w-full max-w-7xl gap-6 bg-slate-50 p-6 dark:bg-slate-950") as container: # Header must be at page level, not inside container
with ui.header(elevated=False).classes( with ui.header(elevated=False).classes(
"items-center justify-between border-b border-slate-200 bg-white/90 px-6 py-4 backdrop-blur dark:border-slate-800 dark:bg-slate-950/90" "items-center justify-between border-b border-slate-200 bg-white/90 px-6 py-4 backdrop-blur dark:border-slate-800 dark:bg-slate-950/90"
): ):
with ui.row().classes("items-center gap-3"): with ui.row().classes("items-center gap-3"):
ui.icon("shield").classes("text-2xl text-sky-500") ui.icon("shield").classes("text-2xl text-sky-500")
with ui.column().classes("gap-0"): with ui.column().classes("gap-0"):
ui.label("Vault Dashboard").classes("text-lg font-bold text-slate-900 dark:text-slate-50") ui.label("Vault Dashboard").classes("text-lg font-bold text-slate-900 dark:text-slate-50")
ui.label("NiceGUI hedging cockpit").classes("text-xs text-slate-500 dark:text-slate-400") ui.label("NiceGUI hedging cockpit").classes("text-xs text-slate-500 dark:text-slate-400")
with ui.row().classes("items-center gap-2 max-sm:flex-wrap"): with ui.row().classes("items-center gap-2 max-sm:flex-wrap"):
for key, href, label in NAV_ITEMS: for key, href, label in NAV_ITEMS:
active = key == current active = key == current
link_classes = "rounded-lg px-4 py-2 text-sm font-medium no-underline transition " + ( link_classes = "rounded-lg px-4 py-2 text-sm font-medium no-underline transition " + (
"bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900" "bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900"
if active if active
else "text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800" else "text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
) )
ui.link(label, href).classes(link_classes) ui.link(label, href).classes(link_classes)
with ui.column().classes("mx-auto w-full max-w-7xl gap-6 bg-slate-50 p-6 dark:bg-slate-950") as container:
with ui.row().classes("w-full items-end justify-between gap-4 max-md:flex-col max-md:items-start"): with ui.row().classes("w-full items-end justify-between gap-4 max-md:flex-col max-md:items-start"):
with ui.column().classes("gap-1"): with ui.column().classes("gap-1"):
ui.label(title).classes("text-3xl font-bold text-slate-900 dark:text-slate-50") ui.label(title).classes("text-3xl font-bold text-slate-900 dark:text-slate-50")

View File

@@ -84,6 +84,9 @@ class DataService:
await self.cache.set_json(cache_key, options_chain) await self.cache.set_json(cache_key, options_chain)
return options_chain return options_chain
# Limit initial load to the nearest expirations so the page can render quickly.
expirations = expirations[:3]
calls: list[dict[str, Any]] = [] calls: list[dict[str, Any]] = []
puts: list[dict[str, Any]] = [] puts: list[dict[str, Any]] = []

View File

@@ -6,3 +6,4 @@ black>=24.0.0
ruff>=0.2.0 ruff>=0.2.0
mypy>=1.8.0 mypy>=1.8.0
httpx>=0.26.0 httpx>=0.26.0
playwright>=1.55.0

View File

@@ -0,0 +1,31 @@
from __future__ import annotations
from pathlib import Path
from playwright.sync_api import Page, expect, sync_playwright
BASE_URL = "http://127.0.0.1:8000"
ARTIFACTS = Path("tests/artifacts")
ARTIFACTS.mkdir(parents=True, exist_ok=True)
def test_homepage_and_options_page_render() -> None:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1440, "height": 1000})
page.goto(BASE_URL, wait_until="networkidle", timeout=30000)
expect(page).to_have_title("NiceGUI")
expect(page.locator("text=Vault Dashboard").first).to_be_visible(timeout=10000)
expect(page.locator("text=Overview").first).to_be_visible(timeout=10000)
page.screenshot(path=str(ARTIFACTS / "overview.png"), full_page=True)
page.goto(f"{BASE_URL}/options", wait_until="domcontentloaded", timeout=30000)
expect(page.locator("text=Options Chain").first).to_be_visible(timeout=15000)
expect(page.locator("text=Filters").first).to_be_visible(timeout=15000)
body_text = page.locator("body").inner_text(timeout=15000)
assert "Server error" not in body_text
assert "RuntimeError" not in body_text
page.screenshot(path=str(ARTIFACTS / "options.png"), full_page=True)
browser.close()