MiniWeb-demo / src /miniwebgen /browser.py
sparklabuofu's picture
Update the demo
22de727 verified
Raw History Blame Contribute Delete
6.18 kB
"""Browser + server plumbing shared by the privileged stages (site map, dry runs)
and the executor.
Each task attempt gets a fresh browser context, hence a fresh Flask session: its
request log (`/_admin/log`, read with the context's own cookies) holds exactly that
attempt's requests, and its mutations land in its own session overlay.
"""
from __future__ import annotations
from helpers.paths import SITES_DIR
import json
import time
from contextlib import contextmanager
from miniwebgen import config
# ── servers ───────────────────────────────────────────────────────────────────
def _port_free(port):
import socket
with socket.socket() as s:
return s.connect_ex(("127.0.0.1", port)) != 0
def _serving(port):
import urllib.request
try:
return urllib.request.urlopen(f"http://{config.HOST}:{port}/", timeout=3).status == 200
except OSError:
return False
class Servers:
"""MiniWeb servers on our own ports (config.PORTS). Reuses one already serving on
a port (e.g. a dev server) and only stops the ones it started."""
def __init__(self, n=1, ports=None):
self.ports = list(ports or config.PORTS)[:max(1, n)]
self.procs = {}
def __enter__(self):
from miniweb.evaluation.server import start_server, wait_for_server
for port in self.ports:
if port not in config.PORTS:
raise ValueError(f"port {port} is outside the datagen range {config.PORTS[0]}–{config.PORTS[-1]}")
if _serving(port):
continue
self.procs[port] = start_server(port)
for port in self.procs:
if not wait_for_server(port, host=config.HOST, timeout=120):
raise RuntimeError(f"MiniWeb server on :{port} did not start")
return self
@property
def bases(self):
return [f"http://{config.HOST}:{p}" for p in self.ports]
def __exit__(self, *exc):
from miniweb.evaluation.server import stop_server
for proc in self.procs.values():
stop_server(proc)
# ── browser ───────────────────────────────────────────────────────────────────
@contextmanager
def browser(headless=True):
"""A Chromium (config.CHROME) for the current thread (sync API: one per thread)."""
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
b = p.chromium.launch(executable_path=config.CHROME, headless=headless,
args=["--disable-dev-shm-usage", "--no-first-run"])
try:
yield b
finally:
b.close()
def _focus(ctx, page):
"""Emulate a focused page: headless pages never have focus, and clipboard writes (a site's
Copy button) reject on an unfocused document."""
try:
ctx.new_cdp_session(page).send("Emulation.setFocusEmulationEnabled", {"enabled": True})
except Exception:
pass
def new_context(b, viewport=config.VIEWPORT):
w, h = viewport
ctx = b.new_context(viewport={"width": w, "height": h}, device_scale_factor=1, locale="en-US",
timezone_id="UTC", accept_downloads=True)
try:
ctx.grant_permissions(["clipboard-read", "clipboard-write"])
except Exception:
pass
ctx.on("page", lambda page: _focus(ctx, page))
return ctx
def session_record(ctx, base):
"""This context's recorder stream (/_admin/record: actions incl. clipboard writes)."""
try:
r = ctx.request.get(base + "/_admin/record", timeout=20000)
return (r.json() or {}).get("entries", []) or []
except Exception:
return []
def clipboard_writes(ctx, base):
return [e.get("value") for e in session_record(ctx, base) if e.get("action") == "clipboard_write"]
def settle(page, nav_grace=0.25, idle_cap=1.5):
"""Let the page react to an action: a navigation it started, its load, a short idle."""
time.sleep(nav_grace)
try:
page.wait_for_load_state("load", timeout=10000)
except Exception:
pass
try: # pages with live polling never go idle β€” cap the wait
page.wait_for_load_state("networkidle", timeout=int(idle_cap * 1000))
except Exception:
pass
def session_log(ctx, base):
"""This context's own request log (its Flask session), oldest first."""
try:
r = ctx.request.get(base + "/_admin/log", timeout=20000)
return (r.json() or {}).get("entries", []) or []
except Exception:
return []
def session_changes(ctx, base, site=None):
"""This context's session data changes vs the base tables (/_admin/changes)."""
try:
r = ctx.request.get(base + "/_admin/changes" + (f"?site={site}" if site else ""), timeout=20000)
return (r.json() or {}).get("changes", []) or []
except Exception:
return None
def session_flags(ctx, base, **flags):
"""Privileged session flags for the context's session (/_admin/session-flags)."""
from urllib.parse import urlencode
try:
return ctx.request.get(base + "/_admin/session-flags?" + urlencode({k: "1" for k, v in flags.items() if v}),
timeout=20000).json()
except Exception:
return None
def goto_start(page, url):
"""The ONE navigation the harness performs: the task's start URL. The session gets its stable
id first: a first page that reads no data would otherwise log its requests under none."""
from urllib.parse import urlsplit
u = urlsplit(url)
try:
page.context.request.get(f"{u.scheme}://{u.netloc}/_admin/session-flags", timeout=10000)
except Exception:
pass
page.goto(url, wait_until="load", timeout=30000)
settle(page)
def site_name(site_id):
try:
return json.loads((SITES_DIR / site_id / "site.json").read_text()).get("name") or site_id
except (OSError, ValueError):
return site_id