From d51fa05d5ace8ec73c9a7757eb806dfcb835d616 Mon Sep 17 00:00:00 2001 From: Bu5hm4nn Date: Mon, 23 Mar 2026 23:11:38 +0100 Subject: [PATCH] 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 --- .gitignore | 3 +++ AGENTS.md | 52 ++++++++++++++++++++++++++++++++++++ app/pages/common.py | 37 ++++++++++++------------- app/services/data_service.py | 3 +++ requirements-dev.txt | 1 + tests/test_e2e_playwright.py | 31 +++++++++++++++++++++ 6 files changed, 109 insertions(+), 18 deletions(-) create mode 100644 AGENTS.md create mode 100644 tests/test_e2e_playwright.py diff --git a/.gitignore b/.gitignore index 551f251..01ce20d 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ config/secrets.yaml data/cache/ .idea/ .vscode/ +.worktrees/ +tests/artifacts/ +secrets/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f828e16 --- /dev/null +++ b/AGENTS.md @@ -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 diff --git a/app/pages/common.py b/app/pages/common.py index 22d0dce..2e7989e 100644 --- a/app/pages/common.py +++ b/app/pages/common.py @@ -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]: 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: - 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" - ): - with ui.row().classes("items-center gap-3"): - ui.icon("shield").classes("text-2xl text-sky-500") - with ui.column().classes("gap-0"): - 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") - with ui.row().classes("items-center gap-2 max-sm:flex-wrap"): - for key, href, label in NAV_ITEMS: - active = key == current - 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" - if active - else "text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800" - ) - ui.link(label, href).classes(link_classes) + # Header must be at page level, not inside container + 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" + ): + with ui.row().classes("items-center gap-3"): + ui.icon("shield").classes("text-2xl text-sky-500") + with ui.column().classes("gap-0"): + 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") + with ui.row().classes("items-center gap-2 max-sm:flex-wrap"): + for key, href, label in NAV_ITEMS: + active = key == current + 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" + if active + else "text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800" + ) + 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.column().classes("gap-1"): ui.label(title).classes("text-3xl font-bold text-slate-900 dark:text-slate-50") diff --git a/app/services/data_service.py b/app/services/data_service.py index 65e5225..c181102 100644 --- a/app/services/data_service.py +++ b/app/services/data_service.py @@ -84,6 +84,9 @@ class DataService: await self.cache.set_json(cache_key, 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]] = [] puts: list[dict[str, Any]] = [] diff --git a/requirements-dev.txt b/requirements-dev.txt index dd45a5f..0ed1a0f 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -6,3 +6,4 @@ black>=24.0.0 ruff>=0.2.0 mypy>=1.8.0 httpx>=0.26.0 +playwright>=1.55.0 diff --git a/tests/test_e2e_playwright.py b/tests/test_e2e_playwright.py new file mode 100644 index 0000000..6aa7b87 --- /dev/null +++ b/tests/test_e2e_playwright.py @@ -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()