1#!/usr/bin/env python3
2"""Bounded local + two-SSH-host acceptance using the real vev UI driver."""
3import json
4import select
5import subprocess
6import sys
7import time
8import uuid
9
10REQUEST_TIMEOUT = 20
11
12
13def run(*args, timeout=30, check=True):
14 result = subprocess.run(args, text=True, capture_output=True, timeout=timeout)
15 if check and result.returncode:
16 raise RuntimeError(f"command failed ({result.returncode}): {' '.join(args)}\n{result.stdout}{result.stderr}")
17 return result
18
19
20class Driver:
21 def __init__(self, container, args):
22 self.p = subprocess.Popen(
23 ["docker", "exec", "-i", "-e", "VEV_LOG=debug", container,
24 "vev", "--ui-driver", "--cols", "100", "--rows", "30", *args],
25 stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
26 text=True,
27 )
28 self.ident = 0
29 ready = self.read("ready")
30 self.attachment = ready["attachment"]
31 self.generation = ready.get("generation", 0)
32 deadline = time.monotonic() + REQUEST_TIMEOUT
33 while ready.get("status") != "attached":
34 if time.monotonic() >= deadline:
35 raise RuntimeError(f"UI driver did not attach: {ready}")
36 ready = self.call("capture").get("context", {})
37 self.generation = ready["generation"]
38
39 def read(self, label):
40 readable, _, _ = select.select([self.p.stdout], [], [], REQUEST_TIMEOUT)
41 if not readable:
42 self.close()
43 raise RuntimeError(f"UI driver timed out during {label}")
44 line = self.p.stdout.readline()
45 if not line:
46 err = self.p.stderr.read()
47 raise RuntimeError(f"UI driver EOF during {label}: {err}")
48 envelope = json.loads(line)
49 if "error" in envelope:
50 raise RuntimeError(f"UI driver error during {label}: {envelope['error']}")
51 return envelope["result"]
52
53 def call(self, op, **fields):
54 self.ident += 1
55 request = {"version": 1, "id": self.ident, "op": op,
56 "attachment": self.attachment, **fields}
57 if op in ("text", "keys"):
58 request.setdefault("generation", self.generation)
59 self.p.stdin.write(json.dumps(request) + "\n")
60 self.p.stdin.flush()
61 result = self.read(op)
62 self.generation = result.get("context", {}).get("generation", self.generation)
63 return result
64
65 def close(self):
66 if self.p.poll() is None:
67 self.p.stdin.close()
68 try:
69 self.p.wait(timeout=5)
70 except subprocess.TimeoutExpired:
71 self.p.kill()
72 self.p.wait(timeout=5)
73
74
75def scenario(container, endpoint=None):
76 name = ("local" if endpoint is None else endpoint.replace("-", "")) + uuid.uuid4().hex[:7]
77 args = ["--session", name]
78 if endpoint:
79 args = ["--remote", endpoint, *args]
80 driver = Driver(container, args)
81 try:
82 context = driver.call("capture")["context"]
83 assert context["status"] == "attached", context
84 assert context["session"]["session_name"] == name, context
85 marker = "OK_" + name
86 accepted = driver.call("text", text=f"printf '{marker}'")
87 assert accepted["status"] == "processed", accepted
88 driver.call("keys", keys=["Enter"])
89 observed = driver.call("wait", expect={"text_contains": marker}, timeout_ms=15000)
90 assert observed["context"]["session"] == context["session"], observed
91 print(f"PASS ui endpoint={endpoint or 'local'} session={name} lifecycle={context['session']['lifecycle_id']}")
92 finally:
93 driver.close()
94 return name
95
96
97def await_remote_listing(container, endpoint, name, timeout=10.0):
98 """Poll `vev ls ENDPOINT` until the broker's observation lists NAME."""
99 deadline = time.monotonic() + timeout
100 listing = ""
101 while True:
102 listing = run("docker", "exec", container, "vev", "ls", endpoint, timeout=15, check=False).stdout
103 if any(line.split()[:1] == [f"{name}@{endpoint}"] for line in listing.splitlines()):
104 print(f"PASS ls endpoint={endpoint} session={name}")
105 return
106 if time.monotonic() >= deadline:
107 raise RuntimeError(f"`vev ls {endpoint}` never listed {name} within {timeout}s:\n{listing}")
108 time.sleep(0.5)
109
110
111def main():
112 if len(sys.argv) != 2:
113 raise SystemExit("usage: sandbox_acceptance.py CLIENT_CONTAINER")
114 client = sys.argv[1]
115 hosts = run("docker", "exec", client, "vev", "host", "list").stdout
116 for endpoint in ("remote-a", "remote-b"):
117 if endpoint not in hosts:
118 raise RuntimeError(f"CLI host list omitted {endpoint}: {hosts}")
119 catalog = run("docker", "exec", client, "vev", "ls", endpoint, timeout=30)
120 print(f"PASS cli endpoint={endpoint} catalog_lines={len(catalog.stdout.splitlines())}")
121 scenario(client)
122 created = {endpoint: scenario(client, endpoint) for endpoint in ("remote-a", "remote-b")}
123 for endpoint, name in created.items():
124 await_remote_listing(client, endpoint, name)
125
126
127if __name__ == "__main__":
128 main()