diff --git a/pyproject.toml b/pyproject.toml index da00ee259..410d7f7ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ asyncio_mode = "auto" # tests/conftest.py, so unknown-mark warnings still flag genuine typos outside # the taxonomy. See tests/_taxonomy.py and tests/README.md. markers = [ + "serial: live smoke tests mutate one externally launched application; use -n 0", "area_security: tests covering auth, owner-scope, SSRF, XSS, confinement, redaction", "area_routes: tests covering HTTP route / API behavior", "area_services: tests covering service-layer behavior (llm, cookbook, email, calendar, ...)", diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 000000000..f99573c1d --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,4 @@ +# The complete application environment plus local parallel test tooling. +-r requirements.txt +# psutil lets `-n auto` use physical cores instead of logical CPU threads. +pytest-xdist[psutil]>=3.8,<4 diff --git a/tests/conftest.py b/tests/conftest.py index c11100012..3211ab8b9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,13 +8,13 @@ import pytest sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -# core.database initializes its engine during import. Always isolate that -# bootstrap from an inherited developer DATABASE_URL, before collection can -# import it. Tests needing files own their disposable databases explicitly. -# Collection runs before ordinary test fixtures can protect these imports. -# Restore the caller's environment when pytest's configuration is torn down. +# Isolate import-time database and filesystem defaults before collection. +# File-backed databases remain fixture-owned. Cleanup restores the caller. +# Keep the existing generated environment-reference locations stable. _database_environment = pytest.MonkeyPatch() _database_environment.setenv("DATABASE_URL", "sqlite:///:memory:") +from tests.helpers.worker_runtime import bootstrap_runtime, configure_runtime +_runtime_environment = bootstrap_runtime() # Pre-import real heavy modules BEFORE any test file's module-level stubs can # replace them with MagicMock. Some test files (e.g. test_llm_core_sanitize_*) @@ -349,3 +349,36 @@ def _no_context_window_network_probe(request): def context_probe_ledger(_no_context_window_network_probe): """Metadata requests the context resolver attempted during this test.""" return _no_context_window_network_probe + + +@pytest.hookimpl(specname="pytest_configure") +def pytest_configure_worker_runtime(config): + configure_runtime(config, _runtime_environment) + + +@pytest.hookimpl(specname="pytest_collection_modifyitems", tryfirst=True) +def pytest_collection_worker_runtime(items): + # Mark before pytest applies -m. The final guard also respects --shard. + for item in items: + if "smoke" in item.path.parts: + item.add_marker(pytest.mark.serial) + + +@pytest.hookimpl(tryfirst=True) +def pytest_collection_finish(session): + """Refuse shared live resources after marker and shard deselection.""" + config = session.config + parallel = bool(getattr(config.option, "numprocesses", None)) or hasattr(config, "workerinput") + if (os.environ.get("APP_PORT") and parallel + and any(item.get_closest_marker("serial") for item in session.items)): + message = ( + "live smoke tests share one external application, accounts, and endpoints; " + "run tests/smoke with -n 0" + ) + # A worker UsageError here races xdist's collection notification and + # can lose its message. Emit a normal collection failure before xdist + # sees any runnable items. Nothing may contact the external instance. + config.hook.pytest_collectreport(report=pytest.CollectReport( + nodeid="tests/smoke", outcome="failed", longrepr=message, result=[], + )) + session.items.clear() diff --git a/tests/helpers/worker_runtime.py b/tests/helpers/worker_runtime.py new file mode 100644 index 000000000..d745ff607 --- /dev/null +++ b/tests/helpers/worker_runtime.py @@ -0,0 +1,64 @@ +"""Private filesystem defaults established before application imports.""" + +import os +import tempfile +from contextlib import contextmanager +from pathlib import Path + +import pytest + + +def bootstrap_runtime(): + """Establish defaults before collection; APP_PORT opts into live smoke.""" + worker = os.environ.get("PYTEST_XDIST_WORKER") + if os.environ.get("APP_PORT") and not worker: + return None + runtime = isolated_runtime(worker or "main") + runtime.__enter__() + return runtime + + +def configure_runtime(config, runtime): + """Register ownership even when configuration or collection fails.""" + if runtime is not None: + config.add_cleanup(lambda: runtime.__exit__(None, None, None)) + parallel = bool(getattr(config.option, "numprocesses", None)) or hasattr(config, "workerinput") + # This consumes the existing test option; its public read and documented + # source location remain in the static-server fixture. + port_variable = "ODYSSEUS_TEST_STATIC_PORT" + if parallel and int(os.environ.get(port_variable) or 0): + raise pytest.UsageError( + "parallel tests require an ephemeral static-server port; " + "unset ODYSSEUS_TEST_STATIC_PORT or use -n 0" + ) + + +@contextmanager +def isolated_runtime(worker="main"): + """Own mutable defaults for one pytest process, including subprocesses. + + The random suffix separates simultaneous runs, even with the same worker + name. Test-specific monkeypatches and function-scoped databases still own + their resources; this is a fallback namespace, not a shared DB fixture. + """ + with tempfile.TemporaryDirectory(prefix=f"ody-{worker}-") as directory: + root = Path(directory) + with pytest.MonkeyPatch.context() as patcher: + paths = { + "ODYSSEUS_DATA_DIR": root / "data", + "ODYSSEUS_MAIL_ATTACHMENTS_DIR": root / "mail", + "FASTEMBED_CACHE_PATH": root / "fastembed", + "XDG_RUNTIME_DIR": root / "runtime", + } + for name, path in paths.items(): + path.mkdir(mode=0o700) + patcher.setenv(name, str(path)) + # Browser resolution falls back to our XDG runtime directory. + patcher.delenv("AGENT_BROWSER_SOCKET_DIR", raising=False) + tmp = root / "tmp" + tmp.mkdir(mode=0o700) + for name in ("TMPDIR", "TMP", "TEMP"): + patcher.setenv(name, str(tmp)) + # tempfile may already have cached the caller's directory. + patcher.setattr(tempfile, "tempdir", str(tmp)) + yield root diff --git a/tests/test_research_report_read.py b/tests/test_research_report_read.py index 5559ee558..d1b586fb1 100644 --- a/tests/test_research_report_read.py +++ b/tests/test_research_report_read.py @@ -14,21 +14,20 @@ These tests pin both halves: web_fetching the HTML report. """ import json -from pathlib import Path import pytest from src.tool_implementations import do_manage_research from src.agent_loop import TOOL_SECTIONS -_DATA_DIR = Path("data/deep_research") - @pytest.fixture -def saved_report(): - _DATA_DIR.mkdir(parents=True, exist_ok=True) +def saved_report(tmp_path, monkeypatch): + from src.tools import research + + monkeypatch.setattr(research, "DEEP_RESEARCH_DIR", str(tmp_path)) rid = "rp-testreport1363" - path = _DATA_DIR / f"{rid}.json" + path = tmp_path / f"{rid}.json" path.write_text(json.dumps({ "query": "trending blender video ideas", "result": "## Findings\nShort-form Geometry Nodes tutorials are trending.", diff --git a/tests/test_stt_leak.py b/tests/test_stt_leak.py index ff752badd..356339113 100644 --- a/tests/test_stt_leak.py +++ b/tests/test_stt_leak.py @@ -3,7 +3,8 @@ import tempfile from services.stt.stt_service import STTService -def test_stt_local_transcribe_leak_on_error(): +def test_stt_local_transcribe_leak_on_error(tmp_path, monkeypatch): + monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) service = STTService() class MockWhisper: diff --git a/tests/test_worker_runtime.py b/tests/test_worker_runtime.py new file mode 100644 index 000000000..5ff9e87d4 --- /dev/null +++ b/tests/test_worker_runtime.py @@ -0,0 +1,72 @@ +"""The default namespace must protect callers and simultaneous pytest runs.""" + +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +import pytest + +from tests.helpers.worker_runtime import isolated_runtime + + +@pytest.mark.parametrize("fail", [False, True]) +def test_runtime_restores_environment_and_removes_files(monkeypatch, tmp_path, fail): + caller = tmp_path / "caller" + caller.mkdir() + sentinel = caller / "sentinel" + sentinel.write_text("keep") + for name in ("ODYSSEUS_DATA_DIR", "ODYSSEUS_MAIL_ATTACHMENTS_DIR", + "FASTEMBED_CACHE_PATH", "XDG_RUNTIME_DIR", "AGENT_BROWSER_SOCKET_DIR", + "TMPDIR", "TMP", "TEMP"): + monkeypatch.setenv(name, str(caller)) + before = dict(os.environ) + previous_tmp = tempfile.tempdir + root = None + try: + with isolated_runtime("gw0") as root: + assert root != caller + assert "AGENT_BROWSER_SOCKET_DIR" not in os.environ + for name in ("ODYSSEUS_DATA_DIR", "ODYSSEUS_MAIL_ATTACHMENTS_DIR", + "FASTEMBED_CACHE_PATH", "XDG_RUNTIME_DIR", "TMPDIR", "TMP", "TEMP"): + path = Path(os.environ[name]) + assert path.is_dir() and path.is_relative_to(root) + (path / "owned").write_text("test") + assert Path(tempfile.gettempdir()).is_relative_to(root) + if fail: + raise RuntimeError("test failure") + except RuntimeError: + assert fail + assert dict(os.environ) == before + assert tempfile.tempdir == previous_tmp + assert root is not None and not root.exists() + assert sentinel.read_text() == "keep" + assert list(caller.iterdir()) == [sentinel] + + +def test_same_worker_name_gets_distinct_namespaces(): + data_variable = "ODYSSEUS_DATA_DIR" + with isolated_runtime("gw0") as first: + (first / "data" / "state").write_text("first") + with isolated_runtime("gw0") as second: + assert first != second + assert not (second / "data" / "state").exists() + assert Path(os.environ[data_variable]) == second / "data" + assert Path(os.environ[data_variable]) == first / "data" + assert (first / "data" / "state").read_text() == "first" + assert not first.exists() and not second.exists() + + +def test_subprocess_inherits_private_temp_and_data_directories(): + with isolated_runtime("gw1") as root: + result = subprocess.run( + [sys.executable, "-c", "import os,tempfile; from pathlib import Path; " + "name = 'ODYSSEUS_DATA_DIR'; Path(os.environ[name], 'child').write_text('data'); " + "Path(tempfile.gettempdir(), 'child').write_text('temp')"], + capture_output=True, text=True, timeout=10, + ) + assert result.returncode == 0, result.stderr + assert (root / "data" / "child").read_text() == "data" + assert (root / "tmp" / "child").read_text() == "temp" + assert not root.exists()