#!/usr/bin/env -S uv run --quiet --script # /// script # requires-python = ">=3.11" # dependencies = [] # /// """End-to-end test: the browser JS stack (web/js, driven headless by test/client.mjs) against the reference server and client in ../smolmail. Two transports are covered: the driver connects directly over TCP, and once more through bridge.py's WebSocket relay — the exact byte path the browser takes. Alice is always the reference smolmail.py client, so every exchange crosses implementations. """ from __future__ import annotations import os import re import signal import socket import subprocess import sys import tempfile import time from pathlib import Path SMOL = Path(__file__).resolve().parent.parent.parent / "smolmail" NODE = "node" failures: list[str] = [] def check(name: str, condition: bool, detail: str = "") -> None: print(("ok " if condition else "FAIL ") + name + (f" — {detail}" if detail and not condition else "")) if not condition: failures.append(name) def wait_port(port: int, timeout: float = 20.0) -> None: deadline = time.time() + timeout while time.time() < deadline: try: with socket.create_connection(("127.0.0.1", port), timeout=1): return except OSError: time.sleep(0.2) raise TimeoutError(f"port {port} never opened") def run(command: list[str], expect: int = 0, env: dict | None = None) -> str: result = subprocess.run(command, capture_output=True, text=True, env=env) if result.returncode != expect: raise RuntimeError(f"command failed ({result.returncode}): {command}\n{result.stdout}{result.stderr}") return result.stdout + result.stderr def main() -> int: work = Path(tempfile.mkdtemp(prefix="gsmol-e2e-")) server_port, bridge_port = 11961, 18096 smolmaild = ["uv", "run", str(SMOL / "smolmaild.py")] smolmail = ["uv", "run", str(SMOL / "smolmail.py")] alice = smolmail + ["--key", str(work / "alice.key"), "--db", str(work / "alice.db")] bob = [NODE, "test/client.mjs", "--state", str(work / "bob.json")] server_key = re.search(r"public key: (\S+)", run( smolmaild + ["keygen", "--key", str(work / "server.key")])).group(1) server = subprocess.Popen( smolmaild + ["serve", "--key", str(work / "server.key"), "--db", str(work / "mail.db"), "--host", "127.0.0.1", "--port", str(server_port)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) bridge = subprocess.Popen( ["uv", "run", "bridge.py", "--port", str(bridge_port), "--allow-port", str(server_port)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) try: wait_port(server_port) wait_port(bridge_port) host = f"127.0.0.1:{server_port}" # --- both sides register on the reference server run(alice + ["keygen"]) run(alice + ["trust", "127.0.0.1", server_key]) run(alice + ["register", f"alice@{host}"]) run(bob + ["keygen"]) run(bob + ["trust", "127.0.0.1", server_key]) run(bob + ["register", f"bob@{host}"]) check("bob registers on the reference server", True) # bob needs alice as a known contact before he can accept her (§5.8). run(bob + ["resolve", f"alice@{host}"]) # --- reference client sends to the JS client, unsolicited: bob has not # accepted alice yet, so it lands in his requests tier, not the main one run(alice + ["send", f"bob@{host}", "--subject", "greetings", "--body", "hello bob"]) print(run(bob + ["fetch"]).strip()) bob_requests = run(bob + ["list", "--requests"]) check("bob's requests tier holds alice's unsolicited message", "greetings" in bob_requests, bob_requests) check("bob's main tier is still empty", "greetings" not in run(bob + ["list"])) bob_read = run(bob + ["read", re.match(r"([0-9a-f]{8})", bob_requests).group(1)]) alice_pub = re.search(r"public key: (\S+)", run(alice + ["whoami"])).group(1) check("bob reads the message", "hello bob" in bob_read, bob_read) check("bob sees alice's key", alice_pub in bob_read) check("signature verifies", "signature verified" in bob_read) # the reference client now emits its signed reply address by default check("reference client emits the signed reply address", "reply-to: smol://alice@" in bob_read, bob_read) # --- bob accepts alice (§5.8): pushed to the server right away, so her # future mail reaches his main tier accepted = run(bob + ["accept", f"alice@{host}"]) check("bob's server now holds an accept token", "server now holds 1 accept token" in accepted, accepted) # --- JS client replies, through the bridge's WebSocket relay. Bob has # accepted alice, so this carries the token issued to her — but it is # still unsolicited from alice's side, landing in her requests tier. ws_env = dict(os.environ, GSMOL_TRANSPORT="ws", GSMOL_BRIDGE=f"ws://127.0.0.1:{bridge_port}") reply_id = re.search(r"sent ([0-9a-f]+)", run(bob + ["send", f"alice@{host}", "--subject", "re: greetings", "--body", "hello alice"], env=ws_env)).group(1) check("bob sends through the bridge", bool(reply_id)) run(alice + ["fetch"]) alice_requests = run(alice + ["list", "--requests"]) check("alice's requests tier holds bob's reply", "re: greetings" in alice_requests, alice_requests) alice_read = run(alice + ["read", reply_id[:8]]) check("alice reads bob's reply", "hello alice" in alice_read, alice_read) check("reference client verifies the JS signature", "signature verified" in alice_read) # bob's reply carries his signed smol:// address; a receiver that does # not know the field must still preserve and display it (SPEC.md §5.5) check("reference client preserves the signed reply address", bool(re.search(r"reply-to:\s+smol://bob@", alice_read)), alice_read) # --- having now learned bob's accept token from that reply's Accept # field, alice's next message to him carries a matching MAC and # reaches his main tier even after he rotates his key bob_rotated = run(bob + ["rotate"]) new_key = re.search(r"new key: (\S+)", bob_rotated).group(1) resolved = run(alice + ["resolve", f"bob@{host}"]) check("chain confirms the rotation", "a signed chain confirms it" in resolved, resolved) run(alice + ["send", f"bob@{host}", "--subject", "post-rotation", "--body", "still here", "--anonymous"]) print(run(bob + ["fetch"]).strip()) bob_list = run(bob + ["list"]) check("bob's main tier reaches mail addressed to the new key", "post-rotation" in bob_list, bob_list) # --anonymous omits the reply address; the field list must show none post_read = run(bob + ["read", re.search(r"([0-9a-f]{8}) .*post-rotation", bob_list).group(1)]) check("--anonymous omits the reply address", "reply-to:" not in post_read, post_read) print(f"\n{len(failures)} failure(s)" + (f": {failures}" if failures else "")) return 1 if failures else 0 finally: for process in (bridge, server): process.send_signal(signal.SIGTERM) process.wait(timeout=10) print(f"workdir left at {work}" if failures else f"cleaning {work}") if __name__ == "__main__": os.chdir(Path(__file__).resolve().parent.parent) sys.exit(main())