test: ensure sentence-transformers is in test env + conftest gate
This commit is contained in:
@@ -37,6 +37,7 @@ dev = [
|
||||
"pytest-cov>=7.0.0",
|
||||
"pytest-asyncio>=0.25.3",
|
||||
"pytest-xdist>=3.6.0",
|
||||
"sentence-transformers~=5.4.1",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
|
||||
@@ -110,12 +110,59 @@ if not _warmup_app_controller.wait_for_warmup(timeout=60.0):
|
||||
import threading
|
||||
_pytest_finished_event: threading.Event = threading.Event()
|
||||
|
||||
def pytest_configure(config: object) -> None:
|
||||
"""
|
||||
Pytest session-start hook. Runs required-dependency check before any
|
||||
test is collected so the user sees a clear, actionable error if the
|
||||
test environment is incomplete.
|
||||
[C: tests/test_required_test_dependencies.py:test_check_succeeds_when_deps_present, tests/test_required_test_dependencies.py:test_check_raises_on_missing_sentence_transformers]
|
||||
"""
|
||||
_check_required_test_dependencies()
|
||||
|
||||
|
||||
def pytest_terminal_summary(terminalreporter: object, exitstatus: int, config: object) -> None:
|
||||
_pytest_finished_event.set()
|
||||
|
||||
def pytest_unconfigure(config: object) -> None:
|
||||
_pytest_finished_event.set()
|
||||
|
||||
_REQUIRED_TEST_IMPORTS: list[tuple[str, str]] = [
|
||||
("sentence_transformers", "sentence-transformers"),
|
||||
]
|
||||
|
||||
def _check_required_test_dependencies() -> None:
|
||||
"""
|
||||
Verify that all packages required by the integration test suite are
|
||||
importable. Raises pytest.UsageError with a clear fix command if a
|
||||
required package is missing.
|
||||
|
||||
This gate is a regression test for the 2026-06-09 incident where
|
||||
sentence-transformers was in [project.optional-dependencies] but the
|
||||
test suite requires it unconditionally. A fresh `uv sync` (without
|
||||
--extra local-rag) produced a confusing "rag_status = error: ...
|
||||
Install with manual_slop[local-rag]" failure inside the live_gui
|
||||
subprocess. This gate fails fast at session start with the exact
|
||||
fix command instead.
|
||||
|
||||
To add a new required test dep, append its import to _REQUIRED_TEST_IMPORTS.
|
||||
[C: tests/test_required_test_dependencies.py:test_check_succeeds_when_deps_present, tests/test_required_test_dependencies.py:test_check_raises_on_missing_sentence_transformers]
|
||||
"""
|
||||
missing: list[str] = []
|
||||
for module_name, package_name in _REQUIRED_TEST_IMPORTS:
|
||||
try:
|
||||
__import__(module_name)
|
||||
except ImportError:
|
||||
missing.append(package_name)
|
||||
if missing:
|
||||
msg = (
|
||||
"Required test dependencies are missing from the venv:\n"
|
||||
f" - {', '.join(missing)}\n\n"
|
||||
"Fix: uv sync --extra local-rag\n"
|
||||
"Or, if pyproject.toml already lists the dep in [dependency-groups].dev:\n"
|
||||
" uv sync\n"
|
||||
)
|
||||
raise pytest.UsageError(msg)
|
||||
|
||||
def _smart_watchdog_exit() -> None:
|
||||
if not _pytest_finished_event.wait(timeout=300.0):
|
||||
os._exit(2)
|
||||
|
||||
@@ -101,39 +101,31 @@ def test_rag_status_remains_error_after_sync_failure() -> None:
|
||||
|
||||
|
||||
def test_rag_engine_init_with_failing_local_embedding_leaves_engine_broken() -> None:
|
||||
"""RAGEngine with local embedding + sentence-transformers missing
|
||||
ends up in a broken state: self.embedding_provider is None.
|
||||
The sync path should detect this and NOT set status to 'ready'.
|
||||
"""When the LocalEmbeddingProvider ctor fails (e.g. sentence-transformers
|
||||
raises), the RAGEngine ctor itself raises ImportError. The sync path
|
||||
catches this and sets rag_status to 'error: ...' (the existing
|
||||
test_rag_status_remains_error_after_sync_failure covers this).
|
||||
|
||||
This is the actual bug surfaced in the test_rag_phase4_final_verify
|
||||
batch failure: in batch context (after the 4 sims run), the sync
|
||||
reports 'ready' but the RAG engine is broken. The AI attempts to
|
||||
use it, embedding fails silently, and the RAG context never
|
||||
appears in the user's history.
|
||||
This test verifies the precondition: that RAGEngine.__init__ actually
|
||||
raises ImportError when the local embedding provider can't be built,
|
||||
rather than silently swallowing the error and leaving a broken engine.
|
||||
|
||||
The 2026-06-08 RAG batch failure root-cause analysis showed the
|
||||
failure mode was NOT "engine is created with embedding_provider=None"
|
||||
(which was the original test docstring's claim) — the constructor
|
||||
RAISES. The actual bug was in the sync path's fallback to 'ready'
|
||||
status, which test_rag_status_remains_error_after_sync_failure
|
||||
verifies. This test is the lower-level sanity check that the
|
||||
precondition for the sync-path test is real.
|
||||
"""
|
||||
import subprocess
|
||||
# Test that the actual RAGEngine (real one) leaves embedding_provider
|
||||
# as None when the import fails
|
||||
result = subprocess.run(
|
||||
["uv", "run", "python", "-c", """
|
||||
import sys
|
||||
sys.path.insert(0, '.')
|
||||
from src import models, rag_engine
|
||||
config = models.RAGConfig(
|
||||
enabled=True,
|
||||
embedding_provider='local',
|
||||
vector_store=models.VectorStoreConfig(provider='chroma', collection_name='t')
|
||||
)
|
||||
try:
|
||||
engine = rag_engine.RAGEngine(config, base_dir='.')
|
||||
print('engine.embedding_provider:', engine.embedding_provider)
|
||||
print('engine.collection:', engine.collection)
|
||||
except ImportError as e:
|
||||
print('ImportError:', str(e)[:60])
|
||||
"""],
|
||||
capture_output=True, text=True, cwd='C:\\\\projects\\\\manual_slop'
|
||||
from src import models
|
||||
from src import rag_engine
|
||||
config = models.RAGConfig(
|
||||
enabled=True,
|
||||
embedding_provider="local",
|
||||
vector_store=models.VectorStoreConfig(provider="chroma", collection_name="t"),
|
||||
)
|
||||
print("STDOUT:", result.stdout)
|
||||
print("STDERR:", result.stderr[:200])
|
||||
assert "ImportError" in result.stdout
|
||||
assert "sentence-transformers" in result.stdout
|
||||
with patch("src.rag_engine._get_sentence_transformers",
|
||||
side_effect=ImportError("Local RAG embeddings require sentence-transformers.")):
|
||||
with pytest.raises(ImportError, match="sentence-transformers"):
|
||||
rag_engine.RAGEngine(config, base_dir=".")
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
Test that the conftest gate catches missing required test dependencies
|
||||
and fails fast with a clear error message.
|
||||
|
||||
This is a TDD test for the regression we just hit: sentence-transformers
|
||||
was in [project.optional-dependencies] but the test suite requires it
|
||||
unconditionally. A fresh `uv sync` (without --extra local-rag) produced
|
||||
a confusing "rag_status = error: ... Install with manual_slop[local-rag]"
|
||||
failure. The gate prevents this by failing at session start with the
|
||||
exact fix command.
|
||||
|
||||
The check itself lives in conftest._check_required_test_dependencies()
|
||||
so it can be unit-tested here without a real conftest import.
|
||||
"""
|
||||
import sys
|
||||
import pytest
|
||||
|
||||
|
||||
def test_check_succeeds_when_deps_present() -> None:
|
||||
"""
|
||||
If sentence_transformers is importable, the gate is a no-op.
|
||||
This is the case for the test session that actually runs (sentence-
|
||||
transformers is installed via `uv sync --extra local-rag`).
|
||||
"""
|
||||
from tests.conftest import _check_required_test_dependencies
|
||||
_check_required_test_dependencies()
|
||||
|
||||
|
||||
def test_check_raises_on_missing_sentence_transformers(monkeypatch) -> None:
|
||||
"""
|
||||
Simulate sentence_transformers not being installed. The gate must
|
||||
raise pytest.UsageError with a clear, actionable error message that
|
||||
tells the user exactly how to fix the issue.
|
||||
"""
|
||||
# Block the import
|
||||
monkeypatch.setitem(sys.modules, "sentence_transformers", None)
|
||||
# Remove any cached successful import
|
||||
for mod_name in list(sys.modules):
|
||||
if mod_name == "sentence_transformers" or mod_name.startswith("sentence_transformers."):
|
||||
del sys.modules[mod_name]
|
||||
# Force ImportError on import
|
||||
import builtins
|
||||
original_import = builtins.__import__
|
||||
def _blocking_import(name, *args, **kwargs):
|
||||
if name == "sentence_transformers" or name.startswith("sentence_transformers."):
|
||||
raise ImportError(f"No module named {name!r} (simulated)")
|
||||
return original_import(name, *args, **kwargs)
|
||||
monkeypatch.setattr(builtins, "__import__", _blocking_import)
|
||||
|
||||
# Also need to invalidate the conftest's already-cached import
|
||||
from tests import conftest
|
||||
# Clear the module so it re-imports and the gate re-runs
|
||||
# Note: the conftest's _check function does its own import so this
|
||||
# is just a sanity check that the function is callable.
|
||||
|
||||
from tests.conftest import _check_required_test_dependencies
|
||||
with pytest.raises(pytest.UsageError) as exc_info:
|
||||
_check_required_test_dependencies()
|
||||
msg = str(exc_info.value)
|
||||
assert "sentence-transformers" in msg
|
||||
assert "uv sync" in msg
|
||||
assert "local-rag" in msg
|
||||
Reference in New Issue
Block a user