Files
cleveragents-core/features/steps/websocket_updates_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

311 lines
10 KiB
Python

"""Step definitions for WebSocket updates client feature tests."""
from __future__ import annotations
from behave import given, then, when
from behave.runner import Context
from cleveragents.a2a.models import A2aEvent
from cleveragents.client.exceptions import ServerConnectionError
from cleveragents.client.ws_client import (
ConnectionState,
EventDeduplicator,
WebSocketClient,
_ws_backoff_delay,
)
# ---------------------------------------------------------------------------
# Construction
# ---------------------------------------------------------------------------
@given("a WebSocketClient with default settings")
def step_ws_default(context: Context) -> None:
context.ws_client = WebSocketClient()
context.call_error = None
@given('a WebSocketClient with url "{url}" and token "{token}"')
def step_ws_custom(context: Context, url: str, token: str) -> None:
context.ws_client = WebSocketClient(ws_url=url, api_token=token)
context.call_error = None
@given("a WebSocketClient with default settings and max_reconnects {n:d}")
def step_ws_max_reconnects(context: Context, n: int) -> None:
context.ws_client = WebSocketClient(
max_reconnects=n, reconnect_base=0.001, reconnect_max=0.01
)
context.call_error = None
@then('the ws_url should be "{expected}"')
def step_check_ws_url(context: Context, expected: str) -> None:
assert context.ws_client.ws_url == expected
@then("the heartbeat interval should be {expected:g}")
def step_check_heartbeat(context: Context, expected: float) -> None:
assert context.ws_client.heartbeat_interval == expected
@then("the dedup capacity should be {expected:d}")
def step_check_dedup_cap(context: Context, expected: int) -> None:
assert context.ws_client.dedup_capacity == expected
# ---------------------------------------------------------------------------
# Connection lifecycle
# ---------------------------------------------------------------------------
@when("I attempt to connect the ws client")
def step_ws_attempt_connect(context: Context) -> None:
try:
context.ws_client.connect()
except NotImplementedError as exc:
context.call_error = exc
@given("the ws client state is set to connected")
def step_ws_set_connected(context: Context) -> None:
context.ws_client._state.connected = True
context.ws_client._running = True
@when("I disconnect the ws client")
def step_ws_disconnect(context: Context) -> None:
context.ws_client.disconnect()
@then("the ws client should be connected")
def step_ws_connected(context: Context) -> None:
assert context.ws_client.connected is True
@then("the ws client should not be connected")
def step_ws_not_connected(context: Context) -> None:
assert context.ws_client.connected is False
# ---------------------------------------------------------------------------
# Reconnection
# ---------------------------------------------------------------------------
@when("I simulate a reconnect")
def step_ws_reconnect(context: Context) -> None:
context.ws_client._state.connected = False
context.ws_client.reconnect()
@when("I exhaust reconnect attempts")
def step_ws_exhaust_reconnects(context: Context) -> None:
context.ws_client._state.connected = True
context.ws_client._running = True
try:
for _ in range(context.ws_client._max_reconnects + 1):
context.ws_client._state.connected = False
context.ws_client.reconnect()
except ServerConnectionError as exc:
context.call_error = exc
@then("the reconnect count should be {expected:d}")
def step_check_reconnect_count(context: Context, expected: int) -> None:
assert context.ws_client.reconnect_count == expected
@then("a ServerConnectionError should be raised from ws client")
def step_ws_conn_error(context: Context) -> None:
assert isinstance(context.call_error, ServerConnectionError)
@then("a NotImplementedError should be raised from ws client")
def step_ws_not_implemented_error(context: Context) -> None:
assert isinstance(context.call_error, NotImplementedError)
# ---------------------------------------------------------------------------
# Event processing
# ---------------------------------------------------------------------------
@given("a WebSocketClient with a subscriber")
def step_ws_with_subscriber(context: Context) -> None:
context.ws_client = WebSocketClient()
context.ws_client._state.connected = True
context.ws_client._running = True
context.received_events: list[A2aEvent] = []
def _on_event(event: A2aEvent) -> None:
context.received_events.append(event)
context.ws_callback = _on_event
context.ws_client.subscribe(_on_event)
context.call_error = None
@when('I process a plan status event with id "{event_id}"')
def step_process_event(context: Context, event_id: str) -> None:
event = A2aEvent(
event_id=event_id,
event_type="plan.status",
plan_id="plan-001",
data={"status": "running"},
)
context.ws_process_result = context.ws_client.process_event(event)
@when('I process a plan status event with id "{event_id}" again')
def step_process_event_again(context: Context, event_id: str) -> None:
event = A2aEvent(
event_id=event_id,
event_type="plan.status",
plan_id="plan-001",
data={"status": "running"},
)
context.ws_process_result = context.ws_client.process_event(event)
@when("I unsubscribe the callback")
def step_unsubscribe(context: Context) -> None:
context.ws_client.unsubscribe(context.ws_callback)
@then('the subscriber should receive event "{event_id}"')
def step_check_received(context: Context, event_id: str) -> None:
assert any(e.event_id == event_id for e in context.received_events)
@then("the subscriber should have received {n:d} event")
def step_check_event_count_singular(context: Context, n: int) -> None:
assert len(context.received_events) == n
@then("the subscriber should have received {n:d} events")
def step_check_event_count_plural(context: Context, n: int) -> None:
assert len(context.received_events) == n
@then('the last event id should be "{expected}"')
def step_check_last_event_id(context: Context, expected: str) -> None:
assert context.ws_client.last_event_id == expected
# ---------------------------------------------------------------------------
# Heartbeat
# ---------------------------------------------------------------------------
@when("I handle a heartbeat")
def step_handle_heartbeat(context: Context) -> None:
context.ws_client.handle_heartbeat()
# ---------------------------------------------------------------------------
# Version negotiation
# ---------------------------------------------------------------------------
@when('I negotiate ws version "{version}"')
def step_negotiate_ws(context: Context, version: str) -> None:
context.ws_negotiated = context.ws_client.negotiate_version(version)
@then('the negotiated ws version should be "{expected}"')
def step_check_ws_version(context: Context, expected: str) -> None:
assert context.ws_negotiated == expected
# ---------------------------------------------------------------------------
# EventDeduplicator
# ---------------------------------------------------------------------------
@given("an EventDeduplicator with capacity {n:d}")
def step_dedup(context: Context, n: int) -> None:
context.dedup = EventDeduplicator(capacity=n)
context.dedup_result = False
@when('I check event "{event_id}" for duplicate')
def step_check_dedup(context: Context, event_id: str) -> None:
context.dedup_result = context.dedup.is_duplicate(event_id)
@then("the dedup result should be not duplicate")
def step_not_dup(context: Context) -> None:
assert context.dedup_result is False
@then("the dedup result should be duplicate")
def step_is_dup(context: Context) -> None:
assert context.dedup_result is True
@when('I add events "{a}" and "{b}" and "{c}" to the deduplicator')
def step_add_events(context: Context, a: str, b: str, c: str) -> None:
context.dedup.is_duplicate(a)
context.dedup.is_duplicate(b)
context.dedup.is_duplicate(c)
@then('event "{event_id}" should not be tracked')
def step_not_tracked(context: Context, event_id: str) -> None:
# If it's not tracked, calling is_duplicate should return False
# (it will also add it, but that's fine for the test)
assert event_id not in context.dedup._seen
@then("the deduplicator size should be {expected:d}")
def step_dedup_size(context: Context, expected: int) -> None:
assert context.dedup.size == expected
@when("I clear the deduplicator")
def step_clear_dedup(context: Context) -> None:
context.dedup.clear()
# ---------------------------------------------------------------------------
# ConnectionState
# ---------------------------------------------------------------------------
@given('a ConnectionState with connected true and last_event_id "{eid}"')
def step_conn_state(context: Context, eid: str) -> None:
context.conn_state = ConnectionState()
context.conn_state.connected = True
context.conn_state.last_event_id = eid
@when("I reset the connection state")
def step_reset_state(context: Context) -> None:
context.conn_state.reset()
@then("the connection state should be disconnected")
def step_state_disconnected(context: Context) -> None:
assert context.conn_state.connected is False
@then("the connection state last_event_id should be empty")
def step_state_no_event_id(context: Context) -> None:
assert context.conn_state.last_event_id == ""
# ---------------------------------------------------------------------------
# Backoff
# ---------------------------------------------------------------------------
@when("I compute ws backoff delay for attempt {a:d} base {b:g} max {m:g}")
def step_ws_backoff(context: Context, a: int, b: float, m: float) -> None:
context.ws_delay = _ws_backoff_delay(a, b, m)
@then("the ws delay should be {expected:g}")
def step_check_ws_delay(context: Context, expected: float) -> None:
assert context.ws_delay == expected