8e9aa7af48
- 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
372 lines
12 KiB
Python
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
|