Files
placeholder/features/steps/resource_file_watcher_coverage_steps.py
freemo 051ee7c290 test(coverage): add Behave BDD tests to improve coverage across 52 source files
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
2026-03-20 21:22:10 +00:00

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)}"
)