Show version-locked plugins in menu

This commit is contained in:
pleb
2026-07-05 15:01:58 -07:00
parent 1858f76766
commit 53ad6e2c0d
10 changed files with 212 additions and 83 deletions
+10 -10
View File
@@ -6,10 +6,10 @@ The first implementation focuses on safe local workflows:
- discover BSManager instances - discover BSManager instances
- scan existing `Plugins/` and `Libs/` files - scan existing `Plugins/` and `Libs/` files
- read checked-in registry and per-version lockfiles - read the checked-in plugin catalog and per-version locks
- generate a machine-readable install plan from local release assets - generate a machine-readable install plan from local release assets
- apply exactly that plan and record install state - apply exactly that plan and record managed install state
- uninstall only files recorded in install state - uninstall only files recorded in managed install state
Default BSManager instance root: Default BSManager instance root:
@@ -125,12 +125,12 @@ That is equivalent to `PYTHONPATH=src python -m plugin_helper menu` when run
from an interactive terminal. from an interactive terminal.
The menu reads `plugin-helper.local.toml` when present, shows each discovered The menu reads `plugin-helper.local.toml` when present, shows each discovered
Beat Saber install with its resolved state directory, and lets you toggle Beat Saber install with its resolved state directory, and lists plugins from
managed plugins with arrow keys and Space. In the plugin table, use `d` to that instance's version lock. Managed install state decides whether each row is
disable all currently enabled managed plugins (except `bsipa`) and `e` to enable all currently checked or unchecked. In the plugin table, use `d` to disable all
currently disabled managed plugins. With a single Beat Saber installation, the currently enabled plugins (except `bsipa`) and `e` to enable all currently
menu opens the plugin table directly. Re-enabling a plugin auto-bootstraps BSIPA when unchecked plugins. With a single Beat Saber installation, the menu opens the
needed. plugin table directly. Re-enabling a plugin auto-bootstraps BSIPA when needed.
The individual subcommands are mostly for automation and debugging. If you use The individual subcommands are mostly for automation and debugging. If you use
them, pass `--state-dir` directly only when you intentionally want to override them, pass `--state-dir` directly only when you intentionally want to override
@@ -216,7 +216,7 @@ custom content or non-obvious user choices rather than pure cache data.
Windows or through Proton on Linux), and records every bootstrap-relevant Windows or through Proton on Linux), and records every bootstrap-relevant
file under root `IPA.exe*`, `winhttp.dll`, `Libs/`, and `IPA/`, including file under root `IPA.exe*`, `winhttp.dll`, `Libs/`, and `IPA/`, including
backups created during patching. backups created during patching.
- If an instance lockfile includes `bsipa`, ordinary plugin plans require a - If an instance version lock includes `bsipa`, ordinary plugin plans require a
recorded bootstrap state plus a `Logs/_latest.log` that shows BSIPA startup. recorded bootstrap state plus a `Logs/_latest.log` that shows BSIPA startup.
Use `bootstrap-check` before planning a batch when you want a quick gate. Use `bootstrap-check` before planning a batch when you want a quick gate.
- Use [`docs/SMOKETEST.md`](docs/SMOKETEST.md) after installing or removing a - Use [`docs/SMOKETEST.md`](docs/SMOKETEST.md) after installing or removing a
+6 -3
View File
@@ -18,6 +18,9 @@ from .scanner import scan_bootstrap_files
from .state import bootstrap_state_path, plugin_downloads_dir, save_bootstrap_state from .state import bootstrap_state_path, plugin_downloads_dir, save_bootstrap_state
DEFAULT_IPA_TIMEOUT_SECONDS = 30
def _now_iso() -> str: def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
@@ -112,7 +115,7 @@ def _fetch_github_release_asset(locked: LockedPlugin, destination: Path) -> dict
def fetch_locked_bsipa_archive(lockfile: Lockfile, state_root: Path) -> dict[str, Any]: def fetch_locked_bsipa_archive(lockfile: Lockfile, state_root: Path) -> dict[str, Any]:
locked = next((plugin for plugin in lockfile.plugins if plugin.id == BSIPA_PLUGIN_ID), None) locked = next((plugin for plugin in lockfile.plugins if plugin.id == BSIPA_PLUGIN_ID), None)
if not locked: if not locked:
raise ValueError("lockfile does not include a bsipa entry") raise ValueError("version lock does not include a bsipa entry")
if not locked.asset: if not locked.asset:
raise ValueError("locked BSIPA entry has no asset") raise ValueError("locked BSIPA entry has no asset")
destination = plugin_downloads_dir(state_root, lockfile.instance, BSIPA_PLUGIN_ID) / locked.asset destination = plugin_downloads_dir(state_root, lockfile.instance, BSIPA_PLUGIN_ID) / locked.asset
@@ -210,7 +213,7 @@ def run_bootstrap(
proton: Path | None = None, proton: Path | None = None,
native: bool | None = None, native: bool | None = None,
progress: Callable[[str], None] | None = None, progress: Callable[[str], None] | None = None,
ipa_timeout_seconds: int = 120, ipa_timeout_seconds: int = DEFAULT_IPA_TIMEOUT_SECONDS,
) -> dict[str, Any]: ) -> dict[str, Any]:
tell = progress or (lambda _message: None) tell = progress or (lambda _message: None)
use_native = is_windows() if native is None else native use_native = is_windows() if native is None else native
@@ -311,7 +314,7 @@ def ensure_healthy_bootstrap(
proton: Path | None = None, proton: Path | None = None,
native: bool | None = None, native: bool | None = None,
progress: Callable[[str], None] | None = None, progress: Callable[[str], None] | None = None,
ipa_timeout_seconds: int = 120, ipa_timeout_seconds: int = DEFAULT_IPA_TIMEOUT_SECONDS,
) -> None: ) -> None:
if not planning_requires_bootstrap(lockfile.plugins, selected_ids): if not planning_requires_bootstrap(lockfile.plugins, selected_ids):
return return
+1 -1
View File
@@ -24,7 +24,7 @@ def check_lock(
messages: list[dict[str, str]] = [] messages: list[dict[str, str]] = []
if not registry_plugin: if not registry_plugin:
messages.append({"level": "warning", "message": "missing registry entry"}) messages.append({"level": "warning", "message": "missing catalog entry"})
if strategy == "manual": if strategy == "manual":
messages.append({"level": "error", "message": "manual install strategy cannot be applied"}) messages.append({"level": "error", "message": "manual install strategy cannot be applied"})
if not locked.asset: if not locked.asset:
+5 -5
View File
@@ -95,14 +95,14 @@ def build_parser() -> argparse.ArgumentParser:
state = subcommands.add_parser( state = subcommands.add_parser(
"state", "state",
help="Show recorded plugin-helper install state", help="Show recorded plugin-helper managed install state",
parents=[_common_parent()], parents=[_common_parent()],
) )
state.add_argument("--instance", required=True) state.add_argument("--instance", required=True)
installed = subcommands.add_parser( installed = subcommands.add_parser(
"installed", "installed",
help="List plugins installed by plugin-helper with locked release versions", help="List plugins from the version lock with managed install state",
parents=[_common_parent()], parents=[_common_parent()],
) )
installed.add_argument("--instance", required=True) installed.add_argument("--instance", required=True)
@@ -112,7 +112,7 @@ def build_parser() -> argparse.ArgumentParser:
check = subcommands.add_parser( check = subcommands.add_parser(
"check", "check",
help="Validate local registry, lockfile, and release asset readiness", help="Validate local catalog, version lock, and release asset readiness",
parents=[_common_parent()], parents=[_common_parent()],
) )
check.add_argument("--instance", required=True) check.add_argument("--instance", required=True)
@@ -154,7 +154,7 @@ def build_parser() -> argparse.ArgumentParser:
plan = subcommands.add_parser( plan = subcommands.add_parser(
"plan", "plan",
help="Create a dry-run install plan from registry and lockfile", help="Create a dry-run install plan from the catalog and version lock",
parents=[_common_parent()], parents=[_common_parent()],
) )
plan.add_argument("--instance", required=True) plan.add_argument("--instance", required=True)
@@ -189,7 +189,7 @@ def build_parser() -> argparse.ArgumentParser:
enable = subcommands.add_parser( enable = subcommands.add_parser(
"enable", "enable",
help="Reinstall a disabled locked plugin from local assets", help="Enable a version-locked plugin from local assets",
parents=[_common_parent()], parents=[_common_parent()],
) )
enable.add_argument("--instance", required=True) enable.add_argument("--instance", required=True)
+2 -2
View File
@@ -88,7 +88,7 @@ def uninstall_plugin(instance: str, instance_path: Path, state_root: Path, plugi
installed_state = load_installed_state(state_root, instance) installed_state = load_installed_state(state_root, instance)
plugin_state = installed_state.get("plugins", {}).get(plugin_id) plugin_state = installed_state.get("plugins", {}).get(plugin_id)
if not plugin_state: if not plugin_state:
raise KeyError(f"plugin is not recorded in install state: {plugin_id}") raise KeyError(f"plugin is not recorded in managed install state: {plugin_id}")
removed: list[str] = [] removed: list[str] = []
skipped: list[dict[str, str]] = [] skipped: list[dict[str, str]] = []
@@ -119,7 +119,7 @@ def disable_plugin(instance: str, instance_path: Path, state_root: Path, plugin_
if not plugin_state: if not plugin_state:
if plugin_id in installed_state.get("disabledPlugins", {}): if plugin_id in installed_state.get("disabledPlugins", {}):
raise KeyError(f"plugin is already disabled: {plugin_id}") raise KeyError(f"plugin is already disabled: {plugin_id}")
raise KeyError(f"plugin is not recorded in install state: {plugin_id}") raise KeyError(f"plugin is not recorded in managed install state: {plugin_id}")
removed: list[str] = [] removed: list[str] = []
skipped: list[dict[str, str]] = [] skipped: list[dict[str, str]] = []
+59 -41
View File
@@ -41,8 +41,8 @@ def enable_disabled_plugin(
progress: Callable[[str], None] | None = None, progress: Callable[[str], None] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
installed_state = load_installed_state(state_root, instance) installed_state = load_installed_state(state_root, instance)
if plugin_id not in installed_state.get("disabledPlugins", {}): if plugin_id in installed_state.get("plugins", {}):
raise KeyError(f"plugin is not recorded as disabled: {plugin_id}") raise KeyError(f"plugin is already enabled: {plugin_id}")
root, registry_path, _lock_path, loaded_lockfile, loaded_registry = _resolve_paths( root, registry_path, _lock_path, loaded_lockfile, loaded_registry = _resolve_paths(
instance=instance, instance=instance,
@@ -51,7 +51,21 @@ def enable_disabled_plugin(
repo=repo, repo=repo,
) )
if not any(plugin.id == plugin_id for plugin in loaded_lockfile.plugins): if not any(plugin.id == plugin_id for plugin in loaded_lockfile.plugins):
raise KeyError(f"plugin is disabled but not locked for this instance: {plugin_id}") raise KeyError(f"plugin is not locked for this instance: {plugin_id}")
tell = progress or (lambda _message: None)
tell(f"Planning {plugin_id} from local assets")
plan, path = create_plan(
instance=instance,
instance_path=instance_path,
beat_saber_version=loaded_lockfile.beat_saber_version,
registry=loaded_registry,
lockfile=loaded_lockfile,
state_root=state_root,
repo_root=root,
selected={plugin_id},
require_bootstrap=False,
)
ensure_healthy_bootstrap( ensure_healthy_bootstrap(
instance=instance, instance=instance,
@@ -65,16 +79,7 @@ def enable_disabled_plugin(
progress=progress, progress=progress,
) )
plan, path = create_plan( tell(f"Applying {plugin_id}")
instance=instance,
instance_path=instance_path,
beat_saber_version=loaded_lockfile.beat_saber_version,
registry=loaded_registry,
lockfile=loaded_lockfile,
state_root=state_root,
repo_root=root,
selected={plugin_id},
)
result = apply_plan(plan, state_root) result = apply_plan(plan, state_root)
return {"planPath": str(path), **result} return {"planPath": str(path), **result}
@@ -100,8 +105,38 @@ def enable_disabled_plugins(
repo=repo, repo=repo,
) )
installed_state = load_installed_state(state_root, instance) installed_state = load_installed_state(state_root, instance)
disabled_plugins = installed_state.get("disabledPlugins", {}) enabled_plugins = installed_state.get("plugins", {})
selected_ids = set(plugin_ids) locked_ids = {plugin.id for plugin in loaded_lockfile.plugins}
enabled: list[dict[str, Any]] = []
errors: list[dict[str, str]] = []
plannable_ids: list[str] = []
for plugin_id in plugin_ids:
if plugin_id in enabled_plugins:
errors.append({"plugin": plugin_id, "error": f"plugin is already enabled: {plugin_id}"})
continue
if plugin_id not in locked_ids:
errors.append({"plugin": plugin_id, "error": f"plugin is not locked for this instance: {plugin_id}"})
continue
plannable_ids.append(plugin_id)
if not plannable_ids:
return {"enabled": enabled, "errors": errors}
selected_ids = set(plannable_ids)
tell = progress or (lambda _message: None)
tell(f"Planning {len(plannable_ids)} plugins from local assets")
batch_plan, batch_path = create_plan(
instance=instance,
instance_path=instance_path,
beat_saber_version=loaded_lockfile.beat_saber_version,
registry=loaded_registry,
lockfile=loaded_lockfile,
state_root=state_root,
repo_root=root,
selected=selected_ids,
require_bootstrap=False,
)
ensure_healthy_bootstrap( ensure_healthy_bootstrap(
instance=instance, instance=instance,
@@ -115,31 +150,14 @@ def enable_disabled_plugins(
progress=progress, progress=progress,
) )
enabled: list[dict[str, Any]] = [] tell(f"Applying {len(plannable_ids)} plugins")
errors: list[dict[str, str]] = [] result = apply_plan(batch_plan, state_root)
for plugin_id in plugin_ids: applied_by_plugin: dict[str, int] = {}
if plugin_id not in disabled_plugins: for item in result["applied"]:
errors.append({"plugin": plugin_id, "error": f"plugin is not recorded as disabled: {plugin_id}"}) plugin_id = item["plugin"]
continue applied_by_plugin[plugin_id] = applied_by_plugin.get(plugin_id, 0) + 1
if not any(plugin.id == plugin_id for plugin in loaded_lockfile.plugins):
errors.append( for plugin_id in plannable_ids:
{"plugin": plugin_id, "error": f"plugin is disabled but not locked for this instance: {plugin_id}"} enabled.append({"plugin": plugin_id, "planPath": str(batch_path), "applied": applied_by_plugin.get(plugin_id, 0)})
)
continue
try:
plan, path = create_plan(
instance=instance,
instance_path=instance_path,
beat_saber_version=loaded_lockfile.beat_saber_version,
registry=loaded_registry,
lockfile=loaded_lockfile,
state_root=state_root,
repo_root=root,
selected={plugin_id},
)
result = apply_plan(plan, state_root)
enabled.append({"plugin": plugin_id, "planPath": str(path), "applied": len(result["applied"])})
except Exception as exc:
errors.append({"plugin": plugin_id, "error": str(exc)})
return {"enabled": enabled, "errors": errors} return {"enabled": enabled, "errors": errors}
+2 -2
View File
@@ -118,11 +118,11 @@ def create_plan(
if strategy not in VALID_STRATEGIES: if strategy not in VALID_STRATEGIES:
raise ValueError(f"{locked.id}: invalid install strategy: {strategy}") raise ValueError(f"{locked.id}: invalid install strategy: {strategy}")
if strategy == "manual": if strategy == "manual":
raise ValueError(f"{locked.id}: install_strategy is manual; add a concrete registry rule first") raise ValueError(f"{locked.id}: install_strategy is manual; add a concrete catalog rule first")
if not locked.asset: if not locked.asset:
raise ValueError(f"{locked.id}: lock entry has no asset") raise ValueError(f"{locked.id}: lock entry has no asset")
if registry_plugin and not _asset_matches_patterns(Path(locked.asset).name, registry_plugin.asset_patterns): if registry_plugin and not _asset_matches_patterns(Path(locked.asset).name, registry_plugin.asset_patterns):
warnings.append(f"{locked.id}: asset does not match registry patterns") warnings.append(f"{locked.id}: asset does not match catalog patterns")
asset_path = _find_asset(locked.asset, state_root, instance, repo_root, locked.id) asset_path = _find_asset(locked.asset, state_root, instance, repo_root, locked.id)
if not asset_path: if not asset_path:
+19 -10
View File
@@ -13,15 +13,24 @@ def installed_plugins_report(
) -> dict[str, Any]: ) -> dict[str, Any]:
locked_by_id = {plugin.id: plugin for plugin in lockfile.plugins} locked_by_id = {plugin.id: plugin for plugin in lockfile.plugins}
plugins: list[dict[str, Any]] = [] plugins: list[dict[str, Any]] = []
state_plugins = [ enabled_plugins = installed_state.get("plugins", {})
(plugin_id, plugin_state, "enabled") disabled_plugins = installed_state.get("disabledPlugins", {})
for plugin_id, plugin_state in installed_state.get("plugins", {}).items() plugin_ids = list(locked_by_id)
] plugin_ids.extend(
state_plugins.extend( sorted(
(plugin_id, plugin_state, "disabled") plugin_id
for plugin_id, plugin_state in installed_state.get("disabledPlugins", {}).items() for plugin_id in set(enabled_plugins) | set(disabled_plugins)
if plugin_id not in locked_by_id
)
) )
for plugin_id, plugin_state, status in sorted(state_plugins):
for plugin_id in plugin_ids:
if plugin_id in enabled_plugins:
plugin_state = enabled_plugins[plugin_id]
status = "enabled"
else:
plugin_state = disabled_plugins.get(plugin_id, {})
status = "disabled"
registry_plugin = registry.get(plugin_id) registry_plugin = registry.get(plugin_id)
locked = locked_by_id.get(plugin_id) locked = locked_by_id.get(plugin_id)
files = plugin_state.get("files", []) files = plugin_state.get("files", [])
@@ -50,9 +59,9 @@ def installed_plugins_report(
def print_installed_plugins(report: dict[str, Any]) -> None: def print_installed_plugins(report: dict[str, Any]) -> None:
plugins = report["plugins"] plugins = report["plugins"]
print(f"{report['instance']} managed plugins ({len(plugins)})") print(f"{report['instance']} version-locked plugins ({len(plugins)})")
if not plugins: if not plugins:
print("No plugins have been installed by plugin-helper yet.") print("No plugins are listed in this instance's version lock.")
return return
headers = ("Plugin", "Status", "Version", "Asset", "Files", "Installed") headers = ("Plugin", "Status", "Version", "Asset", "Files", "Installed")
+5 -5
View File
@@ -140,7 +140,7 @@ class PluginHelperTui(App[int]):
state_root=target.state_root, state_root=target.state_root,
plugin_id=plugin_id, plugin_id=plugin_id,
repo=self.repo_root, repo=self.repo_root,
progress=self._bootstrap_progress, progress=self._operation_progress,
) )
self._set_status(f"Enabled {plugin_id}; applied {len(result['applied'])} files.") self._set_status(f"Enabled {plugin_id}; applied {len(result['applied'])} files.")
else: else:
@@ -217,7 +217,7 @@ class PluginHelperTui(App[int]):
state_root=target.state_root, state_root=target.state_root,
plugin_ids=plugin_ids, plugin_ids=plugin_ids,
repo=self.repo_root, repo=self.repo_root,
progress=self._bootstrap_progress, progress=self._operation_progress,
) )
except Exception as exc: except Exception as exc:
self._set_status(f"Could not enable plugins: {exc}") self._set_status(f"Could not enable plugins: {exc}")
@@ -229,8 +229,8 @@ class PluginHelperTui(App[int]):
self._set_bulk_status("Enabled", changed, errors) self._set_bulk_status("Enabled", changed, errors)
self._show_plugins(preserve_status=True) self._show_plugins(preserve_status=True)
def _bootstrap_progress(self, message: str) -> None: def _operation_progress(self, message: str) -> None:
self.call_from_thread(self._set_status, f"Bootstrapping: {message}") self.call_from_thread(self._set_status, message)
def _show_installations(self) -> None: def _show_installations(self) -> None:
self.mode = "installations" self.mode = "installations"
@@ -297,7 +297,7 @@ class PluginHelperTui(App[int]):
f"Space toggles selected. d disables all. e enables all.{back_hint}" f"Space toggles selected. d disables all. e enables all.{back_hint}"
) )
else: else:
self._set_status("No managed plugins recorded for this installation.") self._set_status("No version-locked plugins for this installation.")
def _cursor_index(self, row_count: int) -> int | None: def _cursor_index(self, row_count: int) -> int | None:
table = self.query_one(DataTable) table = self.query_one(DataTable)
+103 -4
View File
@@ -547,7 +547,7 @@ state_dir = ".state"
check_health.assert_not_called() check_health.assert_not_called()
run_bootstrap.assert_not_called() run_bootstrap.assert_not_called()
def test_enable_disabled_plugin_bootstraps_before_plan(self) -> None: def test_enable_disabled_plugin_plans_before_bootstrap(self) -> None:
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp) work = Path(tmp)
instance = work / "instances" / "1.44.1" instance = work / "instances" / "1.44.1"
@@ -628,7 +628,55 @@ install_strategy = "dll-to-plugins"
lockfile=str(lock_path), lockfile=str(lock_path),
repo=work, repo=work,
) )
self.assertEqual(call_order, ["ensure", "plan", "apply"]) 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: def test_plan_apply_and_uninstall_dll(self) -> None:
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
@@ -1399,6 +1447,33 @@ sha256 = "{sha256_file(asset)}"
self.assertEqual(report["plugins"][0]["asset"], "Example.dll") self.assertEqual(report["plugins"][0]["asset"], "Example.dll")
self.assertEqual(report["plugins"][0]["fileCount"], 1) self.assertEqual(report["plugins"][0]["fileCount"], 1)
def test_installed_plugins_report_starts_from_version_lock(self) -> None:
registry = Registry(
{
"alpha": RegistryPlugin(id="alpha", name="Alpha", repo="owner/alpha"),
"beta": RegistryPlugin(id="beta", name="Beta", repo="owner/beta"),
}
)
lockfile = Lockfile(
beat_saber_version="1.40.8",
instance="1.40.8",
plugins=(
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"])
self.assertEqual([plugin["status"] for plugin in report["plugins"]], ["disabled", "disabled"])
self.assertEqual([plugin["fileCount"] for plugin in report["plugins"]], [0, 0])
self.assertEqual(report["plugins"][1]["version"], "v2.0.0")
def test_update_check_reports_current_matching_asset(self) -> None: def test_update_check_reports_current_matching_asset(self) -> None:
registry = Registry( registry = Registry(
{ {
@@ -1535,7 +1610,13 @@ sha256 = "{sha256_file(asset)}"
self.assertEqual(result["plugins"][0]["latestAssetSha256"], "new") self.assertEqual(result["plugins"][0]["latestAssetSha256"], "new")
def _make_tui_fixture(root: Path, *, disabled: bool = False, hash_mismatch: bool = False) -> tuple[PluginHelperTui, Path, Path]: def _make_tui_fixture(
root: Path,
*,
disabled: bool = False,
hash_mismatch: bool = False,
recorded: bool = True,
) -> tuple[PluginHelperTui, Path, Path]:
repo = root / "repo" repo = root / "repo"
instance_root = root / "instances" instance_root = root / "instances"
instance = instance_root / "1.40.8" instance = instance_root / "1.40.8"
@@ -1574,7 +1655,10 @@ sha256 = "{sha256_file(asset)}"
) )
target = instance / "Plugins" / "Example.dll" target = instance / "Plugins" / "Example.dll"
if disabled: if not recorded:
plugins = {}
disabled_plugins = {}
elif disabled:
plugins = {} plugins = {}
disabled_plugins = { disabled_plugins = {
"example": { "example": {
@@ -1762,6 +1846,21 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
self.assertIn("example", updated["plugins"]) self.assertIn("example", updated["plugins"])
self.assertNotIn("example", updated["disabledPlugins"]) 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")
self.assertIn("example", updated["plugins"])
self.assertNotIn("example", updated.get("disabledPlugins", {}))
async def test_disable_all_disables_enabled_plugins(self) -> None: async def test_disable_all_disables_enabled_plugins(self) -> None:
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
app, instance, state = _make_tui_fixture(Path(tmp)) app, instance, state = _make_tui_fixture(Path(tmp))