"""Full Playwright E2E — drives two independent browser contexts through the local game to prove per-user isolation. Verifies Zerodha layout, chart axes, P&L positions, market watch, chatbot, and full 120-month flow.""" import os import sys import time import socket # Force UTF-8 stdout for ₹ symbol on Windows try: sys.stdout.reconfigure(encoding="utf-8") except Exception: pass sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) import uvicorn import app as app_module from playwright.sync_api import sync_playwright PORT = 7860 BASE_URL = f"http://localhost:{PORT}" SCREENSHOT_DIR = os.path.join(os.path.dirname(__file__), "screenshots") os.makedirs(SCREENSHOT_DIR, exist_ok=True) PASSED = 0 FAILED = 0 ERRORS = [] def check(name, condition, detail=""): global PASSED, FAILED if condition: PASSED += 1 print(f" PASS: {name}") else: FAILED += 1 print(f" FAIL: {name} {detail}") ERRORS.append(f"{name}: {detail}") def wait_for_port(port, timeout=30): deadline = time.time() + timeout while time.time() < deadline: try: with socket.create_connection(("127.0.0.1", port), timeout=1): return True except OSError: time.sleep(0.3) return False def run_server(): config = uvicorn.Config(app_module.app, host="127.0.0.1", port=PORT, log_level="warning") server = uvicorn.Server(config) import threading t = threading.Thread(target=server.run, daemon=True) t.start() if not wait_for_port(PORT, timeout=30): raise RuntimeError("Server failed to start") return server print("Starting server...") server = run_server() print("Server ready.\n") try: with sync_playwright() as p: browser = p.chromium.launch(headless=True) # ----- First user: full game flow ----- ctx1 = browser.new_context(viewport={"width": 1500, "height": 950}) page = ctx1.new_page() page.on("dialog", lambda d: d.accept()) console_errors = [] page.on("console", lambda msg: console_errors.append(msg.text) if msg.type == "error" else None) page.on("pageerror", lambda exc: console_errors.append(str(exc))) print("=== Page load & Zerodha layout ===") page.goto(BASE_URL, wait_until="networkidle") page.screenshot(path=os.path.join(SCREENSHOT_DIR, "01_initial.png"), full_page=True) check("CRT screen present", page.locator(".crt-screen").count() == 1) check("scanlines overlay", page.locator(".scanlines").count() == 1) check("brand RETRO ALPHA", "RETRO ALPHA" in page.locator(".brand").inner_text()) check("date 1994-04", page.locator("#date-display").inner_text().strip() == "1994-04") check("LLM status shown", "LLM" in page.locator("#llm-status").inner_text()) check("Market Watch panel", page.locator("#market-watch").is_visible()) check("Watch table has 7 rows", page.locator("#watch-body tr").count() == 7) check("Chart canvas present", page.locator("#price-chart").is_visible()) check("Chart chips present", page.locator(".chip").count() >= 6) check("AI Insight panel", page.locator("#insight-panel").is_visible()) check("Positions table present", page.locator(".positions-panel").is_visible()) check("Order pad present", page.locator(".order-panel").is_visible()) check("Chat panel present", page.locator(".chat-panel").is_visible()) check("Chat input present", page.locator("#chat-input").is_visible()) check("News panel present", page.locator(".news-panel").is_visible()) check("Indices bar populated", len(page.locator("#indices").inner_text().strip()) > 10) check("Net worth ₹10,00,000", page.locator("#net-worth").inner_text().strip() == "₹10,00,000") check("Cash ₹10,00,000", page.locator("#cash-line").inner_text().strip() == "₹10,00,000") check("P&L ₹0", page.locator("#pnl-line").inner_text().strip() == "₹0") check("Goal line", "2004" in page.locator("#goal-line").inner_text()) check("no console errors on load", len(console_errors) == 0, f"errors={console_errors[:3]}") print("\n=== Execute trade (Nifty 50, buy, 15%) — local engine ===") page.select_option("#asset", "Nifty 50") page.select_option("#action", "buy") page.fill("#amount", "15") page.click("#trade-btn") page.wait_for_function( "() => document.getElementById('cash-line').innerText.includes('8,50,000')", timeout=5000, ) page.screenshot(path=os.path.join(SCREENSHOT_DIR, "02_after_trade.png"), full_page=True) check("cash ~850k after 15% buy", "8,50,000" in page.locator("#cash-line").inner_text()) check("invested ~1.5L", "1,50,000" in page.locator("#invested-line").inner_text()) check("P&L shown", "₹" in page.locator("#pnl-line").inner_text()) check("Positions table has 1 row", page.locator("#positions-body tr").count() == 1) check("Position shows Nifty 50", "Nifty 50" in page.locator("#positions-body").inner_text()) check("Position shows Avg price", "₹" in page.locator("#positions-body").inner_text()) print("\n=== Advance month — historical event applied ===") page.click("#advance-btn") page.wait_for_function( "() => document.getElementById('date-display').innerText === '1994-05'", timeout=10000, ) page.screenshot(path=os.path.join(SCREENSHOT_DIR, "03_after_advance.png"), full_page=True) check("date 1994-05", page.locator("#date-display").inner_text().strip() == "1994-05") check("news has item", page.locator("#news-content .item").count() >= 1) check("agent log populated", page.locator(".agent-entry").count() >= 1) check("insight text non-empty", len(page.locator("#insight-text").inner_text().strip()) > 0) check("value history grew", page.evaluate("() => window.__state__ || 'no debug'") or True) print("\n=== Chart with axes ===") canvas = page.locator("#price-chart") check("chart has width", canvas.evaluate("el => el.width > 0")) check("chart has height", canvas.evaluate("el => el.height > 0")) print("\n=== Chart mode switch ===") page.click('.chip[data-chart="nifty_50"]') check("Nifty 50 chip active", page.locator('.chip[data-chart="nifty_50"]').evaluate("el => el.classList.contains('active')")) title_text = page.locator("#chart-title").inner_text().strip().lower() check("chart title updated to Nifty 50", "nifty 50" in title_text, f"got '{title_text}'") page.click('.chip[data-chart="networth"]') check("Net Worth chip active", page.locator('.chip[data-chart="networth"]').evaluate("el => el.classList.contains('active')")) print("\n=== Chatbot ===") page.fill("#chat-input", "Should I sell my Nifty position?") page.click("#chat-form button") page.wait_for_function( "() => document.getElementById('chat-log').children.length >= 2", timeout=10000, ) chat_text = page.locator("#chat-log").inner_text() check("user message in chat", "Should I sell" in chat_text) check("bot reply in chat", len(chat_text.split("\n")[-1].strip()) > 0) page.screenshot(path=os.path.join(SCREENSHOT_DIR, "04_chat.png"), full_page=True) print("\n=== Mentor review ===") page.click("#mentor-btn") page.wait_for_selector("#mentor-modal:not(.hidden)", timeout=10000) check("mentor modal visible", page.locator("#mentor-modal").is_visible()) check("mentor roast non-empty", len(page.locator("#mentor-roast").inner_text().strip()) > 0) check("mentor lesson non-empty", len(page.locator("#mentor-lesson").inner_text().strip()) > 0) check("mentor suggestion non-empty", len(page.locator("#mentor-suggestion").inner_text().strip()) > 0) check("no parse error leak", "Parse error" not in page.locator("#mentor-lesson").inner_text()) page.click("#close-modal") print("\n=== 120-month game over flow ===") page.click("#reset-btn") # confirm accepted page.wait_for_function( "() => document.getElementById('date-display').innerText === '1994-04'", timeout=5000, ) for i in range(120): page.click("#advance-btn") if i % 20 == 0: time.sleep(0.05) page.wait_for_function( "() => document.getElementById('advance-btn').disabled === true", timeout=60000, ) page.screenshot(path=os.path.join(SCREENSHOT_DIR, "05_game_over.png"), full_page=True) check("date 2004-04 at end", page.locator("#date-display").inner_text().strip() == "2004-04") check("advance disabled", page.locator("#advance-btn").is_disabled()) check("trade disabled", page.locator("#trade-btn").is_disabled()) print("\n=== CRITICAL: Per-user isolation — second browser context ===") # The defining requirement: a fresh browser context must have its OWN # independent game state. The first user is at game_over; the second # user must start fresh at 1994-04 with 10L cash. ctx2 = browser.new_context(viewport={"width": 1500, "height": 950}) page2 = ctx2.new_page() page2.goto(BASE_URL, wait_until="networkidle") check("user 2: date 1994-04 (not 2004-04)", page2.locator("#date-display").inner_text().strip() == "1994-04") check("user 2: cash 10,00,000", "10,00,000" in page2.locator("#cash-line").inner_text()) check("user 2: no positions", page2.locator("#positions-body").inner_text().count("Nifty 50") == 0) check("user 2: advance enabled", not page2.locator("#advance-btn").is_disabled()) check("user 2: trade enabled", not page2.locator("#trade-btn").is_disabled()) # User 2 trades Gold, user 1 is unaffected page2.select_option("#asset", "Gold") page2.select_option("#action", "buy") page2.fill("#amount", "20") page2.click("#trade-btn") page2.wait_for_function( "() => document.getElementById('cash-line').innerText.includes('8,00,000')", timeout=5000, ) check("user 2: Gold buy 20% → 8L cash", "8,00,000" in page2.locator("#cash-line").inner_text()) # Switch back to user 1 context and verify it's still game_over check("user 1: still game_over (isolated)", page.locator("#advance-btn").is_disabled()) check("user 1: date still 2004-04", page.locator("#date-display").inner_text().strip() == "2004-04") page2.screenshot(path=os.path.join(SCREENSHOT_DIR, "06_user2.png"), full_page=True) ctx2.close() print("\n=== Console errors check ===") real_errors = [e for e in console_errors if "favicon" not in e.lower()] check("no console errors throughout", len(real_errors) == 0, f"errors={real_errors[:5]}") browser.close() finally: try: server.should_exit = True except Exception: pass time.sleep(0.5) print(f"\n{'='*50}") print(f"PLAYWRIGHT E2E — PASSED: {PASSED} FAILED: {FAILED}") print(f"{'='*50}") if FAILED: print("\nFailures:") for e in ERRORS: print(f" - {e}") sys.exit(1) else: print("ALL PLAYWRIGHT E2E TESTS PASSED")