1#!/usr/bin/env python3
2"""Table-driven UI-driver acceptance against disposable demo-image containers.
3
4Each scenario starts real ``vev --ui-driver`` clients against real daemons
5and asserts committed outcomes (action IDs, generation changes, exact
6lifecycle/session/focus context, observed published output). Sending a key
7or accepting a request is never success: every scenario ends on a
8postcondition ``wait`` or a verified capture.
9
10Transport selection is host registration state: before each scenario the
11``remote`` host is re-registered in the client container with
12``vev host add --transport quic|ssh remote``. A bare scenario or ``@quic``
13means QUIC; ``@stdio`` means the SSH stdio carriage (``--transport ssh``).
14``VEV_ACCEPTANCE_TRANSPORT`` carries the selected name to the scenarios for
15evidence labels only.
16
17Direct-remote scenarios use ``--remote ENDPOINT --session NAME`` against a
18named fixture session: a named exact remote target, never an ephemeral
19attach. The warm-reuse scenario keeps one client across a
20local -> named remote -> local -> same named remote journey and pins the
21broker's warm physical transport with the broker's remote-dial log, plus
22the committed remote lifecycle and retained output.
23"""
24
25import json
26import os
27import subprocess
28import sys
29import time
30import uuid
31
32COLS = "100"
33ROWS = "30"
34REQUEST_TIMEOUT_S = 15
35WAIT_TIMEOUT_MS = 15000
36
37STATE_LOG_DIR = "$HOME/.local/state/vev"
38CLIENT_LOG = "vev-client.log"
39DAEMON_LOG = "vev-daemon.log"
40STDIO_LOG = "vev-stdio.log"
41# The broker logs one broker_remote_dial per remote physical bootstrap.
42BROKER_LOG = "broker/log/vev-daemon.log"
43REMOTE_HOST = "remote"
44INVENTORY_TIMEOUT_S = 45
45INPUT_BUSY_RETRY_S = 3
46ARTIFACTS_DIR = os.environ.get("VEV_ACCEPTANCE_ARTIFACTS_DIR", "").strip()
47# The warm-reuse scenario requires client debug events. Every driver gets
48# debug logging unless a scenario explicitly overrides it.
49DRIVER_BASE_ENV = {"VEV_LOG": "debug"}
50
51
52class DriverError(Exception):
53 def __init__(self, message, code=None):
54 super().__init__(message)
55 self.code = code
56
57
58class Driver:
59 """One headless client with bounded reads and reliable termination."""
60
61 def __init__(self, container, args, env=None):
62 effective = dict(DRIVER_BASE_ENV)
63 if env:
64 effective.update(env)
65 cmd = ["docker", "exec", "-i"]
66 for key in sorted(effective):
67 cmd += ["-e", f"{key}={effective[key]}"]
68 cmd += [container, "vev", "--ui-driver",
69 "--cols", COLS, "--rows", ROWS] + args
70 # The environment must be forwarded into the container (-e); passing
71 # it to the docker CLI process alone never reaches the driver.
72 self.process = subprocess.Popen(
73 cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True,
74 )
75 self.request_id = 0
76 ready = self._read("discovery")
77 self.attachment = ready["attachment"]
78 # Ready never promises an attachment: the generation is zero until a
79 # committed attachment publishes one, and the status may be any
80 # presentation. Wait for the attached publication explicitly instead
81 # of assuming ready.Generation == 1.
82 self.generation = ready.get("generation", 0)
83 if ready.get("status") != "attached":
84 self.generation = self._await_attached()
85
86 def _await_attached(self):
87 """Wait for the committed attached publication and return its generation.
88
89 ``capture`` works in every presentation state, so polling it is safe
90 while the driver is still connecting. Only a committed ``attached``
91 publication with a nonzero generation is returned; anything else is
92 polled again until the bounded deadline.
93 """
94 deadline = time.monotonic() + INVENTORY_TIMEOUT_S
95 while True:
96 if time.monotonic() > deadline:
97 self.close()
98 raise DriverError("driver never published an attached generation")
99 self.request_id += 1
100 self.process.stdin.write(json.dumps(dict(
101 version=1, id=self.request_id, op="capture",
102 attachment=self.attachment)) + "\n")
103 self.process.stdin.flush()
104 context = self._read("attached publication").get("context", {})
105 if context.get("status") == "attached" and context.get("generation"):
106 return context["generation"]
107
108 def _read(self, what):
109 deadline = time.monotonic() + REQUEST_TIMEOUT_S
110 while True:
111 remaining = deadline - time.monotonic()
112 if remaining <= 0:
113 self.close()
114 raise DriverError(f"timed out reading {what}")
115 line = self.process.stdout.readline()
116 if line == "":
117 raise DriverError(f"driver EOF while reading {what}")
118 line = line.strip()
119 if not line:
120 continue
121 try:
122 envelope = json.loads(line)
123 except json.JSONDecodeError as exc:
124 raise DriverError(f"invalid driver JSON while reading {what}: {exc}") from exc
125 if "error" in envelope:
126 error = envelope["error"]
127 raise DriverError(f"driver error while reading {what}: {error}",
128 code=error.get("code") if isinstance(error, dict) else None)
129 return envelope["result"]
130
131 def call(self, operation, **fields):
132 """Send one request; retry only an input admission the owner refused.
133
134 ``input_busy`` is returned with ``accepted: false`` while the input
135 owner is still delivering an earlier terminal read (for example a
136 reply to a query the new attachment's first frame emitted). Nothing
137 was admitted, so resending is the documented client behavior; every
138 other error, and every accepted action, is reported unchanged.
139 """
140 deadline = time.monotonic() + INPUT_BUSY_RETRY_S
141 while True:
142 try:
143 return self._call_once(operation, **fields)
144 except DriverError as exc:
145 if exc.code != "input_busy" or time.monotonic() >= deadline:
146 raise
147 time.sleep(0.05)
148
149 def _call_once(self, operation, **fields):
150 self.request_id += 1
151 request = dict(version=1, id=self.request_id, op=operation,
152 attachment=self.attachment, **fields)
153 if operation in ("keys", "text"):
154 request.setdefault("generation", self.generation)
155 try:
156 self.process.stdin.write(json.dumps(request) + "\n")
157 self.process.stdin.flush()
158 except BrokenPipeError as exc:
159 raise DriverError(f"driver stdin closed for {operation}") from exc
160 result = self._read(operation)
161 context = result.get("context", {})
162 if "generation" in context:
163 self.generation = context["generation"]
164 return result
165
166 def text(self):
167 return self.call("capture")["text"]
168
169 def wait_text(self, fragment, timeout_ms=WAIT_TIMEOUT_MS):
170 return self.call("wait", expect={"text_contains": fragment}, timeout_ms=timeout_ms)
171
172 def wait_focus(self, tab_id, pane_id, timeout_ms=WAIT_TIMEOUT_MS):
173 return self.call("wait", expect={"focus": {
174 "tab_id": tab_id, "pane_id": pane_id,
175 }}, timeout_ms=timeout_ms)
176
177 def palette(self, query, label):
178 keys_result = self.call("keys", keys=["Alt+Space"])
179 if query:
180 text_result = self.call("text", text=query)
181 if text_result.get("status") != "processed":
182 raise DriverError(
183 f"palette query not processed: {json.dumps(text_result)[:500]} "
184 f"keys={json.dumps(keys_result)[:500]}")
185 # Prove the query reached the palette before Enter executes the
186 # selected row: the label alone is visible in the unfiltered
187 # list too, so waiting on it can return before filtering lands.
188 self.wait_text("> " + query)
189 self.wait_text(label)
190 return self.text()
191
192 def close(self):
193 process, self.process = self.process, None
194 if process is None:
195 return
196 try:
197 process.stdin.close()
198 except (BrokenPipeError, ValueError):
199 pass
200 try:
201 process.wait(timeout=10)
202 except subprocess.TimeoutExpired:
203 process.kill()
204 process.wait(timeout=10)
205
206
207def check(condition, message, diagnostics):
208 if not condition:
209 raise DriverError(f"{message}\n diagnostics: {json.dumps(diagnostics)[:2000]}")
210
211
212def docker_capture(args, timeout=30):
213 return subprocess.run(["docker", *args], capture_output=True, text=True,
214 timeout=timeout)
215
216
217def log_size(container, filename):
218 """Byte length of one state log, so a run can read only its own events."""
219 script = (f'f="{STATE_LOG_DIR}/{filename}"; '
220 'if [ -f "$f" ]; then wc -c < "$f"; else echo 0; fi')
221 try:
222 result = docker_capture(["exec", container, "sh", "-c", script])
223 except subprocess.TimeoutExpired:
224 return 0
225 if result.returncode != 0:
226 return 0
227 try:
228 return int(result.stdout.strip() or "0")
229 except ValueError:
230 return 0
231
232
233def log_since(container, filename, offset):
234 """Raw log bytes appended after offset; empty when the file is absent."""
235 script = (f'f="{STATE_LOG_DIR}/{filename}"; '
236 f'if [ -f "$f" ]; then tail -c +{offset + 1} "$f"; fi')
237 try:
238 result = docker_capture(["exec", container, "sh", "-c", script])
239 except subprocess.TimeoutExpired:
240 return ""
241 return result.stdout if result.returncode == 0 else ""
242
243
244def json_events(text):
245 """Parse structured vev log lines, tolerating a truncated boundary line."""
246 events = []
247 for line in text.splitlines():
248 line = line.strip()
249 if not line:
250 continue
251 try:
252 event = json.loads(line)
253 except json.JSONDecodeError:
254 continue
255 if isinstance(event, dict) and "msg" in event:
256 events.append(event)
257 return events
258
259
260def count_events(events, message, **fields):
261 return sum(1 for event in events
262 if event.get("msg") == message
263 and all(event.get(key) == value for key, value in fields.items()))
264
265
266def write_artifact(name, payload):
267 """Persist scenario evidence under the configured artifacts directory.
268
269 The payload carries only committed contexts and structured log counts:
270 no credentials, key material, or raw environment are recorded.
271 """
272 if not ARTIFACTS_DIR:
273 return
274 os.makedirs(ARTIFACTS_DIR, mode=0o700, exist_ok=True)
275 path = os.path.join(ARTIFACTS_DIR, name)
276 fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
277 with os.fdopen(fd, "w", encoding="utf-8") as handle:
278 json.dump(payload, handle, indent=2, sort_keys=True)
279 handle.write("\n")
280
281
282def await_remote_inventory(container, session, timeout_s=INVENTORY_TIMEOUT_S, interval_s=0.5):
283 """Wait until the broker publishes ``session`` for its remote host.
284
285 The broker owns remote observation: it re-probes each configured host on
286 its own healthy cadence, and every attached client derives its palette
287 and picker rows from the broker's committed publication. A freshly created
288 remote session is invisible until the next observation, so wait on that
289 publication itself (``vev ls HOST`` reads the broker snapshot) rather than
290 guessing a timeout. The following palette search then asserts the row
291 independently.
292 """
293 display = f"{session}@{REMOTE_HOST}"
294 script = f'vev ls {REMOTE_HOST} 2>/dev/null | grep -qF "{display} "'
295 deadline = time.monotonic() + timeout_s
296 while True:
297 try:
298 if docker_capture(["exec", container, "sh", "-c", script]).returncode == 0:
299 return
300 except subprocess.TimeoutExpired:
301 pass
302 if time.monotonic() >= deadline:
303 raise DriverError(
304 f"remote session {session} never reached the broker publication "
305 f"within {timeout_s}s")
306 time.sleep(interval_s)
307
308
309def switch_route(driver, name, origin, lifecycle_id=None, timeout_ms=WAIT_TIMEOUT_MS):
310 """Switch by exact qualified palette label and prove the commit.
311
312 A named target with a known lifecycle is additionally pinned with a
313 ``wait`` on that exact lifecycle, so a stale or freshly-created session
314 can never satisfy the check.
315 """
316 driver.palette(name, f"Switch to session {name}@{origin}")
317 committed = driver.call("keys", keys=["Enter"])
318 check(committed.get("status") == "processed",
319 f"route switch to {name}@{origin} not processed", committed)
320 session = committed.get("context", {}).get("session", {})
321 check(session.get("session_name") == name,
322 f"route switch did not commit {name}@{origin}", committed)
323 if lifecycle_id is not None:
324 driver.call("wait", expect={"session": {
325 "session_name": name, "lifecycle_id": lifecycle_id,
326 }}, timeout_ms=timeout_ms)
327 return committed["context"]
328
329
330def commit_output(driver, command, sentinel):
331 """Type a command, run it, and wait for output the command text lacks.
332
333 The sentinel must not appear contiguously in the typed command; callers
334 build it with shell interpolation so an echoed command line can never
335 satisfy the wait.
336 """
337 entered = driver.call("text", text=command)
338 check(entered.get("status") == "processed", "command text not processed", entered)
339 driver.call("keys", keys=["Enter"])
340 driver.wait_text(sentinel)
341
342
343def scenario_local_palette_cycle(client_container):
344 """M1: local IPC palette open/action/close with committed observation."""
345 suffix = uuid.uuid4().hex[:8]
346 name = "local" + suffix
347 driver = Driver(client_container, ["--session", name])
348 try:
349 capture = driver.call("capture")
350 session = capture["context"]["session"]
351 check(session["session_name"] == name, "local session name mismatch", capture)
352 screen = driver.palette("", "Commands")
353 check("Commands" in screen, "palette did not list commands", {"screen": screen[:500]})
354 opened = driver.call("keys", keys=["Escape"])
355 check(opened["context"]["session"] == session, "escape changed session", opened)
356 entered = driver.call("text", text="printf 'LOCAL_OK'")
357 check(entered.get("status") == "processed", "text action not processed", entered)
358 driver.call("keys", keys=["Enter"])
359 driver.wait_text("LOCAL_OK")
360 final = driver.call("capture")
361 check(final["context"]["session"] == session, "local lifecycle changed", final)
362 print(f"PASS local-palette-cycle session={name} lifecycle={session['lifecycle_id'][:8]}")
363 finally:
364 driver.close()
365
366
367def scenario_direct_remote_named(client_container, remote_container, env):
368 """M2/M3: direct attach to a named exact remote target, committed output.
369
370 The remote daemon owns a pre-created named session. ``--remote remote
371 --session NAME`` attaches to that exact lifecycle instead of creating an
372 ephemeral one, so the daemon-owned target is pinned, not inferred.
373 """
374 mode = env.get("VEV_ACCEPTANCE_TRANSPORT", "quic")
375 suffix = uuid.uuid4().hex[:8]
376 name = "direct" + suffix
377 fixture = Driver(remote_container, ["--session", name])
378 fixture_capture = fixture.call("capture")
379 fixture_session = fixture_capture["context"]["session"]
380 fixture.close()
381 check(fixture_session["session_name"] == name,
382 "remote fixture did not commit the named target", fixture_capture)
383 driver = Driver(client_container, ["--remote", "remote", "--session", name], env=env)
384 evidence = {"scenario": "direct-remote-named", "mode": mode,
385 "remote_session": name, "fixture_session": fixture_session}
386 try:
387 capture = driver.call("capture")
388 session = capture["context"]["session"]
389 check(session["session_name"] == name,
390 "named remote target not committed", capture)
391 check(session == fixture_session,
392 "direct attach did not reuse the exact named remote lifecycle", capture)
393 # The sentinel is built by the shell so the echoed command line cannot
394 # satisfy the wait before the command actually runs.
395 commit_output(driver, "printf 'DIRECT_%s' OK", "DIRECT_OK")
396 final = driver.call("capture")
397 check(final["context"]["session"] == session, "remote lifecycle changed", final)
398 evidence["remote_session_context"] = session
399 evidence["final_context"] = final["context"]
400 print(f"PASS direct-remote-named mode={mode} session={name} "
401 f"lifecycle={session['lifecycle_id'][:8]}")
402 finally:
403 driver.close()
404 write_artifact(f"direct-remote-named-{mode}.json", evidence)
405
406
407def scenario_warm_reuse(client_container, remote_container, env):
408 """Warm remote reuse through the broker's warm transport pool.
409
410 Journey: local -> named remote -> local -> same named remote. Leaving the
411 remote ends its logical attachment, but the broker keeps the physical
412 SSH/QUIC transport warm (broker.json warmTransports/warmIdleTimeout).
413 The return attaches afresh over that transport: same committed remote
414 lifecycle, retained pane output, and new committed output. No-redial
415 evidence is deterministic: the broker log records zero broker_remote_dial
416 events from the first remote commit to the end of the journey, so a second
417 SSH bootstrap or QUIC handshake (by the attach or by an observation probe)
418 fails the scenario. The remote daemon's attach count is recorded, not
419 asserted: each visit is a new logical attachment by design.
420 """
421 mode = env.get("VEV_ACCEPTANCE_TRANSPORT", "quic")
422 suffix = uuid.uuid4().hex[:8]
423 local_name = "locl" + suffix
424 remote_name = "remt" + suffix
425 fixture = Driver(remote_container, ["--session", remote_name])
426 fixture_capture = fixture.call("capture")
427 fixture_session = fixture_capture["context"]["session"]
428 fixture.close()
429 check(fixture_session["session_name"] == remote_name,
430 "remote fixture did not commit the named target", fixture_capture)
431 remote_lifecycle = fixture_session["lifecycle_id"]
432 # Snapshot log positions after the fixture so only events from this client
433 # journey are counted.
434 remote_offset = log_size(remote_container, DAEMON_LOG)
435 driver = Driver(client_container, ["--session", local_name], env=env)
436 evidence = {"scenario": "warm-reuse", "mode": mode,
437 "local_session": local_name, "remote_session": remote_name,
438 "remote_lifecycle": remote_lifecycle,
439 "fixture_session": fixture_session}
440 broker_offset = None
441 try:
442 initial = driver.call("capture")["context"]
443 check(initial["session"]["session_name"] == local_name,
444 "local session not committed", initial)
445 local_lifecycle = initial["session"]["lifecycle_id"]
446 # 1. local -> named exact remote.
447 await_remote_inventory(client_container, remote_name)
448 switch_route(driver, remote_name, "remote", remote_lifecycle)
449 first_remote = driver.call("capture")["context"]
450 check(first_remote["session"] == fixture_session,
451 "first remote attach did not commit the exact named lifecycle", first_remote)
452 evidence["first_remote"] = first_remote
453 # Every dial after this point would be a re-bootstrap.
454 broker_offset = log_size(client_container, BROKER_LOG)
455 # Committed output while the remote pane is authoritative.
456 commit_output(driver, "printf 'WARM_%s' PERSIST", "WARM_PERSIST")
457 # 2. remote -> local: the logical attachment ends; the transport stays warm.
458 switch_route(driver, local_name, "local", local_lifecycle)
459 local_return = driver.call("capture")["context"]
460 check(local_return["session"]["session_name"] == local_name,
461 "return to local did not commit", local_return)
462 evidence["local_return"] = local_return
463 # 3. local -> same named remote: a new attach over the warm transport.
464 switch_route(driver, remote_name, "remote", remote_lifecycle)
465 second_remote = driver.call("capture")["context"]
466 check(second_remote["session"]["lifecycle_id"] == remote_lifecycle,
467 "warm return changed the remote lifecycle", second_remote)
468 check(second_remote["session"] == fixture_session,
469 "warm return did not reuse the exact named target", second_remote)
470 evidence["second_remote"] = second_remote
471 # State and new committed output both survive the warm round trip.
472 driver.wait_text("WARM_PERSIST")
473 commit_output(driver, "printf 'WARM_%s' AFTER", "WARM_AFTER")
474 after_output = driver.call("capture")["context"]
475 check(after_output["session"]["lifecycle_id"] == remote_lifecycle,
476 "lifecycle changed after warm committed output", after_output)
477 evidence["after_warm_output"] = after_output
478 finally:
479 driver.close()
480 if broker_offset is not None:
481 broker_events = json_events(log_since(client_container, BROKER_LOG, broker_offset))
482 remote_events = json_events(log_since(remote_container, DAEMON_LOG, remote_offset))
483 evidence["log_counts"] = {
484 "broker_remote_dials_after_first_attach": count_events(
485 broker_events, "broker_remote_dial"),
486 "remote_client_attached": count_events(
487 remote_events, "client attached", session=remote_name),
488 }
489 write_artifact(f"warm-reuse-{mode}.json", evidence)
490 counts = evidence["log_counts"]
491 check(counts["broker_remote_dials_after_first_attach"] == 0,
492 "broker re-bootstrapped the remote instead of reusing the warm transport", counts)
493 print(f"PASS warm-reuse mode={mode} local={local_name} remote={remote_name} "
494 f"lifecycle={remote_lifecycle[:8]} "
495 f"attaches={counts['remote_client_attached']} redials=0")
496
497
498def picker_topology(env):
499 """Topology for the client-picker scenario: which daemon serves it.
500
501 Only the topologies whose serving attachment answers the palette
502 session-picker command itself are applicable. A hybrid client that
503 advertises the home-picker capability is answered with a navigation
504 directive instead (the R1-preserved home-picker delegation), so its
505 picker is served by the home route's overlay rather than by the
506 client-owned interaction; hybrid coverage stays on
507 ``hybrid-exact-return``.
508 """
509 topology = env.get("VEV_ACCEPTANCE_TOPOLOGY", "local")
510 if topology not in ("local", "direct"):
511 raise SystemExit(f"unknown topology {topology}; want local or direct")
512 return topology
513
514
515def open_client_picker(driver, session, timeout_ms=WAIT_TIMEOUT_MS):
516 """Run the palette session-picker command and wait for the client frame.
517
518 The client owns presentation, so the frame lists the serving daemon's
519 sessions with no daemon title chrome. The opening action must be
520 processed: it completes only against the daemon's committed output
521 boundary, never from sending the key.
522 """
523 # The query is the command's exact palette code: commands are searched
524 # by code, so the exact match ranks ahead of every fuzzy session row
525 # and the selection stays on the session-picker command instead of
526 # whichever session row the query text also matched. The label is the
527 # rendered command row, not its name.
528 driver.palette("SSP", "Open the session picker")
529 entered = driver.call("keys", keys=["Enter"])
530 check(entered.get("status") == "processed", "palette enter not processed", entered)
531 driver.wait_text(session, timeout_ms=timeout_ms)
532 return entered
533
534
535def commit_target(driver, target, before):
536 """Search the observed list down to one target, then commit it.
537
538 Row order is never assumed: the search narrows the selectable set to
539 rows matching the observed name, and the committed context is asserted
540 against the intended target.
541 """
542 searched = driver.call("keys", keys=["/"])
543 check(searched.get("accepted"), "picker search not admitted", searched)
544 typed = driver.call("text", text=target)
545 check(typed.get("accepted"), "picker search text not admitted", typed)
546 driver.wait_text(target, timeout_ms=WAIT_TIMEOUT_MS)
547 committed = driver.call("keys", keys=["Enter"])
548 check(committed.get("status") == "processed", "picker commit not processed", committed)
549 check(committed["context"]["session"]["session_name"] == target,
550 "client-picker did not commit the observed target", committed)
551 check(committed["context"]["session"] != before["session"],
552 "commit did not change the committed session identity", committed)
553 check(committed["context"]["focus"]["pane_id"] != "", "commit published no focus", committed)
554 check(committed["context"]["status"] == "attached", "commit did not publish attached status", committed)
555 return committed
556
557
558def scenario_client_picker_navigate(client_container, remote_container, env):
559 """P6.2: client-owned navigation picker end to end, plus its cancel path."""
560 topology = picker_topology(env)
561 mode = env.get("VEV_ACCEPTANCE_TRANSPORT", "quic")
562 suffix = uuid.uuid4().hex[:8]
563 first, second, entry = (name + suffix for name in ("picka", "pickb", "pickc"))
564 # Direct mode is served by the remote daemon: the picker interaction
565 # belongs to the serving attachment, so its rows are the remote
566 # daemon's sessions. The attachment itself is a named remote session so
567 # the scenario never races an ephemeral attach for its starting point.
568 serving_container = remote_container if topology == "direct" else client_container
569 fixture = Driver(serving_container, ["--session", second])
570 fixture.close()
571 if topology == "direct":
572 entry_fixture = Driver(remote_container, ["--session", entry])
573 entry_fixture.close()
574 driver = Driver(client_container, ["--remote", "remote", "--session", entry], env=env)
575 else:
576 driver = Driver(client_container, ["--session", first], env=env)
577 try:
578 initial = driver.call("capture")["context"]
579 want_initial = entry if topology == "direct" else first
580 check(initial["session"]["session_name"] == want_initial, "initial session mismatch", initial)
581 # Open, search to the observed target, commit. The opener's result
582 # envelope carries the boundary reached when it completed, not the
583 # session at admission, so only captures are compared.
584 open_client_picker(driver, second)
585 before_commit = driver.call("capture")["context"]
586 committed = commit_target(driver, second, before_commit)
587 # Cancel subcase: reopen the picker from the committed session and
588 # leave it with Escape. The source route and screen come back
589 # unchanged.
590 before_cancel = driver.call("capture")["context"]
591 cancelled = open_client_picker(driver, second)
592 check(cancelled["context"]["session"] == committed["context"]["session"],
593 "reopening the picker changed the session", cancelled)
594 escape = driver.call("keys", keys=["Escape"])
595 check(escape.get("status") == "processed", "picker cancel not processed", escape)
596 after = driver.call("capture")
597 check(after["context"]["session"] == before_cancel["session"], "cancel changed the session", after)
598 check(after["context"]["generation"] == before_cancel["generation"], "cancel changed the generation", after)
599 print(f"PASS client-picker-navigate topology={topology} mode={mode} target={second}")
600 finally:
601 driver.close()
602
603
604def scenario_hybrid_exact_return(client_container, remote_container, env):
605 """M4/M5: local A -> local B -> remote -> exact local A with committed action."""
606 mode = env.get("VEV_ACCEPTANCE_TRANSPORT", "quic")
607 suffix = uuid.uuid4().hex[:8]
608 first, second, remote = (name + suffix for name in ("sample", "second", "other"))
609 fixture = Driver(remote_container, ["--session", remote])
610 fixture.close()
611 driver = Driver(client_container, ["--session", first], env=env)
612 try:
613 initial = driver.call("capture")["context"]["session"]
614 check(initial["session_name"] == first, "initial session mismatch", initial)
615 fixture = Driver(client_container, ["--session", second], env=env)
616 fixture.close()
617 driver.palette(second, "Switch to session " + second + "@local")
618 switched = driver.call("keys", keys=["Enter"])
619 check(switched["context"]["session"]["session_name"] == second,
620 "local switch did not commit", switched)
621 # The local daemon refreshes remote inventory on its own cadence; wait
622 # for the fixture session to be published before searching the palette.
623 await_remote_inventory(client_container, remote)
624 driver.palette(remote, "Switch to session " + remote + "@remote")
625 entered = driver.call("keys", keys=["Enter"])
626 check(entered["context"]["session"]["session_name"] == remote,
627 "remote switch did not commit", entered)
628 label = "Switch to session " + first + "@local"
629 screen = driver.palette(first, label)
630 check(screen.count(label) == 1, "expected exactly one client-qualified row", {"screen": screen[:800]})
631 check("Switch to session " + first + " " not in screen, "unqualified row leaked", {"screen": screen[:800]})
632 selected = driver.call("keys", keys=["Enter"])
633 check(selected["context"]["session"] == initial, "exact local return failed", selected)
634 print(f"PASS hybrid-exact-return mode={mode} first={first}")
635 finally:
636 driver.close()
637
638
639SCENARIOS = {
640 "local-palette-cycle": lambda client, remote, env: scenario_local_palette_cycle(client),
641 "direct-remote-named": scenario_direct_remote_named,
642 "warm-reuse": scenario_warm_reuse,
643 "hybrid-exact-return": scenario_hybrid_exact_return,
644 "client-picker-navigate": scenario_client_picker_navigate,
645}
646
647
648REMOTE_HOST = "remote"
649HOST_TRANSPORT_FLAGS = {"quic": "quic", "stdio": "ssh"}
650
651
652def register_remote(client_container, transport):
653 """Re-register the remote host with the requested carriage.
654
655 A host keeps the transport it was added with, so switching requires
656 ``host rm`` then ``host add``. The rm may fail when nothing is registered.
657 """
658 docker_capture(["exec", client_container, "vev", "host", "rm", REMOTE_HOST])
659 result = docker_capture(["exec", client_container, "vev", "host", "add",
660 "--transport", HOST_TRANSPORT_FLAGS[transport], REMOTE_HOST])
661 check(result.returncode == 0,
662 f"vev host add --transport {HOST_TRANSPORT_FLAGS[transport]} failed",
663 {"stdout": result.stdout, "stderr": result.stderr})
664
665
666def main(argv):
667 if len(argv) != 4:
668 raise SystemExit("usage: acceptance.py CLIENT_CONTAINER REMOTE_CONTAINER SCENARIO[@stdio|@quic]")
669 client_container, remote_container, spec = argv[1], argv[2], argv[3]
670 name, _, transport = spec.partition("@")
671 if name not in SCENARIOS:
672 raise SystemExit(f"unknown scenario {name}; want one of {sorted(SCENARIOS)}")
673 env = {}
674 if transport and transport not in HOST_TRANSPORT_FLAGS:
675 raise SystemExit(f"unknown transport {transport}; want stdio or quic")
676 transport = transport or "quic"
677 env["VEV_ACCEPTANCE_TRANSPORT"] = transport
678 register_remote(client_container, transport)
679 topology = os.environ.get("VEV_ACCEPTANCE_TOPOLOGY")
680 if topology:
681 env["VEV_ACCEPTANCE_TOPOLOGY"] = topology
682 SCENARIOS[name](client_container, remote_container, env)
683
684
685if __name__ == "__main__":
686 main(sys.argv)