mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-15 00:19:14 +00:00
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
This commit is contained in:
parent
f66cb8e376
commit
b1dad2480a
122
backend/tests/support/shell.py
Normal file
122
backend/tests/support/shell.py
Normal file
@ -0,0 +1,122 @@
|
||||
"""Locate a shell that can actually run the repo's POSIX shell scripts.
|
||||
|
||||
On POSIX hosts ``bash``/``sh`` from PATH are fine. On Windows the suite must
|
||||
run Git Bash (MSYS2): the WSL launcher at ``%SystemRoot%\\System32\\bash.exe``
|
||||
and the Microsoft Store alias stubs under ``WindowsApps`` also answer to the
|
||||
name ``bash`` — and CreateProcess searches System32 before PATH, so even a
|
||||
literal ``["bash", ...]`` argv with Git Bash first on PATH still reaches
|
||||
WSL — but neither can run repo scripts against Windows checkout paths. The
|
||||
discovery below mirrors what ``scripts/run-with-git-bash.cmd`` already does
|
||||
for the Makefile.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _script_bash_candidates(git: str | None, program_files: str | None, path_bash: str | None) -> list[Path]:
|
||||
"""Build the ordered bash candidate list for Windows hosts."""
|
||||
candidates: list[Path] = []
|
||||
if git is not None:
|
||||
# Git for Windows layout: <root>/cmd/git.exe (or <root>/bin/git.exe)
|
||||
# both resolve to <root>/bin/bash.exe two levels up from git's parent.
|
||||
candidates.append(Path(git).resolve().parent.parent / "bin" / "bash.exe")
|
||||
if program_files:
|
||||
candidates.append(Path(program_files) / "Git" / "bin" / "bash.exe")
|
||||
if path_bash is not None:
|
||||
candidates.append(Path(path_bash))
|
||||
return candidates
|
||||
|
||||
|
||||
def _script_sh_candidates(git: str | None, program_files: str | None) -> list[Path]:
|
||||
"""Build the ordered POSIX-sh candidate list for Windows hosts.
|
||||
|
||||
Git for Windows ships a real ``sh.exe`` at ``<root>/bin/sh.exe`` (and
|
||||
``<root>/usr/bin/sh.exe``); preferring it over bash lets ``#!/bin/sh``
|
||||
scripts run under an actual sh, like the POSIX CI legs.
|
||||
"""
|
||||
candidates: list[Path] = []
|
||||
if git is not None:
|
||||
git_root = Path(git).resolve().parent.parent
|
||||
candidates.append(git_root / "bin" / "sh.exe")
|
||||
candidates.append(git_root / "usr" / "bin" / "sh.exe")
|
||||
if program_files:
|
||||
program_files_git = Path(program_files) / "Git"
|
||||
candidates.append(program_files_git / "bin" / "sh.exe")
|
||||
candidates.append(program_files_git / "usr" / "bin" / "sh.exe")
|
||||
return candidates
|
||||
|
||||
|
||||
def _first_runnable_shell(candidates: list[Path], system_root: Path) -> str | None:
|
||||
"""Return the first candidate that is a real shell, rejecting stub launchers.
|
||||
|
||||
The WSL launcher lives in System32/SysWOW64 and the Microsoft Store alias
|
||||
stubs live under WindowsApps; neither is an MSYS2 shell that can run repo
|
||||
scripts against Windows checkout paths.
|
||||
"""
|
||||
rejected_parents = (system_root / "System32", system_root / "SysWOW64")
|
||||
for candidate in candidates:
|
||||
try:
|
||||
resolved = candidate.resolve()
|
||||
except OSError:
|
||||
continue
|
||||
if not resolved.is_file():
|
||||
continue
|
||||
if any(parent in resolved.parents for parent in rejected_parents):
|
||||
continue
|
||||
if "WindowsApps" in resolved.parts:
|
||||
continue
|
||||
return str(resolved)
|
||||
return None
|
||||
|
||||
|
||||
def find_script_bash() -> str | None:
|
||||
"""Return a bash able to run the repo's shell scripts, or ``None``."""
|
||||
if os.name != "nt":
|
||||
return shutil.which("bash")
|
||||
|
||||
candidates = _script_bash_candidates(
|
||||
git=shutil.which("git"),
|
||||
program_files=os.environ.get("ProgramFiles"),
|
||||
path_bash=shutil.which("bash"),
|
||||
)
|
||||
system_root = Path(os.environ.get("SystemRoot", r"C:\Windows"))
|
||||
return _first_runnable_shell(candidates, system_root)
|
||||
|
||||
|
||||
def require_script_bash() -> str:
|
||||
"""Return :func:`find_script_bash`'s result, skipping the test when absent."""
|
||||
bash = find_script_bash()
|
||||
if bash is None:
|
||||
pytest.skip("repo shell-script tests need Git Bash on Windows")
|
||||
return bash
|
||||
|
||||
|
||||
def find_posix_sh() -> str | None:
|
||||
"""Return a shell able to run the repo's POSIX-sh scripts.
|
||||
|
||||
Plain ``sh`` on POSIX hosts. On Windows no ``sh`` exists on PATH outside
|
||||
an MSYS2 installation, so prefer the real ``sh.exe`` shipped with Git for
|
||||
Windows and fall back to Git Bash when it is absent.
|
||||
"""
|
||||
if os.name != "nt":
|
||||
return shutil.which("sh")
|
||||
candidates = _script_sh_candidates(
|
||||
git=shutil.which("git"),
|
||||
program_files=os.environ.get("ProgramFiles"),
|
||||
)
|
||||
system_root = Path(os.environ.get("SystemRoot", r"C:\Windows"))
|
||||
return _first_runnable_shell(candidates, system_root) or find_script_bash()
|
||||
|
||||
|
||||
def require_posix_sh() -> str:
|
||||
"""Return :func:`find_posix_sh`'s result, skipping the test when absent."""
|
||||
sh = find_posix_sh()
|
||||
if sh is None:
|
||||
pytest.skip("repo shell-script tests need Git Bash on Windows")
|
||||
return sh
|
||||
@ -7,10 +7,16 @@ import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from support.shell import find_script_bash
|
||||
|
||||
from deerflow.config.app_config import AppConfig
|
||||
|
||||
# Only the upgrade-script test shells out; it needs Git Bash on Windows (the
|
||||
# WSL launcher and Store alias stubs cannot run the repo scripts).
|
||||
SCRIPT_BASH = find_script_bash()
|
||||
|
||||
|
||||
def _make_config_files(tmpdir: Path, user_config: dict, example_config: dict) -> Path:
|
||||
"""Write user config.yaml and config.example.yaml to a temp dir, return config path."""
|
||||
@ -126,6 +132,7 @@ def test_newer_user_version_no_warning(caplog):
|
||||
assert "outdated" not in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.skipif(SCRIPT_BASH is None, reason="repo shell-script tests need Git Bash on Windows")
|
||||
def test_version_26_config_upgrades_to_checkpoint_channel_mode(tmp_path, caplog):
|
||||
"""A v26 user config must be flagged outdated and merge the new persisted field.
|
||||
|
||||
@ -158,7 +165,7 @@ def test_version_26_config_upgrades_to_checkpoint_channel_mode(tmp_path, caplog)
|
||||
|
||||
env = {**os.environ, "DEER_FLOW_CONFIG_PATH": str(config_path)}
|
||||
result = subprocess.run(
|
||||
["bash", str(repo_root / "scripts" / "config-upgrade.sh")],
|
||||
[SCRIPT_BASH, str(repo_root / "scripts" / "config-upgrade.sh")],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
|
||||
@ -10,8 +10,16 @@ import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from support.shell import find_script_bash
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
# Every test here shells out to sh/bash; on Windows that must be Git Bash —
|
||||
# the WSL launcher and Store alias stubs cannot run the repo scripts.
|
||||
BASH = find_script_bash()
|
||||
pytestmark = pytest.mark.skipif(BASH is None, reason="repo shell-script tests need Git Bash on Windows")
|
||||
|
||||
|
||||
def _backend_dockerfile_uv_sync_script() -> str:
|
||||
dockerfile = (REPO_ROOT / "backend" / "Dockerfile").read_text(encoding="utf-8")
|
||||
@ -41,7 +49,7 @@ def test_backend_dockerfile_expands_multiple_uv_extras(tmp_path):
|
||||
env["UV_EXTRAS"] = "discord,postgres"
|
||||
|
||||
subprocess.run(
|
||||
["sh", "-c", _backend_dockerfile_uv_sync_script()],
|
||||
[BASH, "-c", _backend_dockerfile_uv_sync_script()],
|
||||
cwd=workdir,
|
||||
env=env,
|
||||
check=True,
|
||||
@ -81,7 +89,7 @@ def test_backend_dockerfile_rejects_glob_uv_extra(tmp_path):
|
||||
env["UV_EXTRAS"] = "postgres,*"
|
||||
|
||||
result = subprocess.run(
|
||||
["sh", "-c", _backend_dockerfile_uv_sync_script()],
|
||||
[BASH, "-c", _backend_dockerfile_uv_sync_script()],
|
||||
cwd=workdir,
|
||||
env=env,
|
||||
check=False,
|
||||
@ -121,7 +129,7 @@ def test_deploy_build_auto_detects_postgres_extra_when_other_extras_are_enabled(
|
||||
env["PATH"] = f"{bin_dir}{os.pathsep}{env['PATH']}"
|
||||
|
||||
subprocess.run(
|
||||
["bash", str(worktree / "scripts" / "deploy.sh"), "build"],
|
||||
[BASH, str(worktree / "scripts" / "deploy.sh"), "build"],
|
||||
cwd=worktree,
|
||||
env=env,
|
||||
check=True,
|
||||
@ -167,7 +175,7 @@ def test_deploy_uses_dotenv_without_sourcing_shell_syntax(tmp_path):
|
||||
env["PATH"] = f"{bin_dir}{os.pathsep}{env['PATH']}"
|
||||
|
||||
subprocess.run(
|
||||
["bash", str(worktree / "scripts" / "deploy.sh"), "build"],
|
||||
[BASH, str(worktree / "scripts" / "deploy.sh"), "build"],
|
||||
cwd=worktree,
|
||||
env=env,
|
||||
check=True,
|
||||
@ -179,7 +187,18 @@ def test_deploy_uses_dotenv_without_sourcing_shell_syntax(tmp_path):
|
||||
assert capture_extras.read_text(encoding="utf-8") == "discord"
|
||||
args = capture_args.read_text(encoding="utf-8").splitlines()
|
||||
assert "--env-file" in args
|
||||
assert str(worktree / ".env") in args
|
||||
env_file_arg = args[args.index("--env-file") + 1]
|
||||
# The flag must reference the worktree's .env, but its spelling depends on
|
||||
# the shell that produced it: Git Bash renders host paths in MSYS form
|
||||
# (/tmp/... for %TEMP%, /c/... otherwise). Resolve through that same bash
|
||||
# instead of comparing against this platform's literal path string.
|
||||
assert env_file_arg.replace("\\", "/").endswith("/.env")
|
||||
probe = subprocess.run(
|
||||
[BASH, "-c", 'test -f "$1" && cmp -s "$1" "$2"', "--", env_file_arg, str(worktree / ".env")],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
)
|
||||
assert probe.returncode == 0, f"--env-file target {env_file_arg!r} is not the worktree .env"
|
||||
|
||||
|
||||
def test_deploy_build_auto_detects_postgres_extra_with_python_fallback(tmp_path):
|
||||
@ -219,7 +238,7 @@ def test_deploy_build_auto_detects_postgres_extra_with_python_fallback(tmp_path)
|
||||
env["PATH"] = f"{bin_dir}{os.pathsep}{env['PATH']}"
|
||||
|
||||
subprocess.run(
|
||||
["bash", str(worktree / "scripts" / "deploy.sh"), "build"],
|
||||
[BASH, str(worktree / "scripts" / "deploy.sh"), "build"],
|
||||
cwd=worktree,
|
||||
env=env,
|
||||
check=True,
|
||||
|
||||
@ -9,15 +9,58 @@ 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,
|
||||
*,
|
||||
@ -35,8 +78,12 @@ def _run(
|
||||
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"],
|
||||
[sh, str(ENTRYPOINT), "--print-extras"],
|
||||
cwd=ENTRYPOINT.parent,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
@ -48,7 +95,8 @@ def _run(
|
||||
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.
|
||||
proc = subprocess.run(["sh", "-n", str(ENTRYPOINT)], capture_output=True, text=True, check=False)
|
||||
sh = require_posix_sh()
|
||||
proc = subprocess.run([sh, "-n", str(ENTRYPOINT)], capture_output=True, text=True, check=False)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
|
||||
|
||||
@ -218,7 +266,8 @@ def _run_sync_block(tmp_path: Path, stub_uv: str) -> subprocess.CompletedProcess
|
||||
bin_dir = tmp_path / "bin"
|
||||
bin_dir.mkdir()
|
||||
uv_stub = bin_dir / "uv"
|
||||
uv_stub.write_text(stub_uv, encoding="utf-8")
|
||||
# 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"
|
||||
@ -231,7 +280,7 @@ def _run_sync_block(tmp_path: Path, stub_uv: str) -> subprocess.CompletedProcess
|
||||
env = os.environ.copy()
|
||||
env["PATH"] = f"{bin_dir}{os.pathsep}{env['PATH']}"
|
||||
env["STUB_UV_STATE"] = str(state_dir)
|
||||
return subprocess.run(["sh", "-c", script], capture_output=True, text=True, check=False, env=env, cwd=tmp_path)
|
||||
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):
|
||||
|
||||
@ -7,23 +7,16 @@ import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from shutil import which
|
||||
|
||||
import pytest
|
||||
from support.shell import find_script_bash
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
SCRIPT_PATH = REPO_ROOT / "scripts" / "docker.sh"
|
||||
BASH_CANDIDATES = [
|
||||
Path(r"C:\Program Files\Git\bin\bash.exe"),
|
||||
Path(which("bash")) if which("bash") else None,
|
||||
]
|
||||
BASH_EXECUTABLE = next(
|
||||
(str(path) for path in BASH_CANDIDATES if path is not None and path.exists() and "WindowsApps" not in str(path)),
|
||||
None,
|
||||
)
|
||||
BASH_EXECUTABLE = find_script_bash()
|
||||
|
||||
if BASH_EXECUTABLE is None:
|
||||
pytestmark = pytest.mark.skip(reason="bash is required for docker.sh detection tests")
|
||||
pytestmark = pytest.mark.skip(reason="Git Bash is required for docker.sh detection tests")
|
||||
|
||||
|
||||
def _detect_mode_with_config(config_content: str) -> str:
|
||||
|
||||
@ -7,8 +7,16 @@ import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from support.shell import find_script_bash
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
# Only the two deploy.sh tests shell out; they need Git Bash on Windows (the
|
||||
# WSL launcher and Store alias stubs cannot run the repo scripts).
|
||||
SCRIPT_BASH = find_script_bash()
|
||||
requires_script_bash = pytest.mark.skipif(SCRIPT_BASH is None, reason="repo shell-script tests need Git Bash on Windows")
|
||||
|
||||
|
||||
def _read(path: str) -> str:
|
||||
return (REPO_ROOT / path).read_text(encoding="utf-8")
|
||||
@ -62,6 +70,7 @@ def test_production_gateway_has_a_real_readiness_probe() -> None:
|
||||
assert "gateway:\n condition: service_healthy" in compose
|
||||
|
||||
|
||||
@requires_script_bash
|
||||
def test_deploy_waits_for_gateway_readiness_before_success(tmp_path: Path) -> None:
|
||||
capture = tmp_path / "docker-args.txt"
|
||||
worktree, env = _deploy_fixture(
|
||||
@ -71,7 +80,7 @@ def test_deploy_waits_for_gateway_readiness_before_success(tmp_path: Path) -> No
|
||||
env["CAPTURE_DOCKER_ARGS"] = str(capture)
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(worktree / "scripts" / "deploy.sh"), "start"],
|
||||
[SCRIPT_BASH, str(worktree / "scripts" / "deploy.sh"), "start"],
|
||||
cwd=worktree,
|
||||
env=env,
|
||||
check=False,
|
||||
@ -86,6 +95,7 @@ def test_deploy_waits_for_gateway_readiness_before_success(tmp_path: Path) -> No
|
||||
assert "DeerFlow is running!" in result.stdout
|
||||
|
||||
|
||||
@requires_script_bash
|
||||
def test_deploy_failure_prints_gateway_diagnostics_and_never_claims_success(tmp_path: Path) -> None:
|
||||
capture = tmp_path / "docker-calls.txt"
|
||||
worktree, env = _deploy_fixture(
|
||||
@ -95,7 +105,7 @@ def test_deploy_failure_prints_gateway_diagnostics_and_never_claims_success(tmp_
|
||||
env["CAPTURE_DOCKER_CALLS"] = str(capture)
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(worktree / "scripts" / "deploy.sh"), "start"],
|
||||
[SCRIPT_BASH, str(worktree / "scripts" / "deploy.sh"), "start"],
|
||||
cwd=worktree,
|
||||
env=env,
|
||||
check=False,
|
||||
|
||||
@ -3,11 +3,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from support.shell import require_script_bash
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
SERVE_SH = REPO_ROOT / "scripts" / "serve.sh"
|
||||
@ -33,12 +33,10 @@ def _is_repo_nginx_pid(
|
||||
*,
|
||||
command: str,
|
||||
args: str,
|
||||
repo_root: Path,
|
||||
repo_root: Path | str,
|
||||
deerflow_pid: bool = False,
|
||||
) -> bool:
|
||||
bash = shutil.which("bash")
|
||||
if bash is None:
|
||||
pytest.skip("bash is required to exercise serve.sh helpers")
|
||||
bash = require_script_bash()
|
||||
|
||||
function = _extract_shell_function("_is_repo_nginx_pid")
|
||||
script = f"""
|
||||
@ -69,8 +67,12 @@ _is_repo_nginx_pid 12345
|
||||
|
||||
|
||||
def test_repo_nginx_pid_accepts_macos_rewritten_master_command(tmp_path):
|
||||
repo_root = tmp_path / "deer-flow"
|
||||
nginx_conf = repo_root / "docker" / "nginx" / "nginx.local.conf"
|
||||
# The simulated macOS ps line embeds POSIX-form paths no matter which host
|
||||
# runs this test, so build the fixture with forward slashes explicitly;
|
||||
# on Windows a Path would render with backslashes and never match the
|
||||
# "$root"/docker/nginx/... pattern the shell function greps for.
|
||||
repo_root = (tmp_path / "deer-flow").as_posix()
|
||||
nginx_conf = f"{repo_root}/docker/nginx/nginx.local.conf"
|
||||
|
||||
assert _is_repo_nginx_pid(
|
||||
command=f"nginx: master process /opt/homebrew/bin/nginx -c {nginx_conf}",
|
||||
|
||||
@ -5,11 +5,10 @@ python alias stubs pass Bash's own PATH lookup but cannot be exec'd through
|
||||
from __future__ import annotations
|
||||
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from support.shell import require_script_bash
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
SERVE_SH = REPO_ROOT / "scripts" / "serve.sh"
|
||||
@ -69,9 +68,7 @@ def _to_bash_path(path: Path) -> str:
|
||||
|
||||
|
||||
def _run_pick_python(tmp_path: Path, *, env_mock: str = "") -> subprocess.CompletedProcess:
|
||||
bash = shutil.which("bash")
|
||||
if bash is None:
|
||||
pytest.skip("bash is required to exercise serve.sh helpers")
|
||||
bash = require_script_bash()
|
||||
|
||||
script = _SCRIPT_TEMPLATE.replace("__BIN__", shlex.quote(_to_bash_path(tmp_path / "bin"))).replace("__STUBS__", "python3 python py").replace("__ENV_MOCK__", env_mock).replace("__FUNCTION__", _extract_shell_function("_pick_python"))
|
||||
# errors="replace": bash's diagnostics may arrive in the console's code
|
||||
|
||||
147
backend/tests/test_support_shell.py
Normal file
147
backend/tests/test_support_shell.py
Normal file
@ -0,0 +1,147 @@
|
||||
"""Unit tests for the Windows shell discovery in tests/support/shell.py.
|
||||
|
||||
``find_script_bash``/``find_posix_sh`` return at the ``os.name != "nt"``
|
||||
early-out on POSIX CI, so the candidate-rejection rules would otherwise only
|
||||
ever run on contributors' Windows machines -- exactly the failure mode the
|
||||
discovery exists to fix. The candidate construction and the rejection scan are
|
||||
pinned here against real files (``tmp_path``) with faked inputs, so the logic
|
||||
is exercised on every CI leg.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from support import shell
|
||||
|
||||
|
||||
def _touch(path: Path) -> Path:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("", encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fake_windows(tmp_path: Path) -> Path:
|
||||
"""A fake Windows layout: git root, ProgramFiles Git, WSL + Store stubs."""
|
||||
_touch(tmp_path / "git" / "cmd" / "git.exe")
|
||||
_touch(tmp_path / "git" / "bin" / "bash.exe")
|
||||
_touch(tmp_path / "git" / "bin" / "sh.exe")
|
||||
_touch(tmp_path / "git" / "usr" / "bin" / "sh.exe")
|
||||
_touch(tmp_path / "ProgramFiles" / "Git" / "bin" / "bash.exe")
|
||||
_touch(tmp_path / "ProgramFiles" / "Git" / "usr" / "bin" / "sh.exe")
|
||||
_touch(tmp_path / "Windows" / "System32" / "bash.exe") # WSL launcher
|
||||
_touch(tmp_path / "Windows" / "SysWOW64" / "bash.exe") # 32-bit WSL launcher
|
||||
_touch(tmp_path / "WindowsApps" / "bash.exe") # Microsoft Store alias stub
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _system_root(root: Path) -> Path:
|
||||
return root / "Windows"
|
||||
|
||||
|
||||
def test_system32_bash_rejected_even_when_only_path_hit(fake_windows: Path):
|
||||
wsl_launcher = fake_windows / "Windows" / "System32" / "bash.exe"
|
||||
|
||||
result = shell._first_runnable_shell([wsl_launcher], _system_root(fake_windows))
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_syswow64_bash_rejected(fake_windows: Path):
|
||||
syswow64 = fake_windows / "Windows" / "SysWOW64" / "bash.exe"
|
||||
|
||||
result = shell._first_runnable_shell([syswow64], _system_root(fake_windows))
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_windowsapps_store_stub_rejected(fake_windows: Path):
|
||||
store_stub = fake_windows / "WindowsApps" / "bash.exe"
|
||||
|
||||
result = shell._first_runnable_shell([store_stub], _system_root(fake_windows))
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_rejected_candidates_fall_through_to_real_shell(fake_windows: Path):
|
||||
wsl_launcher = fake_windows / "Windows" / "System32" / "bash.exe"
|
||||
git_bash = fake_windows / "git" / "bin" / "bash.exe"
|
||||
|
||||
result = shell._first_runnable_shell([wsl_launcher, git_bash], _system_root(fake_windows))
|
||||
|
||||
assert result == str(git_bash.resolve())
|
||||
|
||||
|
||||
def test_missing_candidates_are_skipped(fake_windows: Path):
|
||||
missing = fake_windows / "no-such-dir" / "bash.exe"
|
||||
git_bash = fake_windows / "git" / "bin" / "bash.exe"
|
||||
|
||||
result = shell._first_runnable_shell([missing, git_bash], _system_root(fake_windows))
|
||||
|
||||
assert result == str(git_bash.resolve())
|
||||
|
||||
|
||||
def test_no_runnable_candidate_returns_none(fake_windows: Path):
|
||||
missing = fake_windows / "no-such-dir" / "bash.exe"
|
||||
wsl_launcher = fake_windows / "Windows" / "System32" / "bash.exe"
|
||||
|
||||
result = shell._first_runnable_shell([missing, wsl_launcher], _system_root(fake_windows))
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_git_derived_bash_candidate_comes_first(fake_windows: Path):
|
||||
git_exe = str(fake_windows / "git" / "cmd" / "git.exe")
|
||||
path_bash = str(fake_windows / "elsewhere" / "bash.exe")
|
||||
|
||||
candidates = shell._script_bash_candidates(git=git_exe, program_files=None, path_bash=path_bash)
|
||||
|
||||
git_root = (fake_windows / "git" / "cmd" / "git.exe").resolve().parent.parent
|
||||
assert candidates == [git_root / "bin" / "bash.exe", Path(path_bash)]
|
||||
|
||||
|
||||
def test_program_files_bash_used_when_git_absent(fake_windows: Path):
|
||||
program_files = fake_windows / "ProgramFiles"
|
||||
|
||||
candidates = shell._script_bash_candidates(git=None, program_files=str(program_files), path_bash=None)
|
||||
|
||||
assert candidates == [program_files / "Git" / "bin" / "bash.exe"]
|
||||
|
||||
|
||||
def test_posix_sh_prefers_git_sh_exe(fake_windows: Path):
|
||||
git_exe = str(fake_windows / "git" / "cmd" / "git.exe")
|
||||
|
||||
candidates = shell._script_sh_candidates(git=git_exe, program_files=None)
|
||||
|
||||
git_root = (fake_windows / "git" / "cmd" / "git.exe").resolve().parent.parent
|
||||
assert candidates == [
|
||||
git_root / "bin" / "sh.exe",
|
||||
git_root / "usr" / "bin" / "sh.exe",
|
||||
]
|
||||
|
||||
|
||||
def test_posix_sh_falls_back_to_program_files(fake_windows: Path):
|
||||
program_files = fake_windows / "ProgramFiles"
|
||||
|
||||
candidates = shell._script_sh_candidates(git=None, program_files=str(program_files))
|
||||
|
||||
assert candidates == [
|
||||
program_files / "Git" / "bin" / "sh.exe",
|
||||
program_files / "Git" / "usr" / "bin" / "sh.exe",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "nt", reason="real Windows environment reads")
|
||||
def test_find_script_bash_never_returns_wsl_or_store_stubs():
|
||||
"""On a real Windows host the result must not be a stub launcher."""
|
||||
result = shell.find_script_bash()
|
||||
|
||||
if result is None:
|
||||
pytest.skip("no Git Bash installed on this machine")
|
||||
assert "WindowsApps" not in Path(result).parts
|
||||
system_root = Path(os.environ.get("SystemRoot", r"C:\Windows"))
|
||||
assert system_root / "System32" not in Path(result).parents
|
||||
assert system_root / "SysWOW64" not in Path(result).parents
|
||||
Loading…
x
Reference in New Issue
Block a user