diff --git a/conftest.py b/conftest.py index 88a2656d29..9f84a061ca 100644 --- a/conftest.py +++ b/conftest.py @@ -11,12 +11,17 @@ from __future__ import annotations import functools +import json +import os +import pathlib import shutil import typing as t +import uuid import pytest from _pytest.doctest import DoctestItem +from libtmux._arena import ArenaSpec from libtmux._internal.control_mode import ControlMode from libtmux.client import Client from libtmux.pane import Pane @@ -25,48 +30,166 @@ from libtmux.session import Session from libtmux.window import Window -if t.TYPE_CHECKING: - import pathlib - pytest_plugins = ["pytester"] +ARENA_EVIDENCE_PREFIX = "LIBTMUX_ARENA_EVIDENCE=" +ARENA_SPEC_KEY: pytest.StashKey[ArenaSpec] = pytest.StashKey() +ARENA_TARGET_KEY: pytest.StashKey[pathlib.Path] = pytest.StashKey() +ARENA_DISCOVERED_PATHS_KEY: pytest.StashKey[frozenset[pathlib.Path]] = pytest.StashKey() +ARENA_DISCOVERED_KEY: pytest.StashKey[frozenset[str]] = pytest.StashKey() +ARENA_COLLECTED_KEY: pytest.StashKey[frozenset[str]] = pytest.StashKey() +ARENA_PASSED_KEY: pytest.StashKey[frozenset[str]] = pytest.StashKey() + + +def _arena_spec(config: pytest.Config) -> ArenaSpec | None: + """Return the validated arena contract for this pytest invocation.""" + return config.stash.get(ARENA_SPEC_KEY, None) + + +def _arena_target(config: pytest.Config) -> pathlib.Path | None: + """Return the validated arena source for this pytest invocation.""" + return config.stash.get(ARENA_TARGET_KEY, None) + + +def pytest_addoption(parser: pytest.Parser) -> None: + """Register the source selected by the arena adapter.""" + parser.addoption( + "--libtmux-arena-target", + metavar="PATH", + help="Run one audited doctest source against an external tmux server", + ) + + +def pytest_configure(config: pytest.Config) -> None: + """Validate the arena contract before pytest initializes fixtures.""" + try: + spec = ArenaSpec.from_environ(os.environ) + except ValueError as exc_info: + raise pytest.UsageError(str(exc_info)) from exc_info + if spec is None: + return + + raw_target = config.getoption("libtmux_arena_target") + expected_relative = ArenaSpec.target_for(spec, pathlib.Path()).as_posix() + if raw_target != expected_relative: + msg = f"arena artifact {spec.artifact!r} requires {expected_relative!r}" + raise pytest.UsageError(msg) + root = pathlib.Path(config.rootpath).resolve() + target = spec.target_for(root).resolve(strict=True) + config.stash[ARENA_SPEC_KEY] = spec + config.stash[ARENA_TARGET_KEY] = target + + +def pytest_collection_finish(session: pytest.Session) -> None: + """Reject selections that include anything besides the audited source.""" + target = _arena_target(session.config) + if target is None: + return + paths = {item.path.resolve() for item in session.items} + selected = frozenset(item.nodeid for item in session.items) + discovered_paths = session.config.stash.get(ARENA_DISCOVERED_PATHS_KEY, frozenset()) + discovered = session.config.stash.get(ARENA_DISCOVERED_KEY, frozenset()) + if ( + paths != {target} + or discovered_paths != {target} + or not discovered + or selected != discovered + ): + msg = "arena requires collection of exactly one audited doctest source" + raise pytest.UsageError(msg) + session.config.stash[ARENA_COLLECTED_KEY] = selected + + +def pytest_itemcollected(item: pytest.Item) -> None: + """Record every arena item before pytest applies filters.""" + target = _arena_target(item.config) + if target is None: + return + path = item.path.resolve() + discovered_paths = item.config.stash.get(ARENA_DISCOVERED_PATHS_KEY, frozenset()) + item.config.stash[ARENA_DISCOVERED_PATHS_KEY] = discovered_paths | {path} + if path == target: + discovered = item.config.stash.get(ARENA_DISCOVERED_KEY, frozenset()) + item.config.stash[ARENA_DISCOVERED_KEY] = discovered | {item.nodeid} + + +def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo[t.Any]) -> None: + """Record successful arena doctest calls for evidence publication.""" + target = _arena_target(item.config) + if target is None or item.path.resolve() != target: + return + if call.when == "call" and call.excinfo is None: + passed = item.config.stash.get(ARENA_PASSED_KEY, frozenset()) + item.config.stash[ARENA_PASSED_KEY] = passed | {item.nodeid} + @pytest.fixture(autouse=True) def add_doctest_fixtures( request: pytest.FixtureRequest, doctest_namespace: dict[str, t.Any], -) -> None: +) -> t.Generator[None]: """Configure doctest fixtures for pytest-doctest.""" - if isinstance(request._pyfuncitem, DoctestItem) and shutil.which("tmux"): + if not isinstance(request._pyfuncitem, DoctestItem): + yield + return + + spec = _arena_spec(request.config) + if spec is None and not shutil.which("tmux"): + yield + return + + if spec is None: request.getfixturevalue("set_home") - doctest_namespace["Server"] = Server - doctest_namespace["Session"] = Session - doctest_namespace["Window"] = Window - doctest_namespace["Pane"] = Pane - doctest_namespace["Client"] = Client - doctest_namespace["server"] = request.getfixturevalue("server") - doctest_namespace["Server"] = request.getfixturevalue("TestServer") + server = request.getfixturevalue("server") + test_server = request.getfixturevalue("TestServer") session: Session = request.getfixturevalue("session") - doctest_namespace["session"] = session - doctest_namespace["window"] = session.active_window - doctest_namespace["pane"] = session.active_pane - doctest_namespace["request"] = request - doctest_namespace["ControlMode"] = ControlMode - doctest_namespace["control_mode"] = functools.partial( - ControlMode, - server=session.server, - session=session, + else: + server = Server(socket_path=spec.socket_path, tmux_bin=spec.tmux_bin) + session_name = f"libtmux_arena_{uuid.uuid4().hex}" + session = server.new_session(session_name=session_name) + test_server = functools.partial( + Server, + socket_path=spec.socket_path, + tmux_bin=spec.tmux_bin, ) - doctest_namespace["monkeypatch"] = request.getfixturevalue("monkeypatch") + + doctest_namespace["Server"] = Server + doctest_namespace["Session"] = Session + doctest_namespace["Window"] = Window + doctest_namespace["Pane"] = Pane + doctest_namespace["Client"] = Client + doctest_namespace["server"] = server + doctest_namespace["Server"] = test_server + doctest_namespace["session"] = session + doctest_namespace["window"] = session.active_window + doctest_namespace["pane"] = session.active_pane + doctest_namespace["request"] = request + doctest_namespace["ControlMode"] = ControlMode + doctest_namespace["control_mode"] = functools.partial( + ControlMode, + server=session.server, + session=session, + ) + doctest_namespace["monkeypatch"] = request.getfixturevalue("monkeypatch") + try: + yield + finally: + if spec is not None: + cleanup = server.cmd("kill-session", target=session_name) + if cleanup.returncode != 0: + msg = "arena session cleanup failed" + raise RuntimeError(msg) @pytest.fixture(autouse=True) def set_home( monkeypatch: pytest.MonkeyPatch, user_path: pathlib.Path, + request: pytest.FixtureRequest, ) -> None: """Configure home directory for pytest tests.""" - monkeypatch.setenv("HOME", str(user_path)) + if _arena_spec(request.config) is None: + monkeypatch.setenv("HOME", str(user_path)) @pytest.fixture(autouse=True) @@ -84,3 +207,40 @@ def setup_session( """Session-level test configuration for pytest.""" if USING_ZSH: request.getfixturevalue("zshrc") + + +def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None: + """Publish evidence only after the selected doctests pass.""" + spec = _arena_spec(session.config) + target = _arena_target(session.config) + if spec is None or target is None or exitstatus != pytest.ExitCode.OK: + return + collected = session.config.stash.get(ARENA_COLLECTED_KEY, frozenset()) + passed = session.config.stash.get(ARENA_PASSED_KEY, frozenset()) + if not collected or passed != collected or session.config.getoption("collectonly"): + return + + server = Server(socket_path=spec.socket_path, tmux_bin=spec.tmux_bin) + result = server.cmd( + "display-message", + "-p", + "#{pid}\t#{socket_path}\t#{@libtmux_arena_challenge}", + ).stdout + if len(result) != 1: + msg = "arena server identity query returned an unexpected result" + raise RuntimeError(msg) + parts = result[0].split("\t", 2) + if len(parts) != 3 or parts[1] != spec.socket_path or not parts[2]: + msg = "arena server identity does not match the requested endpoint" + raise RuntimeError(msg) + evidence = { + "artifact": spec.artifact, + "challenge": parts[2], + "schema": 1, + "server_pid": int(parts[0]), + "socket_path": parts[1], + "source": target.relative_to(session.config.rootpath).as_posix(), + } + print( + "\n" + ARENA_EVIDENCE_PREFIX + json.dumps(evidence, sort_keys=True), flush=True + ) diff --git a/src/libtmux/_arena.py b/src/libtmux/_arena.py new file mode 100644 index 0000000000..4f96859e3b --- /dev/null +++ b/src/libtmux/_arena.py @@ -0,0 +1,42 @@ +"""Contract values for the opt-in arena doctest adapter.""" + +from __future__ import annotations + +import dataclasses +import pathlib +import typing as t + +ARENA_ARTIFACT_TARGETS = { + "python-exact-binary": "docs/topics/workspace_setup.md", + "python-workspace-setup": "docs/topics/workspace_setup.md", +} + + +@dataclasses.dataclass(frozen=True) +class ArenaSpec: + """Describe one explicitly selected external tmux endpoint.""" + + artifact: str + socket_path: str + tmux_bin: str + + @classmethod + def from_environ(cls, environ: t.Mapping[str, str]) -> ArenaSpec | None: + """Return an active specification only for a complete descriptor contract.""" + if not environ.get("LIBTMUX_ARENA_DESCRIPTOR"): + return None + + artifact = environ.get("LIBTMUX_ARENA_ARTIFACT") + socket_path = environ.get("LIBTMUX_SOCKET_PATH") + tmux_bin = environ.get("LIBTMUX_TMUX_BIN") + if not artifact or not socket_path or not tmux_bin: + msg = "arena descriptor, artifact, socket, and tmux executable are required" + raise ValueError(msg) + if artifact not in ARENA_ARTIFACT_TARGETS: + msg = f"arena artifact {artifact!r} has no audited source mapping" + raise ValueError(msg) + return cls(artifact=artifact, socket_path=socket_path, tmux_bin=tmux_bin) + + def target_for(self, root: pathlib.Path) -> pathlib.Path: + """Resolve the source bound to this artifact inside ``root``.""" + return root / ARENA_ARTIFACT_TARGETS[self.artifact] diff --git a/tests/test_arena.py b/tests/test_arena.py new file mode 100644 index 0000000000..3716178595 --- /dev/null +++ b/tests/test_arena.py @@ -0,0 +1,430 @@ +"""Tests for the arena doctest adapter.""" + +from __future__ import annotations + +import dataclasses +import importlib +import json +import os +import pathlib +import shlex +import shutil +import subprocess +import sys +import typing as t + +import pytest + +from libtmux.server import Server + +ROOT = pathlib.Path(__file__).parents[1] +TARGET = "docs/topics/workspace_setup.md" + + +@dataclasses.dataclass(frozen=True) +class ArenaEndpoint: + """Retain an externally owned tmux server and its hold session.""" + + server: Server + hold_name: str + socket_path: str + challenge: str | None + + +def _external_endpoint( + TestServer: t.Callable[..., Server], + *, + hold_name: str = "arena-hold", + challenge: str | None = "arena-challenge", +) -> ArenaEndpoint: + """Start one external tmux daemon that the adapter must not own.""" + server = TestServer() + server.new_session(session_name=hold_name) + socket_path = server.cmd("display-message", "-p", "#{socket_path}").stdout[0] + if challenge is not None: + server.cmd("set-option", "-g", "@libtmux_arena_challenge", challenge) + return ArenaEndpoint(server, hold_name, socket_path, challenge) + + +def _arena_environ(endpoint: ArenaEndpoint, tmux_bin: str) -> dict[str, str]: + """Build the complete activated environment for one external endpoint.""" + return os.environ | { + "LIBTMUX_ARENA_DESCRIPTOR": "arena", + "LIBTMUX_ARENA_ARTIFACT": "python-exact-binary", + "LIBTMUX_SOCKET_PATH": endpoint.socket_path, + "LIBTMUX_TMUX_BIN": tmux_bin, + } + + +def _run_arena( + environ: dict[str, str], *arguments: str +) -> subprocess.CompletedProcess[str]: + """Run the native pytest entrypoint with the supplied arena selection.""" + return subprocess.run( + [sys.executable, "-m", "pytest", "--reruns=0", *arguments], + capture_output=True, + check=False, + cwd=ROOT, + env=environ, + text=True, + ) + + +def _assert_only_hold(endpoint: ArenaEndpoint) -> None: + """Assert the adapter did not own or retain the external daemon state.""" + assert endpoint.server.is_alive() + assert [session.session_name for session in endpoint.server.sessions] == [ + endpoint.hold_name + ] + + +def _remove_adapter_sessions(endpoint: ArenaEndpoint) -> None: + """Remove only sessions that a failed adapter run could have created.""" + for session in endpoint.server.sessions: + session_name = session.session_name + if session_name is not None and session_name != endpoint.hold_name: + endpoint.server.kill_session(session_name) + + +def _failing_tmux_wrapper( + tmp_path: pathlib.Path, + tmux_bin: str, + rejected_command: str, +) -> tuple[pathlib.Path, pathlib.Path]: + """Create a wrapper that logs every invocation and fails one subcommand.""" + wrapper = tmp_path / f"fail-{rejected_command}-tmux" + invocation_log = tmp_path / "tmux-invocations" + wrapper.write_text( + "#!/usr/bin/env python3\n" + "import os\n" + "import sys\n" + f"with open({str(invocation_log)!r}, 'a', encoding='utf-8') as stream:\n" + " print(sys.argv[1:], file=stream)\n" + f"if {rejected_command!r} in sys.argv:\n" + " raise SystemExit(1)\n" + f"os.execv({tmux_bin!r}, [{tmux_bin!r}, *sys.argv[1:]])\n", + encoding="utf-8", + ) + wrapper.chmod(0o755) + return wrapper, invocation_log + + +def test_descriptor_is_the_only_arena_activation_switch() -> None: + """An alias without a descriptor preserves the ordinary doctest path.""" + arena = importlib.import_module("libtmux._arena") + + assert ( + arena.ArenaSpec.from_environ({"LIBTMUX_ARENA_ARTIFACT": "python-exact-binary"}) + is None + ) + assert ( + arena.ArenaSpec.from_environ( + { + "LIBTMUX_ARENA_DESCRIPTOR": "", + "LIBTMUX_ARENA_ARTIFACT": "python-exact-binary", + "LIBTMUX_SOCKET_PATH": "socket", + "LIBTMUX_TMUX_BIN": "tmux", + } + ) + is None + ) + + +@pytest.mark.parametrize( + "environ", + [ + {"LIBTMUX_ARENA_DESCRIPTOR": "arena"}, + { + "LIBTMUX_ARENA_DESCRIPTOR": "arena", + "LIBTMUX_ARENA_ARTIFACT": "", + "LIBTMUX_SOCKET_PATH": "socket", + "LIBTMUX_TMUX_BIN": "tmux", + }, + { + "LIBTMUX_ARENA_DESCRIPTOR": "arena", + "LIBTMUX_ARENA_ARTIFACT": "python-exact-binary", + "LIBTMUX_SOCKET_PATH": "", + "LIBTMUX_TMUX_BIN": "tmux", + }, + { + "LIBTMUX_ARENA_DESCRIPTOR": "arena", + "LIBTMUX_ARENA_ARTIFACT": "python-exact-binary", + "LIBTMUX_SOCKET_PATH": "socket", + "LIBTMUX_TMUX_BIN": "", + }, + { + "LIBTMUX_ARENA_DESCRIPTOR": "arena", + "LIBTMUX_ARENA_ARTIFACT": "wrong-artifact", + "LIBTMUX_SOCKET_PATH": "socket", + "LIBTMUX_TMUX_BIN": "tmux", + }, + ], +) +def test_activated_contract_rejects_empty_or_unknown_values( + environ: dict[str, str], +) -> None: + """An incomplete contract cannot fall through to ambient tmux.""" + arena = importlib.import_module("libtmux._arena") + + with pytest.raises(ValueError): + arena.ArenaSpec.from_environ(environ) + + +@pytest.mark.parametrize("artifact", ["python-exact-binary", "python-workspace-setup"]) +def test_artifact_requires_the_workspace_setup_source( + artifact: str, +) -> None: + """Both audited artifacts bind evidence to the one documented source.""" + arena = importlib.import_module("libtmux._arena") + spec = arena.ArenaSpec.from_environ( + { + "LIBTMUX_ARENA_DESCRIPTOR": "arena", + "LIBTMUX_ARENA_ARTIFACT": artifact, + "LIBTMUX_SOCKET_PATH": "socket", + "LIBTMUX_TMUX_BIN": "tmux", + } + ) + + assert spec is not None + assert spec.target_for(pathlib.Path("/repo")) == pathlib.Path( + "/repo/docs/topics/workspace_setup.md" + ) + + +@pytest.mark.parametrize( + ("artifact", "target"), + [ + ("", "docs/topics/workspace_setup.md"), + ("unknown", "docs/topics/workspace_setup.md"), + ("python-exact-binary", "README.md"), + ], +) +def test_activated_pytest_rejects_invalid_contract_before_talking_to_tmux( + artifact: str, + target: str, + tmp_path: pathlib.Path, +) -> None: + """Bad activation input cannot reach a default or external server.""" + invocation_log = tmp_path / "tmux-invocations" + wrapper = tmp_path / "tmux" + wrapper.write_text( + f"#!/bin/sh\nprintf invoked >> {shlex.quote(str(invocation_log))}\nexit 1\n", + encoding="utf-8", + ) + wrapper.chmod(0o755) + environ = os.environ | { + "LIBTMUX_ARENA_DESCRIPTOR": "arena", + "LIBTMUX_ARENA_ARTIFACT": artifact, + "LIBTMUX_SOCKET_PATH": "/not-a-tmux-socket", + "LIBTMUX_TMUX_BIN": str(wrapper), + } + + result = _run_arena(environ, "--libtmux-arena-target", target, target) + + assert result.returncode == 4 + assert not invocation_log.exists() + assert "LIBTMUX_ARENA_EVIDENCE=" not in result.stdout + + +def test_arena_runs_the_exact_doctest_on_an_external_server( + TestServer: t.Callable[..., Server], + tmp_path: pathlib.Path, +) -> None: + """The selected page owns sessions without taking the external daemon.""" + endpoint = _external_endpoint(TestServer) + assert endpoint.challenge is not None + tmux_bin = shutil.which("tmux") + assert tmux_bin is not None + invocation_log = tmp_path / "tmux-invocations" + wrapper = tmp_path / "exact-tmux" + wrapper.write_text( + "#!/bin/sh\n" + f"printf '%s\\n' \"$@\" >> {shlex.quote(str(invocation_log))}\n" + f'exec {shlex.quote(tmux_bin)} "$@"\n', + encoding="utf-8", + ) + wrapper.chmod(0o755) + result = _run_arena( + _arena_environ(endpoint, str(wrapper)), + "--libtmux-arena-target", + TARGET, + TARGET, + ) + + assert result.returncode == 0, result.stdout + result.stderr + evidence_lines = [ + line.removeprefix("LIBTMUX_ARENA_EVIDENCE=") + for line in result.stdout.splitlines() + if line.startswith("LIBTMUX_ARENA_EVIDENCE=") + ] + assert len(evidence_lines) == 1 + evidence = json.loads(evidence_lines[0]) + assert evidence == { + "artifact": "python-exact-binary", + "challenge": endpoint.challenge, + "schema": 1, + "server_pid": int( + endpoint.server.cmd("display-message", "-p", "#{pid}").stdout[0] + ), + "socket_path": endpoint.socket_path, + "source": TARGET, + } + assert invocation_log.read_text(encoding="utf-8") + _assert_only_hold(endpoint) + + +def test_arena_rejects_a_deselected_doctest_subset( + TestServer: t.Callable[..., Server], +) -> None: + """A passing subset cannot produce evidence for the complete source.""" + endpoint = _external_endpoint(TestServer) + tmux_bin = shutil.which("tmux") + assert tmux_bin is not None + result = _run_arena( + _arena_environ(endpoint, tmux_bin), + "--libtmux-arena-target", + TARGET, + "-k", + "0", + TARGET, + ) + + assert result.returncode == 4 + assert "LIBTMUX_ARENA_EVIDENCE=" not in result.stdout + _assert_only_hold(endpoint) + + +def test_arena_rejects_a_deselected_second_source( + TestServer: t.Callable[..., Server], +) -> None: + """A filtered second source cannot disappear from collection proof.""" + endpoint = _external_endpoint(TestServer) + tmux_bin = shutil.which("tmux") + assert tmux_bin is not None + result = _run_arena( + _arena_environ(endpoint, tmux_bin), + "--libtmux-arena-target", + TARGET, + "-k", + "workspace_setup", + TARGET, + "README.md", + ) + + assert result.returncode == 4 + assert "LIBTMUX_ARENA_EVIDENCE=" not in result.stdout + _assert_only_hold(endpoint) + + +def test_arena_rejects_an_external_server_without_a_challenge( + TestServer: t.Callable[..., Server], +) -> None: + """A successful doctest run cannot publish an empty challenge.""" + endpoint = _external_endpoint(TestServer, challenge=None) + tmux_bin = shutil.which("tmux") + assert tmux_bin is not None + result = _run_arena( + _arena_environ(endpoint, tmux_bin), + "--libtmux-arena-target", + TARGET, + TARGET, + ) + + assert result.returncode != 0 + assert "LIBTMUX_ARENA_EVIDENCE=" not in result.stdout + _assert_only_hold(endpoint) + + +def test_arena_rejects_a_wrapper_redirected_socket( + TestServer: t.Callable[..., Server], + tmp_path: pathlib.Path, +) -> None: + """Evidence cannot name a socket different from the requested endpoint.""" + expected = _external_endpoint(TestServer, hold_name="arena-expected-hold") + alternate = _external_endpoint(TestServer, hold_name="arena-alternate-hold") + tmux_bin = shutil.which("tmux") + assert tmux_bin is not None + wrapper = tmp_path / "redirect-tmux" + wrapper.write_text( + "#!/usr/bin/env python3\n" + "import os\n" + "import sys\n" + f"alternate_socket = {alternate.socket_path!r}\n" + "arguments = sys.argv[1:]\n" + "for index, argument in enumerate(arguments):\n" + " if argument == '-S':\n" + " arguments[index + 1] = alternate_socket\n" + " elif argument.startswith('-S'):\n" + " arguments[index] = '-S' + alternate_socket\n" + f"os.execv({tmux_bin!r}, [{tmux_bin!r}, *arguments])\n", + encoding="utf-8", + ) + wrapper.chmod(0o755) + result = _run_arena( + _arena_environ(expected, str(wrapper)), + "--libtmux-arena-target", + TARGET, + TARGET, + ) + + assert result.returncode != 0 + assert "LIBTMUX_ARENA_EVIDENCE=" not in result.stdout + _assert_only_hold(expected) + _assert_only_hold(alternate) + + +def test_arena_does_not_publish_evidence_after_session_cleanup_fails( + TestServer: t.Callable[..., Server], + tmp_path: pathlib.Path, +) -> None: + """A failed adapter cleanup fails the run instead of hiding a leaked session.""" + endpoint = _external_endpoint(TestServer) + tmux_bin = shutil.which("tmux") + assert tmux_bin is not None + wrapper, invocation_log = _failing_tmux_wrapper(tmp_path, tmux_bin, "kill-session") + try: + result = _run_arena( + _arena_environ(endpoint, str(wrapper)), + "--libtmux-arena-target", + TARGET, + TARGET, + ) + + assert result.returncode != 0 + assert "LIBTMUX_ARENA_EVIDENCE=" not in result.stdout + assert "kill-session" in invocation_log.read_text(encoding="utf-8") + finally: + _remove_adapter_sessions(endpoint) + + _assert_only_hold(endpoint) + + +def test_arena_cleanup_does_not_treat_a_failed_probe_as_an_absent_session( + TestServer: t.Callable[..., Server], + tmp_path: pathlib.Path, +) -> None: + """The adapter cleans its session without a lenient existence probe.""" + endpoint = _external_endpoint(TestServer) + tmux_bin = shutil.which("tmux") + assert tmux_bin is not None + wrapper, invocation_log = _failing_tmux_wrapper(tmp_path, tmux_bin, "has-session") + try: + result = _run_arena( + _arena_environ(endpoint, str(wrapper)), + "--libtmux-arena-target", + TARGET, + TARGET, + ) + + assert result.returncode == 0, result.stdout + result.stderr + assert "LIBTMUX_ARENA_EVIDENCE=" in result.stdout + assert "has-session" in invocation_log.read_text(encoding="utf-8") + assert [ + session.session_name + for session in endpoint.server.sessions + if session.session_name != endpoint.hold_name + ] == [] + finally: + _remove_adapter_sessions(endpoint) + + _assert_only_hold(endpoint)