CI: build pipeline testet nun immer automatisch vor dem build alle Test. Ausserdem gibts einen deploy-pfad
This commit is contained in:
1 parent
971459c9bf
commit
2aad2c9d99
12 files changed
+543
-27
No files matched your search
@@ -0,0 +1,185 @@
|
||||
"""Verify pipeline gates and deployment against temporary local directories."""
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from unittest.mock import patch, Mock
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
|
||||
PIPELINE_DIR = Path(__file__).resolve().parents[2] / "1_build-pipeline"
|
||||
sys.path.insert(0, str(PIPELINE_DIR))
|
||||
import deploy
|
||||
|
||||
spec = importlib.util.spec_from_file_location("build_pipeline", PIPELINE_DIR / "build.py")
|
||||
build = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(build)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def release(tmp_path):
|
||||
directory = tmp_path / "build"
|
||||
directory.mkdir()
|
||||
for name in deploy.PACKAGE_FILES:
|
||||
path = directory / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(b"test release payload")
|
||||
deploy.write_release_manifest(directory, "1.3")
|
||||
target = tmp_path / "share"
|
||||
target.mkdir()
|
||||
return directory, target
|
||||
|
||||
|
||||
def test_deployment_packages_release_without_work_files(release):
|
||||
directory, target = release
|
||||
for folder in ["input", "output"]:
|
||||
(directory / folder).mkdir()
|
||||
(directory / folder / "private.xlsx").write_bytes(b"private")
|
||||
result = deploy.deploy(directory, target)
|
||||
assert result.name == "Tätigkeitsnachweis-Generator v1.3.0.zip"
|
||||
with zipfile.ZipFile(result) as archive:
|
||||
assert set(archive.namelist()) == set(deploy.PACKAGE_FILES + ["input/", "output/"])
|
||||
assert archive.testzip() is None
|
||||
assert not list(target.glob(".upload-*"))
|
||||
# An unchanged release can be retried without overwriting the published ZIP.
|
||||
original_time = result.stat().st_mtime_ns
|
||||
assert deploy.deploy(directory, target) == result
|
||||
assert result.stat().st_mtime_ns == original_time
|
||||
|
||||
|
||||
def test_missing_vpn_target_is_not_created(release):
|
||||
directory, target = release
|
||||
missing = target / "missing-share"
|
||||
with pytest.raises(OSError, match="VPN"):
|
||||
deploy.deploy(directory, missing)
|
||||
assert not missing.exists()
|
||||
assert not list(directory.glob("*.zip"))
|
||||
|
||||
|
||||
def test_changed_payload_is_not_deployed(release):
|
||||
directory, target = release
|
||||
(directory / deploy.EXE_NAME).write_bytes(b"different executable")
|
||||
with pytest.raises(ValueError, match="verändert"):
|
||||
deploy.deploy(directory, target)
|
||||
assert not list(target.iterdir())
|
||||
|
||||
|
||||
def test_unconfirmed_old_build_is_not_deployed(release):
|
||||
directory, target = release
|
||||
(directory / deploy.MANIFEST_NAME).unlink()
|
||||
with pytest.raises(ValueError, match="bestätigter Build"):
|
||||
deploy.deploy(directory, target)
|
||||
|
||||
|
||||
def test_existing_different_release_is_preserved(release):
|
||||
directory, target = release
|
||||
existing = target / "Tätigkeitsnachweis-Generator v1.3.0.zip"
|
||||
existing.write_bytes(b"already published")
|
||||
with pytest.raises(FileExistsError, match="anderem Inhalt"):
|
||||
deploy.deploy(directory, target)
|
||||
assert existing.read_bytes() == b"already published"
|
||||
|
||||
|
||||
def test_interrupted_upload_is_not_published(release):
|
||||
directory, target = release
|
||||
with patch.object(deploy.shutil, "copyfile", side_effect=OSError("VPN disconnected")):
|
||||
with pytest.raises(OSError, match="lokale Build bleibt erhalten"):
|
||||
deploy.deploy(directory, target)
|
||||
assert not list(target.iterdir())
|
||||
assert list(directory.glob("*.zip"))
|
||||
assert deploy.validate_release(directory) == "1.3.0"
|
||||
|
||||
|
||||
def test_corrupt_upload_is_not_published(release):
|
||||
directory, target = release
|
||||
def corrupt_copy(source, destination):
|
||||
Path(destination).write_bytes(b"corrupted")
|
||||
with patch.object(deploy.shutil, "copyfile", side_effect=corrupt_copy):
|
||||
with pytest.raises(OSError):
|
||||
deploy.deploy(directory, target)
|
||||
assert not list(target.iterdir())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pipeline(tmp_path, monkeypatch):
|
||||
changelog = tmp_path / "CHANGELOG.md"
|
||||
changelog.write_text("## [1.3]\n\nOriginal release\n", encoding="utf-8")
|
||||
version_file = tmp_path / ".last_version"
|
||||
version_file.write_text("1.3", encoding="utf-8")
|
||||
monkeypatch.setattr(build, "CHANGELOG_PATH", changelog)
|
||||
monkeypatch.setattr(build, "VERSION_FILE", version_file)
|
||||
monkeypatch.setattr(build, "BUILD_OUTPUT_DIR", tmp_path)
|
||||
monkeypatch.setattr(sys, "argv", ["build.py", "--bump", "patch", "--message", "Fix"])
|
||||
events = []
|
||||
names = ["_run_tests", "_ensure_pyinstaller", "_prepare_build_dir", "_run_pyinstaller",
|
||||
"_copy_to_build", "write_release_manifest", "_cleanup", "_print_summary", "deploy"]
|
||||
mocks = {}
|
||||
for name in names:
|
||||
mocks[name] = Mock(side_effect=lambda *args, _name=name: events.append(_name))
|
||||
monkeypatch.setattr(build, name, mocks[name])
|
||||
return changelog, version_file, events, mocks
|
||||
|
||||
|
||||
def test_pipeline_tests_build_then_deploy(pipeline):
|
||||
changelog, version_file, events, mocks = pipeline
|
||||
build.main()
|
||||
assert events == ["_run_tests", "_ensure_pyinstaller", "_prepare_build_dir", "_run_pyinstaller",
|
||||
"_copy_to_build", "write_release_manifest", "_cleanup", "_print_summary", "deploy"]
|
||||
assert version_file.read_text() == "1.3.1"
|
||||
assert "## [1.3.1]" in changelog.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_failing_tests_prevent_version_change_build_and_deploy(pipeline):
|
||||
changelog, version_file, events, mocks = pipeline
|
||||
original = changelog.read_bytes()
|
||||
mocks["_run_tests"].side_effect = SystemExit(1)
|
||||
with pytest.raises(SystemExit):
|
||||
build.main()
|
||||
assert changelog.read_bytes() == original
|
||||
assert version_file.read_text() == "1.3"
|
||||
mocks["_run_pyinstaller"].assert_not_called()
|
||||
mocks["deploy"].assert_not_called()
|
||||
|
||||
|
||||
def test_build_failure_rolls_back_changelog_and_prevents_deployment(pipeline):
|
||||
changelog, version_file, events, mocks = pipeline
|
||||
original = changelog.read_text(encoding="utf-8")
|
||||
mocks["_run_pyinstaller"].side_effect = SystemExit(1)
|
||||
with pytest.raises(SystemExit):
|
||||
build.main()
|
||||
assert changelog.read_text(encoding="utf-8") == original
|
||||
assert version_file.read_text() == "1.3"
|
||||
mocks["deploy"].assert_not_called()
|
||||
mocks["_cleanup"].assert_called_once()
|
||||
|
||||
|
||||
def test_deploy_failure_keeps_successful_build_version(pipeline):
|
||||
changelog, version_file, events, mocks = pipeline
|
||||
mocks["deploy"].side_effect = OSError("VPN unavailable")
|
||||
with pytest.raises(SystemExit):
|
||||
build.main()
|
||||
assert version_file.read_text() == "1.3.1"
|
||||
assert "## [1.3.1]" in changelog.read_text(encoding="utf-8")
|
||||
mocks["write_release_manifest"].assert_called_once()
|
||||
|
||||
|
||||
def test_dry_run_does_not_test_build_or_deploy(pipeline, monkeypatch):
|
||||
changelog, version_file, events, mocks = pipeline
|
||||
monkeypatch.setattr(sys, "argv", ["build.py", "--bump", "patch", "--dry-run"])
|
||||
build.main()
|
||||
assert not events
|
||||
assert version_file.read_text() == "1.3"
|
||||
|
||||
|
||||
def test_rebuild_preserves_changelog_and_version(pipeline, monkeypatch):
|
||||
changelog, version_file, events, mocks = pipeline
|
||||
original = changelog.read_bytes()
|
||||
monkeypatch.setattr(sys, "argv", ["build.py", "--no-bump", "--no-deploy"])
|
||||
build.main()
|
||||
assert changelog.read_bytes() == original
|
||||
assert version_file.read_text() == "1.3"
|
||||
mocks["write_release_manifest"].assert_called_once_with(build.BUILD_OUTPUT_DIR, "1.3")
|
||||
mocks["_run_tests"].assert_called_once()
|
||||
mocks["_run_pyinstaller"].assert_called_once()
|
||||
mocks["deploy"].assert_not_called()
|
||||
@@ -13,14 +13,18 @@ import server_lifecycle
|
||||
|
||||
@unittest.skipUnless(os.name == "nt", "Windows process management")
|
||||
class ServerLifecycleTests(unittest.TestCase):
|
||||
def simulate(self, process, listening=True, stop_error=False, confirm=True):
|
||||
def simulate(self, process, listening=True, stop_error=False, confirm=True, frozen=False):
|
||||
captured = {}
|
||||
|
||||
def capture(command, **kwargs):
|
||||
captured.update(script=command[-1], env=kwargs["env"])
|
||||
return subprocess.CompletedProcess(command, 0, "[]", "")
|
||||
|
||||
with patch.object(server_lifecycle.subprocess, "run", side_effect=capture):
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
with patch.object(server_lifecycle.subprocess, "run", side_effect=capture), \
|
||||
patch.object(server_lifecycle.sys, "frozen", frozen, create=True), \
|
||||
patch.object(server_lifecycle.sys, "executable", str(root / "2_build" / "Tätigkeitsnachweis-Generator.exe")), \
|
||||
patch.object(server_lifecycle, "__file__", str(root / "_MEI-test" / "server_lifecycle.py") if frozen else server_lifecycle.__file__):
|
||||
server_lifecycle.stop_existing_server()
|
||||
|
||||
setup = r'''
|
||||
@@ -140,6 +144,32 @@ function Confirm-ServerRestart {
|
||||
self.assertNotIn("CONFIRM_CALLED", result.stderr)
|
||||
self.assertNotIn("STOP_CALLED", result.stderr)
|
||||
|
||||
def test_exe_recognizes_sibling_development_server(self):
|
||||
process = r'''[pscustomobject]@{
|
||||
Name='python3.13.exe'; ExecutablePath='C:\Python\python3.13.exe';
|
||||
CommandLine=('".venv\Scripts\python.exe" -m streamlit run "' +
|
||||
(($env:APP_RESTART_PATHS | ConvertFrom-Json)[1]) +
|
||||
'" --server.port=8501 --server.address=localhost')
|
||||
}'''
|
||||
for answer, expected_code in [(True, 0), (False, 2)]:
|
||||
with self.subTest(confirm=answer):
|
||||
result = self.simulate(process, frozen=True, confirm=answer)
|
||||
self.assertEqual(result.returncode, expected_code, result.stderr)
|
||||
self.assertIn("CONFIRM_CALLED", result.stderr)
|
||||
if answer:
|
||||
self.assertIn("STOP_CALLED", result.stderr)
|
||||
else:
|
||||
self.assertNotIn("STOP_CALLED", result.stderr)
|
||||
|
||||
def test_exe_preserves_unrelated_development_server(self):
|
||||
result = self.simulate(r'''[pscustomobject]@{
|
||||
Name='python3.13.exe'; ExecutablePath='C:\Python\python3.13.exe';
|
||||
CommandLine='python -m streamlit run "C:\other-project\app.py"'
|
||||
}''', frozen=True)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertNotIn("CONFIRM_CALLED", result.stderr)
|
||||
self.assertNotIn("STOP_CALLED", result.stderr)
|
||||
|
||||
def test_unrelated_server_is_preserved(self):
|
||||
result = self.simulate(r'''[pscustomobject]@{
|
||||
Name='python.exe'; CommandLine='streamlit run app.py';
|
||||
|
||||
Reference in new issue
Block a user