diff --git a/scripts/test.sh b/scripts/test.sh index 9c32b1f9..8b0dd7af 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -3,9 +3,21 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" VENV_DIR="$ROOT/.venv" -VENV_PY="$VENV_DIR/bin/python" REQ_FILE="$ROOT/requirements-dev.txt" +resolve_venv_python() { + local candidate + for candidate in "$VENV_DIR/bin/python" "$VENV_DIR/Scripts/python.exe"; do + if [[ -x "$candidate" ]]; then + printf '%s\n' "$candidate" + return 0 + fi + done + return 1 +} + +VENV_PY="$(resolve_venv_python || true)" + is_supported_python() { "$1" - <<'PY' >/dev/null 2>&1 import sys @@ -115,9 +127,10 @@ create_or_rebuild_venv() { venv_guidance "$base_py" return 2 fi + VENV_PY="$(resolve_venv_python || true)" if [[ ! -x "$VENV_PY" ]]; then rm -rf "$VENV_DIR" - echo "$VENV_DIR was created but does not contain bin/python." >&2 + echo "$VENV_DIR was created but does not contain bin/python or Scripts/python.exe." >&2 venv_guidance "$base_py" return 2 fi @@ -157,6 +170,7 @@ select_python() { desired_major_minor="" fi + VENV_PY="$(resolve_venv_python || true)" if [[ -x "$VENV_PY" ]]; then if is_supported_python "$VENV_PY"; then current_major_minor="$(python_major_minor "$VENV_PY")" @@ -182,7 +196,7 @@ select_python() { fi if [[ -e "$VENV_DIR" && ! -x "$VENV_PY" ]]; then - echo "$VENV_DIR exists but does not contain bin/python; rebuilding." >&2 + echo "$VENV_DIR exists but does not contain bin/python or Scripts/python.exe; rebuilding." >&2 create_or_rebuild_venv "$base_py" rebuild || return $? printf '%s\n' "$VENV_PY" return 0 diff --git a/tests/test_ci_hygiene.py b/tests/test_ci_hygiene.py index 88130537..110d26f9 100644 --- a/tests/test_ci_hygiene.py +++ b/tests/test_ci_hygiene.py @@ -1,9 +1,17 @@ """Small hygiene regression checks for CI and frontend console noise.""" +import os +import shutil +import stat +import subprocess +import textwrap from pathlib import Path ROOT = Path(__file__).resolve().parents[1] +def _make_executable(path): + path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + def test_github_actions_quotes_pyyaml_version_specifier(): """Unquoted `pyyaml>=6.0` is parsed by the shell as stdout redirection.""" @@ -29,6 +37,8 @@ def test_local_test_runner_uses_supported_venv_before_pytest_collection(): assert "python3.13 python3.12 python3.11 python3" in runner assert "requirements-dev.txt" in runner assert 'HERMES_WEBUI_TEST_PYTHON' in runner + assert "resolve_venv_python()" in runner + assert '"$VENV_DIR/bin/python" "$VENV_DIR/Scripts/python.exe"' in runner assert '[[ -x "$candidate" ]] && printf' in runner assert "exec \"$PYTHON_BIN\" -m pytest" in runner # Destructive-fs guard: never create/clear a virtualenv through a symlinked .venv @@ -46,16 +56,152 @@ def test_local_test_runner_bootstrap_handles_broken_venvs_safely(): assert "has_pip()" in runner assert 'if ! "$base_py" -m venv "${venv_args[@]}"; then' in create_body assert 'rm -rf "$VENV_DIR"' in create_body - assert '[[ ! -x "$VENV_PY" ]]' in create_body + assert 'VENV_PY="$(resolve_venv_python || true)"' in create_body + assert 'does not contain bin/python or Scripts/python.exe' in create_body assert 'if ! has_pip "$VENV_PY"; then' in create_body assert 'venv_guidance "$base_py"' in create_body assert 'printf \'%s\\n\' "$requested_path"' not in select_body assert 'base_py="$requested_path"' in select_body assert 'desired_major_minor="$(python_major_minor "$base_py")"' in select_body + assert 'VENV_PY="$(resolve_venv_python || true)"' in select_body assert 'if has_pip "$VENV_PY"; then' in select_body + assert 'does not contain bin/python or Scripts/python.exe; rebuilding.' in select_body assert 'create_or_rebuild_venv "$base_py" rebuild' in select_body +def test_local_test_runner_accepts_windows_layout_venv_from_base_python(tmp_path): + repo = tmp_path / "repo" + scripts_dir = repo / "scripts" + scripts_dir.mkdir(parents=True) + shutil.copy2(ROOT / "scripts" / "test.sh", scripts_dir / "test.sh") + _make_executable(scripts_dir / "test.sh") + (repo / "requirements-dev.txt").write_text("", encoding="utf-8") + + fake_venv_python_source = repo / "fake-venv-python-source" + fake_venv_python_source.write_text(textwrap.dedent("""\ + #!/usr/bin/env bash + set -euo pipefail + if [[ "${1:-}" == "-" ]]; then + program="$(cat)" + if [[ "$program" == *"sys.version_info[:3]"* ]]; then + echo "3.11.15" + elif [[ "$program" == *"sys.version_info[:2]"* ]]; then + echo "3.11" + fi + exit 0 + fi + if [[ "${1:-}" == "-m" && "${2:-}" == "pip" && "${3:-}" == "--version" ]]; then + echo "pip 24.0" + exit 0 + fi + if [[ "${1:-}" == "-m" && "${2:-}" == "pytest" ]]; then + printf '%s\\n' "$0" > "$PYTEST_PROOF_FILE" + printf '%s\\n' "$@" >> "$PYTEST_PROOF_FILE" + exit 0 + fi + exit 99 + """), encoding="utf-8") + _make_executable(fake_venv_python_source) + + fake_base_python = repo / "fake-python" + fake_base_python.write_text(textwrap.dedent("""\ + #!/usr/bin/env bash + set -euo pipefail + if [[ "${1:-}" == "-" ]]; then + program="$(cat)" + if [[ "$program" == *"sys.version_info[:3]"* ]]; then + echo "3.11.15" + elif [[ "$program" == *"sys.version_info[:2]"* ]]; then + echo "3.11" + fi + exit 0 + fi + if [[ "${1:-}" == "-m" && "${2:-}" == "venv" ]]; then + target="${@: -1}" + mkdir -p "$target/Scripts" + cp "$FAKE_VENV_PY_SOURCE" "$target/Scripts/python.exe" + chmod +x "$target/Scripts/python.exe" + exit 0 + fi + exit 99 + """), encoding="utf-8") + _make_executable(fake_base_python) + + proof = repo / "pytest-proof.txt" + env = os.environ.copy() + env["HERMES_WEBUI_TEST_PYTHON"] = "./fake-python" + env["FAKE_VENV_PY_SOURCE"] = "./fake-venv-python-source" + env["PYTEST_PROOF_FILE"] = str(proof) + + result = subprocess.run( + ["bash", "scripts/test.sh", "tests/example_test.py", "-v", "--timeout=60"], + cwd=repo, + env=env, + text=True, + capture_output=True, + timeout=20, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert (repo / ".venv" / "Scripts" / "python.exe").exists() + proof_lines = proof.read_text(encoding="utf-8").splitlines() + assert proof_lines[0].replace("\\", "/").endswith("/.venv/Scripts/python.exe") + assert proof_lines[1:] == ["-m", "pytest", "tests/example_test.py", "-v", "--timeout=60"] + +def test_local_test_runner_rejects_venv_without_accepted_python_path(tmp_path): + repo = tmp_path / "repo" + scripts_dir = repo / "scripts" + scripts_dir.mkdir(parents=True) + shutil.copy2(ROOT / "scripts" / "test.sh", scripts_dir / "test.sh") + _make_executable(scripts_dir / "test.sh") + (repo / "requirements-dev.txt").write_text("", encoding="utf-8") + (repo / ".venv").mkdir() + (repo / ".venv" / "pyvenv.cfg").write_text("home = fake\n", encoding="utf-8") + + fake_base_python = repo / "fake-python" + fake_base_python.write_text(textwrap.dedent("""\ + #!/usr/bin/env bash + set -euo pipefail + if [[ "${1:-}" == "-" ]]; then + program="$(cat)" + if [[ "$program" == *"sys.version_info[:3]"* ]]; then + echo "3.11.15" + elif [[ "$program" == *"sys.version_info[:2]"* ]]; then + echo "3.11" + fi + exit 0 + fi + if [[ "${1:-}" == "-m" && "${2:-}" == "venv" ]]; then + target="${@: -1}" + mkdir -p "$target" + touch "$target/pyvenv.cfg" + exit 0 + fi + exit 99 + """), encoding="utf-8") + _make_executable(fake_base_python) + + proof = repo / "pytest-proof.txt" + env = os.environ.copy() + env["HERMES_WEBUI_TEST_PYTHON"] = "./fake-python" + env["PYTEST_PROOF_FILE"] = str(proof) + + result = subprocess.run( + ["bash", "scripts/test.sh", "tests/example_test.py", "-v", "--timeout=60"], + cwd=repo, + env=env, + text=True, + capture_output=True, + timeout=20, + check=False, + ) + + assert result.returncode == 2 + assert "does not contain bin/python or Scripts/python.exe" in result.stderr + assert not proof.exists() + assert not (repo / ".venv").exists() + def test_live_model_success_log_is_debug_not_default_console_log(): ui = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")