acceptance.py RawHistoryFolder

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)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close