Files
cleveragents-core/features/steps/plan_sync_steps.py
freemo 8e9aa7af48 fix(client): address server client chain review findings
- Promote _request() to public method request() in ServerHttpClient
  to fix encapsulation violation across sync_client and remote_project
- Fix WebSocket connect() to raise NotImplementedError with clear TODO
  documenting that real websockets transport is not yet implemented
- Fix thread safety: protect last_event_id write under self._lock
  in ws_client.process_event()
- Broaden exception handling in sync() to catch ServerTimeoutError
  and A2aNotAvailableError in addition to ServerConnectionError
- Add has_next to PageResult in benchmark and test helpers
- Add comments explaining Retry-After header logging-only behavior
  and why blocking time.sleep is acceptable in sync client
- Update all test steps, robot helpers, and benchmarks to use the
  renamed public request() method and updated connect() behavior

Refs: #335, #336, #337, #338
2026-03-24 20:28:19 +00:00

372 lines
12 KiB
Python

"""Step definitions for plan sync and remote execution feature tests."""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock
import httpx
from behave import given, then, when
from behave.runner import Context
from cleveragents.client.http_client import ServerHttpClient
from cleveragents.client.sync_client import (
ConflictPolicy,
ExecutionResult,
PlanSyncClient,
SyncScope,
SyncSummary,
)
def _mock_response(
status: int = 200,
body: dict[str, Any] | list[Any] | None = None,
) -> httpx.Response:
return httpx.Response(
status_code=status,
json=body,
headers={},
request=httpx.Request("GET", "http://mock"),
)
# ---------------------------------------------------------------------------
# SyncScope
# ---------------------------------------------------------------------------
@given("a SyncScope with all flags enabled")
def step_scope_all(context: Context) -> None:
context.sync_scope = SyncScope()
@given("a SyncScope with only actions and tools enabled")
def step_scope_partial(context: Context) -> None:
context.sync_scope = SyncScope(
actions=True, skills=False, tools=True, projects=False
)
@then('the active types should be "{expected}"')
def step_check_active_types(context: Context, expected: str) -> None:
result = ",".join(context.sync_scope.active_types())
assert result == expected, f"Expected {expected!r}, got {result!r}"
# ---------------------------------------------------------------------------
# PlanSyncClient construction
# ---------------------------------------------------------------------------
def _build_sync_client(
policy: ConflictPolicy = ConflictPolicy.LOCAL_WINS,
) -> tuple[PlanSyncClient, MagicMock]:
mock_http = MagicMock(spec=ServerHttpClient)
client = PlanSyncClient(mock_http, conflict_policy=policy)
return client, mock_http
@given("a PlanSyncClient with a mock HTTP client")
def step_sync_client(context: Context) -> None:
context.sync_client, context.mock_http = _build_sync_client()
context.call_error = None
@given("a PlanSyncClient with server_wins conflict policy")
def step_sync_client_server_wins(context: Context) -> None:
context.sync_client, context.mock_http = _build_sync_client(
ConflictPolicy.SERVER_WINS
)
context.call_error = None
@given("a PlanSyncClient with local_wins conflict policy")
def step_sync_client_local_wins(context: Context) -> None:
context.sync_client, context.mock_http = _build_sync_client(
ConflictPolicy.LOCAL_WINS
)
context.call_error = None
# ---------------------------------------------------------------------------
# Item lists
# ---------------------------------------------------------------------------
@given("a list of {n:d} new items without server_id")
def step_new_items(context: Context, n: int) -> None:
context.sync_items = [{"id": f"item-{i}", "name": f"Item {i}"} for i in range(n)]
# Mock POST returns new server_id
context.mock_http.request.return_value = _mock_response(200, {"id": "srv-new-001"})
@given("a list of {n:d} existing items with server_id and newer version")
def step_existing_newer(context: Context, n: int) -> None:
context.sync_items = [
{"id": f"item-{i}", "server_id": f"srv-{i}", "version": 2} for i in range(n)
]
# Mock GET returns older version, PUT returns OK
def _side_effect(method: str, path: str, **kw: Any) -> httpx.Response:
if method == "GET":
return _mock_response(200, {"version": 1})
return _mock_response(200, {"id": "srv-updated"})
context.mock_http.request.side_effect = _side_effect
@given("a list of {n:d} existing items with server_id and same version")
def step_existing_same(context: Context, n: int) -> None:
context.sync_items = [
{"id": f"item-{i}", "server_id": f"srv-{i}", "version": 1} for i in range(n)
]
context.mock_http.request.return_value = _mock_response(200, {"version": 1})
@given("a list with one item missing id")
def step_item_missing_id(context: Context) -> None:
context.sync_items = [{"name": "no-id"}]
@given("a list of {n:d} existing items where server version is newer")
def step_existing_server_newer(context: Context, n: int) -> None:
context.sync_items = [
{"id": f"item-{i}", "server_id": f"srv-{i}", "version": 1} for i in range(n)
]
context.mock_http.request.return_value = _mock_response(200, {"version": 5})
# ---------------------------------------------------------------------------
# Sync operations
# ---------------------------------------------------------------------------
@when('I sync the items as "{resource_type}"')
def step_sync(context: Context, resource_type: str) -> None:
context.sync_summary = context.sync_client.sync(context.sync_items, resource_type)
@when('I sync the items as "{resource_type}" with dry_run true')
def step_sync_dry(context: Context, resource_type: str) -> None:
context.sync_summary = context.sync_client.sync(
context.sync_items, resource_type, dry_run=True
)
@then("the sync summary should show {created:d} created and {updated:d} updated")
def step_check_sync_created_updated(
context: Context, created: int, updated: int
) -> None:
assert context.sync_summary.created == created, (
f"Expected {created} created, got {context.sync_summary.created}"
)
assert context.sync_summary.updated == updated, (
f"Expected {updated} updated, got {context.sync_summary.updated}"
)
@then("the sync summary should show {c:d} created and {u:d} updated and {s:d} skipped")
def step_check_sync_skipped(context: Context, c: int, u: int, s: int) -> None:
assert context.sync_summary.created == c
assert context.sync_summary.updated == u
assert context.sync_summary.skipped == s
@then("the sync summary should show {c:d} created and {u:d} updated and {e:d} error")
def step_check_sync_errors(context: Context, c: int, u: int, e: int) -> None:
assert context.sync_summary.created == c
assert context.sync_summary.updated == u
assert context.sync_summary.errors == e
@then("the sync summary should not be a dry run")
def step_not_dry_run(context: Context) -> None:
assert context.sync_summary.dry_run is False
@then("the sync summary should be a dry run")
def step_is_dry_run(context: Context) -> None:
assert context.sync_summary.dry_run is True
# ---------------------------------------------------------------------------
# Sync all
# ---------------------------------------------------------------------------
@given("resources for actions and tools")
def step_resources(context: Context) -> None:
context.resources = {
"actions": [{"id": "a1"}],
"tools": [{"id": "t1"}],
}
context.mock_http.request.return_value = _mock_response(200, {"id": "srv-001"})
@when("I sync all with a scope limited to actions only")
def step_sync_all_actions(context: Context) -> None:
scope = SyncScope(actions=True, skills=False, tools=False, projects=False)
context.sync_results = context.sync_client.sync_all(context.resources, scope=scope)
@then("the sync result should contain actions but not tools")
def step_check_sync_all(context: Context) -> None:
assert "actions" in context.sync_results
assert "tools" not in context.sync_results
# ---------------------------------------------------------------------------
# Remote execution
# ---------------------------------------------------------------------------
@given("a PlanSyncClient with a mock HTTP client for execution")
def step_sync_client_exec(context: Context) -> None:
context.sync_client, context.mock_http = _build_sync_client()
context.call_error = None
def _exec_side_effect(method: str, path: str, **kw: Any) -> httpx.Response:
if "execute" in path:
return _mock_response(
200,
{
"server_plan_id": "srv-exec-001",
"status": "submitted",
},
)
if "apply" in path:
return _mock_response(
200,
{
"server_plan_id": "srv-apply-001",
"status": "applying",
},
)
return _mock_response(200, {})
context.mock_http.request.side_effect = _exec_side_effect
@given("a PlanSyncClient with a mock HTTP client for status")
def step_sync_client_status(context: Context) -> None:
context.sync_client, context.mock_http = _build_sync_client()
context.mock_http.request.return_value = _mock_response(
200, {"phase": "running", "progress": 50}
)
context.call_error = None
@when('I execute plan "{plan_id}"')
def step_execute_plan(context: Context, plan_id: str) -> None:
context.exec_result = context.sync_client.execute_plan(plan_id)
@when('I apply plan "{plan_id}"')
def step_apply_plan(context: Context, plan_id: str) -> None:
context.exec_result = context.sync_client.apply_plan(plan_id)
@when('I get status for server plan "{server_plan_id}"')
def step_get_status(context: Context, server_plan_id: str) -> None:
context.plan_status = context.sync_client.get_plan_status(server_plan_id)
@when("I execute plan with empty id")
def step_execute_empty(context: Context) -> None:
try:
context.sync_client.execute_plan("")
except ValueError as exc:
context.call_error = exc
@when("I apply plan with empty id")
def step_apply_empty(context: Context) -> None:
try:
context.sync_client.apply_plan("")
except ValueError as exc:
context.call_error = exc
@when("I get status with empty server plan id")
def step_status_empty(context: Context) -> None:
try:
context.sync_client.get_plan_status("")
except ValueError as exc:
context.call_error = exc
@then('the execution result plan_id should be "{expected}"')
def step_check_exec_plan_id(context: Context, expected: str) -> None:
assert context.exec_result.plan_id == expected
@then('the execution result status should be "{expected}"')
def step_check_exec_status(context: Context, expected: str) -> None:
assert context.exec_result.status == expected
@then('the plan status should contain phase "{expected}"')
def step_check_plan_phase(context: Context, expected: str) -> None:
assert context.plan_status.get("phase") == expected
@then("a ValueError should be raised for empty sync plan_id")
def step_value_error_plan_id(context: Context) -> None:
assert isinstance(context.call_error, ValueError)
# ---------------------------------------------------------------------------
# Conflict policy
# ---------------------------------------------------------------------------
@when("I change the conflict policy to server_wins")
def step_change_policy(context: Context) -> None:
context.sync_client.conflict_policy = ConflictPolicy.SERVER_WINS
@then("the conflict policy should be server_wins")
def step_check_policy(context: Context) -> None:
assert context.sync_client.conflict_policy == ConflictPolicy.SERVER_WINS
# ---------------------------------------------------------------------------
# SyncSummary
# ---------------------------------------------------------------------------
@given("a SyncSummary with {c:d} created {u:d} updated {s:d} skipped {e:d} errors")
def step_sync_summary(context: Context, c: int, u: int, s: int, e: int) -> None:
context.test_summary = SyncSummary(created=c, updated=u, skipped=s, errors=e)
@then("the total processed should be {expected:d}")
def step_check_total(context: Context, expected: int) -> None:
assert context.test_summary.total_processed == expected
# ---------------------------------------------------------------------------
# ExecutionResult
# ---------------------------------------------------------------------------
@given(
'an ExecutionResult with plan_id "{pid}" server_plan_id "{spid}" '
'status "{st}" message "{msg}"'
)
def step_exec_result(context: Context, pid: str, spid: str, st: str, msg: str) -> None:
context.exec_result = ExecutionResult(
plan_id=pid, server_plan_id=spid, status=st, message=msg
)
@then('the execution result message should be "{expected}"')
def step_check_exec_msg(context: Context, expected: str) -> None:
assert context.exec_result.message == expected
@then('the execution result server_plan_id should be "{expected}"')
def step_check_exec_spid(context: Context, expected: str) -> None:
assert context.exec_result.server_plan_id == expected