#!/usr/bin/env python3 """End-to-end smoke test driving the real app in a browser. Engine vitest covers move logic; this covers the Svelte reactivity wiring that vitest can't see — specifically the turn glow / interactivity advancing player to player (the bug where `active` froze on North after move 1). Boots `vite` itself, drives chromium via Playwright, asserts a full N->S->E->W round plays and each player's two boards light up in turn. Self-contained: python3 scripts/smoke.py Needs system chromium + the `playwright` python package. Exits non-zero on failure. """ import subprocess, sys, time, urllib.request, os, signal from playwright.sync_api import sync_playwright ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) URL = "http://localhost:5177/duplicate/" BID = ["NW", "NE", "SW", "SE"] # (player, board to act on, from-square, to-square) — one legal opening per player. ROUND = [("N", "NW", "e2", "e4"), ("S", "SW", "d2", "d4"), ("E", "NE", "e7", "e5"), ("W", "SW", "d7", "d5")] EXPECT_ACTIVE = {"N": {"NW", "NE"}, "S": {"SW", "SE"}, "E": {"NE", "SE"}, "W": {"NW", "SW"}} def wait_server(timeout=30): for _ in range(timeout * 2): try: urllib.request.urlopen(URL, timeout=1); return True except Exception: time.sleep(0.5) return False def active_boards(pg): return {BID[i] for i, bd in enumerate(pg.query_selector_all(".board")) if "active" in (bd.get_attribute("class") or "")} def piece_at(pg, board, sq): bi = BID.index(board) span = pg.locator(".board").nth(bi).locator(f'button[aria-label="{sq}"] span.pc') return span.inner_text() if span.count() else None def click(pg, board, sq): pg.locator(".board").nth(BID.index(board)).locator(f'button[aria-label="{sq}"]').click() pg.wait_for_timeout(80) def main(): vite = subprocess.Popen(["pnpm", "dev", "--port", "5177"], cwd=ROOT, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, preexec_fn=os.setsid) try: assert wait_server(), "vite did not come up" with sync_playwright() as p: b = p.chromium.launch(executable_path="/usr/bin/chromium", args=["--no-sandbox"]) pg = b.new_page(viewport={"width": 1400, "height": 1000}) pg.goto(URL, wait_until="networkidle"); pg.wait_for_timeout(300) for player, board, frm, to in ROUND: got = active_boards(pg) assert got == EXPECT_ACTIVE[player], \ f"{player}'s turn: expected active {EXPECT_ACTIVE[player]}, got {got}" other = next(x for x in EXPECT_ACTIVE[player] if x != board) click(pg, board, frm) click(pg, board, to) # move must apply IDENTICALLY on both of the player's boards for bd in (board, other): assert piece_at(pg, bd, frm) is None, f"{player} {bd} {frm} not vacated" assert piece_at(pg, bd, to) is not None, f"{player} {bd} {to} empty after move" print(f"OK {player}: {frm}{to} applied on {board}+{other}, turn advanced") assert active_boards(pg) == EXPECT_ACTIVE["N"], "did not cycle back to North" print("OK full N->S->E->W round cycled back to North") b.close() print("\nSMOKE PASS") finally: os.killpg(os.getpgid(vite.pid), signal.SIGTERM) if __name__ == "__main__": try: main() except AssertionError as e: print(f"\nSMOKE FAIL: {e}"); sys.exit(1)