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

292 lines
11 KiB
Python

"""Boot and tear down a local OpenClaw instance for end-to-end tests.
Implements Unit A of #1484: spawn ``openclaw gateway run`` in dev mode
on an isolated port + workspace, poll the WebSocket health endpoint
until ready, return a populated :class:`OpenClawHandle`. Teardown sends
SIGTERM, waits 5s, escalates to SIGKILL if needed.
The MCP bridge (``openclaw mcp serve``) is **not** spawned here — it's a
stdio-only MCP server owned by whichever ``mcp.client.stdio.stdio_client``
context spawns it (see ``integrations.openclaw._open_openclaw_session``).
Fault injectors and use-case drivers spawn it themselves per-call.
"""
from __future__ import annotations
import contextlib
import os
import re
import shutil
import signal
import subprocess
import time
from dataclasses import dataclass, field
from pathlib import Path
import pytest
# ``--dev`` profile defaults — keeps tests isolated from a user's real
# OpenClaw setup. ``openclaw gateway run --dev`` writes state under
# ``~/.openclaw-dev`` and binds the WebSocket Gateway on port 19001.
_DEV_GATEWAY_PORT = 19001
_DEV_STATE_DIR = Path.home() / ".openclaw-dev"
# Healthcheck polling: tail the Gateway's own log for the deterministic
# ``[gateway] ready`` line that the daemon prints after binding its
# listeners. Boot is typically <2s; 20s gives plenty of headroom for
# cold-start on first run after ``--dev`` workspace creation.
#
# Why not ``openclaw gateway health``: the subcommand reads the user's
# real ``~/.openclaw/openclaw.json`` config to pick a target URL, so it
# probes the wrong port (18789 default) when we're on the dev port
# 19001. Passing ``--url`` is rejected without ``--token``/``--password``
# even when the Gateway runs with ``--auth none``. Log-tail is simpler,
# deterministic, and doesn't depend on auth setup or config state.
_HEALTHCHECK_POLL_INTERVAL_S = 0.1
_HEALTHCHECK_TIMEOUT_S = 20.0
_GATEWAY_READY_MARKER = b"[gateway] ready"
# OpenClaw emits ANSI color codes when it detects a TTY-ish environment
# (it does even when stdout is redirected to a file on darwin), which
# splits the ``[gateway] ready`` literal into ``[gateway]\x1b[…]m
# ready``. Strip ANSI escapes before searching so the marker matches
# either colored or uncolored output (CI runners are uncolored).
_ANSI_ESCAPE_RE = re.compile(rb"\x1b\[[0-9;]*[A-Za-z]")
# SIGTERM grace before escalating to SIGKILL — matches the pattern used
# by ``interactive_shell.runtime.subprocess_runner.terminate_child_process``.
_TEARDOWN_GRACE_S = 5.0
@dataclass
class OpenClawHandle:
"""Live handle to a booted local OpenClaw Gateway.
Returned by :func:`boot_openclaw` and consumed by every fault
injector. ``gateway_pid`` and ``gateway_url`` are populated only when
the handle was booted with ``with_gateway=True``.
"""
gateway_pid: int | None = None
gateway_port: int | None = None
gateway_url: str | None = None
state_dir: Path | None = None
log_path: Path | None = None
_process: subprocess.Popen[bytes] | None = field(default=None, repr=False)
extra: dict[str, object] = field(default_factory=dict)
def openclaw_cli_available() -> bool:
"""True when the ``openclaw`` CLI is on ``$PATH``.
Public helper so scenario test files can ``skipif`` on a single
canonical check instead of redefining the function locally.
"""
return shutil.which("openclaw") is not None
# Shared skip-reason strings + LLM credential probe used by every
# scenario test file. Centralized so the wording stays consistent and a
# new scenario doesn't have to copy/paste them.
OPENCLAW_CLI_SKIP_REASON = "openclaw CLI not installed — see tests/e2e/openclaw/README.md"
LLM_CREDENTIAL_SKIP_REASON = (
"No LLM credential set (ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY) "
"— full RCA invocation skipped."
)
_LLM_CREDENTIAL_ENV_VARS: tuple[str, ...] = (
"ANTHROPIC_API_KEY",
"OPENAI_API_KEY",
"GEMINI_API_KEY",
)
def llm_credentials_present() -> bool:
"""True when at least one OpenSRE-supported LLM API key is set.
Full-RCA sub-tests skipif on this; use-case sub-tests run without
an LLM key.
"""
return any(os.environ.get(var) for var in _LLM_CREDENTIAL_ENV_VARS)
def _node_version_ok() -> bool:
"""OpenClaw needs Node 22.12+; older Node prints the requirement to
stderr and exits non-zero, so we can detect by trying ``--version``.
"""
if not openclaw_cli_available():
return False
try:
result = subprocess.run(
["openclaw", "--version"],
capture_output=True,
text=True,
timeout=5,
check=False,
)
except (subprocess.TimeoutExpired, OSError):
return False
combined = (result.stdout + result.stderr).lower()
if "node.js" in combined and "required" in combined:
return False
return result.returncode == 0
def _skip_if_openclaw_unavailable() -> None:
"""Pytest-skip with a clear reason when OpenClaw can't run locally.
Two failure modes:
- CLI not installed → ``shutil.which`` returns None
- CLI installed but Node version too old → ``openclaw --version``
exits non-zero with the Node-version error
"""
if not openclaw_cli_available():
pytest.skip("openclaw CLI not installed — see tests/e2e/openclaw/README.md")
if not _node_version_ok():
pytest.skip(
"openclaw requires Node 22.12+ — run `nvm install 22 && nvm use 22` "
"and rerun (see tests/e2e/openclaw/README.md)"
)
def _gateway_log_contains_ready(log_path: Path) -> bool:
"""True when the Gateway log contains the ``[gateway] ready`` marker."""
try:
with log_path.open("rb") as fh:
raw = fh.read()
except OSError:
return False
return _GATEWAY_READY_MARKER in _ANSI_ESCAPE_RE.sub(b"", raw)
def _wait_for_healthy(
process: subprocess.Popen[bytes], log_path: Path, port: int, deadline: float
) -> None:
"""Poll the Gateway log until it prints the ready marker.
Bails early if the process dies before getting ready — re-raises a
TimeoutError with a hint pointing at the captured log so the user
can see the actual gateway error rather than waiting 20s.
"""
while time.monotonic() < deadline:
if _gateway_log_contains_ready(log_path):
return
if process.poll() is not None:
raise TimeoutError(
f"OpenClaw Gateway exited with code {process.returncode} before "
f"becoming healthy. See {log_path} for the captured output."
)
time.sleep(_HEALTHCHECK_POLL_INTERVAL_S)
raise TimeoutError(
f"OpenClaw Gateway on port {port} did not become healthy within "
f"{_HEALTHCHECK_TIMEOUT_S:.0f}s. See {log_path} for the captured output."
)
def boot_openclaw(
*,
with_gateway: bool = True,
port: int = _DEV_GATEWAY_PORT,
log_dir: Path | None = None,
) -> OpenClawHandle:
"""Spawn ``openclaw gateway run --dev --bind loopback --auth none`` and
block until the Gateway WebSocket healthcheck answers.
Skips the calling test cleanly via :func:`pytest.skip` when the
``openclaw`` CLI is missing or running on too-old Node — keeps the
suite green on contributor machines that haven't installed it.
Always uses ``--dev`` so test state lives under ``~/.openclaw-dev``
and never touches a user's real ``~/.openclaw`` directory.
``--bind loopback`` keeps the Gateway off the LAN. ``--auth none``
skips token/password setup for test simplicity. ``--force`` cleans
up any previous-test orphans on the same port.
With ``with_gateway=False`` no process is spawned — the handle is a
bare envelope used by fault scenarios that need to assert behavior
when the Gateway is unreachable.
"""
_skip_if_openclaw_unavailable()
handle = OpenClawHandle(state_dir=_DEV_STATE_DIR)
if not with_gateway:
return handle
log_dir = log_dir or Path(os.environ.get("TMPDIR", "/tmp")) / "openclaw-e2e-logs"
log_dir.mkdir(parents=True, exist_ok=True)
log_path = log_dir / f"gateway-{port}-{int(time.time())}.log"
handle.log_path = log_path
handle.gateway_port = port
handle.gateway_url = f"ws://127.0.0.1:{port}"
# ``--allow-unconfigured`` is needed because a contributor's real
# ``~/.openclaw/`` config can have an incomplete ``gateway.mode``
# entry that blocks Gateway start with "existing config is missing
# gateway.mode". The flag bypasses that check; ``--dev`` still
# isolates runtime state under ``~/.openclaw-dev``.
log_file = log_path.open("wb")
try:
process = subprocess.Popen(
[
"openclaw",
"gateway",
"run",
"--dev",
"--allow-unconfigured",
"--bind",
"loopback",
"--auth",
"none",
"--force",
"--port",
str(port),
],
stdout=log_file,
stderr=subprocess.STDOUT,
stdin=subprocess.DEVNULL,
start_new_session=True,
)
except OSError:
log_file.close()
raise
handle._process = process
handle.gateway_pid = process.pid
try:
_wait_for_healthy(
process,
log_path,
port,
deadline=time.monotonic() + _HEALTHCHECK_TIMEOUT_S,
)
except BaseException:
# If healthcheck times out (or the user Ctrl+C's mid-boot), the
# Gateway process is still alive — tear it down so we don't
# leak it into the next test or back to the user's shell.
teardown_openclaw(handle)
raise
return handle
def teardown_openclaw(handle: OpenClawHandle) -> None:
"""Tear down a previously booted OpenClaw Gateway.
SIGTERM → wait :data:`_TEARDOWN_GRACE_S` → SIGKILL. Idempotent and
safe to call on partial-boot handles (e.g. when healthcheck timed
out leaving the Gateway alive but never ready).
"""
process = handle._process
if process is None or process.poll() is not None:
return
with contextlib.suppress(OSError):
# ``start_new_session=True`` put the gateway in its own process
# group — kill the group so any child processes (the gateway
# spawns its own internal services) go down with it.
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
try:
process.wait(timeout=_TEARDOWN_GRACE_S)
except subprocess.TimeoutExpired:
with contextlib.suppress(OSError):
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
with contextlib.suppress(subprocess.TimeoutExpired):
process.wait(timeout=_TEARDOWN_GRACE_S)