"""Unit tests for docker/dev-entrypoint.sh (UV_EXTRAS validation + parsing). Exercises the script via its `--print-extras` dry-run hook so we don't actually launch uvicorn or hit /app/logs. Together with test_detect_uv_extras.py these cover both the local make-dev path and the docker-compose-dev path with the same shape — see PR #2767 / Issue #2754. """ from __future__ import annotations import os import shlex import shutil import subprocess import sys import tempfile from collections.abc import Iterator from pathlib import Path import pytest from support.shell import require_posix_sh REPO_ROOT = Path(__file__).resolve().parents[2] ENTRYPOINT = REPO_ROOT / "docker" / "dev-entrypoint.sh" _PYTHON_SHIM_DIR: str | None = None def _windows_python_shim_dir() -> str | None: """Return a bin dir with working python3/python shims (Windows only). The Microsoft Store alias stubs answer `command -v python3` but exit 49 when exec'd — the failure mode #5179 hardened serve.sh against — which would send the entrypoint's detector probe to a broken interpreter. """ global _PYTHON_SHIM_DIR if os.name != "nt": return None if _PYTHON_SHIM_DIR is None: shim_dir = tempfile.mkdtemp(prefix="dev-entrypoint-python-shim-") for name in ("python3", "python"): shim = Path(shim_dir) / name # newline="\n": the default newline=None would write CRLF on # Windows, gluing a stray \r onto the shim's last argument. shim.write_text( f'#!/bin/sh\nexec {shlex.quote(sys.executable)} "$@"\n', encoding="utf-8", newline="\n", ) shim.chmod(0o755) _PYTHON_SHIM_DIR = shim_dir return _PYTHON_SHIM_DIR @pytest.fixture(scope="module", autouse=True) def _cleanup_python_shim_dir() -> Iterator[None]: """Remove the session-scoped python shim directory after the module.""" yield if _PYTHON_SHIM_DIR is not None: shutil.rmtree(_PYTHON_SHIM_DIR, ignore_errors=True) def _run( uv_extras: str | None, *, config_path: Path | None = None, stream_bridge_redis_url: str | None = None, ) -> subprocess.CompletedProcess[str]: """Invoke the entrypoint's public extras-resolution dry run.""" env = os.environ.copy() env.pop("UV_EXTRAS", None) env.pop("DEER_FLOW_CONFIG_PATH", None) env.pop("DEER_FLOW_STREAM_BRIDGE_REDIS_URL", None) if uv_extras is not None: env["UV_EXTRAS"] = uv_extras if config_path is not None: env["DEER_FLOW_CONFIG_PATH"] = str(config_path) if stream_bridge_redis_url is not None: env["DEER_FLOW_STREAM_BRIDGE_REDIS_URL"] = stream_bridge_redis_url python_shim_dir = _windows_python_shim_dir() if python_shim_dir is not None: env["PATH"] = f"{python_shim_dir}{os.pathsep}{env['PATH']}" sh = require_posix_sh() return subprocess.run( [sh, str(ENTRYPOINT), "--print-extras"], cwd=ENTRYPOINT.parent, env=env, capture_output=True, text=True, check=False, ) def test_entrypoint_script_exists_and_is_posix_sh(): assert ENTRYPOINT.is_file() # Catch syntax errors before runtime — `sh -n` is a parse-only check. sh = require_posix_sh() proc = subprocess.run([sh, "-n", str(ENTRYPOINT)], capture_output=True, text=True, check=False) assert proc.returncode == 0, proc.stderr def test_entrypoint_excludes_runtime_state_from_uvicorn_reload(): content = ENTRYPOINT.read_text(encoding="utf-8") assert ': "${DEER_FLOW_HOME:=/app/backend/.deer-flow}"' in content # sandbox must be created too, not just .deer-flow (#3459 / #3454). assert 'mkdir -p "$DEER_FLOW_HOME" /app/backend/.deer-flow /app/backend/sandbox' in content assert "--reload-include='*.yaml .env'" not in content assert "--reload-include='*.yaml'" in content assert "--reload-include='.env'" in content assert "--reload-exclude=/app/backend/sandbox" in content assert '--reload-exclude="$DEER_FLOW_HOME"' in content assert "--reload-exclude=/app/backend/.deer-flow" in content def test_failed_sync_recreates_a_clean_virtual_environment(): content = ENTRYPOINT.read_text(encoding="utf-8") assert "uv venv --clear .venv" in content assert "uv venv --allow-existing .venv" not in content def test_no_uv_extras_yields_empty_flags(): proc = _run(None) assert proc.returncode == 0 assert proc.stdout.strip() == "" def test_no_explicit_extras_uses_the_runtime_selected_config(tmp_path: Path): config_path = tmp_path / "deployment.yaml" config_path.write_text( "database:\n backend: postgres\ntools:\n - name: browser_navigate\n", encoding="utf-8", ) proc = _run(None, config_path=config_path) assert proc.returncode == 0, proc.stderr assert proc.stdout.strip() == "--extra browser --extra postgres" def test_single_extra(): proc = _run("postgres") assert proc.returncode == 0 assert proc.stdout.strip() == "--extra postgres" def test_multi_extra_comma_separated(): proc = _run("postgres,ollama") assert proc.returncode == 0 assert proc.stdout.strip() == "--extra postgres --extra ollama" def test_multi_extra_whitespace_separated(): proc = _run("postgres ollama") assert proc.returncode == 0 assert proc.stdout.strip() == "--extra postgres --extra ollama" def test_multi_extra_mixed_separators(): proc = _run(" postgres , ollama ,") assert proc.returncode == 0 assert proc.stdout.strip() == "--extra postgres --extra ollama" def test_explicit_extras_override_config_and_are_deduplicated(tmp_path: Path): config_path = tmp_path / "deployment.yaml" config_path.write_text("database:\n backend: postgres\n", encoding="utf-8") proc = _run("redis,redis browser redis", config_path=config_path) assert proc.returncode == 0, proc.stderr assert proc.stdout.strip() == "--extra redis --extra browser" def test_explicit_extras_keep_runtime_required_redis_without_duplicates(): proc = _run( "postgres,postgres", stream_bridge_redis_url="redis://redis:6379/0", ) assert proc.returncode == 0, proc.stderr assert proc.stdout.strip() == "--extra postgres --extra redis" def test_empty_string_yields_empty_flags(): proc = _run("") assert proc.returncode == 0 assert proc.stdout.strip() == "" @pytest.mark.parametrize( "bad_value", [ "; rm -rf /", # the canonical injection attempt "$(whoami)", # command substitution "`echo bad`", # backticks "postgres;evil", # mixed legal+illegal in a single token "1postgres", # leading digit "-postgres", # leading hyphen "post gres extra/path", # contains slash ], ) def test_metacharacters_abort_with_nonzero_exit(bad_value): proc = _run(bad_value) assert proc.returncode != 0, f"expected abort for {bad_value!r}, got 0" assert "is invalid" in proc.stderr assert proc.stdout.strip() == "" def test_underscores_and_hyphens_in_name_are_allowed(): """Mirrors uv's accepted shape for `[project.optional-dependencies]` keys.""" proc = _run("post_gres,post-gres") assert proc.returncode == 0 assert proc.stdout.strip() == "--extra post_gres --extra post-gres" # ── Dependency-sync failure branch ────────────────────────────────────────── # # The self-heal retry reuses `--locked`, so a lock that genuinely disagrees with # the environment fails the same way twice. `set -e` already stops the script # there -- these tests pin that the handoff to uvicorn is never reached, and # that the operator is told what to do instead of reading a bare uv traceback. # # `/app/backend` only exists inside the container, so the sync block is sliced # out of the real script and run against a stub `uv`. The block is read from # the file rather than duplicated here: editing the script changes what runs. _SYNC_BLOCK_START = "# ── Sync dependencies (with self-heal) ──" _SYNC_BLOCK_END = "# ── Hand off to uvicorn ──" _STUB_UV_ALWAYS_FAILS_SYNC = """#!/bin/sh # `uv venv` succeeds so the retry is actually reached; every sync fails. case "$1" in sync) exit 1 ;; *) exit 0 ;; esac """ _STUB_UV_SUCCEEDS = """#!/bin/sh exit 0 """ _STUB_UV_FAILS_THEN_SUCCEEDS = """#!/bin/sh case "$1" in sync) if [ -f "$STUB_UV_STATE/first_sync_done" ]; then exit 0; fi : > "$STUB_UV_STATE/first_sync_done" exit 1 ;; *) exit 0 ;; esac """ def _sync_block() -> str: content = ENTRYPOINT.read_text(encoding="utf-8") start = content.index(_SYNC_BLOCK_START) end = content.index(_SYNC_BLOCK_END) return content[start:end] def _run_sync_block(tmp_path: Path, stub_uv: str) -> subprocess.CompletedProcess[str]: """Execute the script's real sync block with a stubbed `uv` on PATH.""" bin_dir = tmp_path / "bin" bin_dir.mkdir() uv_stub = bin_dir / "uv" # newline="\n": keep the stub POSIX-sh clean on Windows (no stray \r). uv_stub.write_text(stub_uv, encoding="utf-8", newline="\n") uv_stub.chmod(0o755) state_dir = tmp_path / "state" state_dir.mkdir() # `cd` is shadowed because /app/backend does not exist outside the # container; everything else in the block runs verbatim. script = f'set -e\ncd() {{ :; }}\nEXTRAS_FLAGS=""\n{_sync_block()}\necho "REACHED_HANDOFF"\n' env = os.environ.copy() env["PATH"] = f"{bin_dir}{os.pathsep}{env['PATH']}" env["STUB_UV_STATE"] = str(state_dir) return subprocess.run([require_posix_sh(), "-c", script], capture_output=True, text=True, check=False, env=env, cwd=tmp_path) def test_successful_sync_reaches_the_uvicorn_handoff(tmp_path: Path): proc = _run_sync_block(tmp_path, _STUB_UV_SUCCEEDS) assert proc.returncode == 0, proc.stderr assert "REACHED_HANDOFF" in proc.stdout def test_self_heal_retry_still_reaches_the_handoff(tmp_path: Path): proc = _run_sync_block(tmp_path, _STUB_UV_FAILS_THEN_SUCCEEDS) assert proc.returncode == 0, proc.stderr assert "recreating .venv" in proc.stdout assert "REACHED_HANDOFF" in proc.stdout def test_failed_retry_aborts_before_starting_uvicorn(tmp_path: Path): proc = _run_sync_block(tmp_path, _STUB_UV_ALWAYS_FAILS_SYNC) assert proc.returncode != 0, "startup continued past an unsatisfied lock" assert "REACHED_HANDOFF" not in proc.stdout, "uvicorn would have been started against a stale or missing environment" def test_failed_retry_tells_the_operator_how_to_recover(tmp_path: Path): proc = _run_sync_block(tmp_path, _STUB_UV_ALWAYS_FAILS_SYNC) assert "make install" in proc.stderr, f"no recovery guidance on stderr: {proc.stderr!r}"