"""Shared test fixtures. Isolate the persistent browser-skill store (and metrics) into throwaway temp dirs for the whole test session, so tests never write skills/metrics into the real ~/Library/Application Support/OpenSwarm/data tree (which would pollute the dev machine and let a stale persisted skill leak across test runs). The workflow fixtures below (isolated_workflows_data, reset_scheduler_state, make_wf, fake_agent_manager) are deliberately NOT autouse: only the scheduled- workflows suites opt in, so they never touch unrelated browser/service tests. The two pre-existing workflow suites still carry their own local copies; new suites lean on these shared ones instead of re-pasting the boilerplate. """ import asyncio import os import tempfile from datetime import datetime, timezone from types import SimpleNamespace import pytest # Tests mock claude_agent_sdk.query, not ClaudeSDKClient; the now-default-ON persistent client would route mocked turns onto a REAL CLI spawn and wedge the suite. Pin it OFF explicitly; the persistent path has its own tests (test_client_pool.py + live gates). os.environ["OPENSWARM_PERSISTENT_CLIENT"] = "0" @pytest.fixture(autouse=True) def _isolate_browser_state(monkeypatch): skills_dir = tempfile.mkdtemp(prefix="os_skills_") metrics_dir = tempfile.mkdtemp(prefix="os_metrics_") playbook_dir = tempfile.mkdtemp(prefix="os_playbook_") monkeypatch.setenv("OPENSWARM_BROWSER_SKILLS_DIR", skills_dir) monkeypatch.setenv("OPENSWARM_BROWSER_METRICS_DIR", metrics_dir) monkeypatch.setenv("OPENSWARM_BROWSER_PLAYBOOK_DIR", playbook_dir) # The speed levers are default-ON in prod; pin them off for the suite so mocked loop tests keep exact aux-call/turn expectations (same pattern as OPENSWARM_PERSISTENT_CLIENT). The levers are exercised by their own live gates + targeted tests that set the flag explicitly. monkeypatch.setenv("OSW_PRESTAGE", "0") monkeypatch.setenv("OSW_FASTREAD_HOP", "0") monkeypatch.setenv("OSW_PRELUDE_TRIM", "0") monkeypatch.setenv("OSW_DEADCARD_EVICT", "0") monkeypatch.setenv("OSW_RECEIPT_DONE", "0") monkeypatch.setenv("OSW_SEND_SCRIPT", "0") def _reset(): for mod in ("browser_skills", "browser_playbook"): try: m = __import__(f"backend.apps.agents.browser.{mod}", fromlist=[mod]) m.clear(wipe_disk=True) except Exception: pass # metrics caches its dir at first use; drop it so each test writes where ITS env var points, not where the first test's pointed try: from backend.apps.agents.browser import browser_metrics as _bm _bm.p_metrics_dir_cache = None except Exception: pass _reset() yield _reset() @pytest.fixture def isolated_workflows_data(monkeypatch, tmp_path): """Point the workflow store at a throwaway tmpdir and start every test with empty in-memory caches, so nothing leaks into a real install or across tests. Mirrors the local fixtures in test_workflows_semantics.py and test_schedule_e2e.py; opt in by requesting this fixture (or depend on it from a file-local autouse).""" from backend.apps.workflows import storage as p_storage from backend.apps.workflows import audit as p_audit from backend.apps.workflows import escalation as p_escalation monkeypatch.setattr(p_storage, "DATA_DIR", str(tmp_path / "workflows")) monkeypatch.setattr(p_storage, "RUNS_DIR", str(tmp_path / "workflows" / "runs")) monkeypatch.setattr(p_storage, "PAUSED_FILE", str(tmp_path / "workflows" / "paused.json")) monkeypatch.setattr(p_storage, "MISSED_FILE", str(tmp_path / "workflows" / "missed.json")) monkeypatch.setattr(p_storage, "_workflow_cache", {}) monkeypatch.setattr(p_storage, "_runs_cache", {}) monkeypatch.setattr(p_storage, "_missed_cache", []) monkeypatch.setattr(p_storage, "_cache_loaded", False) monkeypatch.setattr(p_storage, "_paused", False) monkeypatch.setattr(p_audit, "AUDIT_DIR", str(tmp_path / "workflows" / "audit")) p_escalation._tasks.clear() p_escalation._state.clear() yield @pytest.fixture def reset_scheduler_state(monkeypatch): """Reset the scheduler + executor module globals that otherwise survive across tests and cause order-dependent flakes: a stale _wake Event bound to a dead loop, a lingering _loop_task, a cached host tz, or a leftover entry in the _running / control maps.""" from backend.apps.workflows import scheduler as p_scheduler from backend.apps.workflows import executor as p_executor p_scheduler._loop_task = None p_scheduler._wake = asyncio.Event() monkeypatch.setattr(p_scheduler, "_host_tz_cache", None) p_executor._running.clear() p_executor._run_control.clear() p_executor._run_pause_override.clear() yield p_executor._running.clear() p_executor._run_control.clear() p_executor._run_pause_override.clear() @pytest.fixture def make_wf(): """Factory for a Workflow with a sane default daily-9am-LA schedule; override any field via kwargs.""" def p_build(**overrides): from backend.apps.workflows.models import Workflow, ScheduleConfig, WorkflowStep base = dict( title="t", steps=[WorkflowStep(text="hi")], schedule=ScheduleConfig( enabled=True, repeat_unit="day", repeat_every=1, hour=9, minute=0, timezone="America/Los_Angeles", ), ) base.update(overrides) return Workflow(**base) return p_build class FakeAgentManager: """Stand-in for agent_manager so executor.execute() drives a full run without a live LLM. Records launched configs + every step prompt sent, and lets a test script the per-step terminal status (the value _await_session_idle reads): default 'completed' (-> advance), or 'error' / 'stopped' to exercise the failure paths. """ def __init__(self): self.sessions: dict[str, SimpleNamespace] = {} self.tasks: dict[str, object] = {} self.live_partial: dict[str, object] = {} self.launched_configs: list[object] = [] self.sent_messages: list[str] = [] # statuses[i] is the session status after the i-th send_message; absent entries default to 'completed'. cost_usd lands on the run. self.statuses: list[str] = [] self.cost_usd: float = 0.0 async def launch_agent(self, config): self.launched_configs.append(config) sid = f"sess-{len(self.sessions)}" sess = SimpleNamespace(id=sid, status="completed", cost_usd=self.cost_usd, messages=[]) self.sessions[sid] = sess return sess async def send_message(self, session_id, text, hidden=False): i = len(self.sent_messages) self.sent_messages.append(text) sess = self.sessions.get(session_id) if sess is not None: sess.status = self.statuses[i] if i < len(self.statuses) else "completed" async def close_session(self, session_id): self.sessions.pop(session_id, None) @pytest.fixture def fake_agent_manager(monkeypatch): """Patch the agent_manager seam (plus the ws + notifier side effects) that executor.execute imports lazily, and hand back the FakeAgentManager so the test can script statuses and assert on launched configs / sent steps.""" from backend.apps.agents import agent_manager as p_am from backend.apps.agents.core.ws_manager import ws_manager as p_ws from backend.apps.workflows import notifier as p_notifier fake = FakeAgentManager() monkeypatch.setattr(p_am.agent_manager, "sessions", fake.sessions) monkeypatch.setattr(p_am.agent_manager, "tasks", fake.tasks) monkeypatch.setattr(p_am.agent_manager, "live_partial", fake.live_partial) monkeypatch.setattr(p_am.agent_manager, "launch_agent", fake.launch_agent) monkeypatch.setattr(p_am.agent_manager, "send_message", fake.send_message) monkeypatch.setattr(p_am.agent_manager, "close_session", fake.close_session) async def p_noop_async(*args, **kwargs): return None from backend.apps.agents.manager.permissions import workflow_approval as p_wa monkeypatch.setattr(p_wa, "set_workflow_approval_memory", lambda *a, **k: None) monkeypatch.setattr(p_wa, "set_workflow_approval_step", lambda *a, **k: None) monkeypatch.setattr(p_wa, "clear_workflow_approval_memory", lambda *a, **k: None) monkeypatch.setattr(p_wa, "get_workflow_step_usage", lambda *a, **k: {}) monkeypatch.setattr(p_ws, "broadcast_global", p_noop_async) monkeypatch.setattr(p_notifier, "notify_run_complete", p_noop_async) return fake