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
519 lines
20 KiB
Python
519 lines
20 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import textwrap
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from config.config import has_credentials_for_active_llm_provider
|
|
from tests.synthetic.eks.run_suite import run_scenario, score_result
|
|
from tests.synthetic.eks.scenario_loader import (
|
|
SUITE_DIR,
|
|
load_all_scenarios,
|
|
load_scenario,
|
|
)
|
|
from tests.synthetic.k8s_schemas import VALID_K8S_EVIDENCE_SOURCES
|
|
from tests.synthetic.mock_datadog_backend.backend import FixtureDatadogBackend
|
|
from tests.synthetic.mock_eks_backend.backend import FixtureEKSBackend
|
|
|
|
# Synthetic E2E uses fixture EKS/Datadog backends; many tool "calls" hit mocks, not real APIs.
|
|
# This gate only reflects whether the *LLM* can authenticate (the reason we skip when keys are absent).
|
|
_SYNTHETIC_SKIP_LLM = (
|
|
"SKIPPED: missing API key for LLM_PROVIDER "
|
|
"(suite uses mock EKS/Datadog backends; configure the key for your selected provider)"
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Loader and fixture validation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_load_all_scenarios_reads_benchmark_cases() -> None:
|
|
fixtures = load_all_scenarios()
|
|
|
|
scenario_ids = [fixture.scenario_id for fixture in fixtures]
|
|
assert "000-healthy" in scenario_ids
|
|
|
|
|
|
def test_scenario_metadata_is_valid() -> None:
|
|
fixtures = load_all_scenarios()
|
|
|
|
for fixture in fixtures:
|
|
meta = fixture.metadata
|
|
assert meta.schema_version, f"{fixture.scenario_id}: schema_version must be set"
|
|
assert meta.engine, f"{fixture.scenario_id}: engine must be set"
|
|
assert meta.cluster_name, f"{fixture.scenario_id}: cluster_name must be set"
|
|
assert meta.namespace, f"{fixture.scenario_id}: namespace must be set"
|
|
assert meta.workload_type, f"{fixture.scenario_id}: workload_type must be set"
|
|
assert meta.workload_name, f"{fixture.scenario_id}: workload_name must be set"
|
|
assert meta.failure_mode, f"{fixture.scenario_id}: failure_mode must be set"
|
|
assert meta.region, f"{fixture.scenario_id}: region must be set"
|
|
assert meta.available_evidence, (
|
|
f"{fixture.scenario_id}: available_evidence must not be empty"
|
|
)
|
|
unknown = set(meta.available_evidence) - VALID_K8S_EVIDENCE_SOURCES
|
|
assert not unknown, f"{fixture.scenario_id}: unknown evidence sources {unknown}"
|
|
|
|
|
|
def test_scenario_evidence_matches_available_evidence() -> None:
|
|
fixtures = load_all_scenarios()
|
|
|
|
for fixture in fixtures:
|
|
evidence_dict = fixture.evidence.as_dict()
|
|
assert set(evidence_dict.keys()) == set(fixture.metadata.available_evidence), (
|
|
f"{fixture.scenario_id}: evidence keys {set(evidence_dict.keys())} "
|
|
f"do not match available_evidence {fixture.metadata.available_evidence}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Mock backend shape tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestMockBackendShapes:
|
|
"""Verify each mock backend method returns the exact envelope the real tool would."""
|
|
|
|
@pytest.fixture
|
|
def placeholder(self):
|
|
return load_scenario(SUITE_DIR / "000-healthy")
|
|
|
|
def test_eks_list_pods_shape(self, placeholder) -> None:
|
|
backend = FixtureEKSBackend(placeholder)
|
|
result = backend.list_pods(cluster_name="override", namespace="override-ns")
|
|
assert result["source"] == "eks"
|
|
assert result["available"] is True
|
|
assert result["error"] is None
|
|
assert "total_pods" in result
|
|
assert "pods" in result
|
|
assert "failing_pods" in result
|
|
assert "high_restart_pods" in result
|
|
assert result["cluster_name"] == "override"
|
|
assert result["namespace"] == "override-ns"
|
|
|
|
def test_eks_list_pods_falls_back_to_metadata(self, placeholder) -> None:
|
|
backend = FixtureEKSBackend(placeholder)
|
|
result = backend.list_pods()
|
|
assert result["cluster_name"] == placeholder.metadata.cluster_name
|
|
assert result["namespace"] == placeholder.metadata.namespace
|
|
|
|
def test_eks_get_events_shape(self, placeholder) -> None:
|
|
backend = FixtureEKSBackend(placeholder)
|
|
result = backend.get_events()
|
|
assert result["source"] == "eks"
|
|
assert result["available"] is True
|
|
assert result["error"] is None
|
|
assert "warning_events" in result
|
|
assert "total_warning_count" in result
|
|
assert result["total_warning_count"] == len(result["warning_events"])
|
|
|
|
def test_eks_list_deployments_shape(self, placeholder) -> None:
|
|
backend = FixtureEKSBackend(placeholder)
|
|
result = backend.list_deployments()
|
|
assert result["source"] == "eks"
|
|
assert result["available"] is True
|
|
assert "deployments" in result
|
|
assert "degraded_deployments" in result
|
|
assert "total_deployments" in result
|
|
for deployment in result["deployments"]:
|
|
for field in (
|
|
"name",
|
|
"namespace",
|
|
"desired",
|
|
"ready",
|
|
"available",
|
|
"unavailable",
|
|
"degraded",
|
|
):
|
|
assert field in deployment, f"missing field {field}"
|
|
|
|
def test_eks_node_health_shape(self, placeholder) -> None:
|
|
backend = FixtureEKSBackend(placeholder)
|
|
result = backend.get_node_health()
|
|
assert result["source"] == "eks"
|
|
assert result["available"] is True
|
|
assert "nodes" in result
|
|
assert "not_ready_count" in result
|
|
assert "total_nodes" in result
|
|
assert result["not_ready_count"] == 0
|
|
|
|
def test_eks_missing_evidence_raises(self, placeholder) -> None:
|
|
"""Calling a method whose evidence source wasn't declared raises ValueError."""
|
|
backend = FixtureEKSBackend(placeholder)
|
|
# The placeholder deliberately omits eks_pod_logs; calling get_pod_logs must fail.
|
|
assert "eks_pod_logs" not in placeholder.metadata.available_evidence
|
|
with pytest.raises(ValueError, match="eks_pod_logs"):
|
|
backend.get_pod_logs(pod_name="payments-api-7f9dd-x7gr9")
|
|
|
|
def test_datadog_query_logs_shape(self, placeholder) -> None:
|
|
backend = FixtureDatadogBackend(placeholder)
|
|
result = backend.query_logs(query="service:payments-api")
|
|
assert result["source"] == "datadog_logs"
|
|
assert result["available"] is True
|
|
assert "logs" in result
|
|
assert "error_logs" in result
|
|
assert result["query"] == "service:payments-api"
|
|
|
|
def test_datadog_query_monitors_shape(self, placeholder) -> None:
|
|
backend = FixtureDatadogBackend(placeholder)
|
|
result = backend.query_monitors()
|
|
assert result["source"] == "datadog_monitors"
|
|
assert result["available"] is True
|
|
assert "monitors" in result
|
|
assert "total" in result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scorer unit tests (no agent run required)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestScorer:
|
|
"""Feed canned final_state dicts into the scorer and verify the result."""
|
|
|
|
@pytest.fixture
|
|
def placeholder(self):
|
|
return load_scenario(SUITE_DIR / "000-healthy")
|
|
|
|
def test_matching_root_cause_passes(self, placeholder) -> None:
|
|
final_state = {
|
|
"root_cause": "The Kubernetes workload is operating within normal parameters. "
|
|
"No failure detected across pods, events, deployments, or monitors.",
|
|
"root_cause_category": "healthy",
|
|
"validated_claims": [],
|
|
"non_validated_claims": [],
|
|
"causal_chain": [],
|
|
"evidence": {},
|
|
"executed_hypotheses": [],
|
|
"investigation_loop_count": 0,
|
|
}
|
|
score = score_result(placeholder, final_state)
|
|
assert score.passed is True, score.failure_reason
|
|
assert score.actual_category == "healthy"
|
|
assert not score.missing_keywords
|
|
|
|
def test_wrong_category_fails(self, placeholder) -> None:
|
|
final_state = {
|
|
"root_cause": "Some narrative.",
|
|
"root_cause_category": "crashloop_backoff",
|
|
"validated_claims": [],
|
|
"non_validated_claims": [],
|
|
"causal_chain": [],
|
|
"evidence": {},
|
|
"executed_hypotheses": [],
|
|
"investigation_loop_count": 0,
|
|
}
|
|
score = score_result(placeholder, final_state)
|
|
assert score.passed is False
|
|
assert "wrong category" in score.failure_reason
|
|
|
|
def test_missing_keyword_fails(self, placeholder) -> None:
|
|
final_state = {
|
|
"root_cause": "nothing important to report",
|
|
"root_cause_category": "healthy",
|
|
"validated_claims": [],
|
|
"non_validated_claims": [],
|
|
"causal_chain": [],
|
|
"evidence": {},
|
|
"executed_hypotheses": [],
|
|
"investigation_loop_count": 0,
|
|
}
|
|
score = score_result(placeholder, final_state)
|
|
assert score.passed is False
|
|
assert "missing required keywords" in score.failure_reason
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Parametrized LLM runs — gated behind scenarios existing at each difficulty level.
|
|
# The placeholder 000-healthy uses scenario_difficulty: 0, so these collections
|
|
# are empty until scenarios #261+ land with real difficulty-tiered content.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
_ALL_SCENARIOS = load_all_scenarios()
|
|
_LLM_ATTEMPTS = 2
|
|
|
|
|
|
def _by_difficulty(level: int) -> list:
|
|
return [f for f in _ALL_SCENARIOS if f.metadata.scenario_difficulty == level]
|
|
|
|
|
|
def _should_assert_trajectory(fixture, actual_category: str) -> bool:
|
|
"""Keep trajectory assertions for lower-difficulty scenarios only.
|
|
|
|
The higher-difficulty suites intentionally exercise ambiguous prompts and
|
|
alternative evidence paths. In practice, real LLMs can still land on the
|
|
correct diagnosis while taking a different-but-reasonable route, so we only
|
|
gate trajectory exactness for the lower-difficulty benchmark tiers.
|
|
"""
|
|
|
|
return fixture.metadata.scenario_difficulty <= 2 and actual_category != "healthy"
|
|
|
|
|
|
def _run_scenario_test(fixture) -> None:
|
|
"""Run scenario with real LLM and mock backends, then assert scoring."""
|
|
if not has_credentials_for_active_llm_provider():
|
|
pytest.skip(_SYNTHETIC_SKIP_LLM)
|
|
|
|
failures: list[str] = []
|
|
for attempt in range(1, _LLM_ATTEMPTS + 1):
|
|
final_state, score = run_scenario(fixture, use_mock_backends=True)
|
|
|
|
try:
|
|
assert final_state["root_cause"]
|
|
assert score.passed is True, (
|
|
f"{fixture.scenario_id} FAILED: {score.failure_reason}\n"
|
|
f" actual_category={score.actual_category!r} "
|
|
f" missing_keywords={score.missing_keywords}"
|
|
)
|
|
|
|
if (
|
|
_should_assert_trajectory(fixture, score.actual_category)
|
|
and score.trajectory is not None
|
|
):
|
|
assert score.trajectory.sequencing_ok, (
|
|
f"{fixture.scenario_id} TRAJECTORY FAIL: "
|
|
f"sequencing={score.trajectory.sequencing_ok} "
|
|
f"calibration={score.trajectory.calibration_ok}\n"
|
|
f" expected={score.trajectory.expected_sequence}\n"
|
|
f" actual={score.trajectory.actual_sequence}"
|
|
)
|
|
return
|
|
except AssertionError as exc:
|
|
failures.append(f"attempt {attempt}/{_LLM_ATTEMPTS}: {exc}")
|
|
|
|
raise AssertionError("\n\n".join(failures))
|
|
|
|
|
|
_LEVEL1_SCENARIOS = _by_difficulty(1)
|
|
_LEVEL2_SCENARIOS = _by_difficulty(2)
|
|
_LEVEL3_SCENARIOS = _by_difficulty(3)
|
|
_LEVEL4_SCENARIOS = _by_difficulty(4)
|
|
|
|
|
|
@pytest.mark.synthetic
|
|
@pytest.mark.skipif(not _LEVEL1_SCENARIOS, reason="no Level 1 K8s scenarios yet")
|
|
@pytest.mark.parametrize(
|
|
"fixture", _LEVEL1_SCENARIOS or [None], ids=lambda f: f.scenario_id if f else "none"
|
|
)
|
|
def test_level1_scenario(fixture) -> None:
|
|
"""Level 1 — single dominant signal, all evidence consistent."""
|
|
_run_scenario_test(fixture)
|
|
|
|
|
|
@pytest.mark.synthetic
|
|
@pytest.mark.skipif(not _LEVEL2_SCENARIOS, reason="no Level 2 K8s scenarios yet")
|
|
@pytest.mark.parametrize(
|
|
"fixture", _LEVEL2_SCENARIOS or [None], ids=lambda f: f.scenario_id if f else "none"
|
|
)
|
|
def test_level2_scenario(fixture) -> None:
|
|
"""Level 2 — one confounder present, second evidence source needed to rule it out."""
|
|
_run_scenario_test(fixture)
|
|
|
|
|
|
@pytest.mark.synthetic
|
|
@pytest.mark.skipif(not _LEVEL3_SCENARIOS, reason="no Level 3 K8s scenarios yet")
|
|
@pytest.mark.parametrize(
|
|
"fixture", _LEVEL3_SCENARIOS or [None], ids=lambda f: f.scenario_id if f else "none"
|
|
)
|
|
def test_level3_scenario(fixture) -> None:
|
|
"""Level 3 — absent or indirect evidence, key signal missing."""
|
|
_run_scenario_test(fixture)
|
|
|
|
|
|
@pytest.mark.synthetic
|
|
@pytest.mark.skipif(not _LEVEL4_SCENARIOS, reason="no Level 4 K8s scenarios yet")
|
|
@pytest.mark.parametrize(
|
|
"fixture", _LEVEL4_SCENARIOS or [None], ids=lambda f: f.scenario_id if f else "none"
|
|
)
|
|
def test_level4_scenario(fixture) -> None:
|
|
"""Level 4 — compositional fault, two failure modes causally linked."""
|
|
_run_scenario_test(fixture)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scenario inheritance unit tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _write_minimal_answer_yml(scenario_dir: Path) -> None:
|
|
(scenario_dir / "answer.yml").write_text(
|
|
textwrap.dedent("""\
|
|
root_cause_category: test_category
|
|
required_keywords:
|
|
- test_keyword
|
|
model_response: "Test model response."
|
|
""")
|
|
)
|
|
|
|
|
|
class TestScenarioInheritance:
|
|
"""Verify base-inheritance and evidence-file fallback in scenario_loader."""
|
|
|
|
def test_metadata_inherited_from_base(self) -> None:
|
|
"""Scenario with base: 000-healthy inherits metadata fields it omits."""
|
|
real_dir = SUITE_DIR / "999-test-inherit"
|
|
real_dir.mkdir(exist_ok=True)
|
|
try:
|
|
(real_dir / "scenario.yml").write_text(
|
|
textwrap.dedent("""\
|
|
base: 000-healthy
|
|
scenario_id: 999-test-inherit
|
|
failure_mode: crashloop_backoff
|
|
severity: critical
|
|
""")
|
|
)
|
|
_write_minimal_answer_yml(real_dir)
|
|
|
|
fixture = load_scenario(real_dir)
|
|
|
|
assert fixture.metadata.scenario_id == "999-test-inherit"
|
|
assert fixture.metadata.failure_mode == "crashloop_backoff"
|
|
assert fixture.metadata.severity == "critical"
|
|
assert fixture.metadata.engine == "eks"
|
|
assert fixture.metadata.cluster_name == "payments-prod-eks"
|
|
assert fixture.metadata.namespace == "payments"
|
|
assert fixture.metadata.workload_type == "deployment"
|
|
assert fixture.metadata.workload_name == "payments-api"
|
|
assert fixture.metadata.region == "us-east-1"
|
|
assert fixture.metadata.schema_version == "1.0"
|
|
assert "eks_pods" in fixture.metadata.available_evidence
|
|
finally:
|
|
for f in real_dir.iterdir():
|
|
f.unlink()
|
|
real_dir.rmdir()
|
|
|
|
def test_evidence_falls_back_to_base(self) -> None:
|
|
"""Scenario without evidence files loads them from the base."""
|
|
real_dir = SUITE_DIR / "999-test-fallback"
|
|
real_dir.mkdir(exist_ok=True)
|
|
try:
|
|
(real_dir / "scenario.yml").write_text(
|
|
textwrap.dedent("""\
|
|
base: 000-healthy
|
|
scenario_id: 999-test-fallback
|
|
failure_mode: healthy
|
|
severity: info
|
|
""")
|
|
)
|
|
_write_minimal_answer_yml(real_dir)
|
|
|
|
fixture = load_scenario(real_dir)
|
|
|
|
assert fixture.evidence.eks_pods is not None
|
|
assert fixture.evidence.eks_events is not None
|
|
assert fixture.evidence.datadog_logs is not None
|
|
|
|
assert fixture.alert["state"] == "normal"
|
|
assert "payments" in fixture.alert["title"].lower()
|
|
finally:
|
|
for f in real_dir.iterdir():
|
|
f.unlink()
|
|
real_dir.rmdir()
|
|
|
|
def test_local_evidence_overrides_base(self) -> None:
|
|
"""Scenario with its own evidence file uses it instead of the base's."""
|
|
real_dir = SUITE_DIR / "999-test-override"
|
|
real_dir.mkdir(exist_ok=True)
|
|
try:
|
|
(real_dir / "scenario.yml").write_text(
|
|
textwrap.dedent("""\
|
|
base: 000-healthy
|
|
scenario_id: 999-test-override
|
|
failure_mode: healthy
|
|
severity: info
|
|
""")
|
|
)
|
|
_write_minimal_answer_yml(real_dir)
|
|
|
|
custom_events = {
|
|
"warning_events": [
|
|
{
|
|
"namespace": "payments",
|
|
"reason": "TestEvent",
|
|
"message": "Custom test event",
|
|
"type": "Warning",
|
|
"count": 1,
|
|
"involved_object": "Pod/custom-test",
|
|
"first_time": "2026-04-01T00:00:00Z",
|
|
"last_time": "2026-04-01T00:00:00Z",
|
|
}
|
|
]
|
|
}
|
|
(real_dir / "eks_events.json").write_text(json.dumps(custom_events))
|
|
|
|
fixture = load_scenario(real_dir)
|
|
|
|
assert fixture.evidence.eks_events is not None
|
|
assert len(fixture.evidence.eks_events["warning_events"]) == 1
|
|
assert (
|
|
fixture.evidence.eks_events["warning_events"][0]["message"] == "Custom test event"
|
|
)
|
|
finally:
|
|
for f in real_dir.iterdir():
|
|
f.unlink()
|
|
real_dir.rmdir()
|
|
|
|
def test_chained_inheritance_rejected(self) -> None:
|
|
"""Declaring base on a scenario that itself has a base raises ValueError."""
|
|
real_dir_a = SUITE_DIR / "999-test-chain-a"
|
|
real_dir_b = SUITE_DIR / "999-test-chain-b"
|
|
real_dir_a.mkdir(exist_ok=True)
|
|
real_dir_b.mkdir(exist_ok=True)
|
|
try:
|
|
(real_dir_a / "scenario.yml").write_text(
|
|
textwrap.dedent("""\
|
|
base: 000-healthy
|
|
scenario_id: 999-test-chain-a
|
|
failure_mode: healthy
|
|
severity: info
|
|
""")
|
|
)
|
|
(real_dir_b / "scenario.yml").write_text(
|
|
textwrap.dedent("""\
|
|
base: 999-test-chain-a
|
|
scenario_id: 999-test-chain-b
|
|
failure_mode: healthy
|
|
severity: info
|
|
""")
|
|
)
|
|
_write_minimal_answer_yml(real_dir_b)
|
|
|
|
with pytest.raises(ValueError, match="Chained inheritance is not supported"):
|
|
load_scenario(real_dir_b)
|
|
finally:
|
|
for d in (real_dir_a, real_dir_b):
|
|
for f in d.iterdir():
|
|
f.unlink()
|
|
d.rmdir()
|
|
|
|
def test_missing_base_raises(self) -> None:
|
|
"""Referencing a non-existent base scenario raises ValueError."""
|
|
real_dir = SUITE_DIR / "999-test-missing-base"
|
|
real_dir.mkdir(exist_ok=True)
|
|
try:
|
|
(real_dir / "scenario.yml").write_text(
|
|
textwrap.dedent("""\
|
|
base: 999-nonexistent
|
|
scenario_id: 999-test-missing-base
|
|
failure_mode: healthy
|
|
severity: info
|
|
""")
|
|
)
|
|
_write_minimal_answer_yml(real_dir)
|
|
|
|
with pytest.raises(ValueError, match="Base scenario '999-nonexistent' not found"):
|
|
load_scenario(real_dir)
|
|
finally:
|
|
for f in real_dir.iterdir():
|
|
f.unlink()
|
|
real_dir.rmdir()
|
|
|
|
def test_no_base_works_unchanged(self) -> None:
|
|
"""Scenarios without a base field still load normally."""
|
|
fixture = load_scenario(SUITE_DIR / "000-healthy")
|
|
assert fixture.metadata.scenario_id == "000-healthy"
|
|
assert fixture.metadata.failure_mode == "healthy"
|
|
assert fixture.evidence.eks_pods is not None
|