Initial SyncVito release with installer updater
This commit is contained in:
commit
b9e4c09f49
41 files changed
+4331
No files matched your search
@@ -0,0 +1,30 @@
|
||||
from pathlib import Path
|
||||
|
||||
from syncvito.config_store import ConfigStore
|
||||
from syncvito.models import AppConfig, AppSettings, SyncDirection, SyncTask
|
||||
|
||||
|
||||
def test_config_roundtrip(tmp_path: Path) -> None:
|
||||
path = tmp_path / "config.json"
|
||||
config = AppConfig(
|
||||
tasks=[
|
||||
SyncTask(
|
||||
name="Archiv",
|
||||
source=str(tmp_path / "quelle"),
|
||||
target=str(tmp_path / "ziel"),
|
||||
direction=SyncDirection.BIDIRECTIONAL,
|
||||
selected_paths=["Dokumente"],
|
||||
)
|
||||
],
|
||||
settings=AppSettings(font_size=12, ui_scale=110, check_updates_on_start=False),
|
||||
)
|
||||
ConfigStore(path).save(config)
|
||||
|
||||
loaded = ConfigStore(path).load()
|
||||
|
||||
assert loaded.settings.font_size == 12
|
||||
assert loaded.settings.ui_scale == 110
|
||||
assert loaded.settings.check_updates_on_start is False
|
||||
assert loaded.tasks[0].name == "Archiv"
|
||||
assert loaded.tasks[0].direction is SyncDirection.BIDIRECTIONAL
|
||||
assert loaded.tasks[0].selected_paths == ["Dokumente"]
|
||||
@@ -0,0 +1,26 @@
|
||||
from syncvito.coordinator import derive_overall_status
|
||||
from syncvito.models import TaskRuntimeState
|
||||
|
||||
|
||||
def test_no_active_task_is_red() -> None:
|
||||
assert derive_overall_status([], globally_paused=False) == ("paused", "Inaktiv")
|
||||
assert derive_overall_status([TaskRuntimeState.PAUSED], globally_paused=False) == ("paused", "Inaktiv")
|
||||
|
||||
|
||||
def test_healthy_active_tasks_are_green() -> None:
|
||||
states = [TaskRuntimeState.ACTIVE, TaskRuntimeState.PAUSED]
|
||||
assert derive_overall_status(states, globally_paused=False) == ("active", "Bereit")
|
||||
|
||||
|
||||
def test_waiting_lock_has_priority_over_active_task() -> None:
|
||||
states = [TaskRuntimeState.ACTIVE, TaskRuntimeState.WAITING_FOR_RELEASE]
|
||||
assert derive_overall_status(states, globally_paused=False) == ("error", "Wartet auf Dateifreigabe")
|
||||
|
||||
|
||||
def test_error_has_highest_priority() -> None:
|
||||
states = [TaskRuntimeState.WAITING_FOR_RELEASE, TaskRuntimeState.ERROR]
|
||||
assert derive_overall_status(states, globally_paused=False) == ("error", "Fehler")
|
||||
|
||||
|
||||
def test_global_pause_is_red_without_errors() -> None:
|
||||
assert derive_overall_status([TaskRuntimeState.ACTIVE], globally_paused=True) == ("paused", "Pausiert")
|
||||
@@ -0,0 +1,126 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from syncvito.models import ConflictMode, DeleteMode, SyncDirection, SyncTask
|
||||
from syncvito.state_store import StateStore
|
||||
from syncvito.sync_engine import SyncEngine
|
||||
|
||||
|
||||
def _engine(tmp_path: Path) -> SyncEngine:
|
||||
return SyncEngine(StateStore(tmp_path / "state.json"))
|
||||
|
||||
|
||||
def test_one_way_copy_and_mirror_delete(tmp_path: Path) -> None:
|
||||
source = tmp_path / "source"
|
||||
target = tmp_path / "target"
|
||||
source.mkdir()
|
||||
target.mkdir()
|
||||
(source / "neu.txt").write_text("aktuell", encoding="utf-8")
|
||||
(target / "alt.txt").write_text("veraltet", encoding="utf-8")
|
||||
task = SyncTask(
|
||||
name="Einweg",
|
||||
source=str(source),
|
||||
target=str(target),
|
||||
delete_mode=DeleteMode.MIRROR,
|
||||
)
|
||||
|
||||
result = _engine(tmp_path).synchronize(task)
|
||||
|
||||
assert result.successful
|
||||
assert (target / "neu.txt").read_text(encoding="utf-8") == "aktuell"
|
||||
assert not (target / "alt.txt").exists()
|
||||
|
||||
|
||||
def test_selection_limits_synced_files(tmp_path: Path) -> None:
|
||||
source = tmp_path / "source"
|
||||
target = tmp_path / "target"
|
||||
(source / "auswahl").mkdir(parents=True)
|
||||
target.mkdir()
|
||||
(source / "auswahl" / "ja.txt").write_text("ja", encoding="utf-8")
|
||||
(source / "nein.txt").write_text("nein", encoding="utf-8")
|
||||
task = SyncTask(
|
||||
name="Auswahl",
|
||||
source=str(source),
|
||||
target=str(target),
|
||||
selected_paths=["auswahl"],
|
||||
)
|
||||
|
||||
_engine(tmp_path).synchronize(task)
|
||||
|
||||
assert (target / "auswahl" / "ja.txt").exists()
|
||||
assert not (target / "nein.txt").exists()
|
||||
|
||||
|
||||
def test_bidirectional_newer_file_wins(tmp_path: Path) -> None:
|
||||
source = tmp_path / "source"
|
||||
target = tmp_path / "target"
|
||||
source.mkdir()
|
||||
target.mkdir()
|
||||
left = source / "datei.txt"
|
||||
right = target / "datei.txt"
|
||||
left.write_text("links", encoding="utf-8")
|
||||
right.write_text("rechts", encoding="utf-8")
|
||||
os.utime(left, ns=(2_000_000_000, 2_000_000_000))
|
||||
os.utime(right, ns=(1_000_000_000, 1_000_000_000))
|
||||
task = SyncTask(
|
||||
name="Beide",
|
||||
source=str(source),
|
||||
target=str(target),
|
||||
direction=SyncDirection.BIDIRECTIONAL,
|
||||
)
|
||||
|
||||
result = _engine(tmp_path).synchronize(task)
|
||||
|
||||
assert result.successful
|
||||
assert right.read_text(encoding="utf-8") == "links"
|
||||
|
||||
|
||||
def test_conflict_copy_preserves_replaced_version(tmp_path: Path) -> None:
|
||||
source = tmp_path / "source"
|
||||
target = tmp_path / "target"
|
||||
source.mkdir()
|
||||
target.mkdir()
|
||||
left = source / "plan.txt"
|
||||
right = target / "plan.txt"
|
||||
left.write_text("neu", encoding="utf-8")
|
||||
right.write_text("alt", encoding="utf-8")
|
||||
os.utime(left, ns=(3_000_000_000, 3_000_000_000))
|
||||
os.utime(right, ns=(1_000_000_000, 1_000_000_000))
|
||||
task = SyncTask(
|
||||
name="Konflikt",
|
||||
source=str(source),
|
||||
target=str(target),
|
||||
direction=SyncDirection.BIDIRECTIONAL,
|
||||
conflict_mode=ConflictMode.CONFLICT_COPY,
|
||||
)
|
||||
|
||||
result = _engine(tmp_path).synchronize(task)
|
||||
|
||||
conflict_files = list(target.glob("plan_conflict_*.txt"))
|
||||
assert result.successful
|
||||
assert result.conflicts == 1
|
||||
assert right.read_text(encoding="utf-8") == "neu"
|
||||
assert conflict_files[0].read_text(encoding="utf-8") == "alt"
|
||||
|
||||
|
||||
def test_bidirectional_mirrors_known_deletion(tmp_path: Path) -> None:
|
||||
source = tmp_path / "source"
|
||||
target = tmp_path / "target"
|
||||
source.mkdir()
|
||||
target.mkdir()
|
||||
(source / "entfernen.txt").write_text("inhalt", encoding="utf-8")
|
||||
task = SyncTask(
|
||||
name="Löschung",
|
||||
source=str(source),
|
||||
target=str(target),
|
||||
direction=SyncDirection.BIDIRECTIONAL,
|
||||
delete_mode=DeleteMode.MIRROR,
|
||||
)
|
||||
engine = _engine(tmp_path)
|
||||
engine.synchronize(task)
|
||||
(target / "entfernen.txt").unlink()
|
||||
|
||||
result = engine.synchronize(task)
|
||||
|
||||
assert result.successful
|
||||
assert not (source / "entfernen.txt").exists()
|
||||
@@ -0,0 +1,165 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import syncvito.updater as updater
|
||||
from syncvito.updater import GiteaUpdateService, UpdateFormatError, UpdateInfo, parse_version
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, payload: bytes, url: str = "https://gitea.example/asset") -> None:
|
||||
self.payload = payload
|
||||
self.offset = 0
|
||||
self.headers = {"Content-Length": str(len(payload))}
|
||||
self.url = url
|
||||
|
||||
def __enter__(self) -> "FakeResponse":
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args: object) -> bool:
|
||||
return False
|
||||
|
||||
def read(self, size: int = -1) -> bytes:
|
||||
if size < 0:
|
||||
size = len(self.payload) - self.offset
|
||||
chunk = self.payload[self.offset : self.offset + size]
|
||||
self.offset += len(chunk)
|
||||
return chunk
|
||||
|
||||
def geturl(self) -> str:
|
||||
return self.url
|
||||
|
||||
|
||||
def _release(version: str, *, prerelease: bool = False) -> dict[str, object]:
|
||||
installer = f"SyncVitoSetup-{version}.exe"
|
||||
return {
|
||||
"tag_name": f"v{version}",
|
||||
"name": f"SyncVito {version}",
|
||||
"body": "Änderungen",
|
||||
"html_url": f"https://gitea.example/releases/v{version}",
|
||||
"draft": False,
|
||||
"prerelease": prerelease,
|
||||
"assets": [
|
||||
{
|
||||
"name": installer,
|
||||
"browser_download_url": f"https://gitea.example/download/{installer}",
|
||||
"size": 1234,
|
||||
},
|
||||
{
|
||||
"name": f"{installer}.sha256",
|
||||
"browser_download_url": f"https://gitea.example/download/{installer}.sha256",
|
||||
"size": 96,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _info(version: str = "1.1.0") -> UpdateInfo:
|
||||
installer = f"SyncVitoSetup-{version}.exe"
|
||||
return UpdateInfo(
|
||||
version=version,
|
||||
title=f"SyncVito {version}",
|
||||
notes="Änderungen",
|
||||
release_url=f"https://gitea.example/releases/v{version}",
|
||||
installer_name=installer,
|
||||
download_url=f"https://gitea.example/download/{installer}",
|
||||
checksum_url=f"https://gitea.example/download/{installer}.sha256",
|
||||
size=0,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_version_accepts_stable_tags() -> None:
|
||||
assert parse_version("v1.2.3") == (1, 2, 3)
|
||||
assert parse_version("10.20.30") == (10, 20, 30)
|
||||
|
||||
|
||||
def test_parse_version_rejects_prerelease() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
parse_version("1.2.3-beta")
|
||||
|
||||
|
||||
def test_check_reads_latest_installer_release(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
service = GiteaUpdateService("1.0.0", base_url="https://gitea.example", update_directory=tmp_path)
|
||||
monkeypatch.setattr(service, "_read_json", lambda _url: _release("1.3.0"))
|
||||
|
||||
update = service.check()
|
||||
|
||||
assert update is not None
|
||||
assert update.version == "1.3.0"
|
||||
assert update.installer_name == "SyncVitoSetup-1.3.0.exe"
|
||||
|
||||
|
||||
def test_check_returns_none_for_current_version(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
service = GiteaUpdateService("1.3.0", base_url="https://gitea.example", update_directory=tmp_path)
|
||||
monkeypatch.setattr(service, "_read_json", lambda _url: _release("1.3.0"))
|
||||
|
||||
assert service.check() is None
|
||||
|
||||
|
||||
def test_release_requires_matching_installer(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
service = GiteaUpdateService("1.0.0", base_url="https://gitea.example", update_directory=tmp_path)
|
||||
release = _release("1.1.0")
|
||||
release["assets"] = []
|
||||
monkeypatch.setattr(service, "_read_json", lambda _url: release)
|
||||
|
||||
with pytest.raises(UpdateFormatError, match="SyncVitoSetup-1.1.0.exe"):
|
||||
service.check()
|
||||
|
||||
|
||||
def test_download_verifies_checksum(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
service = GiteaUpdateService("1.0.0", base_url="https://gitea.example", update_directory=tmp_path)
|
||||
payload = b"MZ-syncvito-installer"
|
||||
digest = hashlib.sha256(payload).hexdigest()
|
||||
responses = iter(
|
||||
[
|
||||
FakeResponse(f"{digest} *SyncVitoSetup-1.1.0.exe\n".encode()),
|
||||
FakeResponse(payload),
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(service, "_request", lambda *_args, **_kwargs: next(responses))
|
||||
progress: list[tuple[int, str]] = []
|
||||
|
||||
result = service.download(_info(), lambda value, text: progress.append((value, text)))
|
||||
|
||||
assert result.read_bytes() == payload
|
||||
assert progress[-1][0] == 100
|
||||
|
||||
|
||||
def test_cleanup_removes_only_managed_installers(tmp_path: Path) -> None:
|
||||
service = GiteaUpdateService("1.0.0", base_url="https://gitea.example", update_directory=tmp_path)
|
||||
managed = [tmp_path / "SyncVitoSetup-1.0.1.exe", tmp_path / "SyncVitoSetup-1.0.2.exe.part"]
|
||||
preserved = [tmp_path / "anderes-setup.exe", tmp_path / "SyncVitoSetup-neu.exe"]
|
||||
for path in managed + preserved:
|
||||
path.write_bytes(b"test")
|
||||
|
||||
assert service.cleanup_installers() == (2, 0)
|
||||
assert all(not path.exists() for path in managed)
|
||||
assert all(path.exists() for path in preserved)
|
||||
|
||||
|
||||
def test_update_watcher_waits_before_installer(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
installer = tmp_path / "SyncVitoSetup-1.1.0.exe"
|
||||
bootstrap_log = tmp_path / "bootstrap.log"
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def capture(executable: str, arguments: list[str]) -> None:
|
||||
captured["executable"] = executable
|
||||
captured["arguments"] = arguments
|
||||
|
||||
monkeypatch.setattr(updater, "_start_detached", capture)
|
||||
monkeypatch.setattr(updater.os, "name", "nt")
|
||||
powershell = Path(os.environ.get("SYSTEMROOT", r"C:\Windows")) / "System32/WindowsPowerShell/v1.0/powershell.exe"
|
||||
monkeypatch.setattr(Path, "is_file", lambda self: self == powershell)
|
||||
|
||||
updater._start_installer_after_exit(installer, ["/VERYSILENT"], [222, 111], bootstrap_log)
|
||||
|
||||
arguments = captured["arguments"]
|
||||
assert isinstance(arguments, list)
|
||||
encoded = arguments[arguments.index("-EncodedCommand") + 1]
|
||||
script = base64.b64decode(encoded).decode("utf-16-le")
|
||||
assert "foreach ($processId in @(111, 222))" in script
|
||||
assert script.index("while (Get-Process") < script.index(str(installer))
|
||||
assert "'/VERYSILENT'" in script
|
||||
Reference in new issue
Block a user