diff --git a/pyproject.toml b/pyproject.toml index 4c2138cb8..dfcf91d19 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,6 +60,7 @@ dev = [ "pytest>=9.0.3", "pexpect>=4.9.0", "pyte>=0.8.2", + "pywinpty==3.0.5; sys_platform == 'win32'", # Pinned so local and CI lint with the identical formatter/type-checker; # ruff/ty output shifts between releases, so bump these deliberately. "ruff==0.16.5", diff --git a/scripts/run_integration.py b/scripts/run_integration.py index 13542f23d..0124bfd0c 100644 --- a/scripts/run_integration.py +++ b/scripts/run_integration.py @@ -7,7 +7,6 @@ from __future__ import annotations import argparse -import ast import base64 import contextlib import datetime as dt @@ -33,10 +32,14 @@ "opencode": "opencode-ai", } WINDOWS_PATHEXT = ".COM;.EXE;.BAT;.CMD" -PTY_MODULES = {"pexpect", "pyte"} -PTY_HELPERS = {"utils.terminal", "utils.mcp"} # Claude exports no spans on Windows, where ug writes no machine-wide Claude settings. WINDOWS_UNSUPPORTED_MODULES = {"test_ug_claude_tracing.py"} +WINDOWS_UNSUPPORTED_NODES = { + # Seeds machine-wide managed settings, which ug has no Windows path for. + "test_ug_configure_claude_lifecycle.py::test_unmanaged_claude_preserves_preexisting_family_defaults", + # Codex's Windows sandbox can't read ug's owner-only session-env dir yet. + "test_ug_smart_routing_hooks.py::test_smart_router_skill_toggles_codex_subagent_routing", +} HEADLESS_TEST_NODES = { "claude": "test_ug_claude_headless.py::test_ug_claude_headless_prompt_argument", "codex": "test_ug_codex_headless.py::test_ug_codex_headless_prompt_argument", @@ -128,34 +131,17 @@ def integration_test_targets( if platform_name == "nt" and installation_only: return [str(suite / "test_installation.py")] if platform_name == "nt": - return [ + modules = [ str(module) for module in sorted(suite.glob("test_*.py")) - if module.name not in WINDOWS_UNSUPPORTED_MODULES and not uses_pty(module) + if module.name not in WINDOWS_UNSUPPORTED_MODULES ] + # pytest matches --deselect against node IDs relative to the suite's pytest.ini. + deselected = [f"--deselect={node}" for node in sorted(WINDOWS_UNSUPPORTED_NODES)] + return [*modules, *deselected] return [str(suite)] -def uses_pty(module: Path) -> bool: - """Whether a suite module drives agents through the POSIX-only PTY helpers.""" - imported_names = [] - for node in ast.walk(ast.parse(module.read_text(encoding="utf-8"))): - if isinstance(node, ast.ImportFrom): - imported_names.append(node.module or "") - imported_names.extend( - f"{node.module}.{alias.name}" if node.module else alias.name - for alias in node.names - if alias.name != "*" - ) - elif isinstance(node, ast.Import): - imported_names.extend(alias.name for alias in node.names) - return any( - name.split(".")[0] in PTY_MODULES - or any(name == helper or name.endswith(f".{helper}") for helper in PTY_HELPERS) - for name in imported_names - ) - - def process_group_options() -> dict: if os.name == "posix": return {"start_new_session": True} @@ -786,9 +772,8 @@ def run(command, *, cwd=output, env=base_env, timeout=600) -> str: if client_id and client_secret: bearer = mint_m2m_token(args.workspace, client_id, client_secret) - test_dependencies = ["pytest==9.0.3", "httpx==0.28.1"] - if os.name == "posix": - test_dependencies.extend(["pexpect==4.9.0", "pyte==0.8.2"]) + test_dependencies = ["pytest==9.0.3", "httpx==0.28.1", "pyte==0.8.2"] + test_dependencies.append("pexpect==4.9.0" if os.name == "posix" else "pywinpty==3.0.5") run( [ uv, diff --git a/tests/integration/README.md b/tests/integration/README.md index 3b0efe993..6e8c987f2 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -202,7 +202,7 @@ through them without changing existing journey commands or completion assertions ## Run a specific combination Prerequisites: Python 3.12+, uv, and Node/npm. Live runs also require Databricks -CLI 1.17.0. Full live/TUI runs require a POSIX host; Windows supports the explicit +CLI 1.17.0. Windows drives TUIs through ConPTY (pywinpty) and supports the explicit headless subset described below. The runner installs the requested agents into a new npm prefix and ug into a new virtualenv. Pytest, the recorder's pinned `httpx`, and, for live runs, the PTY/screen libraries (pexpect and pyte) live in a different diff --git a/tests/integration/test_ug_claude_custom_oauth.py b/tests/integration/test_ug_claude_custom_oauth.py index 527fac632..288d2e773 100644 --- a/tests/integration/test_ug_claude_custom_oauth.py +++ b/tests/integration/test_ug_claude_custom_oauth.py @@ -2,10 +2,12 @@ import configparser import json -import shlex +import os +from pathlib import Path from urllib.parse import urlparse import pytest +from utils.harness import split_command from utils.terminal import AgentTerminal pytestmark = [pytest.mark.live, pytest.mark.tui, pytest.mark.claude] @@ -49,11 +51,16 @@ def test_ug_claude_custom_oauth_cli_boots(live_session, workspace): assert profile == f"ug-oauth-{hostname}-{CLIENT_ID}" assert profiles[profile]["client_id"] == CLIENT_ID - managed_settings = json.loads( - session.run(MANAGED_SETTINGS_PATH, binary=session.which("cat"), timeout=30).stdout - ) - assert shlex.split(managed_settings["apiKeyHelper"]) == [ - str(session.binary.with_name("ug")), + if os.name == "nt": + # ug has no Windows managed-settings writer, so the helper lands in user settings. + settings_text = (session.home / ".claude/ucode-settings.json").read_text() + else: + settings_text = session.run( + MANAGED_SETTINGS_PATH, binary=session.which("cat"), timeout=30 + ).stdout + helper = split_command(json.loads(settings_text)["apiKeyHelper"]) + assert Path(helper[0]).with_suffix("") == session.binary.with_name("ug") + assert helper[1:] == [ "auth-token", "--host", workspace, diff --git a/tests/integration/test_ug_configure_claude.py b/tests/integration/test_ug_configure_claude.py index 9b2ebb882..9af96ecd9 100644 --- a/tests/integration/test_ug_configure_claude.py +++ b/tests/integration/test_ug_configure_claude.py @@ -1,11 +1,11 @@ """CUJs: configure Claude through ug, then use its real interactive session.""" import json -import shlex from pathlib import Path import pytest from utils.evidence import FileTask +from utils.harness import split_command from utils.terminal import AgentTerminal, ConfigureTerminal pytestmark = [pytest.mark.live, pytest.mark.tui, pytest.mark.claude] @@ -39,8 +39,8 @@ def test_ug_configure_claude_databricks(live_session, workspace): assert not session.workspace_state().get("provider_services", {}).get("claude") settings = json.loads((session.home / ".claude/ucode-settings.json").read_text()) - helper = shlex.split(settings["apiKeyHelper"]) - assert Path(helper[0]) == session.binary.with_name("ug") + helper = split_command(settings["apiKeyHelper"]) + assert Path(helper[0]).with_suffix("") == session.binary.with_name("ug") assert helper[1] == "auth-token" token_result = session.run(*helper[1:], binary=helper[0], strip_ansi=False, timeout=30) assert token_result.stdout == "\n" diff --git a/tests/integration/test_ug_configure_codex.py b/tests/integration/test_ug_configure_codex.py index bc5d68222..8cc6d987f 100644 --- a/tests/integration/test_ug_configure_codex.py +++ b/tests/integration/test_ug_configure_codex.py @@ -40,7 +40,7 @@ def test_ug_configure_codex_databricks(live_session, workspace): config = tomllib.loads((session.home / ".codex/ucode.config.toml").read_text()) helper = config["model_providers"][config["model_provider"]]["auth"] - assert Path(helper["command"]) == session.binary.with_name("ug") + assert Path(helper["command"]).with_suffix("") == session.binary.with_name("ug") assert helper["args"][0] == "auth-token" token_result = session.run( *helper["args"], binary=helper["command"], strip_ansi=False, timeout=30 diff --git a/tests/integration/test_ug_configure_codex_lifecycle.py b/tests/integration/test_ug_configure_codex_lifecycle.py index c745ad27a..2f329e2b2 100644 --- a/tests/integration/test_ug_configure_codex_lifecycle.py +++ b/tests/integration/test_ug_configure_codex_lifecycle.py @@ -1,5 +1,6 @@ """CUJs for changing and undoing codex's ug setup.""" +import os import tomllib import pytest @@ -22,6 +23,9 @@ def test_ug_configure_codex_repeat_and_revert(live_session, workspace): # Ordinary pre-existing user settings, not manufactured gateway state. user_path = session.home / ".codex/config.toml" original = "# user comment\n[notice]\nhide_rate_limit_model_nudge = true\n" + if os.name == "nt": + # A Windows user has picked a sandbox; `codex exec` rejects shell commands without one. + original += '[windows]\nsandbox = "unelevated"\n' user_path.parent.mkdir(parents=True) user_path.write_text(original) diff --git a/tests/integration/test_ug_smart_routing_hooks.py b/tests/integration/test_ug_smart_routing_hooks.py index 0c02ffea5..01ed497a6 100644 --- a/tests/integration/test_ug_smart_routing_hooks.py +++ b/tests/integration/test_ug_smart_routing_hooks.py @@ -185,10 +185,17 @@ def completion_counts(): before_answers, before_confirmations = completion_counts() tui.submit(invocation) - def toggled(_screen): + def toggled(screen): + if tui.approve_windows_command(screen): + return False + try: + controls_now = json.loads(controls[0].read_text()) + except PermissionError: + # Windows denies reads while ug replaces the controls file; try again next frame. + return False answers, confirmations = completion_counts() return ( - json.loads(controls[0].read_text()) == expected + controls_now == expected and confirmations > before_confirmations and answers > before_answers ) diff --git a/tests/integration/utils/constants.py b/tests/integration/utils/constants.py index 57cfab96c..1783073db 100644 --- a/tests/integration/utils/constants.py +++ b/tests/integration/utils/constants.py @@ -1,7 +1,10 @@ """Shared constants for the integration CUJs.""" +import os + CLAUDE_TEST_MODEL = "system.ai.claude-haiku-4-5" -CODEX_TEST_MODEL = "system.ai.gpt-5-4-nano" +# nano sometimes nests powershell inside Codex's pwsh, breaking `$` expansion in its file reads. +CODEX_TEST_MODEL = "system.ai.gpt-5-6-luna" if os.name == "nt" else "system.ai.gpt-5-4-nano" MANAGED_CLAUDE_PROVIDER_SERVICE = "main.default.ci_e2e_anthropic_mps" MANAGED_CODEX_PROVIDER_SERVICE = "main.default.ci_e2e_openai_mps" diff --git a/tests/integration/utils/harness.py b/tests/integration/utils/harness.py index abaca355e..8f5998576 100644 --- a/tests/integration/utils/harness.py +++ b/tests/integration/utils/harness.py @@ -3,10 +3,12 @@ from __future__ import annotations import contextlib +import ctypes import json import os import queue import re +import shlex import shutil import signal import subprocess @@ -19,6 +21,21 @@ ANSI = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]") +def split_command(line: str) -> list[str]: + """Split a command line the way the OS will when an agent runs it.""" + if os.name != "nt": + return shlex.split(line) + # ug writes Windows commands with list2cmdline; CreateProcess splits them like this. + split = ctypes.windll.shell32.CommandLineToArgvW + split.restype = ctypes.POINTER(ctypes.c_wchar_p) + count = ctypes.c_int() + argv = split(line, ctypes.byref(count)) + try: + return [argv[index] for index in range(count.value)] + finally: + ctypes.windll.kernel32.LocalFree(argv) + + def process_group_options() -> dict: if os.name == "posix": return {"start_new_session": True} @@ -182,7 +199,9 @@ def choose_codex_windows_sandbox(self) -> None: return config = self.home / ".codex/config.toml" config.parent.mkdir(parents=True, exist_ok=True) - config.write_text('[windows]\nsandbox = "unelevated"\n') + existing = config.read_text(encoding="utf-8") if config.is_file() else "" + if "[windows]" not in existing: + config.write_text(existing + '\n[windows]\nsandbox = "unelevated"\n', encoding="utf-8") def record(self, name: str, value: object) -> None: (self.artifacts / name).write_text(self.redact(json.dumps(value, indent=2))) diff --git a/tests/integration/utils/terminal.py b/tests/integration/utils/terminal.py index 83cde4ff0..9a35c9e29 100644 --- a/tests/integration/utils/terminal.py +++ b/tests/integration/utils/terminal.py @@ -8,15 +8,20 @@ import contextlib import os +import queue import re +import shutil import signal +import subprocess +import threading import time -import pexpect import pyte from .evidence import agent_sessions, assert_no_terminal_api_error, fresh_id +SELECTED = "[›❯>]" + def _claude_background_task_menu(text): return re.search( @@ -66,6 +71,119 @@ def write_process_input(self, data): self.send(data) +class PosixPty: + """pexpect-backed PTY; `read` returns None on timeout and raises EOFError at end.""" + + def __init__(self, command, cwd, env, dimensions): + import pexpect + + self._pexpect = pexpect + self.child = pexpect.spawn( + command[0], + command[1:], + cwd=cwd, + env=env, + encoding="utf-8", + codec_errors="replace", + dimensions=dimensions, + timeout=120, + ) + + def read(self, timeout): + try: + return self.child.read_nonblocking(size=65536, timeout=timeout) + except self._pexpect.TIMEOUT: + return None + except self._pexpect.EOF: + raise EOFError from None + + def send(self, keys): + self.child.send(keys) + + def kill(self): + # pexpect creates a new session with a controlling terminal. Clean up + # its process group even if the ug leader exited before its children. + with contextlib.suppress(ProcessLookupError): + os.killpg(self.child.pid, signal.SIGTERM) + # close() gives up if the child outlives its kill delay; the SIGKILL below still kills it. + with contextlib.suppress(self._pexpect.ExceptionPexpect): + self.child.close(force=True) + with contextlib.suppress(ProcessLookupError): + os.killpg(self.child.pid, signal.SIGKILL) + + def close(self): + self.child.close(force=False) + + @property + def exitstatus(self): + return self.child.exitstatus + + @property + def signalstatus(self): + return self.child.signalstatus + + +class ConPty: + """pywinpty (ConPTY) backend; a daemon thread feeds the blocking `read` into a queue.""" + + def __init__(self, command, cwd, env, dimensions): + import winpty # ty: ignore[unresolved-import] + + # ConPTY launches executables only; batch wrappers need cmd.exe. + argv = [shutil.which(command[0]) or command[0], *command[1:]] + if argv[0].lower().endswith((".cmd", ".bat")): + argv = [os.environ.get("COMSPEC", "cmd.exe"), "/c", *argv] + self.child = winpty.PtyProcess.spawn(argv, cwd=cwd, env=env, dimensions=dimensions) + self._chunks = queue.Queue() + threading.Thread(target=self._pump, daemon=True).start() + + def _pump(self): + try: + while chunk := self.child.read(65536): + self._chunks.put(chunk) + except Exception: # noqa: BLE001 - EOFError or a closed pty both mean the stream ended + pass + self._chunks.put(None) + + def read(self, timeout): + try: + chunk = self._chunks.get(timeout=timeout) + except queue.Empty: + return None + if chunk is None: + self._chunks.put(None) + raise EOFError + return chunk + + def send(self, keys): + self.child.write(keys) + + def kill(self): + # terminate() ends only the leader; taskkill /T takes its descendants too. + subprocess.run( + ["taskkill", "/T", "/F", "/PID", str(self.child.pid)], + capture_output=True, + check=False, + ) + with contextlib.suppress(Exception): + self.child.terminate(force=True) + + def close(self): + with contextlib.suppress(Exception): + self.child.wait() + + @property + def exitstatus(self): + return self.child.exitstatus + + signalstatus = None + + +def spawn_pty(command, cwd, env, dimensions): + backend = ConPty if os.name == "nt" else PosixPty + return backend(command, cwd, env, dimensions) + + class TerminalProcess: def __init__(self, session, agent, command, name): self.session = session @@ -73,21 +191,16 @@ def __init__(self, session, agent, command, name): self.name = name self.command = command env = {**session.env, "TERM": "xterm-256color"} - self.child = pexpect.spawn( - self.command[0], - self.command[1:], - cwd=str(session.cwd), - env=env, - encoding="utf-8", - codec_errors="replace", - dimensions=(60, 140), - timeout=120, - ) + if agent == "codex": + # Seed the sandbox choice so the Codex TUI skips its arrow-key picker. + session.choose_codex_windows_sandbox() + self.child = spawn_pty(self.command, str(session.cwd), env, (60, 140)) self.screen = TerminalScreen(140, 60, self.child.send) self.stream = pyte.Stream(self.screen) self.output = [] self.actions = [] self.ended = False + self.approving = False @property def visible(self): @@ -97,15 +210,7 @@ def __enter__(self): return self def __exit__(self, exc_type, exc, traceback): - # pexpect creates a new session with a controlling terminal. Clean up - # its process group even if the ug leader exited before its children. - with contextlib.suppress(ProcessLookupError): - os.killpg(self.child.pid, signal.SIGTERM) - # close() gives up if the child outlives its kill delay; the SIGKILL below still kills it. - with contextlib.suppress(pexpect.ExceptionPexpect): - self.child.close(force=True) - with contextlib.suppress(ProcessLookupError): - os.killpg(self.child.pid, signal.SIGKILL) + self.child.kill() routing_log = ( self.session.home / ".ucode" @@ -131,12 +236,12 @@ def __exit__(self, exc_type, exc, traceback): def read(self): try: - chunk = self.child.read_nonblocking(size=65536, timeout=0.2) - except pexpect.TIMEOUT: - return - except pexpect.EOF: + chunk = self.child.read(timeout=0.2) + except EOFError: self.ended = True return + if chunk is None: + return self.output.append(chunk) self.stream.feed(chunk) @@ -167,7 +272,11 @@ def wait_for(self, predicate, description, timeout=30, stable_for=0.3): def selected_line(self): return next( - (line.strip() for line in self.visible.splitlines() if re.match(r"^\s*[›❯>]", line)), + ( + line.strip() + for line in self.visible.splitlines() + if re.match(rf"^\s*{SELECTED}", line) + ), "", ) @@ -207,7 +316,7 @@ def finish(self, timeout=120): while not self.ended and time.monotonic() < deadline: self.read() assert self.ended, f"Process did not exit within {timeout}s:\n{self.visible}" - self.child.close(force=False) + self.child.close() assert self.child.exitstatus == 0, ( f"exit={self.child.exitstatus}, signal={self.child.signalstatus}:\n{self.visible}" ) @@ -246,6 +355,7 @@ class AgentTerminal(TerminalProcess): def boot(self, timeout=120): """Handle only recognized visible onboarding; unknown screens fail.""" handled = set() + answered = {} ready_since = None deadline = time.monotonic() + timeout while time.monotonic() < deadline: @@ -299,7 +409,7 @@ def boot(self, timeout=120): and bool( re.search( rf"(?m)^\s*>\s+You are in " - rf"{re.escape(str(self.session.cwd.parent))}/[^/\r\n]*$", + rf"{re.escape(str(self.session.cwd.parent))}[\\/][^\\/\r\n]*$", text, ) ) @@ -308,7 +418,7 @@ def boot(self, timeout=120): or "directory allows project-local config, hooks, and exec policies to load." in text ) - and bool(re.search(r"(?m)^\s*[›❯>]\s*1[.)]\s+Yes, continue\s*$", text)) + and bool(re.search(rf"(?m)^\s*{SELECTED}\s*1[.)]\s+Yes, continue\s*$", text)) and bool(re.search(r"(?m)^\s*2[.)]\s+No, quit\s*$", text)) and "Press enter to continue" in text, "\r", @@ -318,9 +428,10 @@ def boot(self, timeout=120): for label, shown, keys in dialogs: if shown: matched = True - if label not in handled: + # Startup can drop the first keypress; answer again if the dialog stays up. + if time.monotonic() - answered.get(label, float("-inf")) > 10: self.send(keys, label) - handled.add(label) + answered[label] = time.monotonic() break if matched: ready_since = None @@ -402,6 +513,21 @@ def open_codex_model_picker(self, *, model_visible): ) return screen + def approve_windows_command(self, screen): + """Approve a Codex-on-Windows command prompt once; return whether one is showing.""" + if "Would you like to run the following command?" not in screen: + self.approving = False + return False + assert self.agent == "codex" and os.name == "nt", ( + "Agent requested an unrecognized command approval:\n" + screen + ) + # Codex on Windows asks before every shell command. Approve so these tests + # exercise ug instead of the model's choice of command; wait until the options render. + if not self.approving and re.search(rf"(?m)^\s*{SELECTED}\s*1\.\s*Yes, proceed", screen): + self.send("\r", "approve the Codex command prompt") + self.approving = True + return True + def wait_for_task(self, task, timeout=180): permission_in_progress = False @@ -421,13 +547,15 @@ def completed(screen): rf'(?m)^\s*find {project_root} -name ["\']{filename}["\'] 2>/dev/null\s*$', screen, ) - first_yes = re.search(r"(?m)^\s*[›❯>]\s*1\.\s*Yes\s*$", screen) + first_yes = re.search(rf"(?m)^\s*{SELECTED}\s*1\.\s*Yes\s*$", screen) assert self.agent == "claude" and safe_find and first_yes, ( "Agent requested an unrecognized tool permission:\n" + screen ) self.send("\r", f"allow read-only search for {task.filename}") permission_in_progress = True return False + if self.approve_windows_command(screen): + return False permission_in_progress = False return task.completed(self.session, self.agent) diff --git a/tests/test_e2e_cuj_helpers.py b/tests/test_e2e_cuj_helpers.py index 50472e147..ec07f1c65 100644 --- a/tests/test_e2e_cuj_helpers.py +++ b/tests/test_e2e_cuj_helpers.py @@ -112,14 +112,20 @@ def test_clean_teardown_records_no_leak(tmp_path, monkeypatch): ) -requires_pty = pytest.mark.skipif(sys.platform == "win32", reason="Terminal needs a POSIX PTY") +# These fakes are shebang scripts that drive the tty with termios, which Windows lacks. +requires_pty = pytest.mark.skipif(sys.platform == "win32", reason="fake ug needs POSIX termios") def _fake_ug(tmp_path, body): """A real executable standing in for `ug`, so the real PTY driver runs offline.""" - script = tmp_path / "ug" - script.write_text("#!/bin/sh\n" + body) - script.chmod(0o755) + (tmp_path / "fake_ug.py").write_text(body) + if sys.platform == "win32": + script = tmp_path / "ug.cmd" + script.write_text(f'@"{sys.executable}" "%~dp0fake_ug.py" %*\r\n') + else: + script = tmp_path / "ug" + script.write_text(f'#!/bin/sh\nexec "{sys.executable}" "$(dirname "$0")/fake_ug.py" "$@"\n') + script.chmod(0o755) return UserSession(tmp_path, script, tmp_path / "artifacts", "token") @@ -133,28 +139,33 @@ def test_terminal_rejects_a_missing_unsupported_or_mismatched_agent(tmp_path, ar Terminal(session, "rejected", args, agent=agent) -@requires_pty def test_wait_until_rejects_a_permission_prompt(tmp_path): - session = _fake_ug(tmp_path, "echo 'Do you want to proceed?'\nsleep 30\n") + session = _fake_ug( + tmp_path, "import time\nprint('Do you want to proceed?', flush=True)\ntime.sleep(30)\n" + ) with Terminal(session, "rejects", [], agent=CLAUDE) as tui: with pytest.raises(AssertionError, match="Unexpected permission request"): tui.wait_until(lambda: False, "never", timeout=10) -@requires_pty def test_wait_until_completes_when_done(tmp_path): done = tmp_path / "done" - session = _fake_ug(tmp_path, f"echo working\ntouch '{done}'\nsleep 30\n") + session = _fake_ug( + tmp_path, + "import pathlib, time\nprint('working', flush=True)\n" + f"pathlib.Path({str(done)!r}).touch()\ntime.sleep(30)\n", + ) with Terminal(session, "completes", [], agent=CODEX) as tui: tui.wait_until(done.exists, "the done marker", timeout=10) assert "working" in tui.visible -@requires_pty def test_wait_until_lets_on_screen_answer_an_expected_dialog(tmp_path): done = tmp_path / "done" session = _fake_ug( - tmp_path, f"echo 'Allow this call?'\nread answer\necho allowed\ntouch '{done}'\nsleep 30\n" + tmp_path, + "import pathlib, time\nprint('Allow this call?', flush=True)\ninput()\n" + f"print('allowed', flush=True)\npathlib.Path({str(done)!r}).touch()\ntime.sleep(30)\n", ) answered = [] diff --git a/tests/test_integration_runner.py b/tests/test_integration_runner.py index 79e1abc92..9730fb83d 100644 --- a/tests/test_integration_runner.py +++ b/tests/test_integration_runner.py @@ -31,14 +31,9 @@ def test_headless_only_selects_exact_nodes_for_requested_agents(tmp_path): ] -def test_windows_live_runs_select_only_modules_without_pty_helpers(tmp_path): +def test_windows_live_runs_select_every_module_except_unsupported_ones(tmp_path): (tmp_path / "test_headless.py").write_text("import json\n") (tmp_path / "test_tui.py").write_text("from utils.terminal import AgentTerminal\n") - (tmp_path / "test_tui_import.py").write_text("from utils import terminal as agent_terminal\n") - (tmp_path / "test_relative_mcp.py").write_text("from .utils import mcp as mcp_tools\n") - (tmp_path / "test_qualified_tui.py").write_text("import tests.integration.utils.terminal\n") - (tmp_path / "test_mcp.py").write_text("from utils.mcp import inventory\n") - (tmp_path / "test_screen.py").write_text("import pyte\n") (tmp_path / "test_ug_claude_tracing.py").write_text("import json\n") assert runner.integration_test_targets( @@ -47,7 +42,11 @@ def test_windows_live_runs_select_only_modules_without_pty_helpers(tmp_path): platform_name="nt", installation_only=False, headless_only=False, - ) == [str(tmp_path / "test_headless.py")] + ) == [ + str(tmp_path / "test_headless.py"), + str(tmp_path / "test_tui.py"), + *(f"--deselect={node}" for node in sorted(runner.WINDOWS_UNSUPPORTED_NODES)), + ] assert runner.integration_test_targets( tmp_path, ["claude"], diff --git a/uv.lock b/uv.lock index a2e745a30..de683fde2 100644 --- a/uv.lock +++ b/uv.lock @@ -825,6 +825,24 @@ wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, ] +[[package]] +name = "pywinpty" +version = "3.0.5" +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } +sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/a9/ef/2d27f30c59a67be7025b2d7858c8c2d282b74d66544b2384730b82de74fd/pywinpty-3.0.5.tar.gz", hash = "sha256:61db0db063de9865adbea66db294628f8577f608d9764a4c7d3384eeacc4e81b", size = 16223484, upload-time = "2026-06-11T00:11:58.93Z" } +wheels = [ + { url = "https://pypi-proxy.cloud.databricks.com/packages/45/34/942cc95ca4e26489875aa8a95192766247a687379ec29543eebe73ec945f/pywinpty-3.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:d62946adf14b15b54c0b8d785f93fe18b04da23f4ad59e2e8c4612646e9abd23", size = 2090915, upload-time = "2026-06-10T23:43:14.98Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/6a/70/5b9053004844139ea8bd86209c57ade12b134b2782f383a095784c8531ec/pywinpty-3.0.5-cp312-cp312-win_arm64.whl", hash = "sha256:e9391c05fbfa7a992a97e831fc6849887b4014a614192e3d984a7ca59592b376", size = 815934, upload-time = "2026-06-10T23:41:42.384Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/b9/f4/2a464b9893cceb3b3f416356e94fdc3e1bca9476993927e4e6d99fe95382/pywinpty-3.0.5-cp313-cp313-win_amd64.whl", hash = "sha256:48db1b0ad9d0a1b81dcaaa7163a99a7808deaceb0c1b2344716dc1fc090c3c4c", size = 2090471, upload-time = "2026-06-10T23:42:11.071Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/3c/2c/a138491a0afbdb50eb79395577bd326d4b0fbde7209417d1a8087ff2493a/pywinpty-3.0.5-cp313-cp313-win_arm64.whl", hash = "sha256:2c6008fb2d3774b48693b2fcb7f2cc317ade9dc581289a964ffeeaf81307c9b5", size = 815518, upload-time = "2026-06-10T23:42:02.363Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/6f/15/54400049a380582acd1282665c70fcf11e0bd3713679aca78e24c3aae738/pywinpty-3.0.5-cp313-cp313t-win_amd64.whl", hash = "sha256:22ce1b780d89821cc52daf6eac0708af22d93d000ce9c7c07e37489db8594598", size = 2089920, upload-time = "2026-06-10T23:44:13.395Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/94/0c/6f24f3c0799f502259b24bdf841a99ad2b0d59df5c2525b4e2a286d14be2/pywinpty-3.0.5-cp313-cp313t-win_arm64.whl", hash = "sha256:9c2919a81bc5cfb09b86fc5a002112b2de95ca4304a07413cbeeb746a1307a5c", size = 814520, upload-time = "2026-06-10T23:43:28.588Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/e9/23/f3cd1b1e5fc56517f54452c49f92049e7dd9ffc8a63de22a495581f50d04/pywinpty-3.0.5-cp314-cp314-win_amd64.whl", hash = "sha256:03bb3c16d691d9242267201830bcd0e64a9b663170e9042bc84b210da9de15ac", size = 2090663, upload-time = "2026-06-10T23:43:59.845Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/9d/dd/96d6cbfc6d9ddab5c1c2f92c26545ae8997446a2ba7ee2024cd43c81f49b/pywinpty-3.0.5-cp314-cp314-win_arm64.whl", hash = "sha256:89c5c6ef08997a3b4b277b214a35fe15cab4dd6d119f0140aa71df5b1168fdbc", size = 815700, upload-time = "2026-06-10T23:40:50.001Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/30/36/d98087bce0acaa4cce7f196103cfa7be3f63ce65f52473bb3e38784ae5d9/pywinpty-3.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7b566165e0c5fdd6abe167a5ac8b954be6a843eb55a85946576d6bc1dea03d6d", size = 2090093, upload-time = "2026-06-10T23:40:58.933Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/57/fd/fe2b0db922ba052ce3976a08f3fc05d0c05047c8b4ebb6102e832b8ef563/pywinpty-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:24366280a8aa677323da87bec729cb3ea3b35367386cece0978bdc6e4695c690", size = 814517, upload-time = "2026-06-10T23:42:34.946Z" }, +] + [[package]] name = "questionary" version = "2.1.1" @@ -1132,6 +1150,7 @@ dev = [ { name = "pexpect" }, { name = "pyte" }, { name = "pytest" }, + { name = "pywinpty", marker = "sys_platform == 'win32'" }, { name = "ruff" }, { name = "ty" }, ] @@ -1152,6 +1171,7 @@ dev = [ { name = "pexpect", specifier = ">=4.9.0" }, { name = "pyte", specifier = ">=0.8.2" }, { name = "pytest", specifier = ">=9.0.3" }, + { name = "pywinpty", marker = "sys_platform == 'win32'", specifier = "==3.0.5" }, { name = "ruff", specifier = "==0.16.5" }, { name = "ty", specifier = "==0.0.75" }, ]