Files
wehub-resource-sync 4b6817381b
Benchmark image — build + push to ECR (any adapter) / build + push (push) Waiting to run
CI / quality (ubuntu-latest) (push) Waiting to run
CI / test (tools-runtime) (push) Waiting to run
CI / test (e2e-general) (push) Waiting to run
CI / test (cli-runtime) (push) Waiting to run
CI / test (e2e-provider-and-openclaw) (push) Waiting to run
CI / test (integrations-and-misc) (push) Waiting to run
CI / coverage-report (push) Blocked by required conditions
CI / test-kubernetes (push) Waiting to run
CI / should-run-thorough (push) Waiting to run
CI / test-thorough (cloudwatch-demo) (push) Blocked by required conditions
CI / test-thorough (flink-ecs) (push) Blocked by required conditions
CI / test-thorough (upstream-lambda) (push) Blocked by required conditions
CI / test-thorough (prefect-ecs-fargate) (push) Blocked by required conditions
CodeQL / Analyze (python) (push) Waiting to run
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Blocked by required conditions
Release / publish-release (push) Blocked by required conditions
Release / publish-main-release (push) Blocked by required conditions
Release / prepare (push) Waiting to run
Release / verify (push) Blocked by required conditions
Release / build-python-dist (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Blocked by required conditions
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Waiting to run
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

194 lines
7.4 KiB
Python

"""ReplDriver: pty-based driver for live interactive REPL testing.
Fakes a TTY so opensre's isatty() check passes, then sends commands and
captures rendered output. Use this for any test that needs to verify
interactive REPL behavior — slash commands, session management, live
display output — that unit tests with mocked consoles cannot cover.
Usage (context manager, recommended)::
from tests.utils.repl_driver import ReplDriver
with ReplDriver() as repl:
repl.send("/sessions", wait=3.0)
repl.send("/resume abc1234", wait=3.0)
assert repl.contains("resumed session")
assert repl.contains("conversation context loaded")
Usage (manual)::
repl = ReplDriver()
repl.start(startup_wait=6.0)
repl.send("/status", wait=2.0)
output = repl.text # ANSI-stripped full output so far
repl.close()
Design notes:
- os.pty creates a master/slave pair; slave is given to opensre as its
stdin/stdout/stderr so prompt_toolkit sees a real TTY.
- select() drains output non-blockingly so we never block forever.
- ANSI escape codes are stripped lazily via the `text` property from
self._raw, so assertions always work on plain text.
- .env is loaded from the repo root so live LLM providers work.
- startup_wait=6.0 covers banner rendering + async event-loop startup.
Increase it if tests are flaky on slow CI machines.
When NOT to use this:
- Unit tests that mock the console — keep those in tests/cli/.
- Tests that only need SessionStore / Session — use tmp_path fixtures.
- Tests that need a real LLM response — use make test-rca instead;
LLM latency makes pty timing unreliable.
"""
from __future__ import annotations
import contextlib
import os
import pty
import re
import select
import subprocess
import time
from pathlib import Path
from typing import Any
from dotenv import dotenv_values
from config.constants.paths import PROJECT_ROOT, REPO_ROOT
_ANSI_ESCAPE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
def _load_env(*, home: str | None = None) -> dict[str, str]:
"""Merge .env into a copy of the current environment."""
env = dict(os.environ)
if home is not None:
env["HOME"] = home
env.setdefault("OPENSRE_SKIP_GITHUB_LOGIN", "1")
env_file = PROJECT_ROOT / ".env"
if env_file.exists():
env.update({k: v for k, v in dotenv_values(env_file).items() if v is not None})
env.setdefault("OPENSRE_SKIP_GITHUB_LOGIN", "1")
return env
class ReplDriver:
"""Context-manager driver for a live opensre REPL process."""
def __init__(
self,
*,
startup_wait: float = 6.0,
cwd: Path | None = None,
home: Path | None = None,
) -> None:
self._startup_wait = startup_wait
self._cwd = str(cwd or REPO_ROOT)
self._home = str(home) if home is not None else None
self._master: int | None = None
self._proc: subprocess.Popen[bytes] | None = None
self._raw: bytes = b""
# ── lifecycle ─────────────────────────────────────────────────────────────
def start(self, startup_wait: float | None = None) -> None:
"""Start the REPL process and wait for the banner to render."""
master, slave = pty.openpty()
self._master = master
try:
self._proc = subprocess.Popen(
["uv", "run", "opensre"],
stdin=slave,
stdout=slave,
stderr=slave,
env=_load_env(home=self._home),
cwd=self._cwd,
)
finally:
os.close(slave)
self._drain(startup_wait if startup_wait is not None else self._startup_wait)
def close(self, exit_wait: float = 3.0) -> None:
"""Send /exit and wait for the process to finish."""
if self._master is not None and self._proc is not None:
with contextlib.suppress(OSError):
os.write(self._master, b"/exit\n")
self._drain(exit_wait)
if self._proc is not None:
try:
self._proc.wait(timeout=5)
except subprocess.TimeoutExpired:
self._proc.kill()
self._proc.wait()
if self._master is not None:
with contextlib.suppress(OSError):
os.close(self._master)
self._master = None
self._proc = None
def __enter__(self) -> ReplDriver:
self.start()
return self
def __exit__(self, *_: Any) -> None:
self.close()
# ── interaction ───────────────────────────────────────────────────────────
def send(self, command: str, *, wait: float = 2.0) -> None:
"""Type a command (newline appended) and wait for output to settle."""
if self._master is None:
raise RuntimeError("ReplDriver not started — call start() or use as context manager")
os.write(self._master, (command + "\n").encode())
self._drain(wait)
# ── output access ─────────────────────────────────────────────────────────
@property
def text(self) -> str:
"""Full captured output with ANSI escape codes stripped."""
return _ANSI_ESCAPE.sub("", self._raw.decode("utf-8", errors="replace"))
def contains(self, substring: str) -> bool:
"""Return True if substring appears anywhere in the stripped output."""
return substring in self.text
def wait_until_contains(self, *substrings: str, timeout: float = 30.0) -> bool:
"""Drain output until any substring appears or the timeout expires."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if any(substring in self.text for substring in substrings):
return True
self._drain(min(0.5, deadline - time.monotonic()))
return any(substring in self.text for substring in substrings)
def lines(self) -> list[str]:
"""Non-empty visible lines from the stripped output."""
return [line for line in self.text.splitlines() if line.strip()]
def reset_output(self) -> None:
"""Clear captured output (useful between phases of a multi-step test)."""
self._raw = b""
# ── internals ─────────────────────────────────────────────────────────────
def _drain(self, timeout: float) -> None:
"""Read all available output from the pty master until timeout."""
if self._master is None:
return
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
remaining = deadline - time.monotonic()
try:
r, _, _ = select.select([self._master], [], [], min(remaining, 0.2))
except (ValueError, OSError):
break
if r:
try:
chunk = os.read(self._master, 8192)
if not chunk:
break
self._raw += chunk
except OSError:
break