Implement SAND-WP-0003: validation meta-framework extraction

Port e2e-framework schema, runner, and reporter into wise-validator with
sand-boxer CLI integration, validate run CLI, unit tests, registry capability,
and operator docs.
This commit is contained in:
2026-06-23 21:37:07 +02:00
parent 9be1c3028d
commit 8d509fc6f1
23 changed files with 1435 additions and 4 deletions

View File

@@ -0,0 +1,5 @@
services:
web:
image: docker.io/library/nginx:alpine
ports:
- "127.0.0.1:8080:80"

View File

@@ -0,0 +1,9 @@
name: fixture-repo
compose_file: docker-compose.yml
health_checks:
- name: web
url: http://localhost:8080
timeout: 30
test_command: "echo ok"
timeout: 60
cleanup: always

37
tests/test_reporter.py Normal file
View File

@@ -0,0 +1,37 @@
"""State Hub reporter tests."""
import json
from unittest.mock import MagicMock, patch
from wisevalidator.models import RunResult
from wisevalidator.reporter import report
def test_report_posts_e2e_result() -> None:
result = RunResult(
sandbox_id="abc12345",
repo="fixture-repo",
passed=True,
exit_code=0,
duration_s=12.3,
output="ok",
)
captured: dict = {}
def fake_urlopen(req, timeout=10):
captured["url"] = req.full_url
captured["body"] = json.loads(req.data.decode())
resp = MagicMock()
resp.status = 201
resp.__enter__ = MagicMock(return_value=resp)
resp.__exit__ = MagicMock(return_value=False)
return resp
with patch("wisevalidator.reporter.urllib.request.urlopen", fake_urlopen):
ok = report(result, workstream_id="ws-uuid")
assert ok is True
assert captured["url"].endswith("/progress/")
assert captured["body"]["event_type"] == "e2e_result"
assert "PASSED" in captured["body"]["summary"]
assert captured["body"]["workstream_id"] == "ws-uuid"

104
tests/test_runner.py Normal file
View File

@@ -0,0 +1,104 @@
"""Validation runner tests with mocked sand-boxer and SSH."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
from wisevalidator.runner import run_validation
from wisevalidator.sandbox_client import SandboxHandle
FIXTURE_REPO = Path(__file__).parent / "fixtures" / "minimal_repo"
def test_run_validation_success() -> None:
handle = SandboxHandle(
sandbox_id="run12345",
host="coulombcore",
ssh="root@coulombcore",
remote_dir="/tmp/sandboxer/run12345",
compose_project="sbx-e2e-run12345",
raw={},
)
class FakeSSH:
remote_dir = handle.remote_dir
def wait_for_url(self, url: str, timeout: int = 120, interval: int = 5) -> bool:
return True
def run(self, cmd: str, *, timeout: int = 60) -> tuple[int, str]:
return 0, "test output\n"
with (
patch("wisevalidator.runner.create_sandbox", return_value=handle),
patch("wisevalidator.runner.destroy_sandbox") as destroy,
patch("wisevalidator.runner.SSHSession.from_reachability", return_value=FakeSSH()),
):
result = run_validation(FIXTURE_REPO, host="coulombcore")
assert result.passed is True
assert result.exit_code == 0
assert result.sandbox_id == "run12345"
destroy.assert_called_once_with("run12345")
def test_run_validation_keep_skips_destroy() -> None:
handle = SandboxHandle(
sandbox_id="keep1234",
host="coulombcore",
ssh="coulombcore",
remote_dir="/tmp/sandboxer/keep1234",
compose_project="sbx-e2e-keep1234",
raw={},
)
class FakeSSH:
remote_dir = handle.remote_dir
def wait_for_url(self, url: str, timeout: int = 120, interval: int = 5) -> bool:
return True
def run(self, cmd: str, *, timeout: int = 60) -> tuple[int, str]:
return 0, ""
with (
patch("wisevalidator.runner.create_sandbox", return_value=handle),
patch("wisevalidator.runner.destroy_sandbox") as destroy,
patch("wisevalidator.runner.SSHSession.from_reachability", return_value=FakeSSH()),
):
result = run_validation(FIXTURE_REPO, keep=True)
assert result.passed is True
destroy.assert_not_called()
def test_run_validation_health_failure() -> None:
handle = SandboxHandle(
sandbox_id="fail1234",
host="coulombcore",
ssh="coulombcore",
remote_dir="/tmp/sandboxer/fail1234",
compose_project=None,
raw={},
)
class FakeSSH:
remote_dir = handle.remote_dir
def wait_for_url(self, url: str, timeout: int = 120, interval: int = 5) -> bool:
return False
def run(self, cmd: str, *, timeout: int = 60) -> tuple[int, str]:
return 0, ""
with (
patch("wisevalidator.runner.create_sandbox", return_value=handle),
patch("wisevalidator.runner.destroy_sandbox") as destroy,
patch("wisevalidator.runner.SSHSession.from_reachability", return_value=FakeSSH()),
):
result = run_validation(FIXTURE_REPO)
assert result.passed is False
assert "Health check failed" in result.error
destroy.assert_called_once_with("fail1234")

View File

@@ -0,0 +1,59 @@
"""sand-boxer CLI client tests."""
import json
from unittest.mock import MagicMock, patch
import pytest
from wisevalidator.sandbox_client import create_sandbox, destroy_sandbox
def test_create_sandbox_parses_ready_status(tmp_path) -> None:
repo = tmp_path / "repo"
repo.mkdir()
payload = {
"sandbox_id": "cli12345",
"state": "ready",
"host": "coulombcore",
"reachability": {
"ssh": "root@coulombcore",
"host": "coulombcore",
"remote_dir": "/tmp/sandboxer/cli12345",
"compose_project": "sbx-e2e-cli12345",
},
}
proc = MagicMock(returncode=0, stdout=json.dumps(payload), stderr="")
with (
patch("wisevalidator.sandbox_client.shutil.which", return_value="/bin/sandboxer"),
patch("wisevalidator.sandbox_client.subprocess.run", return_value=proc),
):
handle = create_sandbox(repo)
assert handle.sandbox_id == "cli12345"
assert handle.remote_dir == "/tmp/sandboxer/cli12345"
def test_create_sandbox_not_ready_raises(tmp_path) -> None:
repo = tmp_path / "repo"
repo.mkdir()
payload = {"sandbox_id": "bad12345", "state": "failed", "error": "compose up failed"}
proc = MagicMock(returncode=0, stdout=json.dumps(payload), stderr="")
with (
patch("wisevalidator.sandbox_client.shutil.which", return_value="/bin/sandboxer"),
patch("wisevalidator.sandbox_client.subprocess.run", return_value=proc),
pytest.raises(RuntimeError, match="sandbox not ready"),
):
create_sandbox(repo)
def test_destroy_sandbox() -> None:
payload = {"sandbox_id": "cli12345", "state": "destroyed"}
proc = MagicMock(returncode=0, stdout=json.dumps(payload), stderr="")
with patch("wisevalidator.sandbox_client.subprocess.run", return_value=proc):
out = destroy_sandbox("cli12345")
assert out["state"] == "destroyed"

25
tests/test_schema.py Normal file
View File

@@ -0,0 +1,25 @@
"""E2EConfig schema tests."""
from pathlib import Path
import pytest
from wisevalidator.schema import E2EConfig, HealthCheck
FIXTURE_REPO = Path(__file__).parent / "fixtures" / "minimal_repo"
def test_load_minimal_fixture() -> None:
config = E2EConfig.load(FIXTURE_REPO)
assert config.name == "fixture-repo"
assert config.compose_file == "docker-compose.yml"
assert config.test_command == "echo ok"
assert len(config.health_checks) == 1
assert config.health_checks[0] == HealthCheck(
name="web", url="http://localhost:8080", timeout=30
)
def test_missing_e2e_yml(tmp_path: Path) -> None:
with pytest.raises(FileNotFoundError, match="No e2e.yml"):
E2EConfig.load(tmp_path)