f/tests/test_e2e_browser.py
Greg Pomerantz d8703a7a63 Stock & Portfolio Analyzer: full UI rework
- single spec grammar for symbol and benchmark fields: commas join one
  portfolio (MSFT:0.6,V:0.4), spaces separate distinct symbols/portfolios;
  both fields accept one or many entries
- benchmarks simulated with the same scheme/cost/tax rules; per-benchmark
  beta/alpha columns; after-tax benchmark curves
- global Curve mode (pre/after/both) above the tabs; clean names in
  single-curve mode
- live updates: field commits on Enter/blur, page recomputes per rerun;
  portfolio+tax sims cached (st.cache_data); plotly.js from CDN (4.6MB ->
  browser-cached) with F_INLINE_PLOTLY=1 offline fallback
- chart: legend underneath, solid lines, pan sticks to data edges
  (width-preserving), zoom edge-clamped
- inputs persist in settings.json across reloads/restarts/devices
- tests: tests/test_app.py (AppTest) + tests/test_e2e_browser.py
  (Playwright) via ./run_tests.sh
2026-08-24 16:05:27 -04:00

158 lines
7.1 KiB
Python

"""End-to-end browser tests (Playwright + headless Chromium).
Exercises the REAL page exactly like a user: typing into the fields,
committing with Enter, clicking tabs/radios, and checking rendered output.
Prereqs (once):
.venv/bin/pip install playwright
.venv/bin/python -m playwright install chromium
Run: .venv/bin/python tests/test_e2e_browser.py [base_url]
(default http://localhost:8599 — the server must already be running;
this script does NOT start or stop it)
"""
from __future__ import annotations
import json
import pathlib
import sys
import time
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
ROOT = pathlib.Path(__file__).resolve().parent.parent
SETTINGS = ROOT / "settings.json"
PASS, FAIL = 0, 0
def check(name: str, cond: bool, extra: str = "") -> None:
global PASS, FAIL
if cond:
PASS += 1
print(f" ok {name}")
else:
FAIL += 1
print(f" FAIL {name} {extra}")
def main() -> int:
base = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:8599"
from playwright.sync_api import sync_playwright
# back up / restore the user's persisted settings
saved_settings = SETTINGS.read_text() if SETTINGS.exists() else None
SETTINGS.unlink(missing_ok=True)
try:
with sync_playwright() as p:
b = p.chromium.launch()
pg = b.new_page(viewport={"width": 1280, "height": 1100})
pg.goto(base, wait_until="domcontentloaded", timeout=30000)
sym = pg.locator('[data-testid="stSidebar"] input[aria-label*="Symbol"]')
bench = pg.locator('[data-testid="stSidebar"] input[aria-label*="Benchmark"]')
sym.wait_for(timeout=60000)
def type_commit(loc, text):
loc.click()
loc.fill("")
loc.type(text, delay=30)
pg.keyboard.press("Enter")
def main_text():
return pg.locator("[data-testid=stMain]").inner_html()
def legend_names():
return pg.evaluate(
"(() => { const d = document.querySelector('[data-testid=stMain] iframe')"
".contentDocument; const gd = d.getElementById('c');"
" return gd.data.map(t => t.name); })()")
def wait_main(cond_js, timeout=30000):
pg.wait_for_function(f"() => {cond_js}", timeout=timeout)
# --- entry & analysis
type_commit(sym, "MSFT:0.6,V:0.4")
wait_main("document.querySelector('[data-testid=stMain] h1')?.textContent.includes('Portfolio: msft, v')")
check("portfolio renders", True)
type_commit(sym, "MSFT V googl:0.5,amzn:0.5")
wait_main("document.querySelector('[data-testid=stMain] h1')?.textContent.includes('Portfolios:')")
check("multiple entries render", "not in the data" not in main_text())
type_commit(sym, "MSFT:0.6,V:0.4")
wait_main("document.querySelector('[data-testid=stMain] h1')?.textContent.includes('Portfolio: msft, v')")
type_commit(bench, "googl:0.5,amzn:0.5 v")
wait_main("document.querySelector('[data-testid=stMain]').innerHTML.includes('benchmark: googl, amzn ; v')")
check("multiple benchmarks render", True)
# invalid input
type_commit(sym, "MSFT:xyz")
wait_main("document.querySelector('[data-testid=stMain]').innerHTML.includes('Invalid input')")
check("invalid weight shows error", True)
type_commit(sym, "MSFT:0.6,V:0.4")
wait_main("document.querySelector('[data-testid=stMain] h1')?.textContent.includes('Portfolio:')")
# --- chart: legend below, solid lines, plain names
pg.get_by_role("tab", name="Equity curves").click()
pg.wait_for_selector('[data-testid=stMain] iframe', timeout=30000)
pg.wait_for_function(
"(() => { const d = document.querySelector('[data-testid=stMain] iframe')"
".contentDocument; const c = d.getElementById('c');"
" return c && c.clientWidth > 100; })()", timeout=30000)
names = legend_names()
check("plain legend names (single mode)",
"Current" in names and not any("" in n for n in names), str(names))
dashes = pg.evaluate(
"(() => { const d = document.querySelector('[data-testid=stMain] iframe')"
".contentDocument; const gd = d.getElementById('c');"
" return gd.data.map(t => t.line && t.line.dash).filter(Boolean); })()")
check("all lines solid", dashes == [], str(dashes))
pos = pg.evaluate("""(() => { const d = document.querySelector('[data-testid=stMain] iframe').contentDocument;
const gd = d.getElementById('c'); const l = gd._fullLayout;
return l ? (l.legend.y < 0 && l.margin.b > 60) : null; })()""")
check("legend below the plot", pos is True, str(pos))
# --- curve mode above the tabs, affects legend
check("Curve radio above tab row", pg.evaluate("""() => {
const m = document.querySelector('[data-testid=stMain]');
const curve = m.querySelector('[role=radiogroup][aria-label=Curve]');
const tabs = m.querySelector('[role=tablist]');
return curve && tabs && (curve.compareDocumentPosition(tabs) & Node.DOCUMENT_POSITION_FOLLOWING);
}"""))
pg.get_by_role("radio", name="After-tax", exact=True).check(force=True)
pg.wait_for_timeout(4000)
names = legend_names()
check("after-tax mode: plain names", "Current" in names, str(names))
pg.get_by_role("radio", name="Pre-tax + after-tax").check(force=True)
pg.wait_for_timeout(4000)
names = legend_names()
check("both mode: suffixed names",
any(n.endswith("— pre-tax") for n in names) and
any(n.endswith("— after-tax") for n in names), str(names))
pg.get_by_role("radio", name="Pre-tax", exact=True).check(force=True)
pg.wait_for_timeout(4000)
# --- persistence across reload (and would be across restart)
pg.reload(wait_until="domcontentloaded")
sym.wait_for(timeout=60000)
wait_main("document.querySelector('[data-testid=stMain] h1')?.textContent.includes('Portfolio:')")
check("inputs restored after reload",
sym.input_value() == "MSFT:0.6,V:0.4" and
bench.input_value() == "googl:0.5,amzn:0.5 v",
f"{sym.input_value()!r} / {bench.input_value()!r}")
check("settings.json written", SETTINGS.exists() and
json.loads(SETTINGS.read_text()).get("spec") == "MSFT:0.6,V:0.4")
b.close()
finally:
if saved_settings is not None:
SETTINGS.write_text(saved_settings)
else:
SETTINGS.unlink(missing_ok=True)
print(f"\n{PASS} passed, {FAIL} failed")
return 1 if FAIL else 0
if __name__ == "__main__":
sys.exit(main())