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