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 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)

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