"""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())