Files
plugin-helper/tests/test_plugin_helper.py
T

2778 lines
109 KiB
Python

from __future__ import annotations
import json
import tempfile
import unittest
import os
from io import StringIO
from pathlib import Path
from unittest.mock import patch
from zipfile import ZipFile
from rich.text import Text
from textual.coordinate import Coordinate
from textual.widgets import DataTable
from plugin_helper.bootstrap import _run_ipa, build_bootstrap_command, ensure_healthy_bootstrap
from plugin_helper.bsipa import check_bsipa_health, planning_requires_bootstrap
from plugin_helper.beatmods import by_version_id, normalize_mods
from plugin_helper.checker import check_lock
from plugin_helper.cli import installed_plugins_report, run
from plugin_helper.config import is_windows, load_local_config, resolve_runtime_config
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_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,
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,
infer_appdata_path,
infer_proton_appdata_path,
infer_windows_appdata_path,
restore_windows_data_repo,
sync_windows_data_repo,
)
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)
registry_dir = root / "registry" / "plugins"
registry_dir.mkdir(parents=True)
(registry_dir / "example.toml").write_text(
"""
id = "example"
name = "Example"
repo = "owner/example"
asset_patterns = ["Example-*.zip"]
install_strategy = "bsipa-zip"
category = "ui"
dependencies = [
{ id = "bsipa", constraint = ">=4.3.7" },
]
""".lstrip(),
encoding="utf-8",
)
registry = load_registry(registry_dir)
plugin = registry.get("example")
self.assertIsNotNone(plugin)
assert plugin is not None
self.assertEqual(plugin.name, "Example")
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": [
{
"mod": {
"id": 10,
"name": "Example",
"gitUrl": "https://github.com/example/mod",
"category": "library",
},
"latest": {
"id": 1234,
"modVersion": "1.2.3",
"zipHash": "abc123",
"dependencies": [2561, {"id": "2567"}],
},
}
]
}
entries = normalize_mods(payload)
self.assertEqual(len(entries), 1)
self.assertEqual(entries[0].name, "Example")
self.assertEqual(entries[0].mod_id, 10)
self.assertEqual(entries[0].version_id, 1234)
self.assertEqual(entries[0].dependencies, (2561, 2567))
self.assertEqual(by_version_id(entries)[1234].zip_hash, "abc123")
def test_normalize_beatmods_legacy_flat_response(self) -> None:
entries = normalize_mods(
[
{
"id": "12",
"name": "FlatExample",
"gitUrl": "",
"category": "mods",
"modVersion": "2.0.0",
"zipHash": "def456",
"dependencies": [{"id": 44}, "45", None],
}
]
)
self.assertEqual(entries[0].name, "FlatExample")
self.assertEqual(entries[0].mod_id, 12)
self.assertEqual(entries[0].version_id, 12)
self.assertIsNone(entries[0].git_url)
self.assertEqual(entries[0].dependencies, (44, 45))
def test_instances_and_scan(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
inst = root / "1.40.8"
(inst / "Beat Saber_Data").mkdir(parents=True)
(inst / "Plugins").mkdir()
(inst / "Libs").mkdir()
(inst / "UserData").mkdir()
(inst / "Plugins" / "Example.dll").write_bytes(b"dll")
(root / "not-an-instance").mkdir()
instances = list_instances(root)
self.assertEqual([item.name for item in instances], ["1.40.8"])
self.assertEqual(get_instance(root, "1.40.8").path, inst)
scan = scan_instance(inst, include_hashes=True)
self.assertEqual(scan["counts"]["plugins"], 1)
self.assertEqual(scan["files"][0]["path"], "Plugins/Example.dll")
self.assertIn("sha256", scan["files"][0])
def test_multi_root_instances_and_ambiguous_lookup(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
windows = root / "windows"
local = root / "local"
win_inst = windows / "1.44.1"
local_inst = local / "1.44.1"
(win_inst / "Beat Saber_Data").mkdir(parents=True)
(local_inst / "Beat Saber_Data").mkdir(parents=True)
instances = list_instances([windows, local])
self.assertEqual(len(instances), 2)
self.assertEqual({item.path for item in instances}, {win_inst, local_inst})
with self.assertRaisesRegex(ValueError, "ambiguous"):
get_instance([windows, local], "1.44.1")
def test_local_config_loads_top_level_paths(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
config = root / "plugin-helper.local.toml"
config.write_text(
"""
instances_root = "~/BSInstances"
state_dir = ".state"
""".lstrip(),
encoding="utf-8",
)
local_config, loaded_path, loaded = load_local_config(config, root=root)
self.assertTrue(loaded)
self.assertEqual(loaded_path, config)
self.assertEqual(local_config.instances_roots, [Path("~/BSInstances").expanduser()])
self.assertEqual(local_config.state_root, root / ".state")
def test_runtime_explicit_overrides_env_and_config(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "plugin-helper.local.toml").write_text(
"""
instances_root = "config-root"
state_dir = "config-state"
""".lstrip(),
encoding="utf-8",
)
with patch.dict(
os.environ,
{
"PLUGIN_HELPER_INSTANCES_ROOT": str(root / "env-root"),
"PLUGIN_HELPER_STATE_DIR": str(root / "env-state"),
},
clear=True,
):
runtime = resolve_runtime_config(
instances_root_value=str(root / "explicit-root"),
state_dir_value="explicit-state",
root=root,
)
self.assertEqual(runtime.instances_roots, [root / "explicit-root"])
self.assertEqual(runtime.state_root, root / "explicit-state")
def test_runtime_env_overrides_config(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "plugin-helper.local.toml").write_text(
"""
instances_root = "config-root"
state_dir = "config-state"
""".lstrip(),
encoding="utf-8",
)
with patch.dict(
os.environ,
{
"PLUGIN_HELPER_INSTANCES_ROOT": f"{root / 'env-root-a'}{os.pathsep}{root / 'env-root-b'}",
"PLUGIN_HELPER_STATE_DIR": str(root / "env-state"),
},
clear=True,
):
runtime = resolve_runtime_config(root=root)
self.assertEqual(runtime.instances_roots, [root / "env-root-a", root / "env-root-b"])
self.assertEqual(runtime.state_root, root / "env-state")
def test_runtime_uses_local_config_before_defaults(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "plugin-helper.local.toml").write_text(
"""
instances_root = "config-root"
state_dir = "config-state"
""".lstrip(),
encoding="utf-8",
)
with patch.dict(os.environ, {}, clear=True):
runtime = resolve_runtime_config(root=root)
self.assertEqual(runtime.instances_roots, [root / "config-root"])
self.assertEqual(runtime.state_root, root / "config-state")
def test_runtime_default_state_uses_xdg_state_home(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
xdg = root / "xdg-state"
with (
patch.dict(os.environ, {"XDG_STATE_HOME": str(xdg), "HOME": str(root)}, clear=True),
patch("plugin_helper.config.is_windows", return_value=False),
):
runtime = resolve_runtime_config(root=root)
self.assertEqual(runtime.state_root, xdg / "plugin-helper")
def test_runtime_default_state_uses_home_local_state(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
env = {"HOME": str(root), "USERPROFILE": str(root)}
with (
patch.dict(os.environ, env, clear=True),
patch("plugin_helper.config.is_windows", return_value=False),
):
runtime = resolve_runtime_config(root=root)
self.assertEqual(runtime.state_root, Path(root) / ".local" / "state" / "plugin-helper")
def test_no_args_prints_help_when_not_interactive(self) -> None:
output = StringIO()
with patch("sys.stdin.isatty", return_value=False), patch("sys.stdout", output):
status = run([])
self.assertEqual(status, 2)
self.assertIn("usage: plugin-helper", output.getvalue())
self.assertIn("menu", output.getvalue())
def test_no_command_defaults_to_menu_when_interactive(self) -> None:
with (
patch("sys.stdin.isatty", return_value=True),
patch("sys.stdout.isatty", return_value=True),
patch("plugin_helper.cli._run_menu", return_value=0) as run_menu,
):
status = run([])
self.assertEqual(status, 0)
run_menu.assert_called_once()
def test_run_ipa_timeout_returns_control(self) -> None:
if is_windows():
self.skipTest("POSIX process-group timeout behavior is Linux-specific")
with tempfile.TemporaryDirectory() as tmp:
result = _run_ipa(
command=["python", "-c", "import time; time.sleep(30)"],
instance_path=Path(tmp),
timeout_seconds=1,
)
self.assertTrue(result["timedOut"])
self.assertNotEqual(result["returncode"], 0)
def test_run_ipa_timeout_returns_control_on_windows(self) -> None:
if not is_windows():
self.skipTest("Windows subprocess timeout behavior is Windows-specific")
with tempfile.TemporaryDirectory() as tmp:
result = _run_ipa(
command=["python", "-c", "import time; time.sleep(30)"],
instance_path=Path(tmp),
timeout_seconds=1,
native=True,
)
self.assertTrue(result["timedOut"])
self.assertNotEqual(result["returncode"], 0)
def test_build_bootstrap_command_native(self) -> None:
ipa = Path("C:/Games/Beat Saber/IPA.exe")
self.assertEqual(
build_bootstrap_command(ipa, native=True),
[str(ipa), str(ipa.with_name("Beat Saber.exe")), "-n"],
)
def test_build_bootstrap_command_proton(self) -> None:
ipa = Path("/tmp/1.44.1/IPA.exe")
proton = Path("/tmp/proton")
self.assertEqual(
build_bootstrap_command(ipa, proton=proton, native=False),
[str(proton), "run", str(ipa), str(ipa.with_name("Beat Saber.exe")), "-n"],
)
def test_profile_config_resolves_windows_paths(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
config = root / "plugin-helper.windows.toml"
config.write_text(
"""
[[profiles]]
id = "windows"
label = "Native Windows BSManager"
instances_root = "~/BSInstances"
state_dir = ".state"
""".lstrip(),
encoding="utf-8",
)
local_config, loaded_path, loaded = load_local_config(config, root=root)
runtime = resolve_runtime_config(config_path_value=str(config), profile_id="windows", root=root)
auto_runtime = resolve_runtime_config(config_path_value=str(config), root=root)
self.assertTrue(loaded)
self.assertEqual(loaded_path, config)
self.assertEqual(len(local_config.profiles), 1)
self.assertEqual(local_config.profiles[0].id, "windows")
self.assertEqual(runtime.instances_roots, [Path("~/BSInstances").expanduser()])
self.assertEqual(runtime.state_root, root / ".state")
self.assertEqual(runtime.profile_id, "windows")
self.assertEqual(auto_runtime.state_root, root / ".state")
self.assertEqual(auto_runtime.profile_id, "windows")
def test_native_bootstrap_health_accepts_state_without_log(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
instance = work / "1.44.1"
state = work / "state"
instance.mkdir(parents=True)
(instance / "IPA").mkdir()
(instance / "Libs").mkdir()
(instance / "IPA.exe").write_bytes(b"ipa")
(instance / "winhttp.dll").write_bytes(b"proxy")
save_bootstrap_state(
state,
"1.44.1",
{
"bootstrapMode": "native",
"ipaExitCode": 0,
"ipaTimedOut": False,
"files": scan_bootstrap_files(instance),
},
)
result = check_bsipa_health(instance, state, "1.44.1")
self.assertTrue(result["ok"])
self.assertEqual(result["bootstrapMode"], "native")
def test_proton_bootstrap_health_still_requires_log(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
instance = work / "1.44.1"
state = work / "state"
instance.mkdir(parents=True)
(instance / "IPA").mkdir()
(instance / "Libs").mkdir()
(instance / "IPA.exe").write_bytes(b"ipa")
(instance / "winhttp.dll").write_bytes(b"proxy")
save_bootstrap_state(
state,
"1.44.1",
{
"bootstrapMode": "proton",
"ipaExitCode": 0,
"ipaTimedOut": False,
"files": scan_bootstrap_files(instance),
},
)
result = check_bsipa_health(instance, state, "1.44.1")
self.assertFalse(result["ok"])
self.assertIn("missing Logs/_latest.log", result["messages"])
def test_planning_requires_bootstrap(self) -> None:
lockfile_plugins = (
LockedPlugin(id="bsipa", repo=None, tag="4.3.7", asset="BSIPA.zip", sha256=None),
LockedPlugin(id="example", repo=None, tag="v1.0.0", asset="Example.dll", sha256=None),
)
self.assertTrue(planning_requires_bootstrap(lockfile_plugins, {"example"}))
self.assertFalse(planning_requires_bootstrap(lockfile_plugins, {"bsipa"}))
self.assertFalse(planning_requires_bootstrap((), {"example"}))
def test_ensure_healthy_bootstrap_noop_when_healthy(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
instance = work / "1.44.1"
state = work / "state"
instance.mkdir(parents=True)
(instance / "IPA").mkdir()
(instance / "Libs").mkdir()
(instance / "IPA.exe").write_bytes(b"ipa")
(instance / "winhttp.dll").write_bytes(b"proxy")
save_bootstrap_state(
state,
"1.44.1",
{
"bootstrapMode": "native",
"ipaExitCode": 0,
"ipaTimedOut": False,
"files": scan_bootstrap_files(instance),
},
)
lockfile = Lockfile(
beat_saber_version="1.44.1",
instance="1.44.1",
plugins=(
LockedPlugin(id="bsipa", repo=None, tag="4.3.7", asset="BSIPA.zip", sha256=None),
LockedPlugin(id="example", repo=None, tag="v1.0.0", asset="Example.dll", sha256=None),
),
)
registry = Registry(
{
"bsipa": RegistryPlugin(id="bsipa", name="BSIPA", repo=None, install_strategy="root-zip"),
"example": RegistryPlugin(
id="example",
name="Example",
repo=None,
install_strategy="dll-to-plugins",
),
}
)
with patch("plugin_helper.bootstrap.run_bootstrap") as run_bootstrap:
ensure_healthy_bootstrap(
instance="1.44.1",
instance_path=instance,
beat_saber_version="1.44.1",
registry=registry,
lockfile=lockfile,
state_root=state,
repo_root=work,
selected_ids={"example"},
)
run_bootstrap.assert_not_called()
def test_ensure_healthy_bootstrap_runs_when_unhealthy(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
instance = work / "1.44.1"
state = work / "state"
instance.mkdir(parents=True)
lockfile = Lockfile(
beat_saber_version="1.44.1",
instance="1.44.1",
plugins=(
LockedPlugin(id="bsipa", repo=None, tag="4.3.7", asset="BSIPA.zip", sha256=None),
LockedPlugin(id="example", repo=None, tag="v1.0.0", asset="Example.dll", sha256=None),
),
)
registry = Registry(
{
"bsipa": RegistryPlugin(id="bsipa", name="BSIPA", repo=None, install_strategy="root-zip"),
"example": RegistryPlugin(
id="example",
name="Example",
repo=None,
install_strategy="dll-to-plugins",
),
}
)
healthy = {
"ok": True,
"messages": [],
"statePath": str(state / "instances" / "1.44.1" / "bootstrap.json"),
"logPath": str(instance / "Logs" / "_latest.log"),
}
unhealthy = {**healthy, "ok": False, "messages": ["missing IPA.exe"]}
with patch("plugin_helper.bootstrap.check_bsipa_health", side_effect=[unhealthy, healthy]) as check_health:
with patch("plugin_helper.bootstrap.run_bootstrap") as run_bootstrap:
ensure_healthy_bootstrap(
instance="1.44.1",
instance_path=instance,
beat_saber_version="1.44.1",
registry=registry,
lockfile=lockfile,
state_root=state,
repo_root=work,
selected_ids={"example"},
)
self.assertEqual(check_health.call_count, 2)
run_bootstrap.assert_called_once()
def test_ensure_healthy_bootstrap_skips_for_bsipa_only(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
instance = work / "1.44.1"
state = work / "state"
instance.mkdir(parents=True)
lockfile = Lockfile(
beat_saber_version="1.44.1",
instance="1.44.1",
plugins=(LockedPlugin(id="bsipa", repo=None, tag="4.3.7", asset="BSIPA.zip", sha256=None),),
)
registry = Registry(
{"bsipa": RegistryPlugin(id="bsipa", name="BSIPA", repo=None, install_strategy="root-zip")}
)
with patch("plugin_helper.bootstrap.check_bsipa_health") as check_health:
with patch("plugin_helper.bootstrap.run_bootstrap") as run_bootstrap:
ensure_healthy_bootstrap(
instance="1.44.1",
instance_path=instance,
beat_saber_version="1.44.1",
registry=registry,
lockfile=lockfile,
state_root=state,
repo_root=work,
selected_ids={"bsipa"},
)
check_health.assert_not_called()
run_bootstrap.assert_not_called()
def test_enable_disabled_plugin_plans_before_bootstrap(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
instance = work / "instances" / "1.44.1"
state = work / "state"
instance.mkdir(parents=True)
(instance / "Beat Saber_Data").mkdir()
(instance / "Plugins").mkdir()
asset = plugin_downloads_dir(state, "1.44.1", "example") / "Example.dll"
asset.write_bytes(b"managed dll")
save_installed_state(
state,
"1.44.1",
{
"disabledPlugins": {
"example": {
"disabledAt": "2026-01-01T00:00:00Z",
"files": [{"path": "Plugins/Example.dll", "sha256": sha256_file(asset)}],
}
}
},
)
(work / "locks").mkdir()
(work / "registry").mkdir()
lock_path = work / "locks" / "1.44.1.lock.toml"
lock_path.write_text(
"""
beat_saber_version = "1.44.1"
instance = "1.44.1"
[[plugins]]
id = "bsipa"
tag = "4.3.7"
asset = "BSIPA.zip"
[[plugins]]
id = "example"
tag = "v1.0.0"
asset = "Example.dll"
sha256 = "%s"
""" % sha256_file(asset),
encoding="utf-8",
)
registry_path = work / "registry" / "plugins.toml"
registry_path.write_text(
"""
[bsipa]
name = "BSIPA"
install_strategy = "root-zip"
[example]
name = "Example"
install_strategy = "dll-to-plugins"
""",
encoding="utf-8",
)
call_order: list[str] = []
def _ensure(**_kwargs: object) -> None:
call_order.append("ensure")
def _create_plan(**_kwargs: object) -> tuple[dict[str, object], Path]:
call_order.append("plan")
return {"changes": [], "instance": "1.44.1", "instancePath": str(instance)}, work / "plan.json"
def _apply_plan(_plan: dict[str, object], _state_root: Path) -> dict[str, object]:
call_order.append("apply")
return {"applied": [], "statePath": str(state / "instances" / "1.44.1" / "installed.json")}
with patch("plugin_helper.operations.ensure_healthy_bootstrap", side_effect=_ensure):
with patch("plugin_helper.operations.create_plan", side_effect=_create_plan):
with patch("plugin_helper.operations.apply_plan", side_effect=_apply_plan):
enable_disabled_plugin(
instance="1.44.1",
instance_path=instance,
state_root=state,
plugin_id="example",
registry=str(registry_path),
lockfile=str(lock_path),
repo=work,
)
self.assertEqual(call_order, ["plan", "ensure", "apply"])
def test_enable_locked_plugin_missing_asset_does_not_bootstrap(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
instance = work / "instances" / "1.44.1"
state = work / "state"
instance.mkdir(parents=True)
(instance / "Beat Saber_Data").mkdir()
(instance / "Plugins").mkdir()
(work / "locks").mkdir()
(work / "registry").mkdir()
lock_path = work / "locks" / "1.44.1.lock.toml"
lock_path.write_text(
"""
beat_saber_version = "1.44.1"
instance = "1.44.1"
[[plugins]]
id = "example"
tag = "v1.0.0"
asset = "Missing.dll"
""".lstrip(),
encoding="utf-8",
)
registry_path = work / "registry" / "plugins.toml"
registry_path.write_text(
"""
[[plugins]]
id = "example"
name = "Example"
install_strategy = "dll-to-plugins"
""".lstrip(),
encoding="utf-8",
)
with patch("plugin_helper.operations.ensure_healthy_bootstrap") as ensure:
with self.assertRaisesRegex(FileNotFoundError, "asset not found"):
enable_disabled_plugin(
instance="1.44.1",
instance_path=instance,
state_root=state,
plugin_id="example",
registry=str(registry_path),
lockfile=str(lock_path),
repo=work,
)
ensure.assert_not_called()
def test_plan_apply_and_uninstall_dll(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
instance = work / "instances" / "1.40.8"
state = work / "state"
instance.mkdir(parents=True)
(instance / "Beat Saber_Data").mkdir()
(instance / "Plugins").mkdir()
asset = plugin_downloads_dir(state, "1.40.8", "example") / "Example.dll"
asset.write_bytes(b"managed dll")
registry = Registry(
{
"example": RegistryPlugin(
id="example",
name="Example",
repo="owner/example",
asset_patterns=("*.dll",),
install_strategy="dll-to-plugins",
)
}
)
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.dll",
sha256=sha256_file(asset),
),
),
)
plan, plan_path = create_plan(
instance="1.40.8",
instance_path=instance,
beat_saber_version="1.40.8",
registry=registry,
lockfile=lockfile,
state_root=state,
repo_root=work,
)
self.assertTrue(plan_path.exists())
self.assertEqual(plan["changes"][0]["target"], "Plugins/Example.dll")
result = apply_plan(plan, state)
self.assertEqual(len(result["applied"]), 1)
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll")
installed = load_installed_state(state, "1.40.8")
self.assertIn("example", installed["plugins"])
removed = uninstall_plugin("1.40.8", instance, state, "example")
self.assertEqual(removed["removed"], ["Plugins/Example.dll"])
self.assertFalse((instance / "Plugins" / "Example.dll").exists())
def test_disable_plugin_removes_files_but_keeps_disabled_state(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
instance = work / "instances" / "1.40.8"
state = work / "state"
instance.mkdir(parents=True)
(instance / "Beat Saber_Data").mkdir()
(instance / "Plugins").mkdir()
target = instance / "Plugins" / "Example.dll"
target.write_bytes(b"managed dll")
installed = {
"instance": "1.40.8",
"plugins": {
"example": {
"installedAt": "2026-06-14T17:18:40Z",
"files": [
{
"path": "Plugins/Example.dll",
"sha256": sha256_file(target),
"size": target.stat().st_size,
}
],
}
},
}
from plugin_helper.state import save_installed_state
save_installed_state(state, "1.40.8", installed)
result = disable_plugin("1.40.8", instance, state, "example")
self.assertEqual(result["removed"], ["Plugins/Example.dll"])
self.assertFalse(target.exists())
updated = load_installed_state(state, "1.40.8")
self.assertNotIn("example", updated["plugins"])
self.assertIn("example", updated["disabledPlugins"])
self.assertEqual(updated["disabledPlugins"]["example"]["files"][0]["path"], "Plugins/Example.dll")
def test_enable_command_reinstalls_disabled_plugin_from_asset(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
instance_root = work / "instances"
instance = instance_root / "1.40.8"
state = work / "state"
registry_dir = work / "registry"
locks_dir = work / "locks"
registry_dir.mkdir()
locks_dir.mkdir()
instance.mkdir(parents=True)
(instance / "Beat Saber_Data").mkdir()
(instance / "Plugins").mkdir()
asset = plugin_downloads_dir(state, "1.40.8", "example") / "Example.dll"
asset.write_bytes(b"managed dll")
(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",
)
disabled = {
"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,
}
],
}
},
}
from plugin_helper.state import save_installed_state
save_installed_state(state, "1.40.8", disabled)
with patch("plugin_helper.operations.repo_root", return_value=work):
status = run(
[
"--instances-root",
str(instance_root),
"--state-dir",
str(state),
"enable",
"--instance",
"1.40.8",
"example",
]
)
self.assertEqual(status, 0)
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll")
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
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
instance_root = work / "instances"
instance = instance_root / "1.40.8"
state = work / "state"
registry_dir = work / "registry"
locks_dir = work / "locks"
registry_dir.mkdir()
locks_dir.mkdir()
instance.mkdir(parents=True)
(instance / "Beat Saber_Data").mkdir()
(instance / "Plugins").mkdir()
plugins_state: dict[str, dict] = {}
registry_entries: list[str] = []
lock_entries: list[str] = []
for plugin_id, name, filename in (
("alpha", "Alpha", "Alpha.dll"),
("beta", "Beta", "Beta.dll"),
("gamma", "Gamma", "Gamma.dll"),
):
asset = plugin_downloads_dir(state, "1.40.8", plugin_id) / filename
asset.write_bytes(f"{plugin_id} dll".encode())
registry_entries.append(
f"""
[[plugins]]
id = "{plugin_id}"
name = "{name}"
repo = "owner/{plugin_id}"
asset_patterns = ["*.dll"]
install_strategy = "dll-to-plugins"
""".lstrip()
)
lock_entries.append(
f"""
[[plugins]]
id = "{plugin_id}"
repo = "owner/{plugin_id}"
tag = "v1.0.0"
asset = "{filename}"
sha256 = "{sha256_file(asset)}"
""".lstrip()
)
if plugin_id in ("alpha", "beta"):
(instance / "Plugins" / filename).write_bytes(f"{plugin_id} dll".encode())
plugins_state[plugin_id] = {
"installedAt": "2026-06-14T17:18:40Z",
"files": [{"path": f"Plugins/{filename}", "sha256": sha256_file(asset), "size": asset.stat().st_size}],
}
(registry_dir / "plugins.toml").write_text("".join(registry_entries), encoding="utf-8")
(locks_dir / "1.40.8.lock.toml").write_text(
f"""
beat_saber_version = "1.40.8"
instance = "1.40.8"
{"".join(lock_entries)}
""".lstrip(),
encoding="utf-8",
)
save_installed_state(state, "1.40.8", {"instance": "1.40.8", "plugins": plugins_state, "disabledPlugins": {}})
known_good = save_known_good_set(instance="1.40.8", state_root=state)
self.assertEqual(known_good["pluginIds"], ["alpha", "beta"])
self.assertEqual(load_known_good_state(state, "1.40.8")["pluginIds"], ["alpha", "beta"])
# Simulate the user disabling alpha and a new mod pulling in gamma as a dependency.
disable_plugin("1.40.8", instance, state, "alpha", False)
with patch("plugin_helper.operations.repo_root", return_value=work):
enable_disabled_plugin(
instance="1.40.8",
instance_path=instance,
state_root=state,
plugin_id="gamma",
repo=work,
)
result = restore_known_good_set(instance="1.40.8", instance_path=instance, state_root=state, repo=work)
self.assertEqual(result["disabled"], ["gamma"])
self.assertEqual(result["enabled"], ["alpha"])
self.assertEqual(result["disableErrors"], [])
self.assertEqual(result["enableErrors"], [])
updated = load_installed_state(state, "1.40.8")
self.assertEqual(set(updated["plugins"]), {"alpha", "beta"})
self.assertIn("gamma", updated["disabledPlugins"])
def test_zip_to_pending_targets_ipa_pending(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
instance = work / "instances" / "1.40.8"
state = work / "state"
instance.mkdir(parents=True)
(instance / "Beat Saber_Data").mkdir()
asset = plugin_downloads_dir(state, "1.40.8", "example") / "Example.zip"
with ZipFile(asset, "w") as archive:
archive.writestr("Plugins/Example.dll", b"dll")
registry = Registry(
{
"example": RegistryPlugin(
id="example",
name="Example",
repo=None,
install_strategy="zip-to-pending",
)
}
)
lockfile = Lockfile(
beat_saber_version="1.40.8",
instance="1.40.8",
plugins=(
LockedPlugin(
id="example",
repo=None,
tag=None,
asset="Example.zip",
sha256=sha256_file(asset),
),
),
)
plan, _ = create_plan(
instance="1.40.8",
instance_path=instance,
beat_saber_version="1.40.8",
registry=registry,
lockfile=lockfile,
state_root=state,
repo_root=work,
)
self.assertEqual(plan["changes"][0]["target"], "IPA/Pending/Plugins/Example.dll")
apply_plan(plan, state)
self.assertEqual((instance / "IPA" / "Pending" / "Plugins" / "Example.dll").read_bytes(), b"dll")
def test_plan_finds_shared_version_downloads(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
instance = work / "instances" / "1.40.8"
state = work / "state"
instance.mkdir(parents=True)
(instance / "Beat Saber_Data").mkdir()
asset = downloads_dir(state, "1.40.8") / "Example.dll"
asset.write_bytes(b"shared version download")
plan, _ = create_plan(
instance="1.40.8",
instance_path=instance,
beat_saber_version="1.40.8",
registry=Registry(
{
"example": RegistryPlugin(
id="example",
name="Example",
repo=None,
install_strategy="dll-to-plugins",
)
}
),
lockfile=Lockfile(
beat_saber_version="1.40.8",
instance="1.40.8",
plugins=(
LockedPlugin(
id="example",
repo=None,
tag=None,
asset="Example.dll",
sha256=sha256_file(asset),
),
),
),
state_root=state,
repo_root=work,
)
self.assertEqual(plan["changes"][0]["source"], str(asset))
def test_plan_expands_required_dependencies_for_selected_plugin(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
instance = work / "instances" / "1.40.8"
state = work / "state"
instance.mkdir(parents=True)
(instance / "Beat Saber_Data").mkdir()
plugin_asset = plugin_downloads_dir(state, "1.40.8", "example") / "Example.dll"
dependency_asset = plugin_downloads_dir(state, "1.40.8", "dependency") / "Dependency.dll"
plugin_asset.write_bytes(b"managed dll")
dependency_asset.write_bytes(b"dependency dll")
plan, _ = create_plan(
instance="1.40.8",
instance_path=instance,
beat_saber_version="1.40.8",
registry=Registry(
{
"example": RegistryPlugin(
id="example",
name="Example",
repo=None,
install_strategy="dll-to-plugins",
dependencies=(Dependency(id="dependency"),),
),
"dependency": RegistryPlugin(
id="dependency",
name="Dependency",
repo=None,
install_strategy="dll-to-plugins",
),
}
),
lockfile=Lockfile(
beat_saber_version="1.40.8",
instance="1.40.8",
plugins=(
LockedPlugin(
id="example",
repo=None,
tag=None,
asset="Example.dll",
sha256=sha256_file(plugin_asset),
),
LockedPlugin(
id="dependency",
repo=None,
tag=None,
asset="Dependency.dll",
sha256=sha256_file(dependency_asset),
),
),
),
state_root=state,
repo_root=work,
selected={"example"},
)
self.assertEqual(
{change["plugin"] for change in plan["changes"]},
{"example", "dependency"},
)
def test_plan_rejects_selected_plugin_with_unlocked_required_dependency(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
instance = work / "instances" / "1.40.8"
state = work / "state"
instance.mkdir(parents=True)
(instance / "Beat Saber_Data").mkdir()
asset = plugin_downloads_dir(state, "1.40.8", "example") / "Example.dll"
asset.write_bytes(b"managed dll")
with self.assertRaisesRegex(ValueError, "required dependency is not locked"):
create_plan(
instance="1.40.8",
instance_path=instance,
beat_saber_version="1.40.8",
registry=Registry(
{
"example": RegistryPlugin(
id="example",
name="Example",
repo=None,
install_strategy="dll-to-plugins",
dependencies=(Dependency(id="missing"),),
),
}
),
lockfile=Lockfile(
beat_saber_version="1.40.8",
instance="1.40.8",
plugins=(
LockedPlugin(
id="example",
repo=None,
tag=None,
asset="Example.dll",
sha256=sha256_file(asset),
),
),
),
state_root=state,
repo_root=work,
selected={"example"},
)
def test_zip_member_cannot_escape_instance(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
instance = work / "instances" / "1.40.8"
state = work / "state"
instance.mkdir(parents=True)
(instance / "Beat Saber_Data").mkdir()
asset = plugin_downloads_dir(state, "1.40.8", "bad") / "Bad.zip"
with ZipFile(asset, "w") as archive:
archive.writestr("../Bad.dll", b"dll")
registry = Registry(
{
"bad": RegistryPlugin(
id="bad",
name="Bad",
repo=None,
install_strategy="zip-to-pending",
)
}
)
lockfile = Lockfile(
beat_saber_version="1.40.8",
instance="1.40.8",
plugins=(
LockedPlugin(
id="bad",
repo=None,
tag=None,
asset="Bad.zip",
sha256=sha256_file(asset),
),
),
)
with self.assertRaises(ValueError):
create_plan(
instance="1.40.8",
instance_path=instance,
beat_saber_version="1.40.8",
registry=registry,
lockfile=lockfile,
state_root=state,
repo_root=work,
)
def test_scan_bootstrap_files_includes_root_ipa_and_bsipa_dirs(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
instance = Path(tmp)
(instance / "IPA" / "Backups").mkdir(parents=True)
(instance / "Libs").mkdir()
(instance / "winhttp.dll").write_bytes(b"proxy")
(instance / "IPA.exe").write_bytes(b"ipa")
(instance / "IPA.exe.config").write_bytes(b"config")
(instance / "IPA" / "Backups" / "Beat Saber.exe.bak").write_bytes(b"backup")
(instance / "Libs" / "0Harmony.dll").write_bytes(b"harmony")
paths = [item["path"] for item in scan_bootstrap_files(instance)]
self.assertEqual(
paths,
[
"IPA.exe",
"IPA.exe.config",
"IPA/Backups/Beat Saber.exe.bak",
"Libs/0Harmony.dll",
"winhttp.dll",
],
)
def test_plan_requires_healthy_bootstrap_for_locked_bsipa_dependencies(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
instance = work / "instances" / "1.44.1"
state = work / "state"
instance.mkdir(parents=True)
(instance / "Beat Saber_Data").mkdir()
asset = plugin_downloads_dir(state, "1.44.1", "example") / "Example.dll"
asset.write_bytes(b"managed dll")
registry = Registry(
{
"bsipa": RegistryPlugin(
id="bsipa",
name="BSIPA",
repo=None,
install_strategy="root-zip",
),
"example": RegistryPlugin(
id="example",
name="Example",
repo=None,
install_strategy="dll-to-plugins",
),
}
)
lockfile = Lockfile(
beat_saber_version="1.44.1",
instance="1.44.1",
plugins=(
LockedPlugin(id="bsipa", repo=None, tag="4.3.7", asset="BSIPA.zip", sha256=None),
LockedPlugin(
id="example",
repo=None,
tag="v1.0.0",
asset="Example.dll",
sha256=sha256_file(asset),
),
),
)
with self.assertRaisesRegex(ValueError, "BSIPA bootstrap is not healthy"):
create_plan(
instance="1.44.1",
instance_path=instance,
beat_saber_version="1.44.1",
registry=registry,
lockfile=lockfile,
state_root=state,
repo_root=work,
selected={"example"},
)
(instance / "IPA").mkdir()
(instance / "Libs").mkdir()
(instance / "Logs").mkdir()
(instance / "IPA.exe").write_bytes(b"ipa")
(instance / "winhttp.dll").write_bytes(b"proxy")
(instance / "Logs" / "_latest.log").write_text("Beat Saber IPA (BSIPA): 4.3.7\n", encoding="utf-8")
save_bootstrap_state(state, "1.44.1", {"files": scan_bootstrap_files(instance)})
plan, _ = create_plan(
instance="1.44.1",
instance_path=instance,
beat_saber_version="1.44.1",
registry=registry,
lockfile=lockfile,
state_root=state,
repo_root=work,
selected={"example"},
)
self.assertEqual(plan["changes"][0]["target"], "Plugins/Example.dll")
def test_userdata_backup_contains_manifest(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
instance = root / "1.40.8"
state = root / "state"
(instance / "UserData").mkdir(parents=True)
(instance / "UserData" / "settings.json").write_text("{}", encoding="utf-8")
result = backup_userdata("1.40.8", instance, state)
self.assertTrue(Path(result["archive"]).exists())
self.assertEqual(result["manifest"]["fileCount"], 1)
def test_infer_windows_appdata_path_from_native_instance(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
instance = root / "Users" / "pleb" / "BSManager" / "BSInstances" / "1.44.1"
instance.mkdir(parents=True)
expected = root / "Users" / "pleb" / "AppData" / "LocalLow" / "Hyperbolic Magnetism" / "Beat Saber"
self.assertEqual(infer_windows_appdata_path(instance), expected)
def test_infer_windows_appdata_path_from_mounted_instance(self) -> None:
instance = Path("/home/pleb/Windows/Users/pleb/BSManager/BSInstances/1.44.1")
expected = Path("/home/pleb/Windows/Users/pleb/AppData/LocalLow/Hyperbolic Magnetism/Beat Saber")
if is_windows():
self.skipTest("POSIX mount paths are Linux-specific")
self.assertEqual(infer_windows_appdata_path(instance), expected)
def test_sync_windows_data_repo_copies_into_stable_backup_root(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
instance = root / "Users" / "pleb" / "BSManager" / "BSInstances" / "1.44.1"
appdata = root / "Users" / "pleb" / "AppData" / "LocalLow" / "Hyperbolic Magnetism" / "Beat Saber"
backup_repo = root / "backup"
(instance / "UserData").mkdir(parents=True)
(instance / "UserData" / "settings.json").write_text("{}", encoding="utf-8")
(instance / "UserData" / "BeatLeader" / "Replays").mkdir(parents=True)
(instance / "UserData" / "BeatLeader" / "Replays" / "big.bsor").write_text("replay", encoding="utf-8")
(instance / "UserData" / "ScoreSaber" / "Replays").mkdir(parents=True)
(instance / "UserData" / "ScoreSaber" / "Replays" / "big.bsor").write_text("replay", encoding="utf-8")
(instance / "UserData" / "BeatSaberPlus" / "Cache").mkdir(parents=True)
(instance / "UserData" / "BeatSaberPlus" / "Cache" / "cached.dat").write_text("cache", encoding="utf-8")
(instance / "UserData" / "BeatSaverNotifier.json").write_text('{"refreshToken":"secret"}', encoding="utf-8")
(instance / "UserData" / "Accsaber").mkdir(parents=True)
(instance / "UserData" / "Accsaber" / "PlayerScoreCache.json").write_text("{}", encoding="utf-8")
appdata.mkdir(parents=True)
(appdata / "Player.log").write_text("log", encoding="utf-8")
(appdata / "settings.cfg").write_text("settings", encoding="utf-8")
result = sync_windows_data_repo(
instance="1.44.1",
instance_path=instance,
backup_root=backup_repo,
)
self.assertEqual(result["backupRoot"], str(backup_repo))
self.assertEqual((backup_repo / "UserData" / "settings.json").read_text(), "{}")
self.assertFalse((backup_repo / "UserData" / "BeatLeader" / "Replays").exists())
self.assertFalse((backup_repo / "UserData" / "ScoreSaber" / "Replays").exists())
self.assertFalse((backup_repo / "UserData" / "BeatSaberPlus" / "Cache").exists())
self.assertFalse((backup_repo / "UserData" / "BeatSaverNotifier.json").exists())
self.assertFalse((backup_repo / "UserData" / "Accsaber" / "PlayerScoreCache.json").exists())
self.assertFalse((backup_repo / "AppData" / "Player.log").exists())
self.assertEqual((backup_repo / "AppData" / "settings.cfg").read_text(), "settings")
descriptor = json.loads((backup_repo / "backup-descriptor.json").read_text(encoding="utf-8"))
self.assertEqual(descriptor["instance"], "1.44.1")
self.assertEqual(descriptor["sources"][0]["source"], str(instance / "UserData"))
self.assertIn("BeatLeader/Replays", descriptor["skipped"])
self.assertIn("*.log", descriptor["excludePatterns"])
def test_infer_proton_appdata_path(self) -> None:
self.assertEqual(
infer_proton_appdata_path(),
Path.home()
/ ".local/share/BSManager/SharedContent/compatdata/pfx/drive_c/users/steamuser/AppData/LocalLow/Hyperbolic Magnetism/Beat Saber",
)
def test_infer_appdata_path_uses_windows_or_proton(self) -> None:
if is_windows():
self.skipTest("POSIX mount paths are Linux-specific")
windows_instance = Path("/home/pleb/Windows/Users/pleb/BSManager/BSInstances/1.44.1")
linux_instance = Path("/home/pleb/.local/share/BSManager/BSInstances/1.44.1")
self.assertEqual(infer_appdata_path(windows_instance), infer_windows_appdata_path(windows_instance))
self.assertEqual(infer_appdata_path(linux_instance), infer_proton_appdata_path())
def test_restore_windows_data_repo_roundtrip(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
instance = root / "Users" / "pleb" / "BSManager" / "BSInstances" / "1.44.1"
appdata = root / "Users" / "pleb" / "AppData" / "LocalLow" / "Hyperbolic Magnetism" / "Beat Saber"
backup_repo = root / "backup"
(instance / "UserData").mkdir(parents=True)
(instance / "UserData" / "settings.json").write_text('{"saved": true}', encoding="utf-8")
appdata.mkdir(parents=True)
(appdata / "settings.cfg").write_text("settings", encoding="utf-8")
sync_windows_data_repo(
instance="1.44.1",
instance_path=instance,
backup_root=backup_repo,
)
(instance / "UserData" / "settings.json").write_text('{"saved": false}', encoding="utf-8")
(appdata / "settings.cfg").write_text("changed", encoding="utf-8")
result = restore_windows_data_repo(
instance="1.44.1",
instance_path=instance,
backup_root=backup_repo,
)
self.assertEqual((instance / "UserData" / "settings.json").read_text(), '{"saved": true}')
self.assertEqual((appdata / "settings.cfg").read_text(), "settings")
self.assertEqual(len(result["restored"]), 2)
self.assertTrue(all(item["snapshot"] for item in result["restored"]))
def test_restore_windows_data_repo_rejects_instance_mismatch(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
instance = root / "1.44.1"
backup_repo = root / "backup"
(instance / "UserData").mkdir(parents=True)
(instance / "UserData" / "settings.json").write_text("{}", encoding="utf-8")
(backup_repo / "UserData").mkdir(parents=True)
(backup_repo / "UserData" / "settings.json").write_text("{}", encoding="utf-8")
(backup_repo / "backup-descriptor.json").write_text(
json.dumps({"instance": "1.40.8"}) + "\n",
encoding="utf-8",
)
with self.assertRaisesRegex(ValueError, "does not match"):
restore_windows_data_repo(
instance="1.44.1",
instance_path=instance,
backup_root=backup_repo,
include_appdata=False,
)
def test_restore_userdata_cli(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
instances_root = root / "instances"
instance = instances_root / "1.44.1"
backup_repo = root / "backup"
(instance / "UserData").mkdir(parents=True)
(instance / "UserData" / "settings.json").write_text('{"restored": true}', encoding="utf-8")
(backup_repo / "UserData").mkdir(parents=True)
(backup_repo / "UserData" / "settings.json").write_text('{"restored": true}', encoding="utf-8")
status = run(
[
"--instances-root",
str(instances_root),
"--state-dir",
str(root / "state"),
"restore-userdata",
"--instance",
"1.44.1",
"--backup-root",
str(backup_repo),
"--no-appdata",
]
)
self.assertEqual(status, 0)
self.assertEqual((instance / "UserData" / "settings.json").read_text(), '{"restored": true}')
def test_check_reports_missing_asset(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
state = work / "state"
registry = Registry(
{
"example": RegistryPlugin(
id="example",
name="Example",
repo=None,
install_strategy="dll-to-plugins",
)
}
)
lockfile = Lockfile(
beat_saber_version="1.40.8",
instance="1.40.8",
plugins=(
LockedPlugin(
id="example",
repo=None,
tag="v1.0.0",
asset="Missing.dll",
sha256=None,
),
),
)
result = check_lock(
instance="1.40.8",
registry=registry,
lockfile=lockfile,
state_root=state,
repo_root=work,
)
self.assertEqual(result["summary"]["errors"], 1)
self.assertEqual(result["plugins"][0]["status"], "error")
def test_installed_plugins_report_includes_locked_version(self) -> None:
registry = Registry(
{
"example": RegistryPlugin(
id="example",
name="Example Plugin",
repo="owner/example",
install_strategy="dll-to-plugins",
)
}
)
lockfile = Lockfile(
beat_saber_version="1.40.8",
instance="1.40.8",
plugins=(
LockedPlugin(
id="example",
repo="owner/example",
tag="v1.2.3",
asset="Example.dll",
sha256="abc123",
),
),
)
report = installed_plugins_report(
installed_state={
"instance": "1.40.8",
"plugins": {
"example": {
"installedAt": "2026-06-14T17:18:40Z",
"files": [{"path": "Plugins/Example.dll"}],
}
},
},
registry=registry,
lockfile=lockfile,
)
self.assertEqual(report["plugins"][0]["name"], "Example Plugin")
self.assertEqual(report["plugins"][0]["version"], "v1.2.3")
self.assertEqual(report["plugins"][0]["asset"], "Example.dll")
self.assertEqual(report["plugins"][0]["fileCount"], 1)
def test_installed_plugins_report_sorts_by_name(self) -> None:
registry = Registry(
{
"alpha": RegistryPlugin(id="alpha", name="Alpha", repo="owner/alpha"),
"beta": RegistryPlugin(id="beta", name="Beta", repo="owner/beta"),
"zebra": RegistryPlugin(id="zebra", name="Zebra", repo="owner/zebra"),
}
)
lockfile = Lockfile(
beat_saber_version="1.40.8",
instance="1.40.8",
plugins=(
LockedPlugin(id="zebra", repo="owner/zebra", tag="v3.0.0", asset="Zebra.dll", sha256="z"),
LockedPlugin(id="alpha", repo="owner/alpha", tag="v1.0.0", asset="Alpha.dll", sha256="a"),
LockedPlugin(id="beta", repo="owner/beta", tag="v2.0.0", asset="Beta.dll", sha256="b"),
),
)
report = installed_plugins_report(
installed_state={"instance": "1.40.8", "plugins": {}},
registry=registry,
lockfile=lockfile,
)
self.assertEqual([plugin["id"] for plugin in report["plugins"]], ["alpha", "beta", "zebra"])
self.assertEqual([plugin["status"] for plugin in report["plugins"]], ["disabled", "disabled", "disabled"])
self.assertEqual([plugin["fileCount"] for plugin in report["plugins"]], [0, 0, 0])
self.assertEqual(report["plugins"][1]["version"], "v2.0.0")
def test_update_check_reports_current_matching_asset(self) -> None:
registry = Registry(
{
"example": RegistryPlugin(
id="example",
name="Example",
repo="owner/example",
asset_patterns=("1.40.8.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.1.0",
asset="1.40.8.zip",
sha256="abc123",
),
),
)
result = check_updates(
registry=registry,
lockfile=lockfile,
fetch_releases=lambda repo: [
{
"tag_name": "v1.1.0",
"published_at": "2026-06-10T00:00:00Z",
"assets": [{"name": "1.40.8.zip", "digest": "sha256:abc123"}],
}
],
)
self.assertEqual(result["summary"]["current"], 1)
self.assertEqual(result["plugins"][0]["status"], "current")
self.assertEqual(result["plugins"][0]["latestAssetSha256"], "abc123")
def test_update_check_reports_new_matching_release(self) -> None:
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.1.0",
asset="1.40.8.zip",
sha256="abc123",
),
),
)
result = check_updates(
registry=registry,
lockfile=lockfile,
fetch_releases=lambda repo: [
{
"tag_name": "v1.2.0",
"published_at": "2026-06-12T00:00:00Z",
"assets": [
{"name": "1.29.1.zip"},
{"name": "1.40.8.zip", "browser_download_url": "https://example.invalid/asset"},
],
},
{
"tag_name": "v1.1.0",
"published_at": "2026-06-10T00:00:00Z",
"assets": [{"name": "1.40.8.zip"}],
},
],
)
self.assertEqual(result["summary"]["updates"], 1)
self.assertEqual(result["plugins"][0]["status"], "update")
self.assertEqual(result["plugins"][0]["latestTag"], "v1.2.0")
self.assertEqual(result["plugins"][0]["latestAsset"], "1.40.8.zip")
def test_update_check_reports_replaced_asset_digest(self) -> None:
registry = Registry(
{
"example": RegistryPlugin(
id="example",
name="Example",
repo="owner/example",
asset_patterns=("1.40.8.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.1.0",
asset="1.40.8.zip",
sha256="old",
),
),
)
result = check_updates(
registry=registry,
lockfile=lockfile,
fetch_releases=lambda repo: [
{
"tag_name": "v1.1.0",
"published_at": "2026-06-10T00:00:00Z",
"assets": [{"name": "1.40.8.zip", "digest": "sha256:new"}],
}
],
)
self.assertEqual(result["summary"]["updates"], 1)
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,
*,
disabled: bool = False,
hash_mismatch: bool = False,
recorded: bool = True,
) -> tuple[PluginHelperTui, Path, Path]:
repo = root / "repo"
instance_root = root / "instances"
instance = instance_root / "1.40.8"
state = root / "state"
(repo / "registry").mkdir(parents=True)
(repo / "locks").mkdir()
(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")
(repo / "registry" / "plugins.toml").write_text(
"""
[[plugins]]
id = "example"
name = "Example"
repo = "owner/example"
asset_patterns = ["*.dll"]
install_strategy = "dll-to-plugins"
""".lstrip(),
encoding="utf-8",
)
(repo / "locks" / "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",
)
target = instance / "Plugins" / "Example.dll"
if not recorded:
plugins = {}
disabled_plugins = {}
elif disabled:
plugins = {}
disabled_plugins = {
"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}],
}
}
else:
target.write_bytes(b"changed dll" if hash_mismatch else b"managed dll")
plugins = {
"example": {
"installedAt": "2026-06-14T17:18:40Z",
"files": [{"path": "Plugins/Example.dll", "sha256": sha256_file(asset), "size": asset.stat().st_size}],
}
}
disabled_plugins = {}
save_installed_state(
state,
"1.40.8",
{"instance": "1.40.8", "plugins": plugins, "disabledPlugins": disabled_plugins},
install_id="test",
)
choice = InstallationChoice(
install_id="test",
install_label="Test Install",
instance_name="1.40.8",
instance_path=instance,
state_root=state,
)
return PluginHelperTui(choices=[choice], repo_root=repo), instance, state
def _make_two_plugin_tui_fixture(root: Path) -> tuple[PluginHelperTui, Path, Path]:
repo = root / "repo"
instance_root = root / "instances"
instance = instance_root / "1.40.8"
state = root / "state"
(repo / "registry").mkdir(parents=True)
(repo / "locks").mkdir()
(instance / "Beat Saber_Data").mkdir(parents=True)
(instance / "Plugins").mkdir()
plugins_state: dict[str, dict] = {}
lock_entries: list[str] = []
registry_entries: list[str] = []
for plugin_id, name, filename in (
("alpha", "Alpha", "Alpha.dll"),
("beta", "Beta", "Beta.dll"),
):
asset = plugin_downloads_dir(state, "1.40.8", plugin_id) / filename
asset.write_bytes(f"{plugin_id} dll".encode())
(instance / "Plugins" / filename).write_bytes(f"{plugin_id} dll".encode())
registry_entries.append(
f"""
[[plugins]]
id = "{plugin_id}"
name = "{name}"
repo = "owner/{plugin_id}"
asset_patterns = ["*.dll"]
install_strategy = "dll-to-plugins"
""".lstrip()
)
lock_entries.append(
f"""
[[plugins]]
id = "{plugin_id}"
repo = "owner/{plugin_id}"
tag = "v1.0.0"
asset = "{filename}"
sha256 = "{sha256_file(asset)}"
""".lstrip()
)
plugins_state[plugin_id] = {
"installedAt": "2026-06-14T17:18:40Z",
"files": [{"path": f"Plugins/{filename}", "sha256": sha256_file(asset), "size": asset.stat().st_size}],
}
(repo / "registry" / "plugins.toml").write_text("".join(registry_entries), encoding="utf-8")
(repo / "locks" / "1.40.8.lock.toml").write_text(
f"""
beat_saber_version = "1.40.8"
instance = "1.40.8"
{"".join(lock_entries)}
""".lstrip(),
encoding="utf-8",
)
save_installed_state(
state,
"1.40.8",
{"instance": "1.40.8", "plugins": plugins_state, "disabledPlugins": {}},
install_id="test",
)
choice = InstallationChoice(
install_id="test",
install_label="Test Install",
instance_name="1.40.8",
instance_path=instance,
state_root=state,
)
return PluginHelperTui(choices=[choice], repo_root=repo), instance, state
class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
async def test_installation_picker_shows_duplicate_instances_with_state_dirs(self) -> None:
choices = [
InstallationChoice(
install_id="linux",
install_label="Linux",
instance_name="1.44.1",
instance_path=Path("/tmp/linux/1.44.1"),
state_root=Path("/tmp/state-linux"),
),
InstallationChoice(
install_id="windows",
install_label="Windows",
instance_name="1.44.1",
instance_path=Path("/tmp/windows/1.44.1"),
state_root=Path("/tmp/state-windows"),
),
]
app = PluginHelperTui(choices=choices, repo_root=Path("/tmp/repo"))
async with app.run_test():
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:
app, _instance, _state = _make_tui_fixture(Path(tmp))
async with app.run_test():
self.assertEqual(app.mode, "plugins")
self.assertEqual(app.selected_installation, app.choices[0])
table = app.query_one(DataTable)
self.assertEqual(table.row_count, 1)
async def test_space_disables_enabled_plugin_without_id_prompt(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
app, instance, state = _make_tui_fixture(Path(tmp))
async with app.run_test() as pilot:
self.assertEqual(app.mode, "plugins")
self.assertEqual(app.plugin_rows[0]["status"], "enabled")
table = app.query_one(DataTable)
self.assertEqual(table.get_cell_at(Coordinate(0, 0)), Text("[x]", no_wrap=True))
await pilot.press("space")
await pilot.pause()
self.assertFalse((instance / "Plugins" / "Example.dll").exists())
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"])
async def test_space_toggle_keeps_cursor_on_selected_plugin(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
app, instance, _state = _make_two_plugin_tui_fixture(Path(tmp))
async with app.run_test() as pilot:
table = app.query_one(DataTable)
self.assertEqual(table.row_count, 2)
await pilot.press("down")
await pilot.pause()
self.assertEqual(table.cursor_row, 1)
self.assertEqual(app.plugin_rows[table.cursor_row]["id"], "beta")
await pilot.press("space")
await pilot.pause()
self.assertEqual(table.cursor_row, 1)
self.assertEqual(app.plugin_rows[table.cursor_row]["id"], "beta")
self.assertFalse((instance / "Plugins" / "Beta.dll").exists())
self.assertTrue((instance / "Plugins" / "Alpha.dll").exists())
async def test_space_enables_disabled_plugin_from_asset(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
app, instance, state = _make_tui_fixture(Path(tmp), disabled=True)
async with app.run_test() as pilot:
self.assertEqual(app.mode, "plugins")
self.assertEqual(app.plugin_rows[0]["status"], "disabled")
await pilot.press("space")
await pilot.pause()
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll")
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"])
async def test_space_enables_locked_unrecorded_plugin_from_asset(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
app, instance, state = _make_tui_fixture(Path(tmp), recorded=False)
async with app.run_test() as pilot:
self.assertEqual(app.mode, "plugins")
self.assertEqual(app.plugin_rows[0]["status"], "disabled")
await pilot.press("space")
await pilot.pause()
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll")
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", {}))
async def test_disable_all_disables_enabled_plugins(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
app, instance, state = _make_tui_fixture(Path(tmp))
async with app.run_test() as pilot:
await pilot.press("d")
await pilot.pause()
self.assertFalse((instance / "Plugins" / "Example.dll").exists())
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)
async def test_enable_all_enables_disabled_plugins(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
app, instance, state = _make_tui_fixture(Path(tmp), disabled=True)
async with app.run_test() as pilot:
await pilot.press("e")
await pilot.pause()
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll")
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)
async def test_save_known_good_records_currently_enabled_plugins(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
app, _instance, state = _make_two_plugin_tui_fixture(Path(tmp))
async with app.run_test() as pilot:
await pilot.press("s")
await pilot.pause()
from plugin_helper.state import load_known_good_state
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)
async def test_restore_known_good_reverts_manual_changes(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
app, instance, state = _make_two_plugin_tui_fixture(Path(tmp))
async with app.run_test() as pilot:
await pilot.press("s")
await pilot.pause()
# Manually disable beta after saving the known-good set.
await pilot.press("down")
await pilot.press("space")
await pilot.pause()
self.assertFalse((instance / "Plugins" / "Beta.dll").exists())
await pilot.press("g")
await pilot.pause()
self.assertIn("Restored known-good set", app.status_message)
self.assertTrue((instance / "Plugins" / "Beta.dll").exists())
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:
with tempfile.TemporaryDirectory() as tmp:
app, _instance, _state = _make_two_plugin_tui_fixture(Path(tmp))
async with app.run_test() as pilot:
await pilot.press("g")
await pilot.pause()
self.assertIn("No known-good set saved yet", app.status_message)
async def test_space_reports_hash_mismatch_without_state_update(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
app, instance, state = _make_tui_fixture(Path(tmp), hash_mismatch=True)
async with app.run_test() as pilot:
await pilot.press("space")
await pilot.pause()
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"changed dll")
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)
if __name__ == "__main__":
unittest.main()