Files
cmbot/tests/test_installer.py
T
adminandClaude Opus 4.8 5a1a004daf feat: non-blocking updates — download in app, apply at launch
Startup no longer blocks on a download. New split:
- services/installer.py: download_and_stage() (runs while the app is open →
  staging\app.new, verified) and apply_staged() (launcher swaps it into app\
  at next launch, when the exe isn't locked).
- launcher.py: slimmed to seed + apply_staged + launch; no network at startup.
- main_window: a new version lights a dot on the ⚙ 配置 button instead of a
  blocking banner; the old open-folder banner is removed.
- settings_dialog: "检查并更新" downloads + stages, then "下次启动生效".

Verified end-to-end over a local HTTP server (download→stage→apply); 101 tests
pass (test_installer +11, test_launcher rewritten).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 11:58:06 +08:00

131 lines
5.3 KiB
Python

"""Tests for services.installer — no GUI/network (download is mocked)."""
import hashlib
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"))
from services import installer
from services.update_service import UpdateInfo
def _make_release_zip(dst_zip, version, exe_body, wrap=False):
tmp = Path(tempfile.mkdtemp())
try:
base = tmp / ("CMBot-" + version) if wrap else tmp
base.mkdir(parents=True, exist_ok=True)
(base / "CMBot.exe").write_text(exe_body, encoding="ascii")
(base / "version.txt").write_text(version, encoding="ascii")
with zipfile.ZipFile(str(dst_zip), "w", zipfile.ZIP_DEFLATED) as z:
for p in base.rglob("*"):
z.write(str(p), str(p.relative_to(tmp)))
finally:
shutil.rmtree(str(tmp), ignore_errors=True)
return hashlib.sha256(dst_zip.read_bytes()).hexdigest()
class _Base(unittest.TestCase):
def setUp(self):
self.tmp = Path(tempfile.mkdtemp())
self.root = self.tmp / "install"
self.app = self.root / "app"
self.app.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")
def tearDown(self):
shutil.rmtree(str(self.tmp), ignore_errors=True)
def _info(self, version, zip_path):
return UpdateInfo(version=version, source="http://x/{}.zip".format(version),
sha256=hashlib.sha256(zip_path.read_bytes()).hexdigest(),
size=zip_path.stat().st_size)
class TestDownloadAndStage(_Base):
def _copy_download(self, src_zip):
def fake(url, dest, user="", password="", timeout=120):
shutil.copy(str(src_zip), str(dest))
return fake
def test_stage_flat_zip(self):
z = self.tmp / "n.zip"
_make_release_zip(z, "1.1.0", "NEW")
with patch("services.installer.download", side_effect=self._copy_download(z)):
ver = installer.download_and_stage(self.root, self._info("1.1.0", z))
self.assertEqual(ver, "1.1.0")
self.assertEqual(installer.staged_version(self.root), "1.1.0")
self.assertTrue((installer.staged_app(self.root) / "CMBot.exe").exists())
# app untouched until apply
self.assertEqual((self.app / "version.txt").read_text(encoding="ascii"), "1.0.0")
def test_stage_wrapped_zip(self):
z = self.tmp / "n.zip"
_make_release_zip(z, "1.1.0", "NEW", wrap=True)
with patch("services.installer.download", side_effect=self._copy_download(z)):
ver = installer.download_and_stage(self.root, self._info("1.1.0", z))
self.assertEqual(ver, "1.1.0")
self.assertEqual(installer.staged_version(self.root), "1.1.0")
def test_sha_mismatch_raises(self):
z = self.tmp / "n.zip"
_make_release_zip(z, "1.1.0", "NEW")
info = self._info("1.1.0", z)
info.sha256 = "deadbeef"
with patch("services.installer.download", side_effect=self._copy_download(z)):
with self.assertRaises(ValueError):
installer.download_and_stage(self.root, info)
self.assertEqual(installer.staged_version(self.root), "")
def test_version_mismatch_raises(self):
z = self.tmp / "n.zip"
_make_release_zip(z, "1.1.0", "NEW")
info = self._info("9.9.9", z) # manifest claims a different version
with patch("services.installer.download", side_effect=self._copy_download(z)):
with self.assertRaises(ValueError):
installer.download_and_stage(self.root, info)
class TestApplyStaged(_Base):
def _stage(self, version, body):
s = installer.staged_app(self.root)
s.mkdir(parents=True, exist_ok=True)
(s / "CMBot.exe").write_text(body, encoding="ascii")
(s / "version.txt").write_text(version, encoding="ascii")
def test_apply_swaps(self):
self._stage("1.1.0", "NEW-1.1.0")
applied = installer.apply_staged(self.root)
self.assertEqual(applied, "1.1.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((installer.old_dir(self.root) / "version.txt").read_text(encoding="ascii"), "1.0.0")
self.assertEqual(installer.staged_version(self.root), "")
def test_nothing_staged_returns_empty(self):
self.assertEqual(installer.apply_staged(self.root), "")
self.assertEqual((self.app / "version.txt").read_text(encoding="ascii"), "1.0.0")
def test_not_newer_staged_is_discarded(self):
self._stage("0.9.0", "OLDER")
self.assertEqual(installer.apply_staged(self.root), "")
self.assertEqual((self.app / "version.txt").read_text(encoding="ascii"), "1.0.0")
self.assertFalse(installer.staged_app(self.root).exists())
class TestHelpers(_Base):
def test_is_writable(self):
self.assertTrue(installer.is_writable(self.root))
def test_install_root_for(self):
self.assertEqual(installer.install_root_for(self.app), self.root.resolve())
if __name__ == "__main__":
unittest.main()