mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-06 06:52:20 +02:00
test: isolate pytest runtime defaults across workers and runs
This commit is contained in:
@@ -7,6 +7,7 @@ asyncio_mode = "auto"
|
|||||||
# tests/conftest.py, so unknown-mark warnings still flag genuine typos outside
|
# tests/conftest.py, so unknown-mark warnings still flag genuine typos outside
|
||||||
# the taxonomy. See tests/_taxonomy.py and tests/README.md.
|
# the taxonomy. See tests/_taxonomy.py and tests/README.md.
|
||||||
markers = [
|
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_security: tests covering auth, owner-scope, SSRF, XSS, confinement, redaction",
|
||||||
"area_routes: tests covering HTTP route / API behavior",
|
"area_routes: tests covering HTTP route / API behavior",
|
||||||
"area_services: tests covering service-layer behavior (llm, cookbook, email, calendar, ...)",
|
"area_services: tests covering service-layer behavior (llm, cookbook, email, calendar, ...)",
|
||||||
|
|||||||
@@ -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
|
||||||
+38
-5
@@ -8,13 +8,13 @@ import pytest
|
|||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
# core.database initializes its engine during import. Always isolate that
|
# Isolate import-time database and filesystem defaults before collection.
|
||||||
# bootstrap from an inherited developer DATABASE_URL, before collection can
|
# File-backed databases remain fixture-owned. Cleanup restores the caller.
|
||||||
# import it. Tests needing files own their disposable databases explicitly.
|
# Keep the existing generated environment-reference locations stable.
|
||||||
# Collection runs before ordinary test fixtures can protect these imports.
|
|
||||||
# Restore the caller's environment when pytest's configuration is torn down.
|
|
||||||
_database_environment = pytest.MonkeyPatch()
|
_database_environment = pytest.MonkeyPatch()
|
||||||
_database_environment.setenv("DATABASE_URL", "sqlite:///:memory:")
|
_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
|
# 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_*)
|
# 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):
|
def context_probe_ledger(_no_context_window_network_probe):
|
||||||
"""Metadata requests the context resolver attempted during this test."""
|
"""Metadata requests the context resolver attempted during this test."""
|
||||||
return _no_context_window_network_probe
|
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()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -14,21 +14,20 @@ These tests pin both halves:
|
|||||||
web_fetching the HTML report.
|
web_fetching the HTML report.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.tool_implementations import do_manage_research
|
from src.tool_implementations import do_manage_research
|
||||||
from src.agent_loop import TOOL_SECTIONS
|
from src.agent_loop import TOOL_SECTIONS
|
||||||
|
|
||||||
_DATA_DIR = Path("data/deep_research")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def saved_report():
|
def saved_report(tmp_path, monkeypatch):
|
||||||
_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
from src.tools import research
|
||||||
|
|
||||||
|
monkeypatch.setattr(research, "DEEP_RESEARCH_DIR", str(tmp_path))
|
||||||
rid = "rp-testreport1363"
|
rid = "rp-testreport1363"
|
||||||
path = _DATA_DIR / f"{rid}.json"
|
path = tmp_path / f"{rid}.json"
|
||||||
path.write_text(json.dumps({
|
path.write_text(json.dumps({
|
||||||
"query": "trending blender video ideas",
|
"query": "trending blender video ideas",
|
||||||
"result": "## Findings\nShort-form Geometry Nodes tutorials are trending.",
|
"result": "## Findings\nShort-form Geometry Nodes tutorials are trending.",
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import tempfile
|
|||||||
from services.stt.stt_service import STTService
|
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()
|
service = STTService()
|
||||||
|
|
||||||
class MockWhisper:
|
class MockWhisper:
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user