- 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
158 lines
7.1 KiB
Python
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())
|