#!/usr/bin/env python3
"""
An invitation waiting on the home page, answered in the real application.
Being added to a group is an invitation until it is accepted (AV33). What only
the running application shows is that the home page lists it, that Accept and
Decline reach the hub, and that an accepted group then appears among the
reader's groups — while a declined one does not.
Loads the shipped `app.js` with `fetch` stubbed, once per case:
accept — the invitation is listed, Accept is clicked
decline — the invitation is listed, Decline is clicked
invitation_probe.py [--engine chrome|firefox]
Prints JSON: one object per case.
"""
import argparse
import http.server
import json
import socketserver
import subprocess
import sys
import tempfile
import threading
import time
from pathlib import Path
STATIC = Path(__file__).resolve().parents[2] / "src" / "meshbay_hub" / "static"
PORT = 8772
RECORDS = []
FINISHED = threading.Event()
socketserver.TCPServer.allow_reuse_address = True
GROUP = "0f8fad5b-d9cb-469f-a165-70867728950e"
PAGE = r"""
__HOLD__
""".replace("__GROUP__", GROUP)
HOLD_TAG = '
'
HOLD = ""
class H(http.server.SimpleHTTPRequestHandler):
def log_message(self, *a):
pass
def do_POST(self):
length = int(self.headers.get("Content-Length") or 0)
body = self.rfile.read(length)
if self.path == "/log":
RECORDS.append(json.loads(body.decode()))
FINISHED.set()
self.send_response(204)
self.end_headers()
def _send(self, body: bytes, ctype: str) -> None:
self.send_response(200)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
path = self.path.split("?")[0]
if path == "/hold":
FINISHED.wait(60)
self._send(b"", "image/gif")
return
if path == "/":
self._send(PAGE.replace("__HOLD__", HOLD).encode(), "text/html; charset=utf-8")
return
asset = (STATIC / path.lstrip("/")).resolve()
if not str(asset).startswith(str(STATIC)) or not asset.is_file():
self.send_response(404)
self.end_headers()
return
ctype = "text/javascript" if asset.suffix in (".js", ".mjs") else (
"application/wasm" if asset.suffix == ".wasm" else "application/octet-stream")
self._send(asset.read_bytes(), ctype)
# Same launchers and the same profile rule as sticky_header_probe.py, which
# explains both: Firefox is a snap here, and needs a profile under $HOME.
ENGINES = {
"chrome": lambda profile: [
"google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox",
f"--user-data-dir={profile}"],
"firefox": lambda profile: [
"firefox", "--headless", "--profile", profile,
"--screenshot", str(Path(profile) / "shot.png")],
}
def _profile_parent(engine: str) -> str | None:
if engine != "firefox":
return None
snap = Path.home() / "snap" / "firefox" / "common"
return str(snap if snap.is_dir() else Path.home())
def _run(engine: str, case: str) -> dict | None:
before = len(RECORDS)
FINISHED.clear()
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True, prefix="meshbay-probe-",
dir=_profile_parent(engine)) as profile:
proc = subprocess.Popen(
ENGINES[engine](profile) + [f"http://127.0.0.1:{PORT}/?case={case}"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
for _ in range(300):
if len(RECORDS) > before:
break
time.sleep(0.1)
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
return RECORDS[before] if len(RECORDS) > before else None
def main() -> int:
global HOLD
ap = argparse.ArgumentParser()
ap.add_argument("--engine", choices=sorted(ENGINES), default="chrome")
args = ap.parse_args()
HOLD = HOLD_TAG if args.engine == "firefox" else ""
with socketserver.ThreadingTCPServer(("127.0.0.1", PORT), H) as srv:
threading.Thread(target=srv.serve_forever, daemon=True).start()
results = [_run(args.engine, "accept"), _run(args.engine, "decline")]
if not all(results):
print(json.dumps({"error": "no measurement", "got": results}), file=sys.stderr)
return 1
print(json.dumps([dict(r, engine=args.engine) for r in results], indent=1))
return 0
if __name__ == "__main__":
raise SystemExit(main())