mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* refactor: add explicit owner scope across channels * fix: tighten routine owner target routing * fix: address owner scope review feedback * Fix owner-scope onboarding and event trigger isolation * Tighten routing fallback and wizard owner validation * fix: address owner-scope follow-up review * fix: tighten owner-scope follow-up details * fix: import Channel trait in telegram test * fix: normalize http webhook sender ids * fix: address remaining owner-scope review issues * fix: reconcile config rebase fallout * fix: reconcile extension manager rebase drift * fix: address current copilot review regressions * fix: restore clippy matrix after rebase
438 lines
16 KiB
Python
438 lines
16 KiB
Python
"""pytest fixtures for E2E tests.
|
|
|
|
Session-scoped: build binary, start mock LLM, start ironclaw, launch browser.
|
|
Function-scoped: fresh browser context and page per test.
|
|
"""
|
|
|
|
import asyncio
|
|
import os
|
|
import signal
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from helpers import (
|
|
AUTH_TOKEN,
|
|
HTTP_WEBHOOK_SECRET,
|
|
OWNER_SCOPE_ID,
|
|
wait_for_port_line,
|
|
wait_for_ready,
|
|
)
|
|
|
|
# Project root (two levels up from tests/e2e/)
|
|
ROOT = Path(__file__).resolve().parent.parent.parent
|
|
|
|
# Git main repo root (for worktree support — WASM build artifacts live
|
|
# in the main repo's tools-src/*/target/ and aren't shared across worktrees)
|
|
_MAIN_ROOT = None
|
|
try:
|
|
import subprocess as _sp
|
|
_common = _sp.check_output(
|
|
["git", "worktree", "list", "--porcelain"],
|
|
cwd=ROOT, text=True, stderr=_sp.DEVNULL,
|
|
)
|
|
for line in _common.splitlines():
|
|
if line.startswith("worktree "):
|
|
_MAIN_ROOT = Path(line.split(" ", 1)[1])
|
|
break # first entry is always the main worktree
|
|
except Exception:
|
|
pass
|
|
|
|
# Temp directory for the libSQL database file (cleaned up automatically)
|
|
_DB_TMPDIR = tempfile.TemporaryDirectory(prefix="ironclaw-e2e-")
|
|
|
|
# Temp HOME so pairing/allowFrom state never touches the developer's real ~/.ironclaw
|
|
_HOME_TMPDIR = tempfile.TemporaryDirectory(prefix="ironclaw-e2e-home-")
|
|
|
|
# Temp directories for WASM extensions. These start empty and are populated by
|
|
# the install pipeline during tests; fixtures do not pre-populate dev build
|
|
# artifacts into them.
|
|
_WASM_TOOLS_TMPDIR = tempfile.TemporaryDirectory(prefix="ironclaw-e2e-wasm-tools-")
|
|
_WASM_CHANNELS_TMPDIR = tempfile.TemporaryDirectory(prefix="ironclaw-e2e-wasm-channels-")
|
|
|
|
|
|
def _latest_mtime(path: Path) -> float:
|
|
"""Return the newest mtime under a file or directory."""
|
|
if not path.exists():
|
|
return 0.0
|
|
if path.is_file():
|
|
return path.stat().st_mtime
|
|
|
|
latest = path.stat().st_mtime
|
|
for root, dirnames, filenames in os.walk(path):
|
|
dirnames[:] = [dirname for dirname in dirnames if dirname != "target"]
|
|
for name in filenames:
|
|
child = Path(root) / name
|
|
try:
|
|
latest = max(latest, child.stat().st_mtime)
|
|
except FileNotFoundError:
|
|
continue
|
|
return latest
|
|
|
|
|
|
def _binary_needs_rebuild(binary: Path) -> bool:
|
|
"""Rebuild when the binary is missing or older than embedded sources."""
|
|
if not binary.exists():
|
|
return True
|
|
|
|
binary_mtime = binary.stat().st_mtime
|
|
inputs = [
|
|
ROOT / "Cargo.toml",
|
|
ROOT / "Cargo.lock",
|
|
ROOT / "build.rs",
|
|
ROOT / "providers.json",
|
|
ROOT / "src",
|
|
ROOT / "channels-src",
|
|
]
|
|
return any(_latest_mtime(path) > binary_mtime for path in inputs)
|
|
|
|
|
|
def _find_free_port() -> int:
|
|
"""Bind to port 0 and return the OS-assigned port."""
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
s.bind(("127.0.0.1", 0))
|
|
return s.getsockname()[1]
|
|
|
|
|
|
def _reserve_loopback_sockets(count: int) -> list[socket.socket]:
|
|
"""Bind loopback sockets and keep them open until the server starts."""
|
|
sockets: list[socket.socket] = []
|
|
try:
|
|
while len(sockets) < count:
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
sock.bind(("127.0.0.1", 0))
|
|
sockets.append(sock)
|
|
return sockets
|
|
except Exception:
|
|
for sock in sockets:
|
|
sock.close()
|
|
raise
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def ironclaw_binary():
|
|
"""Ensure ironclaw binary is built. Returns the binary path."""
|
|
binary = ROOT / "target" / "debug" / "ironclaw"
|
|
if _binary_needs_rebuild(binary):
|
|
print("Building ironclaw (this may take a while)...")
|
|
subprocess.run(
|
|
["cargo", "build", "--no-default-features", "--features", "libsql"],
|
|
cwd=ROOT,
|
|
check=True,
|
|
timeout=600,
|
|
)
|
|
assert binary.exists(), f"Binary not found at {binary}"
|
|
return str(binary)
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def server_ports():
|
|
"""Reserve dynamic ports for the gateway and HTTP webhook channel."""
|
|
reserved = _reserve_loopback_sockets(2)
|
|
try:
|
|
yield {
|
|
"gateway": reserved[0].getsockname()[1],
|
|
"http": reserved[1].getsockname()[1],
|
|
"sockets": reserved,
|
|
}
|
|
finally:
|
|
for sock in reserved:
|
|
sock.close()
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
async def mock_llm_server():
|
|
"""Start the mock LLM server. Yields the base URL."""
|
|
server_script = Path(__file__).parent / "mock_llm.py"
|
|
proc = await asyncio.create_subprocess_exec(
|
|
sys.executable, str(server_script), "--port", "0",
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
try:
|
|
port = await wait_for_port_line(proc, r"MOCK_LLM_PORT=(\d+)", timeout=10)
|
|
url = f"http://127.0.0.1:{port}"
|
|
await wait_for_ready(f"{url}/v1/models", timeout=10)
|
|
yield url
|
|
finally:
|
|
proc.send_signal(signal.SIGTERM)
|
|
try:
|
|
await asyncio.wait_for(proc.wait(), timeout=5)
|
|
except asyncio.TimeoutError:
|
|
proc.kill()
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def wasm_tools_dir(_wasm_build_symlinks):
|
|
"""Empty temp dir for WASM tools.
|
|
|
|
Starts empty so the server has no pre-loaded extensions at boot.
|
|
The install API (POST /api/extensions/install) downloads and writes
|
|
WASM files here; tests exercise the full install pipeline.
|
|
|
|
NOTE on capabilities file naming: Cargo builds with underscored stems
|
|
(web_search_tool.wasm) but capabilities use hyphens (web-search-tool.
|
|
capabilities.json). The loader expects matching stems. If you pre-load
|
|
files, rename caps: web-search-tool → web_search_tool.
|
|
"""
|
|
return str(Path(_WASM_TOOLS_TMPDIR.name))
|
|
|
|
|
|
@pytest.fixture(scope="session", autouse=True)
|
|
def _wasm_build_symlinks():
|
|
"""Symlink WASM build artifacts from the main repo into the worktree.
|
|
|
|
In a git worktree, tools-src/*/target/ directories don't exist because
|
|
Cargo build artifacts aren't shared. The install API's source fallback
|
|
checks these paths. Symlinking makes the fallback work without rebuilding.
|
|
"""
|
|
if _MAIN_ROOT is None or _MAIN_ROOT == ROOT:
|
|
yield
|
|
return
|
|
|
|
created = []
|
|
tools_src = ROOT / "tools-src"
|
|
main_tools_src = _MAIN_ROOT / "tools-src"
|
|
if tools_src.is_dir() and main_tools_src.is_dir():
|
|
for tool_dir in tools_src.iterdir():
|
|
if not tool_dir.is_dir():
|
|
continue
|
|
target = tool_dir / "target"
|
|
main_target = main_tools_src / tool_dir.name / "target"
|
|
if not target.exists() and main_target.is_dir():
|
|
target.symlink_to(main_target)
|
|
created.append(target)
|
|
yield
|
|
for link in created:
|
|
if link.is_symlink():
|
|
link.unlink()
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
async def ironclaw_server(
|
|
ironclaw_binary,
|
|
mock_llm_server,
|
|
wasm_tools_dir,
|
|
server_ports,
|
|
):
|
|
"""Start the ironclaw gateway. Yields the base URL."""
|
|
home_dir = _HOME_TMPDIR.name
|
|
gateway_port = server_ports["gateway"]
|
|
http_port = server_ports["http"]
|
|
for sock in server_ports["sockets"]:
|
|
if sock.fileno() != -1:
|
|
sock.close()
|
|
env = {
|
|
# Minimal env: PATH for process spawning, HOME for Rust/cargo defaults
|
|
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
|
|
"HOME": home_dir,
|
|
"IRONCLAW_BASE_DIR": os.path.join(home_dir, ".ironclaw"),
|
|
"RUST_LOG": "ironclaw=info",
|
|
"RUST_BACKTRACE": "1",
|
|
"IRONCLAW_OWNER_ID": OWNER_SCOPE_ID,
|
|
"GATEWAY_ENABLED": "true",
|
|
"GATEWAY_HOST": "127.0.0.1",
|
|
"GATEWAY_PORT": str(gateway_port),
|
|
"GATEWAY_AUTH_TOKEN": AUTH_TOKEN,
|
|
"GATEWAY_USER_ID": "e2e-web-sender",
|
|
"HTTP_HOST": "127.0.0.1",
|
|
"HTTP_PORT": str(http_port),
|
|
"HTTP_WEBHOOK_SECRET": HTTP_WEBHOOK_SECRET,
|
|
"CLI_ENABLED": "false",
|
|
"LLM_BACKEND": "openai_compatible",
|
|
"LLM_BASE_URL": mock_llm_server,
|
|
"LLM_MODEL": "mock-model",
|
|
"DATABASE_BACKEND": "libsql",
|
|
"LIBSQL_PATH": os.path.join(_DB_TMPDIR.name, "e2e.db"),
|
|
"SANDBOX_ENABLED": "false",
|
|
"SKILLS_ENABLED": "true",
|
|
"ROUTINES_ENABLED": "true",
|
|
"HEARTBEAT_ENABLED": "false",
|
|
"EMBEDDING_ENABLED": "false",
|
|
# WASM tool/channel support
|
|
"WASM_ENABLED": "true",
|
|
"WASM_TOOLS_DIR": wasm_tools_dir,
|
|
"WASM_CHANNELS_DIR": _WASM_CHANNELS_TMPDIR.name,
|
|
# Prevent onboarding wizard from triggering
|
|
"ONBOARD_COMPLETED": "true",
|
|
# Force gateway OAuth callback mode (non-loopback URL) and point
|
|
# token exchange at mock_llm.py so OAuth tests work without Google.
|
|
"IRONCLAW_OAUTH_CALLBACK_URL": "https://oauth.test.example/oauth/callback",
|
|
"IRONCLAW_OAUTH_EXCHANGE_URL": mock_llm_server,
|
|
}
|
|
# Forward LLVM coverage instrumentation env vars when present
|
|
# (allows cargo-llvm-cov to collect profraw data from E2E runs).
|
|
# Use prefix matching to stay resilient to cargo-llvm-cov changes.
|
|
COV_ENV_PREFIXES = ("CARGO_LLVM_COV", "LLVM_")
|
|
COV_ENV_EXTRAS = ("CARGO_ENCODED_RUSTFLAGS", "CARGO_INCREMENTAL")
|
|
for key, val in os.environ.items():
|
|
if key.startswith(COV_ENV_PREFIXES) or key in COV_ENV_EXTRAS:
|
|
env[key] = val
|
|
proc = await asyncio.create_subprocess_exec(
|
|
ironclaw_binary, "--no-onboard",
|
|
stdin=asyncio.subprocess.DEVNULL,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
env=env,
|
|
)
|
|
base_url = f"http://127.0.0.1:{gateway_port}"
|
|
try:
|
|
await wait_for_ready(f"{base_url}/api/health", timeout=60)
|
|
yield base_url
|
|
except TimeoutError:
|
|
# Dump stderr so CI logs show why the server failed to start
|
|
returncode = proc.returncode
|
|
stderr_bytes = b""
|
|
if proc.stderr:
|
|
try:
|
|
stderr_bytes = await asyncio.wait_for(proc.stderr.read(8192), timeout=2)
|
|
except (asyncio.TimeoutError, Exception):
|
|
pass
|
|
stderr_text = stderr_bytes.decode("utf-8", errors="replace")
|
|
proc.kill()
|
|
pytest.fail(
|
|
f"ironclaw server failed to start on port {gateway_port} "
|
|
f"(returncode={returncode}).\nstderr:\n{stderr_text}"
|
|
)
|
|
finally:
|
|
if proc.returncode is None:
|
|
# Use SIGINT (not SIGTERM) so tokio's ctrl_c handler triggers a
|
|
# graceful shutdown. This lets the LLVM coverage runtime run its
|
|
# atexit handler and flush .profraw files for cargo-llvm-cov.
|
|
proc.send_signal(signal.SIGINT)
|
|
try:
|
|
await asyncio.wait_for(proc.wait(), timeout=10)
|
|
except asyncio.TimeoutError:
|
|
proc.kill()
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
async def http_channel_server(ironclaw_server, server_ports):
|
|
"""HTTP webhook channel base URL."""
|
|
base_url = f"http://127.0.0.1:{server_ports['http']}"
|
|
await wait_for_ready(f"{base_url}/health", timeout=30)
|
|
return base_url
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
async def ironclaw_server_with_webhook_secret(ironclaw_binary, mock_llm_server, wasm_tools_dir):
|
|
"""Start ironclaw with HTTP_WEBHOOK_SECRET configured for webhook tests.
|
|
|
|
Yields a dict with:
|
|
- 'url': base URL of the gateway
|
|
- 'secret': the webhook secret value
|
|
"""
|
|
gateway_port = _find_free_port()
|
|
webhook_secret = "test-webhook-secret-e2e-12345"
|
|
env = {
|
|
# Minimal env: PATH for process spawning, HOME for Rust/cargo defaults
|
|
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
|
|
"HOME": os.environ.get("HOME", "/tmp"),
|
|
"RUST_LOG": "ironclaw=info",
|
|
"RUST_BACKTRACE": "1",
|
|
"GATEWAY_ENABLED": "true",
|
|
"GATEWAY_HOST": "127.0.0.1",
|
|
"GATEWAY_PORT": str(gateway_port),
|
|
"GATEWAY_AUTH_TOKEN": AUTH_TOKEN,
|
|
"GATEWAY_USER_ID": "e2e-tester",
|
|
"HTTP_WEBHOOK_SECRET": webhook_secret,
|
|
"CLI_ENABLED": "false",
|
|
"LLM_BACKEND": "openai_compatible",
|
|
"LLM_BASE_URL": mock_llm_server,
|
|
"LLM_MODEL": "mock-model",
|
|
"DATABASE_BACKEND": "libsql",
|
|
"LIBSQL_PATH": os.path.join(_DB_TMPDIR.name, "e2e-webhook.db"),
|
|
"SANDBOX_ENABLED": "false",
|
|
"SKILLS_ENABLED": "true",
|
|
"ROUTINES_ENABLED": "false",
|
|
"HEARTBEAT_ENABLED": "false",
|
|
"EMBEDDING_ENABLED": "false",
|
|
# WASM tool/channel support
|
|
"WASM_ENABLED": "true",
|
|
"WASM_TOOLS_DIR": wasm_tools_dir,
|
|
"WASM_CHANNELS_DIR": _WASM_CHANNELS_TMPDIR.name,
|
|
# Prevent onboarding wizard from triggering
|
|
"ONBOARD_COMPLETED": "true",
|
|
# Force gateway OAuth callback mode (non-loopback URL) and point
|
|
# token exchange at mock_llm.py so OAuth tests work without Google.
|
|
"IRONCLAW_OAUTH_CALLBACK_URL": "https://oauth.test.example/oauth/callback",
|
|
"IRONCLAW_OAUTH_EXCHANGE_URL": mock_llm_server,
|
|
}
|
|
# Forward LLVM coverage instrumentation env vars when present
|
|
COV_ENV_PREFIXES = ("CARGO_LLVM_COV", "LLVM_")
|
|
COV_ENV_EXTRAS = ("CARGO_ENCODED_RUSTFLAGS", "CARGO_INCREMENTAL")
|
|
for key, val in os.environ.items():
|
|
if key.startswith(COV_ENV_PREFIXES) or key in COV_ENV_EXTRAS:
|
|
env[key] = val
|
|
proc = await asyncio.create_subprocess_exec(
|
|
ironclaw_binary, "--no-onboard",
|
|
stdin=asyncio.subprocess.DEVNULL,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
env=env,
|
|
)
|
|
base_url = f"http://127.0.0.1:{gateway_port}"
|
|
try:
|
|
await wait_for_ready(f"{base_url}/api/health", timeout=60)
|
|
yield {
|
|
"url": base_url,
|
|
"secret": webhook_secret,
|
|
}
|
|
except TimeoutError:
|
|
# Dump stderr so CI logs show why the server failed to start
|
|
returncode = proc.returncode
|
|
stderr_bytes = b""
|
|
if proc.stderr:
|
|
try:
|
|
stderr_bytes = await asyncio.wait_for(proc.stderr.read(8192), timeout=2)
|
|
except (asyncio.TimeoutError, Exception):
|
|
pass
|
|
stderr_text = stderr_bytes.decode("utf-8", errors="replace")
|
|
proc.kill()
|
|
pytest.fail(
|
|
f"ironclaw server with webhook secret failed to start on port {gateway_port} "
|
|
f"(returncode={returncode}).\nstderr:\n{stderr_text}"
|
|
)
|
|
finally:
|
|
if proc.returncode is None:
|
|
# Use SIGINT (not SIGTERM) so tokio's ctrl_c handler triggers a
|
|
# graceful shutdown. This lets the LLVM coverage runtime run its
|
|
# atexit handler and flush .profraw files for cargo-llvm-cov.
|
|
proc.send_signal(signal.SIGINT)
|
|
try:
|
|
await asyncio.wait_for(proc.wait(), timeout=10)
|
|
except asyncio.TimeoutError:
|
|
proc.kill()
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
async def browser(ironclaw_server):
|
|
"""Session-scoped Playwright browser instance.
|
|
|
|
Reuses a single browser process across all tests. Individual tests
|
|
get isolated contexts via the ``page`` fixture.
|
|
"""
|
|
from playwright.async_api import async_playwright
|
|
|
|
headless = os.environ.get("HEADED", "").strip() not in ("1", "true")
|
|
async with async_playwright() as p:
|
|
b = await p.chromium.launch(headless=headless)
|
|
yield b
|
|
await b.close()
|
|
|
|
|
|
@pytest.fixture
|
|
async def page(ironclaw_server, browser):
|
|
"""Fresh Playwright browser context + page, navigated to the gateway with auth."""
|
|
context = await browser.new_context(viewport={"width": 1280, "height": 720})
|
|
pg = await context.new_page()
|
|
await pg.goto(f"{ironclaw_server}/?token={AUTH_TOKEN}")
|
|
# Wait for the app to initialize (auth screen hidden, SSE connected)
|
|
await pg.wait_for_selector("#auth-screen", state="hidden", timeout=15000)
|
|
yield pg
|
|
await context.close()
|