Add known working config profile for reverting changes
This commit is contained in:
@@ -133,6 +133,15 @@ currently enabled plugins (except `bsipa`) and `e` to enable all currently
|
||||
unchecked plugins. With a single Beat Saber installation, the menu opens the
|
||||
plugin table directly. Re-enabling a plugin auto-bootstraps BSIPA when needed.
|
||||
|
||||
Use `s` to save the set of currently enabled plugins as that instance's
|
||||
known-good set, and `g` to restore it later: plugins outside the saved set are
|
||||
disabled and any saved plugins that are currently disabled/missing are
|
||||
re-enabled from local assets. This is handy after a newly added mod pulls in
|
||||
extra dependencies you don't actually want — press `g` to snap back to
|
||||
the combination you know works. Only one known-good set is kept per instance;
|
||||
saving again overwrites it. The equivalent automation commands are
|
||||
`save-known-good` and `restore-known-good`.
|
||||
|
||||
The individual subcommands are mostly for automation and debugging. If you use
|
||||
them, pass `--state-dir` directly only when you intentionally want to override
|
||||
the configured state directory for one command.
|
||||
|
||||
@@ -88,6 +88,13 @@ For a batch install, check for:
|
||||
- there are no repeated unhandled exceptions from newly installed plugins.
|
||||
- the game reaches the main menu.
|
||||
|
||||
Several `ERROR` and `WARNING` lines are benign on a healthy 1.44.1 stack (older
|
||||
manifest warnings, BeatLeader optional-interop probes, brief
|
||||
`AudioTimeSyncController` resume noise, custom-map `Leaderboard id ... not
|
||||
found`, transient KeyRemapper controller reconnects, and similar). See
|
||||
[`docs/notes/install-and-verify-plugins-1.44.1.md`](notes/install-and-verify-plugins-1.44.1.md#known-ipa-log-noise)
|
||||
before treating every red log line as a plugin failure.
|
||||
|
||||
After the first successful launch, `plugin-helper bootstrap-check --instance
|
||||
<version>` should pass. Ordinary plugin plans for lockfiles that include BSIPA
|
||||
depend on that recorded bootstrap state and the latest IPA log.
|
||||
|
||||
@@ -83,6 +83,44 @@ Suggested log checks:
|
||||
- custom songs still enumerate
|
||||
- playlist and downloader UI still opens when relevant
|
||||
|
||||
Treat the patterns in [Known IPA log noise](#known-ipa-log-noise) as non-blocking
|
||||
unless they appear together with a startup failure, missing plugin load line, or
|
||||
broken in-game behavior.
|
||||
|
||||
## Known IPA Log Noise
|
||||
|
||||
These messages show up in healthy 1.44.1 Windows VR sessions and do not, by
|
||||
themselves, block continuing the install pass. They were confirmed on
|
||||
`Logs/_latest.log` from 2026-07-05 after FailButton and the current gameplay
|
||||
stack loaded successfully.
|
||||
|
||||
| Pattern | Example | Why it is usually harmless |
|
||||
| --- | --- | --- |
|
||||
| Older manifest game version | `[IPA/Loader] Mod <name> developed for game version 1.42.0, so it may not work properly.` | IPA warns when a plugin manifest predates 1.44.1. Common across the stack; only actionable if the plugin misbehaves in VR. |
|
||||
| BeatLeader optional interop missing | `[BeatLeader] Plugin ScoreSaber not found, ScoreSaberInterop will not be loaded` | BeatLeader probes for optional companion mods. Expected when those mods are not installed. |
|
||||
| ScoreSaber Harmony stub when ScoreSaber absent | `[Harmony] Could not find type named ScoreSaber.Core.ReplaySystem.HarmonyPatches.PatchHandleHMDUnmounted` | Harmless when ScoreSaber is not in the instance. |
|
||||
| Vanilla custom-map leaderboard lookup | `[UnityEngine] Leaderboard id for custom_level_<hash><diff> not found` | Unity/official leaderboard plumbing on custom songs. BeatLeader API calls can still succeed in the same session. |
|
||||
| Brief audio resume desync | `[UnityEngine] AudioTimeSyncController: audio should be playing ... isPlaying=False` | Short burst when resuming from pause or squat gate; not the same class of failure as HitsoundTweaks' `InvalidProgramException` on an affinity patch. |
|
||||
| Transient XR controller drop | `[KeyRemapper/UnityXRInputManager] Left controller not found.` | Headset/controller reconnect noise. Controllers typically reappear a line or two later. |
|
||||
| SiraUtil duplicate expose | `[SiraUtil] Could not expose NoteCutSoundEffectManager. It is already binded.` | Seen when multiple installers touch the same gameplay binding. |
|
||||
| BSML duplicate type handlers | `[BeatSaberMarkupLanguage] Registering type handler BeatLeader.UI.BSML_Addons...` | BeatLeader registers BSML extensions that overlap core handlers. Cosmetic warning only so far. |
|
||||
| BeatLeader SteamVR console | `[BeatLeader] SteamVR console connection failed: Unable to connect to the remote server` | Optional SteamVR console integration; unrelated to BeatLeader login/API traffic. |
|
||||
| SongCore WIP cache folder | `[SongCore] Folder: '...CustomWIPLevels\Cache' is missing Info.dat file!` | Empty or placeholder WIP cache directory. |
|
||||
| Settings migration noise | `[SettingsIO] Decode: Unknown property 'quality.depth_texture'` | Stale graphics setting key in saved settings. |
|
||||
| DLC promo assets | `[DlcPromoPanelModel] No PromoPanel assets discovered` | Normal when promo panel content is absent. |
|
||||
| SquatToBegin lifecycle notice | `[IPA/Loader] Plugin SquatToBegin has no methods marked [OnStart] or [OnEnable]` | Upstream plugin shape; gate still arms from gameplay scene setup. |
|
||||
| IPA feature deprecation | `[IPA/Loader/Features] Encountered old features used` / `No such feature SiraLocalizer.LocalizedPlugin` | Loader metadata drift; not a load failure. |
|
||||
|
||||
Still treat these as real problems when investigating a failed smoketest:
|
||||
|
||||
- `Mono.Cecil.AssemblyResolutionException`, `TypeLoadException`, or missing
|
||||
assembly errors for an installed plugin
|
||||
- Harmony `Patching exception` or repeated `NullReferenceException` stacks from
|
||||
a newly installed plugin (LeaderboardCore `PanelView_SetIsLoaded` is a known
|
||||
pre-existing warning on this stack; revisit only if in-game leaderboards break)
|
||||
- Plugin absent from the IPA load list after install
|
||||
- Startup never reaches `[MainSystemInit] Initialization was completed`
|
||||
|
||||
## Status Legend
|
||||
|
||||
- <span style="color:#8b949e; font-weight:600">todo</span>: not attempted
|
||||
@@ -170,7 +208,7 @@ stable.
|
||||
|
||||
| Plugin | Upstream | Status | Source/version | Verification notes |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| ScoreSaber | [github](https://github.com/ScoreSaber/pc-mod) | <span style="color:#d29922; font-weight:600">verified with warning</span> | GitHub `ScoreSaber/pc-mod` tag `v3.3.27`, asset `ScoreSaber-v3.3.27-bs1.42.0-to-1.44.0-9b4cfcf.zip`; GitHub release digest matched downloaded asset | IPA loaded ScoreSaber 3.3.27 and synchronized its clock. Warning: FPFC/no-VR smoke logged `openxr_loader not found` while trying to get HMD info. |
|
||||
| ScoreSaber | [github](https://github.com/ScoreSaber/pc-mod) | <span style="color:#d29922; font-weight:600">verified with warning</span> | GitHub `ScoreSaber/pc-mod` tag `v3.3.27`, asset `ScoreSaber-v3.3.27-bs1.42.0-to-1.44.0-9b4cfcf.zip`; GitHub release digest matched downloaded asset | IPA loads 3.3.27, but auth fails on 1.44.1: `/api/v2/game/auth` returns 403 `Official build does not match upload version hash` because upstream only registers upload-trust hashes through 1.44.0. |
|
||||
| BeatLeader + LeaderboardCore | [github](https://github.com/BeatLeader/beatleader-mod) | <span style="color:#d29922; font-weight:600">verified with warning</span> | GitHub `BeatLeader/beatleader-mod` tag `v0.10.0`, asset `BeatLeader-0.10.0-bs1.42+.zip`; GitHub release digest matched downloaded asset; archive bundles `Plugins/LeaderboardCore.dll`, superseding the standalone BeatMods CDN trial | IPA loaded BeatLeader 0.10.0 and bundled LeaderboardCore 1.7.0, made BeatLeader API requests, and patched 13 ScoreSaber installers. Warnings: optional interop plugins missing; FPFC/no-VR smoke logged OpenXR session not running; bundled LeaderboardCore logged a Harmony patch error for `PanelView_SetIsLoaded`. |
|
||||
| AccSaber | [github](https://github.com/not-dexter/accsaber-reloaded-plugin) | <span style="color:#f85149; font-weight:600">failed compatibility trial</span> | GitHub `not-dexter/accsaber-reloaded-plugin` tag `v1.1.3`, asset `1.40.8.zip`; GitHub release digest matched downloaded asset; latest release has no 1.44.x asset | IPA loaded AccSaber 1.1.3, but the smoke log hit `TypeLoadException: Invalid type AccSaber.Managers.AccSaberStore` for `AccSaberMissionScreen`. Removed from the live instance after the failed trial. |
|
||||
| SongRankedBadge | [github](https://github.com/qe201020335/SongRankedBadge) | <span style="color:#d29922; font-weight:600">verified with warning</span> | GitHub `qe201020335/SongRankedBadge` tag `v1.0.6`, asset `SongRankedBadge-1.0.6-bs1.40.0-88ee233.zip`; BeatMods version id 2267, zipHash `c6944b8a4b00b0c0bb1d44f273b3bb18` | IPA loaded SongRankedBadge 1.0.6, resolved SongDetailsCache, loaded song details, and initialized. Warning: manifest targets Beat Saber 1.40.0. |
|
||||
|
||||
@@ -8,5 +8,6 @@ dependencies = [
|
||||
{ id = "beatsabermarkuplanguage", constraint = ">=1.14.1" },
|
||||
{ id = "sirautil", constraint = ">=3.3.1" },
|
||||
{ id = "songcore", constraint = ">=3.16.0" },
|
||||
{ id = "protobuf-net", constraint = ">=3.0.102" },
|
||||
{ id = "bsipa", constraint = ">=4.3.7" },
|
||||
]
|
||||
|
||||
@@ -14,7 +14,7 @@ from .github import fetch_releases
|
||||
from .installer import apply_plan, disable_plugin, uninstall_plugin
|
||||
from .instances import get_instance, list_instances
|
||||
from .models import load_lockfile, load_registry
|
||||
from .operations import enable_disabled_plugin
|
||||
from .operations import enable_disabled_plugin, restore_known_good_set, save_known_good_set
|
||||
from .planner import create_plan
|
||||
from .reports import installed_plugins_report, print_installed_plugins
|
||||
from .scanner import scan_instance
|
||||
@@ -197,6 +197,24 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
enable.add_argument("--lockfile")
|
||||
enable.add_argument("plugin")
|
||||
|
||||
save_known_good = subcommands.add_parser(
|
||||
"save-known-good",
|
||||
help="Record the currently enabled plugins as the known-good set for this instance",
|
||||
parents=[_common_parent()],
|
||||
)
|
||||
save_known_good.add_argument("--instance", required=True)
|
||||
save_known_good.add_argument("--json", action="store_true", help="Print full JSON output")
|
||||
|
||||
restore_known_good = subcommands.add_parser(
|
||||
"restore-known-good",
|
||||
help="Disable plugins outside the saved known-good set and re-enable any that are missing",
|
||||
parents=[_common_parent()],
|
||||
)
|
||||
restore_known_good.add_argument("--instance", required=True)
|
||||
restore_known_good.add_argument("--registry", default="registry/plugins")
|
||||
restore_known_good.add_argument("--lockfile")
|
||||
restore_known_good.add_argument("--json", action="store_true", help="Print full JSON output")
|
||||
|
||||
backup = subcommands.add_parser(
|
||||
"backup-userdata",
|
||||
help="Copy UserData and Windows AppData into the adjacent backups repo",
|
||||
@@ -520,6 +538,38 @@ def run(argv: list[str] | None = None) -> int:
|
||||
print(f"State: {result['statePath']}")
|
||||
return 0
|
||||
|
||||
if args.command == "save-known-good":
|
||||
result = save_known_good_set(instance=args.instance, state_root=st_root)
|
||||
if args.json:
|
||||
_json(result)
|
||||
else:
|
||||
print(f"Saved known-good set for {args.instance}: {len(result['pluginIds'])} enabled plugins.")
|
||||
for plugin_id in result["pluginIds"]:
|
||||
print(f" {plugin_id}")
|
||||
return 0
|
||||
|
||||
if args.command == "restore-known-good":
|
||||
instance = get_instance(inst_roots, args.instance)
|
||||
progress = (lambda message: print(f" {message}", flush=True))
|
||||
result = restore_known_good_set(
|
||||
instance=args.instance,
|
||||
instance_path=instance.path,
|
||||
state_root=st_root,
|
||||
registry=args.registry,
|
||||
lockfile=args.lockfile,
|
||||
progress=progress,
|
||||
)
|
||||
if args.json:
|
||||
_json(result)
|
||||
else:
|
||||
print(f"Disabled: {len(result['disabled'])}")
|
||||
print(f"Enabled: {len(result['enabled'])}")
|
||||
for item in result["disableErrors"]:
|
||||
print(f" disable error {item['plugin']}: {item['error']}")
|
||||
for item in result["enableErrors"]:
|
||||
print(f" enable error {item['plugin']}: {item['error']}")
|
||||
return 2 if result["disableErrors"] or result["enableErrors"] else 0
|
||||
|
||||
if args.command == "backup-userdata":
|
||||
instance = get_instance(inst_roots, args.instance)
|
||||
root = repo_root()
|
||||
|
||||
@@ -6,10 +6,10 @@ from typing import Any
|
||||
|
||||
from .bootstrap import ensure_healthy_bootstrap
|
||||
from .config import repo_root
|
||||
from .installer import apply_plan
|
||||
from .installer import apply_plan, disable_plugin
|
||||
from .models import Lockfile, Registry, load_lockfile, load_registry
|
||||
from .planner import create_plan
|
||||
from .state import load_installed_state
|
||||
from .state import load_installed_state, load_known_good_state, save_known_good_state
|
||||
|
||||
|
||||
def _resolve_paths(
|
||||
@@ -161,3 +161,72 @@ def enable_disabled_plugins(
|
||||
enabled.append({"plugin": plugin_id, "planPath": str(batch_path), "applied": applied_by_plugin.get(plugin_id, 0)})
|
||||
|
||||
return {"enabled": enabled, "errors": errors}
|
||||
|
||||
|
||||
def save_known_good_set(*, instance: str, state_root: Path) -> dict[str, Any]:
|
||||
"""Record the currently enabled plugin ids as the known-good set for this instance."""
|
||||
installed_state = load_installed_state(state_root, instance)
|
||||
plugin_ids = sorted(installed_state.get("plugins", {}))
|
||||
known_good = {
|
||||
"instance": instance,
|
||||
"beatSaberVersion": installed_state.get("beatSaberVersion"),
|
||||
"pluginIds": plugin_ids,
|
||||
}
|
||||
save_known_good_state(state_root, instance, known_good)
|
||||
return known_good
|
||||
|
||||
|
||||
def restore_known_good_set(
|
||||
*,
|
||||
instance: str,
|
||||
instance_path: Path,
|
||||
state_root: Path,
|
||||
registry: str = "registry/plugins",
|
||||
lockfile: str | None = None,
|
||||
repo: Path | None = None,
|
||||
progress: Callable[[str], None] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Disable plugins outside the saved known-good set and re-enable the ones that are missing."""
|
||||
known_good = load_known_good_state(state_root, instance)
|
||||
known_good_ids = set(known_good.get("pluginIds", []))
|
||||
if not known_good:
|
||||
raise KeyError(f"no known-good set is saved for this instance: {instance}")
|
||||
|
||||
installed_state = load_installed_state(state_root, instance)
|
||||
currently_enabled = set(installed_state.get("plugins", {}))
|
||||
|
||||
to_disable = sorted(currently_enabled - known_good_ids)
|
||||
disabled: list[str] = []
|
||||
disable_errors: list[dict[str, str]] = []
|
||||
for plugin_id in to_disable:
|
||||
try:
|
||||
result = disable_plugin(instance, instance_path, state_root, plugin_id, False)
|
||||
if result["stateUpdated"]:
|
||||
disabled.append(plugin_id)
|
||||
else:
|
||||
skipped = "; ".join(f"{item['path']} {item['reason']}" for item in result["skipped"])
|
||||
disable_errors.append({"plugin": plugin_id, "error": skipped or "no files changed"})
|
||||
except Exception as exc:
|
||||
disable_errors.append({"plugin": plugin_id, "error": str(exc)})
|
||||
|
||||
installed_state = load_installed_state(state_root, instance)
|
||||
currently_enabled = set(installed_state.get("plugins", {}))
|
||||
to_enable = sorted(known_good_ids - currently_enabled)
|
||||
enable_result = enable_disabled_plugins(
|
||||
instance=instance,
|
||||
instance_path=instance_path,
|
||||
state_root=state_root,
|
||||
plugin_ids=to_enable,
|
||||
registry=registry,
|
||||
lockfile=lockfile,
|
||||
repo=repo,
|
||||
progress=progress,
|
||||
)
|
||||
|
||||
return {
|
||||
"knownGood": known_good,
|
||||
"disabled": disabled,
|
||||
"disableErrors": disable_errors,
|
||||
"enabled": [item["plugin"] for item in enable_result["enabled"]],
|
||||
"enableErrors": enable_result["errors"],
|
||||
}
|
||||
|
||||
@@ -64,3 +64,17 @@ def backups_dir(state_root: Path, instance: str) -> Path:
|
||||
path = instance_state_dir(state_root, instance) / "backups"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def known_good_state_path(state_root: Path, instance: str) -> Path:
|
||||
return instance_state_dir(state_root, instance) / "known-good.json"
|
||||
|
||||
|
||||
def load_known_good_state(state_root: Path, instance: str) -> dict[str, Any]:
|
||||
return read_json(known_good_state_path(state_root, instance), {})
|
||||
|
||||
|
||||
def save_known_good_state(state_root: Path, instance: str, state: dict[str, Any]) -> None:
|
||||
state.setdefault("instance", instance)
|
||||
state["savedAt"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
atomic_write_json(known_good_state_path(state_root, instance), state)
|
||||
|
||||
@@ -13,9 +13,14 @@ from textual.widgets import DataTable, Footer, Header, Static
|
||||
from .bsipa import BSIPA_PLUGIN_ID
|
||||
from .installer import disable_plugin
|
||||
from .models import load_lockfile, load_registry
|
||||
from .operations import enable_disabled_plugin, enable_disabled_plugins
|
||||
from .operations import (
|
||||
enable_disabled_plugin,
|
||||
enable_disabled_plugins,
|
||||
restore_known_good_set,
|
||||
save_known_good_set,
|
||||
)
|
||||
from .reports import installed_plugins_report
|
||||
from .state import load_installed_state
|
||||
from .state import load_installed_state, load_known_good_state
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -44,6 +49,8 @@ class PluginHelperTui(App[int]):
|
||||
Binding("space", "toggle_plugin", "Toggle", priority=True),
|
||||
Binding("d", "disable_all_plugins", "Disable all", priority=True),
|
||||
Binding("e", "enable_all_plugins", "Enable all", priority=True),
|
||||
Binding("s", "save_known_good", "Save known-good", priority=True),
|
||||
Binding("g", "restore_known_good", "Restore known-good", priority=True),
|
||||
Binding("r", "refresh", "Refresh"),
|
||||
Binding("b", "back", "Back"),
|
||||
Binding("q", "quit", "Quit"),
|
||||
@@ -229,6 +236,53 @@ class PluginHelperTui(App[int]):
|
||||
self._set_bulk_status("Enabled", changed, errors)
|
||||
self._show_plugins(preserve_status=True)
|
||||
|
||||
def action_save_known_good(self) -> None:
|
||||
if self._busy or self.mode != "plugins" or self.selected_installation is None:
|
||||
return
|
||||
target = self.selected_installation
|
||||
known_good = save_known_good_set(instance=target.instance_name, state_root=target.state_root)
|
||||
count = len(known_good["pluginIds"])
|
||||
self._set_status(f"Saved known-good set: {count} enabled plugins.")
|
||||
|
||||
async def action_restore_known_good(self) -> None:
|
||||
if self._busy or self.mode != "plugins" or self.selected_installation is None:
|
||||
return
|
||||
target = self.selected_installation
|
||||
if not load_known_good_state(target.state_root, target.instance_name):
|
||||
self._set_status("No known-good set saved yet. Press s to save the current selection.")
|
||||
return
|
||||
self._busy = True
|
||||
self._set_status("Restoring known-good set...")
|
||||
try:
|
||||
result = await asyncio.to_thread(
|
||||
restore_known_good_set,
|
||||
instance=target.instance_name,
|
||||
instance_path=target.instance_path,
|
||||
state_root=target.state_root,
|
||||
repo=self.repo_root,
|
||||
progress=self._operation_progress,
|
||||
)
|
||||
except Exception as exc:
|
||||
self._set_status(f"Could not restore known-good set: {exc}")
|
||||
return
|
||||
finally:
|
||||
self._busy = False
|
||||
errors = [f"{item['plugin']}: {item['error']}" for item in result["disableErrors"]] + [
|
||||
f"{item['plugin']}: {item['error']}" for item in result["enableErrors"]
|
||||
]
|
||||
if errors:
|
||||
preview = "; ".join(errors[:3])
|
||||
suffix = f"; {len(errors) - 3} more" if len(errors) > 3 else ""
|
||||
self._set_status(
|
||||
f"Restored known-good set: disabled {len(result['disabled'])}, "
|
||||
f"enabled {len(result['enabled'])}; {len(errors)} failed: {preview}{suffix}"
|
||||
)
|
||||
else:
|
||||
self._set_status(
|
||||
f"Restored known-good set: disabled {len(result['disabled'])}, enabled {len(result['enabled'])}."
|
||||
)
|
||||
self._show_plugins(preserve_status=True)
|
||||
|
||||
def _operation_progress(self, message: str) -> None:
|
||||
self.call_from_thread(self._set_status, message)
|
||||
|
||||
@@ -294,7 +348,8 @@ class PluginHelperTui(App[int]):
|
||||
if self.plugin_rows:
|
||||
back_hint = "" if len(self.choices) == 1 else " b returns to installations."
|
||||
self._set_status(
|
||||
f"Space toggles selected. d disables all. e enables all.{back_hint}"
|
||||
"Space toggles selected. d disables all. e enables all. "
|
||||
f"s saves known-good. g restores known-good.{back_hint}"
|
||||
)
|
||||
else:
|
||||
self._set_status("No version-locked plugins for this installation.")
|
||||
|
||||
@@ -861,6 +861,97 @@ sha256 = "{sha256_file(asset)}"
|
||||
self.assertIn("example", updated["plugins"])
|
||||
self.assertNotIn("example", updated["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)
|
||||
@@ -1892,6 +1983,51 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
|
||||
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")
|
||||
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")
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user