forked from HAL9000/cleveragents-core
051ee7c290
Added 52 new .feature files and corresponding _steps.py files targeting previously uncovered code paths in the following areas: - TUI layer: app, commands, persona (state/schema/registry), widgets, input (shell_exec, reference_parser) - Application services: plan lifecycle/service/executor, session, project, repo indexing, correction, checkpoint, actor, llm_actors, strategy coordinator, resource file watcher, service retry wiring - CLI commands: session, resource, repl, plan, db, automation_profile - Domain models: retry_policy, resource_type, cost_budget, docker_compose_analyzer, detail_level, _sql_string_aware, _postgresql_helpers - Core: circuit_breaker, retry_service_patterns - Infrastructure: repositories, transaction_sandbox, strategy_registry, plugins/loader, container - Config: settings - Agents: plan_generation, context_analysis, auto_debug - A2A: facade All new tests follow the Behave/Gherkin BDD standard. Resolved step definition collisions with unique prefixes. Fixed Alembic fileConfig logger disabling issue (disable_existing_loggers=False). ISSUES CLOSED: #1068
451 lines
16 KiB
Python
451 lines
16 KiB
Python
"""Step definitions for resource_file_watcher_coverage.feature.
|
|
|
|
These steps target specific uncovered lines in resource_file_watcher.py:
|
|
- Lines 168-183: watch() OSError fallback to polling / re-raise
|
|
- Lines 255-264: start() OSError fallback to PollingObserver
|
|
- Lines 295-304: _switch_to_polling_observer_locked()
|
|
- Lines 325-329: stop() observer join timeout warning
|
|
- _fire_change with dest_path in event details
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from behave import given, then, when
|
|
|
|
from cleveragents.application.services.resource_file_watcher import (
|
|
FileChangeType,
|
|
ResourceFileWatcher,
|
|
)
|
|
from cleveragents.infrastructure.events.models import DomainEvent
|
|
from cleveragents.infrastructure.events.types import EventType
|
|
|
|
__all__: list[str] = []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _cleanup_watcher(context: Any) -> None:
|
|
"""Stop watcher and clean up temp directory."""
|
|
watcher = getattr(context, "rfw_watcher", None)
|
|
if watcher is not None:
|
|
# Force-reset running flag to avoid errors during cleanup
|
|
watcher._running = False
|
|
watcher._observer = None
|
|
tmpdir = getattr(context, "rfw_tmpdir_obj", None)
|
|
if tmpdir is not None:
|
|
tmpdir.cleanup()
|
|
# Restore any patches
|
|
for patcher in getattr(context, "rfw_patches", []):
|
|
with contextlib.suppress(RuntimeError):
|
|
patcher.stop()
|
|
|
|
|
|
def _make_mock_observer(
|
|
*, schedule_raises: bool = False, alive_after_join: bool = False
|
|
) -> MagicMock:
|
|
"""Create a mock observer with configurable behaviour."""
|
|
mock_obs = MagicMock()
|
|
mock_obs.daemon = True
|
|
if schedule_raises:
|
|
mock_obs.schedule.side_effect = OSError("Simulated inotify limit")
|
|
if alive_after_join:
|
|
mock_obs.is_alive.return_value = True
|
|
else:
|
|
mock_obs.is_alive.return_value = False
|
|
return mock_obs
|
|
|
|
|
|
class _TrackingEventBus:
|
|
"""Minimal event bus that records emitted events."""
|
|
|
|
def __init__(self) -> None:
|
|
self.events: list[DomainEvent] = []
|
|
|
|
def emit(self, event: DomainEvent) -> None:
|
|
self.events.append(event)
|
|
|
|
def subscribe(self, event_type: EventType, handler: Any) -> None:
|
|
pass
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Background
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("the resource_file_watcher module is imported")
|
|
def step_rfw_module_imported(context: Any) -> None:
|
|
"""Ensure the module is importable."""
|
|
assert ResourceFileWatcher is not None
|
|
assert FileChangeType is not None
|
|
context.rfw_patches = []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# start() OSError fallback to PollingObserver (lines 255-264)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a ResourceFileWatcher with default auto_reindex enabled")
|
|
def step_rfw_default_watcher(context: Any) -> None:
|
|
context.rfw_watcher = ResourceFileWatcher(auto_reindex=True, debounce_seconds=0.01)
|
|
context.add_cleanup(_cleanup_watcher, context)
|
|
|
|
|
|
@given("the native Observer start method is patched to raise OSError")
|
|
def step_rfw_patch_observer_start(context: Any) -> None:
|
|
"""Patch Observer so its start() raises OSError, and PollingObserver works."""
|
|
mock_native = MagicMock()
|
|
mock_native.return_value = _make_mock_observer()
|
|
mock_native.return_value.start.side_effect = OSError("No inotify watches available")
|
|
mock_native.return_value.daemon = True
|
|
|
|
mock_polling = MagicMock()
|
|
mock_polling_instance = _make_mock_observer()
|
|
mock_polling_instance.start.side_effect = None # polling works fine
|
|
mock_polling.return_value = mock_polling_instance
|
|
|
|
p1 = patch(
|
|
"cleveragents.application.services.resource_file_watcher.Observer",
|
|
mock_native,
|
|
)
|
|
p2 = patch(
|
|
"cleveragents.application.services.resource_file_watcher.PollingObserver",
|
|
mock_polling,
|
|
)
|
|
p1.start()
|
|
p2.start()
|
|
context.rfw_patches.extend([p1, p2])
|
|
context.rfw_mock_polling_instance = mock_polling_instance
|
|
|
|
|
|
@when("the watcher start method is called")
|
|
def step_rfw_start(context: Any) -> None:
|
|
context.rfw_watcher.start()
|
|
|
|
|
|
@then("the watcher should be running")
|
|
def step_rfw_is_running(context: Any) -> None:
|
|
assert context.rfw_watcher.is_running, "Expected watcher to be running"
|
|
|
|
|
|
@then("the watcher should be using polling observer")
|
|
def step_rfw_using_polling(context: Any) -> None:
|
|
assert context.rfw_watcher._using_polling, "Expected watcher to be using polling"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# watch() schedule OSError fallback (lines 168-180, 295-304)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a ResourceFileWatcher that is running with a mock native observer")
|
|
def step_rfw_running_with_native_observer(context: Any) -> None:
|
|
"""Create a watcher and set it to running state with a mock observer."""
|
|
context.rfw_watcher = ResourceFileWatcher(debounce_seconds=0.01)
|
|
context.add_cleanup(_cleanup_watcher, context)
|
|
context.rfw_mock_observer = _make_mock_observer()
|
|
context.rfw_watcher._observer = context.rfw_mock_observer
|
|
context.rfw_watcher._running = True
|
|
context.rfw_watcher._using_polling = False
|
|
|
|
|
|
@given("a temporary file exists for watching")
|
|
def step_rfw_temp_file(context: Any) -> None:
|
|
context.rfw_tmpdir_obj = tempfile.TemporaryDirectory()
|
|
tmpdir = Path(context.rfw_tmpdir_obj.name)
|
|
fpath = tmpdir / "testfile.py"
|
|
fpath.write_text("# test content\n")
|
|
context.rfw_temp_file = fpath
|
|
|
|
|
|
@given("the mock observer schedule method raises OSError")
|
|
def step_rfw_mock_schedule_raises(context: Any) -> None:
|
|
context.rfw_mock_observer.schedule.side_effect = OSError("Simulated inotify limit")
|
|
|
|
# Patch PollingObserver constructor to return a working mock
|
|
mock_polling_cls = MagicMock()
|
|
mock_polling_instance = _make_mock_observer()
|
|
mock_polling_instance.schedule.return_value = MagicMock() # watch handle
|
|
mock_polling_cls.return_value = mock_polling_instance
|
|
context.rfw_mock_polling_instance = mock_polling_instance
|
|
|
|
p = patch(
|
|
"cleveragents.application.services.resource_file_watcher.PollingObserver",
|
|
mock_polling_cls,
|
|
)
|
|
p.start()
|
|
context.rfw_patches.append(p)
|
|
|
|
|
|
@when('I watch the temporary file with resource "{rid}" project "{proj}"')
|
|
def step_rfw_watch_file(context: Any, rid: str, proj: str) -> None:
|
|
context.rfw_watcher.watch(
|
|
context.rfw_temp_file,
|
|
resource_id=rid,
|
|
project=proj,
|
|
)
|
|
|
|
|
|
@then("the watcher should have switched to polling observer")
|
|
def step_rfw_switched_to_polling(context: Any) -> None:
|
|
assert context.rfw_watcher._using_polling, (
|
|
"Expected watcher to have switched to polling observer"
|
|
)
|
|
|
|
|
|
@then("the temporary file should be in the watched paths")
|
|
def step_rfw_temp_file_in_watched(context: Any) -> None:
|
|
resolved = str(context.rfw_temp_file.resolve())
|
|
assert resolved in context.rfw_watcher._watched_paths, (
|
|
f"Expected {resolved} in watched paths"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# watch() schedule OSError re-raise when already polling (line 183)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a ResourceFileWatcher that is running with a mock polling observer")
|
|
def step_rfw_running_with_polling_observer(context: Any) -> None:
|
|
"""Create a watcher set to running state with polling already active."""
|
|
context.rfw_watcher = ResourceFileWatcher(debounce_seconds=0.01)
|
|
context.add_cleanup(_cleanup_watcher, context)
|
|
context.rfw_mock_observer = _make_mock_observer(schedule_raises=True)
|
|
context.rfw_watcher._observer = context.rfw_mock_observer
|
|
context.rfw_watcher._running = True
|
|
context.rfw_watcher._using_polling = True # already polling
|
|
|
|
|
|
@when(
|
|
'I watch the temporary file expecting OSError with resource "{rid}" project "{proj}"'
|
|
)
|
|
def step_rfw_watch_file_expect_error(context: Any, rid: str, proj: str) -> None:
|
|
context.rfw_watch_error = None
|
|
try:
|
|
context.rfw_watcher.watch(
|
|
context.rfw_temp_file,
|
|
resource_id=rid,
|
|
project=proj,
|
|
)
|
|
except OSError as exc:
|
|
context.rfw_watch_error = exc
|
|
|
|
|
|
@then("the watch call should have raised OSError")
|
|
def step_rfw_watch_raised_oserror(context: Any) -> None:
|
|
assert context.rfw_watch_error is not None, "Expected OSError to be raised"
|
|
assert isinstance(context.rfw_watch_error, OSError)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# stop() with observer that stays alive (lines 325-329)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a ResourceFileWatcher with a mock observer that stays alive after join")
|
|
def step_rfw_mock_observer_stays_alive(context: Any) -> None:
|
|
context.rfw_watcher = ResourceFileWatcher(debounce_seconds=0.01)
|
|
context.add_cleanup(_cleanup_watcher, context)
|
|
|
|
mock_obs = _make_mock_observer(alive_after_join=True)
|
|
context.rfw_watcher._observer = mock_obs
|
|
context.rfw_watcher._running = True
|
|
context.rfw_watcher._using_polling = False
|
|
context.rfw_mock_alive_observer = mock_obs
|
|
|
|
|
|
@when("the watcher is stopped")
|
|
def step_rfw_stop(context: Any) -> None:
|
|
context.rfw_watcher.stop()
|
|
|
|
|
|
@then("the watcher should not be running after stop")
|
|
def step_rfw_not_running_after_stop(context: Any) -> None:
|
|
assert not context.rfw_watcher.is_running, "Expected watcher to not be running"
|
|
|
|
|
|
@then("the observer join timeout warning path should have been exercised")
|
|
def step_rfw_observer_join_timeout_exercised(context: Any) -> None:
|
|
"""Verify the observer's is_alive was checked (which triggers the warning log)."""
|
|
context.rfw_mock_alive_observer.stop.assert_called_once()
|
|
context.rfw_mock_alive_observer.join.assert_called_once()
|
|
context.rfw_mock_alive_observer.is_alive.assert_called_once()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _switch_to_polling_observer_locked (lines 295-304)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a ResourceFileWatcher with a mock native observer and watched paths")
|
|
def step_rfw_watcher_with_native_and_paths(context: Any) -> None:
|
|
context.rfw_watcher = ResourceFileWatcher(debounce_seconds=0.01)
|
|
context.add_cleanup(_cleanup_watcher, context)
|
|
context.rfw_old_observer = _make_mock_observer()
|
|
context.rfw_watcher._observer = context.rfw_old_observer
|
|
context.rfw_watcher._running = True
|
|
context.rfw_watcher._using_polling = False
|
|
|
|
|
|
@when("_switch_to_polling_observer_locked is called directly")
|
|
def step_rfw_call_switch_to_polling(context: Any) -> None:
|
|
# Need to add a watched path so _build_observer schedules something
|
|
resolved = str(context.rfw_temp_file.resolve())
|
|
context.rfw_watcher._watched_paths[resolved] = ("res-switch", "proj-switch")
|
|
|
|
# Patch PollingObserver to return a mock
|
|
mock_polling_cls = MagicMock()
|
|
mock_polling_instance = _make_mock_observer()
|
|
mock_polling_instance.schedule.return_value = MagicMock()
|
|
mock_polling_cls.return_value = mock_polling_instance
|
|
context.rfw_new_polling_instance = mock_polling_instance
|
|
|
|
p = patch(
|
|
"cleveragents.application.services.resource_file_watcher.PollingObserver",
|
|
mock_polling_cls,
|
|
)
|
|
p.start()
|
|
context.rfw_patches.append(p)
|
|
|
|
with context.rfw_watcher._lock:
|
|
context.rfw_watcher._switch_to_polling_observer_locked()
|
|
|
|
|
|
@then("the old observer should have been stopped")
|
|
def step_rfw_old_observer_stopped(context: Any) -> None:
|
|
context.rfw_old_observer.stop.assert_called_once()
|
|
context.rfw_old_observer.join.assert_called_once()
|
|
|
|
|
|
@then("the watcher should now have a polling observer")
|
|
def step_rfw_now_has_polling_observer(context: Any) -> None:
|
|
assert context.rfw_watcher._using_polling, "Expected _using_polling to be True"
|
|
assert context.rfw_watcher._observer is context.rfw_new_polling_instance
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# start() with pre-registered paths + polling fallback (lines 255-264)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a ResourceFileWatcher with a pre-registered watched path")
|
|
def step_rfw_watcher_with_preregistered_path(context: Any) -> None:
|
|
context.rfw_tmpdir_obj = tempfile.TemporaryDirectory()
|
|
tmpdir = Path(context.rfw_tmpdir_obj.name)
|
|
fpath = tmpdir / "preregistered.py"
|
|
fpath.write_text("# pre-registered\n")
|
|
context.rfw_temp_file = fpath
|
|
|
|
context.rfw_watcher = ResourceFileWatcher(auto_reindex=True, debounce_seconds=0.01)
|
|
context.add_cleanup(_cleanup_watcher, context)
|
|
|
|
# Pre-register the path by directly injecting into _watched_paths
|
|
# (Normally watch() requires file to exist and watcher to be started,
|
|
# but _build_observer reads _watched_paths to set up directory watches.)
|
|
resolved = str(fpath.resolve())
|
|
context.rfw_watcher._watched_paths[resolved] = ("res-pre", "proj-pre")
|
|
context.rfw_preregistered_parent = str(fpath.resolve().parent)
|
|
|
|
|
|
@then("the directory watches should include the pre-registered path parent")
|
|
def step_rfw_dir_watches_include_parent(context: Any) -> None:
|
|
parent = context.rfw_preregistered_parent
|
|
assert parent in context.rfw_watcher._dir_watches, (
|
|
f"Expected {parent} in dir_watches, got {list(context.rfw_watcher._dir_watches.keys())}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _fire_change with dest_path in event details
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a ResourceFileWatcher with a tracking EventBus and debounce 0")
|
|
def step_rfw_with_tracking_event_bus(context: Any) -> None:
|
|
context.rfw_event_bus = _TrackingEventBus()
|
|
context.rfw_watcher = ResourceFileWatcher(
|
|
event_bus=context.rfw_event_bus,
|
|
debounce_seconds=0.0,
|
|
)
|
|
context.add_cleanup(_cleanup_watcher, context)
|
|
|
|
|
|
@given("the watcher is marked as running")
|
|
def step_rfw_mark_running(context: Any) -> None:
|
|
context.rfw_watcher._running = True
|
|
|
|
|
|
@when("_fire_change is invoked with a dest_path argument")
|
|
def step_rfw_fire_change_with_dest_path(context: Any) -> None:
|
|
context.rfw_watcher._fire_change(
|
|
"/tmp/src_file.py",
|
|
"res-dest",
|
|
"proj-dest",
|
|
FileChangeType.MOVED,
|
|
dest_path="/tmp/dest_file.py",
|
|
)
|
|
|
|
|
|
@then("the emitted event details should contain the dest_path")
|
|
def step_rfw_event_has_dest_path(context: Any) -> None:
|
|
assert len(context.rfw_event_bus.events) == 1, (
|
|
f"Expected 1 event, got {len(context.rfw_event_bus.events)}"
|
|
)
|
|
details = context.rfw_event_bus.events[0].details
|
|
assert details.get("dest_path") == "/tmp/dest_file.py", (
|
|
f"Expected dest_path='/tmp/dest_file.py' in details, got {details}"
|
|
)
|
|
assert details.get("change_type") == "moved"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _fire_change exits early when not running (line 459)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a ResourceFileWatcher with a tracking callback")
|
|
def step_rfw_with_tracking_callback(context: Any) -> None:
|
|
context.rfw_callback_calls = []
|
|
|
|
def _on_change(rid: str, proj: str, ct: FileChangeType) -> None:
|
|
context.rfw_callback_calls.append((rid, proj, ct))
|
|
|
|
context.rfw_watcher = ResourceFileWatcher(
|
|
on_change=_on_change,
|
|
debounce_seconds=0.0,
|
|
)
|
|
context.add_cleanup(_cleanup_watcher, context)
|
|
|
|
|
|
@given("the watcher is not running")
|
|
def step_rfw_not_running(context: Any) -> None:
|
|
context.rfw_watcher._running = False
|
|
|
|
|
|
@when("_fire_change is invoked directly")
|
|
def step_rfw_fire_change_not_running(context: Any) -> None:
|
|
context.rfw_watcher._fire_change(
|
|
"/tmp/some_file.py",
|
|
"res-early",
|
|
"proj-early",
|
|
FileChangeType.MODIFIED,
|
|
)
|
|
|
|
|
|
@then("the callback should not have been called")
|
|
def step_rfw_callback_not_called(context: Any) -> None:
|
|
assert len(context.rfw_callback_calls) == 0, (
|
|
f"Expected no callback calls, got {len(context.rfw_callback_calls)}"
|
|
)
|