feat: native disable/enable fallback and package whitelist; bump to 1.8.0
Publish to Comfy registry / Publish Custom Node to registry (push) Waiting to run
Publish to Comfy registry / Publish Custom Node to registry (push) Waiting to run
Disable/enable no longer require ComfyUI Manager: - New pack_fs.py moves packs in/out of custom_nodes/.disabled/ (no import, delete, or re-clone). Fallback for hand-cloned packs, loose single-file nodes, or when Manager is absent. enable strips the @version suffix so packs restore as clean, importable dir names. - Routes: native-disable, native-enable, disabled-packs. - Frontend routes each disable per-pack (Manager queue vs native move), and shows an Enable button on recoverable packs in the Uninstalled tier. The restart banner degrades to a manual-restart notice when no Manager exists. Whitelist (packages-only): a star toggle protects a pack — pulled into its own pinned group, no Disable button, skipped by the 7-day trial auto-disable. - New whitelist_packages table; whitelisted flag on package stats. - Routes: whitelist, whitelist/add, whitelist/remove. Tests: test_pack_fs.py, test_whitelist.py (60 passing). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
import pack_fs
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def custom_nodes(tmp_path):
|
||||
"""A custom_nodes root wired into the mocked folder_paths."""
|
||||
import folder_paths
|
||||
|
||||
root = tmp_path / "custom_nodes"
|
||||
root.mkdir()
|
||||
folder_paths.get_folder_paths.return_value = [str(root)]
|
||||
return root
|
||||
|
||||
|
||||
def _make_pack(root, name):
|
||||
pack = root / name
|
||||
pack.mkdir()
|
||||
(pack / "__init__.py").write_text("NODE_CLASS_MAPPINGS = {}\n")
|
||||
return pack
|
||||
|
||||
|
||||
# --- find_active_pack_path ------------------------------------------------
|
||||
|
||||
def test_find_active_dir(custom_nodes):
|
||||
_make_pack(custom_nodes, "MyPack")
|
||||
found = pack_fs.find_active_pack_path("MyPack")
|
||||
assert found == os.path.normpath(str(custom_nodes / "MyPack"))
|
||||
|
||||
|
||||
def test_find_active_case_insensitive(custom_nodes):
|
||||
_make_pack(custom_nodes, "MyPack")
|
||||
assert pack_fs.find_active_pack_path("mypack") is not None
|
||||
|
||||
|
||||
def test_find_active_single_py_file(custom_nodes):
|
||||
(custom_nodes / "loose_node.py").write_text("NODE_CLASS_MAPPINGS = {}\n")
|
||||
found = pack_fs.find_active_pack_path("loose_node")
|
||||
assert found == os.path.normpath(str(custom_nodes / "loose_node.py"))
|
||||
|
||||
|
||||
def test_find_active_ignores_disabled_dir(custom_nodes):
|
||||
(custom_nodes / ".disabled").mkdir()
|
||||
_make_pack(custom_nodes / ".disabled", "MyPack")
|
||||
assert pack_fs.find_active_pack_path("MyPack") is None
|
||||
|
||||
|
||||
def test_find_active_rejects_path_traversal(custom_nodes):
|
||||
assert pack_fs.find_active_pack_path("../evil") is None
|
||||
assert pack_fs.find_active_pack_path("a/b") is None
|
||||
assert pack_fs.find_active_pack_path("") is None
|
||||
|
||||
|
||||
# --- disable_pack_native --------------------------------------------------
|
||||
|
||||
def test_disable_moves_dir_into_disabled(custom_nodes):
|
||||
_make_pack(custom_nodes, "MyPack")
|
||||
ok, msg = pack_fs.disable_pack_native("MyPack")
|
||||
assert ok, msg
|
||||
assert not (custom_nodes / "MyPack").exists()
|
||||
assert (custom_nodes / ".disabled" / "MyPack" / "__init__.py").exists()
|
||||
|
||||
|
||||
def test_disable_moves_single_py_file(custom_nodes):
|
||||
(custom_nodes / "loose_node.py").write_text("x = 1\n")
|
||||
ok, msg = pack_fs.disable_pack_native("loose_node")
|
||||
assert ok, msg
|
||||
assert not (custom_nodes / "loose_node.py").exists()
|
||||
assert (custom_nodes / ".disabled" / "loose_node.py").exists()
|
||||
|
||||
|
||||
def test_disable_missing_pack_fails(custom_nodes):
|
||||
ok, msg = pack_fs.disable_pack_native("Ghost")
|
||||
assert not ok
|
||||
assert "not found" in msg
|
||||
|
||||
|
||||
def test_disable_collision_fails(custom_nodes):
|
||||
_make_pack(custom_nodes, "MyPack")
|
||||
(custom_nodes / ".disabled").mkdir()
|
||||
(custom_nodes / ".disabled" / "MyPack").mkdir() # pre-existing disabled copy
|
||||
ok, msg = pack_fs.disable_pack_native("MyPack")
|
||||
assert not ok
|
||||
assert "already exists" in msg
|
||||
# original left untouched
|
||||
assert (custom_nodes / "MyPack").exists()
|
||||
|
||||
|
||||
# --- enable_pack_native ---------------------------------------------------
|
||||
|
||||
def test_enable_moves_back(custom_nodes):
|
||||
_make_pack(custom_nodes, "MyPack")
|
||||
assert pack_fs.disable_pack_native("MyPack")[0]
|
||||
ok, msg = pack_fs.enable_pack_native("MyPack")
|
||||
assert ok, msg
|
||||
assert (custom_nodes / "MyPack" / "__init__.py").exists()
|
||||
assert not (custom_nodes / ".disabled" / "MyPack").exists()
|
||||
|
||||
|
||||
def test_enable_missing_fails(custom_nodes):
|
||||
ok, msg = pack_fs.enable_pack_native("Ghost")
|
||||
assert not ok
|
||||
assert "not found" in msg
|
||||
|
||||
|
||||
def test_enable_strips_version_suffix(custom_nodes):
|
||||
# A Manager-disabled pack on disk carries an @version suffix; enabling should
|
||||
# restore it as a clean, importable directory name.
|
||||
ddir = custom_nodes / ".disabled"
|
||||
ddir.mkdir()
|
||||
pack = ddir / "ComfyMath@nightly"
|
||||
pack.mkdir()
|
||||
(pack / "__init__.py").write_text("NODE_CLASS_MAPPINGS = {}\n")
|
||||
|
||||
ok, msg = pack_fs.enable_pack_native("ComfyMath")
|
||||
assert ok, msg
|
||||
assert (custom_nodes / "ComfyMath" / "__init__.py").exists()
|
||||
assert not (custom_nodes / "ComfyMath@nightly").exists()
|
||||
assert not (ddir / "ComfyMath@nightly").exists()
|
||||
|
||||
|
||||
def test_disable_then_enable_roundtrip_py_file(custom_nodes):
|
||||
(custom_nodes / "loose_node.py").write_text("x = 1\n")
|
||||
assert pack_fs.disable_pack_native("loose_node")[0]
|
||||
assert pack_fs.enable_pack_native("loose_node")[0]
|
||||
assert (custom_nodes / "loose_node.py").exists()
|
||||
|
||||
|
||||
# --- list_disabled_packs --------------------------------------------------
|
||||
|
||||
def test_list_disabled_empty(custom_nodes):
|
||||
assert pack_fs.list_disabled_packs() == set()
|
||||
|
||||
|
||||
def test_list_disabled_strips_version_and_ext(custom_nodes):
|
||||
ddir = custom_nodes / ".disabled"
|
||||
ddir.mkdir()
|
||||
(ddir / "ComfyMath@nightly").mkdir() # Manager-style @version suffix
|
||||
(ddir / "PlainPack").mkdir()
|
||||
(ddir / "loose_node.py").write_text("x = 1\n") # single-file pack
|
||||
assert pack_fs.list_disabled_packs() == {"ComfyMath", "PlainPack", "loose_node"}
|
||||
|
||||
|
||||
def test_list_disabled_reflects_a_native_disable(custom_nodes):
|
||||
_make_pack(custom_nodes, "MyPack")
|
||||
assert pack_fs.disable_pack_native("MyPack")[0]
|
||||
assert "MyPack" in pack_fs.list_disabled_packs()
|
||||
@@ -0,0 +1,72 @@
|
||||
import pytest
|
||||
|
||||
from tracker import UsageTracker
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tracker(tmp_path):
|
||||
return UsageTracker(db_path=str(tmp_path / "test.db"))
|
||||
|
||||
|
||||
def test_whitelist_starts_empty(tracker):
|
||||
assert tracker.get_whitelist() == set()
|
||||
|
||||
|
||||
def test_add_and_get(tracker):
|
||||
tracker.add_to_whitelist("My-Pack")
|
||||
assert tracker.get_whitelist() == {"My-Pack"}
|
||||
|
||||
|
||||
def test_add_is_idempotent(tracker):
|
||||
tracker.add_to_whitelist("My-Pack")
|
||||
tracker.add_to_whitelist("My-Pack")
|
||||
assert tracker.get_whitelist() == {"My-Pack"}
|
||||
|
||||
|
||||
def test_remove(tracker):
|
||||
tracker.add_to_whitelist("My-Pack")
|
||||
tracker.remove_from_whitelist("My-Pack")
|
||||
assert tracker.get_whitelist() == set()
|
||||
|
||||
|
||||
def test_remove_absent_is_noop(tracker):
|
||||
tracker.remove_from_whitelist("Nope") # must not raise
|
||||
assert tracker.get_whitelist() == set()
|
||||
|
||||
|
||||
def test_reset_clears_whitelist(tracker):
|
||||
tracker.add_to_whitelist("My-Pack")
|
||||
tracker.reset()
|
||||
assert tracker.get_whitelist() == set()
|
||||
|
||||
|
||||
class _Mapper:
|
||||
"""Minimal stand-in for NodePackageMapper with a fixed mapping."""
|
||||
|
||||
def __init__(self, mapping):
|
||||
self.mapping = mapping
|
||||
|
||||
def get_package(self, ct):
|
||||
return self.mapping.get(ct, "__unknown__")
|
||||
|
||||
def get_all_packages(self):
|
||||
return set(self.mapping.values()) - {"__builtin__"}
|
||||
|
||||
|
||||
def test_package_stats_flags_whitelisted(tracker):
|
||||
mapper = _Mapper({"NodeA": "Pack-A", "NodeB": "Pack-B"})
|
||||
tracker.record_usage(["NodeA", "NodeB"], mapper)
|
||||
tracker.add_to_whitelist("Pack-A")
|
||||
|
||||
stats = {p["package"]: p for p in tracker.get_package_stats(mapper)}
|
||||
assert stats["Pack-A"]["whitelisted"] is True
|
||||
assert stats["Pack-B"]["whitelisted"] is False
|
||||
|
||||
|
||||
def test_package_stats_whitelist_is_case_insensitive(tracker):
|
||||
mapper = _Mapper({"NodeA": "Pack-A"})
|
||||
tracker.record_usage(["NodeA"], mapper)
|
||||
tracker.add_to_whitelist("pack-a") # different case than the package name
|
||||
|
||||
stats = {p["package"]: p for p in tracker.get_package_stats(mapper)}
|
||||
assert stats["Pack-A"]["whitelisted"] is True
|
||||
Reference in New Issue
Block a user