feat: src/launcher.py — Python update launcher (HTTP, app/app.old swap)
Launcher core that reuses services/update_service: seed default config into ~/.cmbot on first run, check the manifest, download the release zip, verify SHA-256, extract, validate version, swap into app/ (keeping app.old/ for rollback), then launch app/CMBot.exe. Degrades to the local version on any failure; checks install-root writability before updating. - update_service: UpdateInfo gains sha256/size/min_supported; release URL resolved to absolute; extract make_auth_header + add download() helper - tests/test_launcher.py: 11 tests (seed, find-root, swap, sha mismatch, no-update); also verified end-to-end against a local HTTP server Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
"""Tests for launcher — no GUI/network dependency (download is mocked)."""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
import launcher
|
||||
from services.update_service import UpdateInfo
|
||||
|
||||
|
||||
def _make_release_zip(dst_zip, version, exe_body):
|
||||
"""Build a flat release zip (CMBot.exe + version.txt) and return its sha256."""
|
||||
tmp = Path(tempfile.mkdtemp())
|
||||
try:
|
||||
(tmp / "CMBot.exe").write_text(exe_body, encoding="ascii")
|
||||
(tmp / "version.txt").write_text(version, encoding="ascii")
|
||||
with zipfile.ZipFile(str(dst_zip), "w", zipfile.ZIP_DEFLATED) as z:
|
||||
z.write(str(tmp / "CMBot.exe"), "CMBot.exe")
|
||||
z.write(str(tmp / "version.txt"), "version.txt")
|
||||
finally:
|
||||
shutil.rmtree(str(tmp), ignore_errors=True)
|
||||
return hashlib.sha256(dst_zip.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
class _LauncherBase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = Path(tempfile.mkdtemp())
|
||||
self.install = self.tmp / "install"
|
||||
self.app = self.install / "app"
|
||||
(self.app / "config").mkdir(parents=True)
|
||||
(self.app / "CMBot.exe").write_text("OLD-1.0.0", encoding="ascii")
|
||||
(self.app / "version.txt").write_text("1.0.0", encoding="ascii")
|
||||
# factory defaults shipped inside app\config
|
||||
(self.app / "config" / "app_config.json").write_text(
|
||||
json.dumps({"update_source": "http://x/manifest.json", "last_template": "正方形"}),
|
||||
encoding="utf-8")
|
||||
(self.app / "config" / "templates.json").write_text("{}", encoding="utf-8")
|
||||
# data root -> a temp dir via the env override
|
||||
self.data = self.tmp / "data"
|
||||
self._prev_env = os.environ.get("CMBOT_DATA_DIR")
|
||||
os.environ["CMBOT_DATA_DIR"] = str(self.data)
|
||||
|
||||
def tearDown(self):
|
||||
if self._prev_env is None:
|
||||
os.environ.pop("CMBOT_DATA_DIR", None)
|
||||
else:
|
||||
os.environ["CMBOT_DATA_DIR"] = self._prev_env
|
||||
shutil.rmtree(str(self.tmp), ignore_errors=True)
|
||||
|
||||
|
||||
class TestHelpers(_LauncherBase):
|
||||
def test_seed_defaults_copies_when_missing(self):
|
||||
launcher.seed_defaults(self.app, self.data)
|
||||
self.assertTrue((self.data / "config" / "app_config.json").exists())
|
||||
self.assertTrue((self.data / "config" / "templates.json").exists())
|
||||
|
||||
def test_seed_defaults_does_not_overwrite(self):
|
||||
(self.data / "config").mkdir(parents=True)
|
||||
(self.data / "config" / "app_config.json").write_text(
|
||||
'{"update_source":"USER"}', encoding="utf-8")
|
||||
launcher.seed_defaults(self.app, self.data)
|
||||
kept = json.loads((self.data / "config" / "app_config.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(kept["update_source"], "USER")
|
||||
|
||||
def test_is_writable(self):
|
||||
self.assertTrue(launcher.is_writable(self.install))
|
||||
|
||||
def test_find_package_root_flat(self):
|
||||
d = self.tmp / "flat"
|
||||
d.mkdir()
|
||||
(d / "CMBot.exe").write_text("x", encoding="ascii")
|
||||
self.assertEqual(launcher._find_package_root(d), d)
|
||||
|
||||
def test_find_package_root_wrapped(self):
|
||||
d = self.tmp / "wrap"
|
||||
(d / "CMBot-1.1.0").mkdir(parents=True)
|
||||
(d / "CMBot-1.1.0" / "CMBot.exe").write_text("x", encoding="ascii")
|
||||
self.assertEqual(launcher._find_package_root(d), d / "CMBot-1.1.0")
|
||||
|
||||
def test_find_package_root_missing(self):
|
||||
d = self.tmp / "empty"
|
||||
d.mkdir()
|
||||
self.assertIsNone(launcher._find_package_root(d))
|
||||
|
||||
def test_read_version_tolerates_bom(self):
|
||||
(self.app / "version.txt").write_text("1.0.0", encoding="utf-8-sig")
|
||||
self.assertEqual(launcher._read_version(self.app), "1.0.0")
|
||||
|
||||
|
||||
class TestRun(_LauncherBase):
|
||||
def _info(self, version, zip_path):
|
||||
sha = hashlib.sha256(zip_path.read_bytes()).hexdigest()
|
||||
return UpdateInfo(version=version, source="http://x/{}.zip".format(version),
|
||||
sha256=sha, size=zip_path.stat().st_size)
|
||||
|
||||
def test_update_applied_and_swapped(self):
|
||||
new_zip = self.tmp / "new.zip"
|
||||
_make_release_zip(new_zip, "1.1.0", "NEW-1.1.0")
|
||||
info = self._info("1.1.0", new_zip)
|
||||
|
||||
def fake_download(url, dest, user="", password="", timeout=120):
|
||||
shutil.copy(str(new_zip), str(dest))
|
||||
|
||||
with patch("launcher.check_for_update", return_value=info), \
|
||||
patch("launcher.download", side_effect=fake_download):
|
||||
rc = launcher.run(self.install, no_launch=True)
|
||||
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertEqual((self.app / "version.txt").read_text(encoding="ascii"), "1.1.0")
|
||||
self.assertEqual((self.app / "CMBot.exe").read_text(encoding="ascii"), "NEW-1.1.0")
|
||||
self.assertEqual(((self.install / "app.old") / "version.txt").read_text(encoding="ascii"), "1.0.0")
|
||||
self.assertFalse((self.install / "staging" / "1.1.0.zip").exists())
|
||||
|
||||
def test_no_update_keeps_local(self):
|
||||
with patch("launcher.check_for_update", return_value=None):
|
||||
rc = launcher.run(self.install, no_launch=True)
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertEqual((self.app / "version.txt").read_text(encoding="ascii"), "1.0.0")
|
||||
self.assertFalse((self.install / "app.old").exists())
|
||||
|
||||
def test_sha256_mismatch_degrades(self):
|
||||
new_zip = self.tmp / "new.zip"
|
||||
_make_release_zip(new_zip, "1.1.0", "NEW")
|
||||
info = self._info("1.1.0", new_zip)
|
||||
info.sha256 = "deadbeef" # force mismatch
|
||||
|
||||
def fake_download(url, dest, user="", password="", timeout=120):
|
||||
shutil.copy(str(new_zip), str(dest))
|
||||
|
||||
with patch("launcher.check_for_update", return_value=info), \
|
||||
patch("launcher.download", side_effect=fake_download):
|
||||
rc = launcher.run(self.install, no_launch=True)
|
||||
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertEqual((self.app / "version.txt").read_text(encoding="ascii"), "1.0.0")
|
||||
self.assertFalse((self.install / "app.old").exists())
|
||||
|
||||
def test_seeds_config_into_data_root(self):
|
||||
with patch("launcher.check_for_update", return_value=None):
|
||||
launcher.run(self.install, no_launch=True)
|
||||
self.assertTrue((self.data / "config" / "app_config.json").exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user