|
| 1 | +"""Direct unit tests for docker-entrypoint.sh plugin requirement reload logic.""" |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +import stat |
| 6 | +import subprocess |
| 7 | +from pathlib import Path |
| 8 | + |
| 9 | +REPO_ROOT = Path(__file__).resolve().parents[2] |
| 10 | +ENTRYPOINT = REPO_ROOT / "docker-entrypoint.sh" |
| 11 | + |
| 12 | + |
| 13 | +def _write_executable(path: Path, content: str) -> None: |
| 14 | + path.write_text(content, encoding="utf-8") |
| 15 | + path.chmod(path.stat().st_mode | stat.S_IXUSR) |
| 16 | + |
| 17 | + |
| 18 | +def _make_app_root(tmp_path: Path) -> Path: |
| 19 | + app_root = tmp_path / "app" |
| 20 | + (app_root / ".venv" / "bin").mkdir(parents=True) |
| 21 | + (app_root / "plugins").mkdir() |
| 22 | + return app_root |
| 23 | + |
| 24 | + |
| 25 | +def _run_install_plugin_requirements(app_root: Path, requirements_path: Path | None = None) -> subprocess.CompletedProcess[str]: |
| 26 | + command = f""" |
| 27 | +set -euo pipefail |
| 28 | +export CONTEXTFORGE_TEST_ONLY_SOURCE=true |
| 29 | +export APP_ROOT="{app_root}" |
| 30 | +source "{ENTRYPOINT}" |
| 31 | +export RELOAD_PLUGIN_REQUIREMENTS_TXT=true |
| 32 | +export PLUGIN_REQUIREMENTS_TXT_PATH="{requirements_path or app_root / 'plugins' / 'requirements.txt'}" |
| 33 | +install_plugin_requirements |
| 34 | +""" |
| 35 | + return subprocess.run( |
| 36 | + ["bash", "-lc", command], |
| 37 | + capture_output=True, |
| 38 | + text=True, |
| 39 | + cwd=REPO_ROOT, |
| 40 | + check=False, |
| 41 | + ) |
| 42 | + |
| 43 | + |
| 44 | +def test_install_plugin_requirements_refuses_path_outside_app_root(tmp_path: Path) -> None: |
| 45 | + app_root = _make_app_root(tmp_path) |
| 46 | + outside_requirements = tmp_path / "outside.txt" |
| 47 | + outside_requirements.write_text("cpex-rate-limiter==0.0.3\n", encoding="utf-8") |
| 48 | + |
| 49 | + result = _run_install_plugin_requirements(app_root, outside_requirements) |
| 50 | + |
| 51 | + assert result.returncode == 1 |
| 52 | + assert "must resolve under" in result.stdout |
| 53 | + |
| 54 | + |
| 55 | +def test_install_plugin_requirements_refuses_missing_file(tmp_path: Path) -> None: |
| 56 | + app_root = _make_app_root(tmp_path) |
| 57 | + missing_requirements = app_root / "plugins" / "missing.txt" |
| 58 | + |
| 59 | + result = _run_install_plugin_requirements(app_root, missing_requirements) |
| 60 | + |
| 61 | + assert result.returncode == 1 |
| 62 | + assert "not found" in result.stdout |
| 63 | + |
| 64 | + |
| 65 | +def test_install_plugin_requirements_retries_three_times_then_fails(tmp_path: Path) -> None: |
| 66 | + app_root = _make_app_root(tmp_path) |
| 67 | + requirements = app_root / "plugins" / "requirements.txt" |
| 68 | + requirements.write_text("cpex-rate-limiter==0.0.3\n", encoding="utf-8") |
| 69 | + attempts_file = tmp_path / "attempts.txt" |
| 70 | + _write_executable( |
| 71 | + app_root / ".venv" / "bin" / "pip", |
| 72 | + f"""#!/usr/bin/env bash |
| 73 | +set -euo pipefail |
| 74 | +echo attempt >> "{attempts_file}" |
| 75 | +exit 1 |
| 76 | +""", |
| 77 | + ) |
| 78 | + |
| 79 | + result = _run_install_plugin_requirements(app_root, requirements) |
| 80 | + |
| 81 | + assert result.returncode == 1 |
| 82 | + assert attempts_file.read_text(encoding="utf-8").count("attempt") == 3 |
| 83 | + assert "failed after 3 attempts" in result.stdout |
| 84 | + |
| 85 | + |
| 86 | +def test_install_plugin_requirements_succeeds_after_retry(tmp_path: Path) -> None: |
| 87 | + app_root = _make_app_root(tmp_path) |
| 88 | + requirements = app_root / "plugins" / "requirements.txt" |
| 89 | + requirements.write_text("# comment\n\ncpex-rate-limiter==0.0.3\n", encoding="utf-8") |
| 90 | + attempts_file = tmp_path / "attempts.txt" |
| 91 | + _write_executable( |
| 92 | + app_root / ".venv" / "bin" / "pip", |
| 93 | + f"""#!/usr/bin/env bash |
| 94 | +set -euo pipefail |
| 95 | +count=0 |
| 96 | +if [[ -f "{attempts_file}" ]]; then |
| 97 | + count=$(wc -l < "{attempts_file}") |
| 98 | +fi |
| 99 | +echo attempt >> "{attempts_file}" |
| 100 | +if [[ "$count" -lt 1 ]]; then |
| 101 | + exit 1 |
| 102 | +fi |
| 103 | +exit 0 |
| 104 | +""", |
| 105 | + ) |
| 106 | + |
| 107 | + result = _run_install_plugin_requirements(app_root, requirements) |
| 108 | + |
| 109 | + assert result.returncode == 0 |
| 110 | + assert attempts_file.read_text(encoding="utf-8").count("attempt") == 2 |
| 111 | + assert "Installing 1 plugin package requirement" in result.stdout |
| 112 | + assert "attempt 1/3 failed" in result.stdout |
| 113 | + |
| 114 | + |
| 115 | +def test_install_plugin_requirements_skips_when_reload_disabled(tmp_path: Path) -> None: |
| 116 | + app_root = _make_app_root(tmp_path) |
| 117 | + marker = tmp_path / "pip-called.txt" |
| 118 | + _write_executable( |
| 119 | + app_root / ".venv" / "bin" / "pip", |
| 120 | + f"""#!/usr/bin/env bash |
| 121 | +set -euo pipefail |
| 122 | +echo called > "{marker}" |
| 123 | +exit 0 |
| 124 | +""", |
| 125 | + ) |
| 126 | + command = f""" |
| 127 | +set -euo pipefail |
| 128 | +export CONTEXTFORGE_TEST_ONLY_SOURCE=true |
| 129 | +export APP_ROOT="{app_root}" |
| 130 | +source "{ENTRYPOINT}" |
| 131 | +export RELOAD_PLUGIN_REQUIREMENTS_TXT=false |
| 132 | +install_plugin_requirements |
| 133 | +""" |
| 134 | + |
| 135 | + result = subprocess.run( |
| 136 | + ["bash", "-lc", command], |
| 137 | + capture_output=True, |
| 138 | + text=True, |
| 139 | + cwd=REPO_ROOT, |
| 140 | + check=False, |
| 141 | + ) |
| 142 | + |
| 143 | + assert result.returncode == 0 |
| 144 | + assert not marker.exists() |
| 145 | + assert result.stdout == "" |
0 commit comments