Merge branch 'main' of gitea.satstack.dev:pleb/beatsaber-plugin-helper
This commit is contained in:
+741
-13
@@ -22,12 +22,20 @@ 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, write_lockfile
|
||||
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.state import (
|
||||
downloads_dir,
|
||||
installation_id,
|
||||
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 +48,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)
|
||||
@@ -69,6 +82,57 @@ dependencies = [
|
||||
self.assertEqual(plugin.asset_patterns, ("Example-*.zip",))
|
||||
self.assertEqual(plugin.dependencies, (Dependency(id="bsipa", constraint=">=4.3.7"),))
|
||||
|
||||
def test_lockfile_download_url_is_optional_and_round_trips(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
old_lock_path = root / "old.lock.toml"
|
||||
old_lock_path.write_text(
|
||||
"""
|
||||
beat_saber_version = "1.40.8"
|
||||
instance = "1.40.8"
|
||||
|
||||
[[plugins]]
|
||||
id = "old"
|
||||
repo = "owner/old"
|
||||
tag = "v1"
|
||||
asset = "Old.zip"
|
||||
sha256 = "abc"
|
||||
install_strategy = "bsipa-zip"
|
||||
""".lstrip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
old_lock = load_lockfile(old_lock_path)
|
||||
|
||||
self.assertIsNone(old_lock.plugins[0].download_url)
|
||||
|
||||
new_lock_path = root / "new.lock.toml"
|
||||
write_lockfile(
|
||||
new_lock_path,
|
||||
Lockfile(
|
||||
beat_saber_version="1.40.8",
|
||||
instance="1.40.8",
|
||||
plugins=(
|
||||
LockedPlugin(
|
||||
id="example",
|
||||
repo="owner/example",
|
||||
tag="v2",
|
||||
asset="Example.zip",
|
||||
download_url="https://example.invalid/Example.zip",
|
||||
sha256="def",
|
||||
install_strategy="bsipa-zip",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
text = new_lock_path.read_text(encoding="utf-8")
|
||||
reloaded = load_lockfile(new_lock_path)
|
||||
|
||||
self.assertIn('download_url = "https://example.invalid/Example.zip"', text)
|
||||
self.assertLess(text.index("asset = "), text.index("download_url = "))
|
||||
self.assertLess(text.index("download_url = "), text.index("sha256 = "))
|
||||
self.assertEqual(reloaded.plugins[0].download_url, "https://example.invalid/Example.zip")
|
||||
|
||||
def test_normalize_beatmods_current_nested_response(self) -> None:
|
||||
payload = {
|
||||
"mods": [
|
||||
@@ -857,10 +921,112 @@ sha256 = "{sha256_file(asset)}"
|
||||
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll")
|
||||
updated = load_installed_state(state, "1.40.8")
|
||||
install_id = installation_id(
|
||||
profile_id=None,
|
||||
root=instance_root,
|
||||
instance="1.40.8",
|
||||
instance_path=instance,
|
||||
)
|
||||
updated = load_installed_state(state, "1.40.8", install_id=install_id)
|
||||
self.assertIn("example", updated["plugins"])
|
||||
self.assertNotIn("example", updated["disabledPlugins"])
|
||||
|
||||
def test_install_states_are_separate_for_duplicate_instance_names(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
work = Path(tmp)
|
||||
linux_root = work / "linux"
|
||||
windows_root = work / "windows"
|
||||
linux_instance = linux_root / "1.40.8"
|
||||
windows_instance = windows_root / "1.40.8"
|
||||
state = work / "state"
|
||||
registry_dir = work / "registry"
|
||||
locks_dir = work / "locks"
|
||||
registry_dir.mkdir()
|
||||
locks_dir.mkdir()
|
||||
for instance in (linux_instance, windows_instance):
|
||||
(instance / "Beat Saber_Data").mkdir(parents=True)
|
||||
(instance / "Plugins").mkdir()
|
||||
|
||||
asset = plugin_downloads_dir(state, "1.40.8", "example") / "Example.dll"
|
||||
asset.write_bytes(b"managed dll")
|
||||
self.assertEqual(asset.parent, state / "cache" / "downloads" / "1.40.8" / "example")
|
||||
(registry_dir / "plugins.toml").write_text(
|
||||
"""
|
||||
[[plugins]]
|
||||
id = "example"
|
||||
name = "Example"
|
||||
repo = "owner/example"
|
||||
asset_patterns = ["*.dll"]
|
||||
install_strategy = "dll-to-plugins"
|
||||
""".lstrip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(locks_dir / "1.40.8.lock.toml").write_text(
|
||||
f"""
|
||||
beat_saber_version = "1.40.8"
|
||||
instance = "1.40.8"
|
||||
|
||||
[[plugins]]
|
||||
id = "example"
|
||||
repo = "owner/example"
|
||||
tag = "v1.0.0"
|
||||
asset = "Example.dll"
|
||||
sha256 = "{sha256_file(asset)}"
|
||||
""".lstrip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
linux_id = installation_id(
|
||||
profile_id="shared",
|
||||
root=linux_root,
|
||||
instance="1.40.8",
|
||||
instance_path=linux_instance,
|
||||
)
|
||||
windows_id = installation_id(
|
||||
profile_id="shared",
|
||||
root=windows_root,
|
||||
instance="1.40.8",
|
||||
instance_path=windows_instance,
|
||||
)
|
||||
disabled_state = {
|
||||
"instance": "1.40.8",
|
||||
"plugins": {},
|
||||
"disabledPlugins": {
|
||||
"example": {
|
||||
"installedAt": "2026-06-14T17:18:40Z",
|
||||
"disabledAt": "2026-06-14T17:20:00Z",
|
||||
"files": [
|
||||
{
|
||||
"path": "Plugins/Example.dll",
|
||||
"sha256": sha256_file(asset),
|
||||
"size": asset.stat().st_size,
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
}
|
||||
save_installed_state(state, "1.40.8", json.loads(json.dumps(disabled_state)), install_id=linux_id)
|
||||
save_installed_state(state, "1.40.8", json.loads(json.dumps(disabled_state)), install_id=windows_id)
|
||||
|
||||
with patch("plugin_helper.operations.repo_root", return_value=work):
|
||||
result = enable_disabled_plugin(
|
||||
instance="1.40.8",
|
||||
instance_path=linux_instance,
|
||||
state_root=state,
|
||||
plugin_id="example",
|
||||
repo=work,
|
||||
install_id=linux_id,
|
||||
)
|
||||
|
||||
self.assertTrue((linux_instance / "Plugins" / "Example.dll").is_file())
|
||||
self.assertFalse((windows_instance / "Plugins" / "Example.dll").exists())
|
||||
self.assertIn(f"installs/{linux_id}/plans", result["planPath"])
|
||||
linux_state = load_installed_state(state, "1.40.8", install_id=linux_id)
|
||||
windows_state = load_installed_state(state, "1.40.8", install_id=windows_id)
|
||||
self.assertIn("example", linux_state["plugins"])
|
||||
self.assertNotIn("example", linux_state["disabledPlugins"])
|
||||
self.assertEqual(windows_state["plugins"], {})
|
||||
self.assertIn("example", windows_state["disabledPlugins"])
|
||||
|
||||
def test_save_and_restore_known_good_set(self) -> None:
|
||||
from plugin_helper.operations import restore_known_good_set, save_known_good_set
|
||||
from plugin_helper.state import load_known_good_state, save_installed_state
|
||||
@@ -1000,7 +1166,7 @@ instance = "1.40.8"
|
||||
apply_plan(plan, state)
|
||||
self.assertEqual((instance / "IPA" / "Pending" / "Plugins" / "Example.dll").read_bytes(), b"dll")
|
||||
|
||||
def test_plan_still_finds_legacy_flat_downloads(self) -> None:
|
||||
def test_plan_finds_shared_version_downloads(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
work = Path(tmp)
|
||||
instance = work / "instances" / "1.40.8"
|
||||
@@ -1008,7 +1174,7 @@ instance = "1.40.8"
|
||||
instance.mkdir(parents=True)
|
||||
(instance / "Beat Saber_Data").mkdir()
|
||||
asset = downloads_dir(state, "1.40.8") / "Example.dll"
|
||||
asset.write_bytes(b"legacy flat download")
|
||||
asset.write_bytes(b"shared version download")
|
||||
|
||||
plan, _ = create_plan(
|
||||
instance="1.40.8",
|
||||
@@ -1705,6 +1871,564 @@ instance = "1.40.8"
|
||||
self.assertEqual(result["plugins"][0]["status"], "update")
|
||||
self.assertEqual(result["plugins"][0]["latestAssetSha256"], "new")
|
||||
|
||||
def test_update_check_reports_beatmods_update(self) -> None:
|
||||
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",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
result = check_updates(
|
||||
registry=registry,
|
||||
lockfile=lockfile,
|
||||
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"},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(result["summary"]["updates"], 1)
|
||||
self.assertEqual(result["plugins"][0]["status"], "update")
|
||||
self.assertEqual(result["plugins"][0]["latestTag"], "beatmods-3.17.0")
|
||||
self.assertEqual(result["plugins"][0]["latestBeatModsZipHash"], "abc")
|
||||
|
||||
def test_update_check_skips_private_sources(self) -> None:
|
||||
registry = Registry(
|
||||
{
|
||||
"naluluna": RegistryPlugin(
|
||||
id="naluluna",
|
||||
name="Naluluna",
|
||||
repo="patreon/NalulunaModAssistant",
|
||||
install_strategy="root-zip",
|
||||
)
|
||||
}
|
||||
)
|
||||
lockfile = Lockfile(
|
||||
beat_saber_version="1.44.1",
|
||||
instance="1.44.1",
|
||||
plugins=(
|
||||
LockedPlugin(
|
||||
id="naluluna",
|
||||
repo="patreon/NalulunaModAssistant",
|
||||
tag="installed-1.44.1",
|
||||
asset="Naluluna-1.44.1-installed-bundle.zip",
|
||||
sha256="hash",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
result = check_updates(
|
||||
registry=registry,
|
||||
lockfile=lockfile,
|
||||
fetch_releases=lambda repo: self.fail("private sources should not query GitHub"),
|
||||
fetch_beatmods=lambda game_version: self.fail("private sources should not query BeatMods"),
|
||||
)
|
||||
|
||||
self.assertEqual(result["summary"]["skipped"], 1)
|
||||
self.assertEqual(result["plugins"][0]["status"], "skipped")
|
||||
self.assertIn("paid/private", result["plugins"][0]["messages"][0])
|
||||
|
||||
def test_update_check_marks_local_pr_build_for_review(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 https://github.com/zeph-yr/JDFixer/pull/26. "
|
||||
"Use this PR build instead of the failed upstream release asset."
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
result = check_updates(
|
||||
registry=registry,
|
||||
lockfile=lockfile,
|
||||
fetch_releases=lambda repo: self.fail("review builds should not be treated as routine GitHub checks"),
|
||||
)
|
||||
|
||||
self.assertEqual(result["summary"]["reviews"], 1)
|
||||
self.assertEqual(result["plugins"][0]["status"], "review")
|
||||
self.assertEqual(result["plugins"][0]["reviewReasons"], [
|
||||
"local build",
|
||||
"pull request build",
|
||||
"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.assertEqual(updated_lock.plugins[0].download_url, "https://example.invalid/Example-v2.zip")
|
||||
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.assertEqual(updated_lock.plugins[0].download_url, "https://beatmods.com/cdn/mod/abc.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,
|
||||
@@ -1776,6 +2500,7 @@ sha256 = "{sha256_file(asset)}"
|
||||
state,
|
||||
"1.40.8",
|
||||
{"instance": "1.40.8", "plugins": plugins, "disabledPlugins": disabled_plugins},
|
||||
install_id="test",
|
||||
)
|
||||
choice = InstallationChoice(
|
||||
install_id="test",
|
||||
@@ -1845,6 +2570,7 @@ instance = "1.40.8"
|
||||
state,
|
||||
"1.40.8",
|
||||
{"instance": "1.40.8", "plugins": plugins_state, "disabledPlugins": {}},
|
||||
install_id="test",
|
||||
)
|
||||
choice = InstallationChoice(
|
||||
install_id="test",
|
||||
@@ -1880,6 +2606,8 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
|
||||
table = app.query_one(DataTable)
|
||||
self.assertEqual(table.row_count, 2)
|
||||
self.assertEqual(app.mode, "installations")
|
||||
self.assertEqual(str(table.get_cell_at(Coordinate(0, 3))), "/tmp/state-linux/installs/linux")
|
||||
self.assertEqual(str(table.get_cell_at(Coordinate(1, 3))), "/tmp/state-windows/installs/windows")
|
||||
|
||||
async def test_single_instance_skips_installation_picker(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
@@ -1904,7 +2632,7 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
|
||||
await pilot.pause()
|
||||
|
||||
self.assertFalse((instance / "Plugins" / "Example.dll").exists())
|
||||
updated = load_installed_state(state, "1.40.8")
|
||||
updated = load_installed_state(state, "1.40.8", install_id=app.choices[0].install_id)
|
||||
self.assertNotIn("example", updated["plugins"])
|
||||
self.assertIn("example", updated["disabledPlugins"])
|
||||
|
||||
@@ -1938,7 +2666,7 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
|
||||
await pilot.pause()
|
||||
|
||||
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll")
|
||||
updated = load_installed_state(state, "1.40.8")
|
||||
updated = load_installed_state(state, "1.40.8", install_id=app.choices[0].install_id)
|
||||
self.assertIn("example", updated["plugins"])
|
||||
self.assertNotIn("example", updated["disabledPlugins"])
|
||||
|
||||
@@ -1953,7 +2681,7 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
|
||||
await pilot.pause()
|
||||
|
||||
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll")
|
||||
updated = load_installed_state(state, "1.40.8")
|
||||
updated = load_installed_state(state, "1.40.8", install_id=app.choices[0].install_id)
|
||||
self.assertIn("example", updated["plugins"])
|
||||
self.assertNotIn("example", updated.get("disabledPlugins", {}))
|
||||
|
||||
@@ -1966,7 +2694,7 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
|
||||
await pilot.pause()
|
||||
|
||||
self.assertFalse((instance / "Plugins" / "Example.dll").exists())
|
||||
updated = load_installed_state(state, "1.40.8")
|
||||
updated = load_installed_state(state, "1.40.8", install_id=app.choices[0].install_id)
|
||||
self.assertNotIn("example", updated["plugins"])
|
||||
self.assertIn("example", updated["disabledPlugins"])
|
||||
self.assertIn("Disabled 1 plugins", app.status_message)
|
||||
@@ -1980,7 +2708,7 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
|
||||
await pilot.pause()
|
||||
|
||||
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll")
|
||||
updated = load_installed_state(state, "1.40.8")
|
||||
updated = load_installed_state(state, "1.40.8", install_id=app.choices[0].install_id)
|
||||
self.assertIn("example", updated["plugins"])
|
||||
self.assertNotIn("example", updated["disabledPlugins"])
|
||||
self.assertIn("Enabled 1 plugins", app.status_message)
|
||||
@@ -1995,7 +2723,7 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
from plugin_helper.state import load_known_good_state
|
||||
|
||||
known_good = load_known_good_state(state, "1.40.8")
|
||||
known_good = load_known_good_state(state, "1.40.8", install_id=app.choices[0].install_id)
|
||||
self.assertEqual(known_good["pluginIds"], ["alpha", "beta"])
|
||||
self.assertIn("Saved known-good set: 2 enabled plugins", app.status_message)
|
||||
|
||||
@@ -2017,7 +2745,7 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertIn("Restored known-good set", app.status_message)
|
||||
|
||||
self.assertTrue((instance / "Plugins" / "Beta.dll").exists())
|
||||
updated = load_installed_state(state, "1.40.8")
|
||||
updated = load_installed_state(state, "1.40.8", install_id=app.choices[0].install_id)
|
||||
self.assertEqual(set(updated["plugins"]), {"alpha", "beta"})
|
||||
|
||||
async def test_restore_known_good_without_saved_set_reports_status(self) -> None:
|
||||
@@ -2039,7 +2767,7 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
|
||||
await pilot.pause()
|
||||
|
||||
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"changed dll")
|
||||
updated = load_installed_state(state, "1.40.8")
|
||||
updated = load_installed_state(state, "1.40.8", install_id=app.choices[0].install_id)
|
||||
self.assertIn("example", updated["plugins"])
|
||||
self.assertNotIn("example", updated.get("disabledPlugins", {}))
|
||||
self.assertIn("hash mismatch", app.status_message)
|
||||
|
||||
Reference in New Issue
Block a user