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
292 lines
11 KiB
Python
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)
|