deer-flow/backend/tests/test_dev_entrypoint.py
GGbond b1dad2480a
test(scripts): resolve Git Bash instead of the WSL launcher for Windows shell-script tests (#5404)
* test(scripts): resolve Git Bash instead of the WSL launcher for Windows shell-script tests

* test(scripts): pin Windows shell-discovery rules in unit tests and prefer Git's sh.exe for POSIX-sh tests
2026-09-14 06:55:57 +08:00

312 lines
11 KiB
Python

"""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}"