Enhance script for handling updates

This commit is contained in:
pleb
2026-07-09 19:08:08 -07:00
parent 1dbe80a36f
commit ef673a76de
5 changed files with 822 additions and 2 deletions
+440 -1
View File
@@ -22,12 +22,13 @@ from plugin_helper.config import is_windows, load_local_config, resolve_runtime_
from plugin_helper.fsutil import sha256_file
from plugin_helper.installer import apply_plan, disable_plugin, uninstall_plugin
from plugin_helper.instances import get_instance, list_instances
from plugin_helper.models import Dependency, Lockfile, LockedPlugin, Registry, RegistryPlugin, load_registry
from plugin_helper.models import Dependency, Lockfile, LockedPlugin, Registry, RegistryPlugin, load_lockfile, load_registry
from plugin_helper.operations import enable_disabled_plugin
from plugin_helper.planner import create_plan
from plugin_helper.scanner import scan_bootstrap_files, scan_instance
from plugin_helper.state import downloads_dir, load_installed_state, plugin_downloads_dir, save_bootstrap_state, save_installed_state
from plugin_helper.tui import InstallationChoice, PluginHelperTui
from plugin_helper.update_runner import run_update
from plugin_helper.updates import check_updates
from plugin_helper.userdata import (
backup_userdata,
@@ -40,6 +41,11 @@ from plugin_helper.userdata import (
class PluginHelperTests(unittest.TestCase):
def _write_plugin_zip(self, path: Path, *, content: bytes = b"updated dll") -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with ZipFile(path, "w") as archive:
archive.writestr("Plugins/Example.dll", content)
def test_load_registry_reads_plugin_directory(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
@@ -1826,6 +1832,439 @@ instance = "1.40.8"
"compatibility failure history",
])
def test_update_command_prepares_github_update_and_writes_lock(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
state = work / "state"
instance = work / "instance"
lock_path = work / "locks" / "1.40.8.lock.toml"
source_zip = work / "source" / "Example-v2.zip"
instance.mkdir()
self._write_plugin_zip(source_zip)
source_sha = sha256_file(source_zip)
registry = Registry(
{
"example": RegistryPlugin(
id="example",
name="Example",
repo="owner/example",
asset_patterns=("*.zip",),
install_strategy="bsipa-zip",
)
}
)
lockfile = Lockfile(
beat_saber_version="1.40.8",
instance="1.40.8",
plugins=(
LockedPlugin(
id="example",
repo="owner/example",
tag="v1.0.0",
asset="Example-v1.zip",
sha256="old",
install_strategy="bsipa-zip",
reason="old reason",
),
),
)
def download(_url: str, destination: Path) -> dict[str, object]:
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(source_zip.read_bytes())
return {"path": str(destination), "sha256": sha256_file(destination)}
result = run_update(
instance="1.40.8",
instance_path=instance,
registry=registry,
lockfile=lockfile,
lock_path=lock_path,
state_root=state,
repo_root=work,
selected={"example"},
fetch_releases=lambda repo: [
{
"tag_name": "v2.0.0",
"html_url": "https://github.com/owner/example/releases/tag/v2.0.0",
"published_at": "2026-06-12T00:00:00Z",
"assets": [
{
"name": "Example-v2.zip",
"browser_download_url": "https://example.invalid/Example-v2.zip",
"digest": f"sha256:{source_sha}",
}
],
}
],
download_asset=download,
)
self.assertEqual(result["updated"][0]["plugin"], "example")
self.assertTrue(Path(result["planPath"]).is_file())
updated_lock = load_lockfile(lock_path)
self.assertEqual(updated_lock.plugins[0].tag, "v2.0.0")
self.assertEqual(updated_lock.plugins[0].asset, "Example-v2.zip")
self.assertEqual(updated_lock.plugins[0].sha256, source_sha)
self.assertIn("GitHub release digest matched", updated_lock.plugins[0].reason or "")
def test_update_command_prepares_beatmods_update(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
state = work / "state"
instance = work / "instance"
lock_path = work / "locks" / "1.44.1.lock.toml"
source_zip = work / "source" / "SongCore-3.17.0.zip"
instance.mkdir()
self._write_plugin_zip(source_zip)
registry = Registry(
{
"songcore": RegistryPlugin(
id="songcore",
name="SongCore",
repo="Kylemc1413/SongCore",
asset_patterns=("SongCore-*.zip",),
install_strategy="bsipa-zip",
)
}
)
lockfile = Lockfile(
beat_saber_version="1.44.1",
instance="1.44.1",
plugins=(
LockedPlugin(
id="songcore",
repo="Kylemc1413/SongCore",
tag="beatmods-3.16.0",
asset="SongCore-3.16.0.zip",
sha256="old",
install_strategy="bsipa-zip",
),
),
)
def download(url: str, destination: Path) -> dict[str, object]:
self.assertEqual(url, "https://beatmods.com/cdn/mod/abc.zip")
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(source_zip.read_bytes())
return {"path": str(destination), "sha256": sha256_file(destination)}
run_update(
instance="1.44.1",
instance_path=instance,
registry=registry,
lockfile=lockfile,
lock_path=lock_path,
state_root=state,
repo_root=work,
selected={"songcore"},
fetch_releases=lambda repo: [],
fetch_beatmods=lambda game_version: [
{
"mod": {"id": 1, "name": "SongCore", "gitUrl": "https://github.com/Kylemc1413/SongCore"},
"latest": {"id": 2600, "modVersion": "3.17.0", "zipHash": "abc"},
}
],
download_asset=download,
)
updated_lock = load_lockfile(lock_path)
self.assertEqual(updated_lock.plugins[0].tag, "beatmods-3.17.0")
self.assertEqual(updated_lock.plugins[0].asset, "SongCore-3.17.0.zip")
self.assertIn("version id 2600", updated_lock.plugins[0].reason or "")
self.assertIn("zipHash abc", updated_lock.plugins[0].reason or "")
def test_update_command_dry_run_does_not_download_or_write(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
lock_path = work / "locks" / "1.40.8.lock.toml"
registry = Registry(
{
"example": RegistryPlugin(
id="example",
name="Example",
repo="owner/example",
asset_patterns=("*.zip",),
install_strategy="bsipa-zip",
)
}
)
lockfile = Lockfile(
beat_saber_version="1.40.8",
instance="1.40.8",
plugins=(LockedPlugin(id="example", repo="owner/example", tag="v1", asset="old.zip", sha256="old"),),
)
result = run_update(
instance="1.40.8",
instance_path=work / "instance",
registry=registry,
lockfile=lockfile,
lock_path=lock_path,
state_root=work / "state",
repo_root=work,
selected={"example"},
fetch_releases=lambda repo: [
{
"tag_name": "v2",
"published_at": "2026-06-12T00:00:00Z",
"assets": [{"name": "new.zip", "browser_download_url": "https://example.invalid/new.zip"}],
}
],
dry_run=True,
download_asset=lambda _url, _destination: self.fail("dry-run should not download"),
)
self.assertTrue(result["dryRun"])
self.assertEqual(result["updated"][0]["toTag"], "v2")
self.assertFalse(lock_path.exists())
self.assertIsNone(result["planPath"])
def test_update_command_refuses_review_plugin(self) -> None:
registry = Registry(
{
"jdfixer": RegistryPlugin(
id="jdfixer",
name="JDFixer",
repo="zeph-yr/JDFixer",
asset_patterns=("JDFixer.dll",),
install_strategy="dll-to-plugins",
)
}
)
lockfile = Lockfile(
beat_saber_version="1.44.1",
instance="1.44.1",
plugins=(
LockedPlugin(
id="jdfixer",
repo="zeph-yr/JDFixer",
tag="pr-26-3fce6ce",
asset="JDFixer.dll",
sha256="hash",
reason="Local build from GitHub PR; failed compatibility trial.",
),
),
)
result = run_update(
instance="1.44.1",
instance_path=Path("/tmp/unused"),
registry=registry,
lockfile=lockfile,
lock_path=Path("/tmp/unused.lock.toml"),
state_root=Path("/tmp/state"),
repo_root=Path("/tmp/repo"),
selected={"jdfixer"},
fetch_releases=lambda repo: self.fail("review plugins should not query GitHub"),
download_asset=lambda _url, _destination: self.fail("review plugins should not download"),
)
self.assertEqual(result["updated"], [])
self.assertEqual(result["refused"][0]["plugin"], "jdfixer")
self.assertEqual(result["refused"][0]["status"], "review")
def test_update_command_apply_failure_leaves_lock_unchanged(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
state = work / "state"
instance = work / "instance"
lock_path = work / "locks" / "1.40.8.lock.toml"
source_zip = work / "source" / "Example-v2.zip"
instance.mkdir()
self._write_plugin_zip(source_zip)
original_lock_text = """
beat_saber_version = "1.40.8"
instance = "1.40.8"
[[plugins]]
id = "example"
repo = "owner/example"
tag = "v1"
asset = "old.zip"
sha256 = "old"
install_strategy = "bsipa-zip"
""".lstrip()
lock_path.parent.mkdir()
lock_path.write_text(original_lock_text, encoding="utf-8")
registry = Registry(
{
"example": RegistryPlugin(
id="example",
name="Example",
repo="owner/example",
asset_patterns=("*.zip",),
install_strategy="bsipa-zip",
)
}
)
def download(_url: str, destination: Path) -> dict[str, object]:
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(source_zip.read_bytes())
return {"path": str(destination), "sha256": sha256_file(destination)}
with self.assertRaises(RuntimeError):
run_update(
instance="1.40.8",
instance_path=instance,
registry=registry,
lockfile=load_lockfile(lock_path),
lock_path=lock_path,
state_root=state,
repo_root=work,
selected={"example"},
fetch_releases=lambda repo: [
{
"tag_name": "v2",
"published_at": "2026-06-12T00:00:00Z",
"assets": [{"name": "Example-v2.zip", "browser_download_url": "https://example.invalid/new.zip"}],
}
],
download_asset=download,
apply=True,
apply_plan_func=lambda _plan, _state: (_ for _ in ()).throw(RuntimeError("boom")),
)
self.assertEqual(lock_path.read_text(encoding="utf-8"), original_lock_text)
def test_update_command_updates_multiple_plugins_preserving_lock_order(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
state = work / "state"
instance = work / "instance"
lock_path = work / "locks" / "1.40.8.lock.toml"
instance.mkdir()
zip_a = work / "source" / "Alpha-v2.zip"
zip_b = work / "source" / "Beta-v2.zip"
self._write_plugin_zip(zip_a, content=b"alpha")
self._write_plugin_zip(zip_b, content=b"beta")
registry = Registry(
{
"alpha": RegistryPlugin(id="alpha", name="Alpha", repo="owner/alpha", asset_patterns=("*.zip",), install_strategy="bsipa-zip"),
"beta": RegistryPlugin(id="beta", name="Beta", repo="owner/beta", asset_patterns=("*.zip",), install_strategy="bsipa-zip"),
}
)
lockfile = Lockfile(
beat_saber_version="1.40.8",
instance="1.40.8",
plugins=(
LockedPlugin(id="alpha", repo="owner/alpha", tag="v1", asset="Alpha-v1.zip", sha256="old-a", install_strategy="bsipa-zip"),
LockedPlugin(id="beta", repo="owner/beta", tag="v1", asset="Beta-v1.zip", sha256="old-b", install_strategy="bsipa-zip"),
),
)
def releases(repo: str) -> list[dict[str, object]]:
name = "Alpha-v2.zip" if repo == "owner/alpha" else "Beta-v2.zip"
return [{"tag_name": "v2", "published_at": "2026-06-12T00:00:00Z", "assets": [{"name": name, "browser_download_url": f"https://example.invalid/{name}"}]}]
def download(url: str, destination: Path) -> dict[str, object]:
source = zip_a if "Alpha" in url else zip_b
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(source.read_bytes())
return {"path": str(destination), "sha256": sha256_file(destination)}
run_update(
instance="1.40.8",
instance_path=instance,
registry=registry,
lockfile=lockfile,
lock_path=lock_path,
state_root=state,
repo_root=work,
selected={"alpha", "beta"},
fetch_releases=releases,
download_asset=download,
)
updated_lock = load_lockfile(lock_path)
self.assertEqual([plugin.id for plugin in updated_lock.plugins], ["alpha", "beta"])
self.assertEqual([plugin.asset for plugin in updated_lock.plugins], ["Alpha-v2.zip", "Beta-v2.zip"])
def test_update_cli_requires_plugin(self) -> None:
with patch("sys.stderr", new_callable=StringIO):
with self.assertRaises(SystemExit):
run(["update", "--instance", "1.40.8"])
def test_update_cli_passes_plugins_and_prints_json(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
instance_root = work / "instances"
instance = instance_root / "1.40.8"
state = work / "state"
locks = work / "locks"
registry = work / "registry"
instance.mkdir(parents=True)
(instance / "Beat Saber_Data").mkdir()
locks.mkdir()
registry.mkdir()
(locks / "1.40.8.lock.toml").write_text(
"""
beat_saber_version = "1.40.8"
instance = "1.40.8"
[[plugins]]
id = "alpha"
repo = "owner/alpha"
tag = "v1"
asset = "Alpha.zip"
sha256 = "old"
""".lstrip(),
encoding="utf-8",
)
(registry / "plugins.toml").write_text(
"""
[[plugins]]
id = "alpha"
name = "Alpha"
repo = "owner/alpha"
asset_patterns = ["*.zip"]
install_strategy = "bsipa-zip"
""".lstrip(),
encoding="utf-8",
)
captured: dict[str, object] = {}
def fake_update(**kwargs: object) -> dict[str, object]:
captured.update(kwargs)
return {
"instance": "1.40.8",
"beatSaberVersion": "1.40.8",
"lockfile": str(locks / "1.40.8.lock.toml"),
"updated": [{"plugin": "alpha"}],
"refused": [],
"downloads": [],
"planPath": str(state / "plan.json"),
"applied": [],
"dryRun": False,
}
with patch("plugin_helper.cli.repo_root", return_value=work):
with patch("plugin_helper.cli.run_update", side_effect=fake_update):
with patch("sys.stdout", new_callable=StringIO) as stdout:
status = run(
[
"--instances-root",
str(instance_root),
"--state-dir",
str(state),
"update",
"--instance",
"1.40.8",
"--plugin",
"alpha",
"--plugin",
"beta",
"--json",
]
)
self.assertEqual(status, 0)
self.assertEqual(captured["selected"], {"alpha", "beta"})
data = json.loads(stdout.getvalue())
self.assertEqual(data["lockfile"], str(locks / "1.40.8.lock.toml"))
self.assertEqual(data["updated"][0]["plugin"], "alpha")
def _make_tui_fixture(
root: Path,