Files
wehub-resource-sync cddb07a176
docs / deploy (push) Has been cancelled
docs / changes (push) Has been cancelled
docs / check-and-build (push) Has been cancelled
build container image / cpu (push) Has been cancelled
build container image / cuda (push) Has been cancelled
build container image / rocm (push) Has been cancelled
frontend checks / frontend-checks (push) Has been cancelled
frontend tests / frontend-tests (push) Has been cancelled
lfs checks / lfs-check (push) Has been cancelled
python checks / python-checks (push) Has been cancelled
python tests / py3.12: macos-default (push) Has been cancelled
python tests / py3.11: windows-cpu (push) Has been cancelled
python tests / py3.12: windows-cpu (push) Has been cancelled
python tests / py3.11: linux-cpu (push) Has been cancelled
typegen checks / typegen-checks (push) Has been cancelled
uv lock checks / uv-lock-checks (push) Has been cancelled
openapi checks / openapi-checks (push) Has been cancelled
python tests / py3.11: macos-default (push) Has been cancelled
python tests / py3.12: linux-cpu (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:22:06 +08:00

500 lines
21 KiB
Python

"""Tests for the custom nodes router."""
import json
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
from invokeai.app.api.routers.custom_nodes import (
PACK_MANIFEST_FILENAME,
_get_installed_packs,
_import_workflows_from_pack,
_load_node_pack,
_purge_pack_modules,
_read_pack_manifest,
_remove_workflows_by_ids,
_write_pack_manifest,
)
from invokeai.app.invocations.baseinvocation import (
BaseInvocation,
InvocationRegistry,
)
class TestGetInstalledPacks:
"""Tests for _get_installed_packs()."""
def test_returns_empty_when_dir_not_exists(self, tmp_path: Path) -> None:
nonexistent = tmp_path / "nonexistent"
with patch("invokeai.app.api.routers.custom_nodes._get_custom_nodes_path", return_value=nonexistent):
packs = _get_installed_packs()
assert packs == []
def test_returns_empty_when_dir_empty(self, tmp_path: Path) -> None:
with patch("invokeai.app.api.routers.custom_nodes._get_custom_nodes_path", return_value=tmp_path):
packs = _get_installed_packs()
assert packs == []
def test_skips_files(self, tmp_path: Path) -> None:
(tmp_path / "some_file.py").touch()
with patch("invokeai.app.api.routers.custom_nodes._get_custom_nodes_path", return_value=tmp_path):
packs = _get_installed_packs()
assert packs == []
def test_skips_hidden_dirs(self, tmp_path: Path) -> None:
hidden = tmp_path / ".hidden_pack"
hidden.mkdir()
(hidden / "__init__.py").touch()
with patch("invokeai.app.api.routers.custom_nodes._get_custom_nodes_path", return_value=tmp_path):
packs = _get_installed_packs()
assert packs == []
def test_skips_dirs_without_init(self, tmp_path: Path) -> None:
no_init = tmp_path / "no_init_pack"
no_init.mkdir()
with patch("invokeai.app.api.routers.custom_nodes._get_custom_nodes_path", return_value=tmp_path):
packs = _get_installed_packs()
assert packs == []
def test_finds_valid_pack(self, tmp_path: Path) -> None:
pack = tmp_path / "my_pack"
pack.mkdir()
(pack / "__init__.py").touch()
with patch("invokeai.app.api.routers.custom_nodes._get_custom_nodes_path", return_value=tmp_path):
packs = _get_installed_packs()
assert len(packs) == 1
assert packs[0].name == "my_pack"
assert packs[0].path == str(pack)
def test_finds_multiple_packs_sorted(self, tmp_path: Path) -> None:
for name in ["zebra_pack", "alpha_pack", "middle_pack"]:
d = tmp_path / name
d.mkdir()
(d / "__init__.py").touch()
with patch("invokeai.app.api.routers.custom_nodes._get_custom_nodes_path", return_value=tmp_path):
packs = _get_installed_packs()
assert len(packs) == 3
assert [p.name for p in packs] == ["alpha_pack", "middle_pack", "zebra_pack"]
class TestImportWorkflowsFromPack:
"""Tests for _import_workflows_from_pack()."""
@staticmethod
def _mock_service_with_id(workflow_id: str = "new-id") -> MagicMock:
"""Returns a mock workflow_records service whose create() yields a DTO with the given id."""
mock_service = MagicMock()
mock_service.create.return_value = MagicMock(workflow_id=workflow_id)
return mock_service
def test_no_json_files(self, tmp_path: Path) -> None:
(tmp_path / "__init__.py").touch()
(tmp_path / "node.py").write_text("# node code")
with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies"):
ids = _import_workflows_from_pack(tmp_path, "test_pack", owner_user_id="admin")
assert ids == []
def test_skips_non_workflow_json(self, tmp_path: Path) -> None:
# JSON without nodes/edges should be skipped
config = {"setting": "value"}
(tmp_path / "config.json").write_text(json.dumps(config))
with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies"):
ids = _import_workflows_from_pack(tmp_path, "test_pack", owner_user_id="admin")
assert ids == []
def test_imports_valid_workflow(self, tmp_path: Path) -> None:
workflow = {
"name": "Test Workflow",
"author": "Test",
"description": "A test workflow",
"version": "1.0.0",
"contact": "",
"tags": "test",
"notes": "",
"exposedFields": [],
"meta": {"version": "3.0.0", "category": "user"},
"nodes": [{"id": "1", "type": "test_node"}],
"edges": [],
}
workflows_dir = tmp_path / "workflows"
workflows_dir.mkdir()
(workflows_dir / "test_workflow.json").write_text(json.dumps(workflow))
mock_service = self._mock_service_with_id("wf-new-1")
with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies") as mock_deps:
mock_deps.invoker.services.workflow_records = mock_service
ids = _import_workflows_from_pack(tmp_path, "test_pack", owner_user_id="admin")
assert ids == ["wf-new-1"]
mock_service.create.assert_called_once()
# Verify the workflow was tagged
create_kwargs = mock_service.create.call_args.kwargs
assert "node-pack:test_pack" in create_kwargs["workflow"].tags
assert create_kwargs["user_id"] == "admin"
assert create_kwargs["is_public"] is True
def test_adds_pack_tag_to_existing_tags(self, tmp_path: Path) -> None:
workflow = {
"name": "Tagged Workflow",
"author": "Test",
"description": "",
"version": "1.0.0",
"contact": "",
"tags": "existing, tags",
"notes": "",
"exposedFields": [],
"meta": {"version": "3.0.0", "category": "user"},
"nodes": [{"id": "1"}],
"edges": [],
}
(tmp_path / "workflow.json").write_text(json.dumps(workflow))
mock_service = self._mock_service_with_id()
with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies") as mock_deps:
mock_deps.invoker.services.workflow_records = mock_service
ids = _import_workflows_from_pack(tmp_path, "my_pack", owner_user_id="admin")
assert len(ids) == 1
created_workflow = mock_service.create.call_args.kwargs["workflow"]
assert "existing, tags" in created_workflow.tags
assert "node-pack:my_pack" in created_workflow.tags
def test_removes_id_before_import(self, tmp_path: Path) -> None:
workflow = {
"id": "should-be-removed",
"name": "Workflow With ID",
"author": "Test",
"description": "",
"version": "1.0.0",
"contact": "",
"tags": "",
"notes": "",
"exposedFields": [],
"meta": {"version": "3.0.0", "category": "user"},
"nodes": [],
"edges": [],
}
(tmp_path / "workflow.json").write_text(json.dumps(workflow))
mock_service = self._mock_service_with_id()
with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies") as mock_deps:
mock_deps.invoker.services.workflow_records = mock_service
ids = _import_workflows_from_pack(tmp_path, "test_pack", owner_user_id="admin")
assert len(ids) == 1
def test_sets_category_to_user(self, tmp_path: Path) -> None:
workflow = {
"name": "Default-like Workflow",
"author": "Test",
"description": "",
"version": "1.0.0",
"contact": "",
"tags": "",
"notes": "",
"exposedFields": [],
"meta": {"version": "3.0.0", "category": "default"},
"nodes": [],
"edges": [],
}
(tmp_path / "workflow.json").write_text(json.dumps(workflow))
mock_service = self._mock_service_with_id()
with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies") as mock_deps:
mock_deps.invoker.services.workflow_records = mock_service
ids = _import_workflows_from_pack(tmp_path, "test_pack", owner_user_id="admin")
assert len(ids) == 1
created_workflow = mock_service.create.call_args.kwargs["workflow"]
assert created_workflow.meta.category.value == "user"
def test_skips_invalid_json(self, tmp_path: Path) -> None:
(tmp_path / "broken.json").write_text("{invalid json")
with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies"):
ids = _import_workflows_from_pack(tmp_path, "test_pack", owner_user_id="admin")
assert ids == []
def test_finds_workflows_recursively(self, tmp_path: Path) -> None:
workflow = {
"name": "Nested Workflow",
"author": "Test",
"description": "",
"version": "1.0.0",
"contact": "",
"tags": "",
"notes": "",
"exposedFields": [],
"meta": {"version": "3.0.0", "category": "user"},
"nodes": [{"id": "1"}],
"edges": [],
}
nested = tmp_path / "sub" / "dir"
nested.mkdir(parents=True)
(nested / "deep_workflow.json").write_text(json.dumps(workflow))
mock_service = self._mock_service_with_id()
with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies") as mock_deps:
mock_deps.invoker.services.workflow_records = mock_service
ids = _import_workflows_from_pack(tmp_path, "test_pack", owner_user_id="admin")
assert len(ids) == 1
def test_skips_manifest_file(self, tmp_path: Path) -> None:
# A manifest inside the pack must not be mistaken for a workflow during import
(tmp_path / PACK_MANIFEST_FILENAME).write_text(json.dumps({"workflow_ids": ["wf-old"]}))
with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies"):
ids = _import_workflows_from_pack(tmp_path, "test_pack", owner_user_id="admin")
assert ids == []
class TestPackManifest:
"""Tests for _write_pack_manifest() and _read_pack_manifest()."""
def test_write_then_read_roundtrip(self, tmp_path: Path) -> None:
_write_pack_manifest(tmp_path, ["wf-1", "wf-2"])
assert _read_pack_manifest(tmp_path) == ["wf-1", "wf-2"]
def test_read_returns_empty_when_manifest_missing(self, tmp_path: Path) -> None:
assert _read_pack_manifest(tmp_path) == []
def test_read_returns_empty_when_manifest_malformed(self, tmp_path: Path) -> None:
(tmp_path / PACK_MANIFEST_FILENAME).write_text("{not valid json")
assert _read_pack_manifest(tmp_path) == []
def test_read_returns_empty_when_workflow_ids_not_a_list(self, tmp_path: Path) -> None:
(tmp_path / PACK_MANIFEST_FILENAME).write_text(json.dumps({"workflow_ids": "oops"}))
assert _read_pack_manifest(tmp_path) == []
class TestRemoveWorkflowsByIds:
"""Tests for _remove_workflows_by_ids()."""
def test_deletes_only_given_ids(self) -> None:
mock_service = MagicMock()
with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies") as mock_deps:
mock_deps.invoker.services.workflow_records = mock_service
count = _remove_workflows_by_ids(["wf-1", "wf-2"], "test_pack")
assert count == 2
assert mock_service.delete.call_count == 2
deleted_ids = [
call.args[0] if call.args else call.kwargs.get("workflow_id") for call in mock_service.delete.call_args_list
]
assert deleted_ids == ["wf-1", "wf-2"]
def test_returns_zero_when_no_ids(self) -> None:
mock_service = MagicMock()
with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies") as mock_deps:
mock_deps.invoker.services.workflow_records = mock_service
count = _remove_workflows_by_ids([], "empty_pack")
assert count == 0
mock_service.delete.assert_not_called()
def test_continues_on_individual_delete_error(self) -> None:
# One workflow is already gone; the helper still removes the others
mock_service = MagicMock()
mock_service.delete.side_effect = [Exception("not found"), None]
with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies") as mock_deps:
mock_deps.invoker.services.workflow_records = mock_service
count = _remove_workflows_by_ids(["wf-gone", "wf-still-here"], "test_pack")
assert count == 1
def test_preserves_user_workflow_with_colliding_tag(self, tmp_path: Path) -> None:
# Regression test for the data-destruction risk the reviewer raised:
# If a user-authored workflow reuses the 'node-pack:<name>' tag, uninstall
# must NOT delete it. The full flow is exercised here: a manifest records
# only the pack's own workflow IDs, and _remove_workflows_by_ids operates
# only on those — so the user's workflow (whose id is NOT in the manifest)
# is never touched.
pack_wf_id = "pack-wf-1"
user_wf_id = "user-owned-wf-with-same-tag"
_write_pack_manifest(tmp_path, [pack_wf_id])
manifest_ids = _read_pack_manifest(tmp_path)
mock_service = MagicMock()
with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies") as mock_deps:
mock_deps.invoker.services.workflow_records = mock_service
_remove_workflows_by_ids(manifest_ids, "test_pack")
assert mock_service.delete.call_count == 1
deleted_id = (
mock_service.delete.call_args.args[0]
if mock_service.delete.call_args.args
else mock_service.delete.call_args.kwargs.get("workflow_id")
)
assert deleted_id == pack_wf_id
# The user-owned workflow id is never passed to delete()
all_delete_args = [
(call.args[0] if call.args else call.kwargs.get("workflow_id"))
for call in mock_service.delete.call_args_list
]
assert user_wf_id not in all_delete_args
class TestUnregisterPack:
"""Tests for InvocationRegistry.unregister_pack()."""
def test_unregister_removes_invocations(self) -> None:
# Save original state
original_invocations = InvocationRegistry._invocation_classes.copy()
original_outputs = InvocationRegistry._output_classes.copy()
try:
# Create a mock invocation class
mock_inv = MagicMock(spec=BaseInvocation)
mock_inv.UIConfig = MagicMock()
mock_inv.UIConfig.node_pack = "test_removable_pack"
mock_inv.get_type.return_value = "test_removable_node"
InvocationRegistry._invocation_classes.add(mock_inv)
# Verify it's registered
assert mock_inv in InvocationRegistry._invocation_classes
# Unregister
removed = InvocationRegistry.unregister_pack("test_removable_pack")
assert "test_removable_node" in removed
assert mock_inv not in InvocationRegistry._invocation_classes
finally:
# Restore original state
InvocationRegistry._invocation_classes = original_invocations
InvocationRegistry._output_classes = original_outputs
def test_unregister_returns_empty_for_unknown_pack(self) -> None:
removed = InvocationRegistry.unregister_pack("nonexistent_pack_xyz")
assert removed == []
def test_unregister_removes_multiple_invocations(self) -> None:
original_invocations = InvocationRegistry._invocation_classes.copy()
original_outputs = InvocationRegistry._output_classes.copy()
try:
mock_inv_1 = MagicMock(spec=BaseInvocation)
mock_inv_1.UIConfig = MagicMock()
mock_inv_1.UIConfig.node_pack = "multi_pack"
mock_inv_1.get_type.return_value = "multi_node_1"
mock_inv_2 = MagicMock(spec=BaseInvocation)
mock_inv_2.UIConfig = MagicMock()
mock_inv_2.UIConfig.node_pack = "multi_pack"
mock_inv_2.get_type.return_value = "multi_node_2"
mock_inv_other = MagicMock(spec=BaseInvocation)
mock_inv_other.UIConfig = MagicMock()
mock_inv_other.UIConfig.node_pack = "other_pack"
mock_inv_other.get_type.return_value = "other_node"
InvocationRegistry._invocation_classes.update({mock_inv_1, mock_inv_2, mock_inv_other})
removed = InvocationRegistry.unregister_pack("multi_pack")
assert len(removed) == 2
assert "multi_node_1" in removed
assert "multi_node_2" in removed
# Other pack's node should remain
assert mock_inv_other in InvocationRegistry._invocation_classes
finally:
InvocationRegistry._invocation_classes = original_invocations
InvocationRegistry._output_classes = original_outputs
class TestPurgePackModules:
"""Tests for _purge_pack_modules() — clears the pack subtree from sys.modules."""
def test_removes_root_module(self) -> None:
sys.modules["purge_test_root"] = MagicMock()
try:
removed = _purge_pack_modules("purge_test_root")
assert "purge_test_root" in removed
assert "purge_test_root" not in sys.modules
finally:
sys.modules.pop("purge_test_root", None)
def test_removes_submodules(self) -> None:
sys.modules["purge_test_pack"] = MagicMock()
sys.modules["purge_test_pack.nodes"] = MagicMock()
sys.modules["purge_test_pack.utils.helpers"] = MagicMock()
try:
removed = _purge_pack_modules("purge_test_pack")
assert set(removed) == {
"purge_test_pack",
"purge_test_pack.nodes",
"purge_test_pack.utils.helpers",
}
assert "purge_test_pack" not in sys.modules
assert "purge_test_pack.nodes" not in sys.modules
assert "purge_test_pack.utils.helpers" not in sys.modules
finally:
for key in ("purge_test_pack", "purge_test_pack.nodes", "purge_test_pack.utils.helpers"):
sys.modules.pop(key, None)
def test_does_not_remove_unrelated_modules_with_prefix_collision(self) -> None:
# "foo_pack_extra" must NOT be removed when purging "foo_pack"
sys.modules["foo_pack"] = MagicMock()
sys.modules["foo_pack_extra"] = MagicMock()
sys.modules["foo_pack.sub"] = MagicMock()
try:
removed = _purge_pack_modules("foo_pack")
assert set(removed) == {"foo_pack", "foo_pack.sub"}
assert "foo_pack_extra" in sys.modules
finally:
for key in ("foo_pack", "foo_pack_extra", "foo_pack.sub"):
sys.modules.pop(key, None)
def test_noop_when_pack_not_loaded(self) -> None:
removed = _purge_pack_modules("never_loaded_pack_xyz")
assert removed == []
class TestUninstallReinstallReloadsSubmodules:
"""Regression test for the uninstall -> reinstall cache bug.
Before the fix, uninstall only cleared sys.modules[pack_name] and left
submodules cached. On reinstall, Python reused the cached submodules,
their @invocation decorators never re-ran, and the pack loaded with
zero registered nodes until a full process restart.
"""
def test_reinstall_re_executes_submodule(self, tmp_path: Path) -> None:
pack_name = "reinstall_regression_pack"
pack_dir = tmp_path / pack_name
pack_dir.mkdir()
# __init__.py imports from a submodule — this is the shape that triggered the bug
(pack_dir / "__init__.py").write_text("from .nodes import * # noqa: F401,F403\n")
submodule = pack_dir / "nodes.py"
# Each import of the submodule must append a marker to this file.
# If the submodule gets reused from sys.modules instead of re-executed,
# the second install won't produce a second marker.
marker_file = tmp_path / "exec_markers.txt"
submodule.write_text(
f"from pathlib import Path\nPath(r'{marker_file.as_posix()}').open('a').write('exec\\n')\n"
)
try:
# First install
_load_node_pack(pack_name, pack_dir)
assert pack_name in sys.modules
assert f"{pack_name}.nodes" in sys.modules
assert marker_file.read_text().count("exec") == 1
# Simulate uninstall's module cleanup
_purge_pack_modules(pack_name)
assert pack_name not in sys.modules
assert f"{pack_name}.nodes" not in sys.modules
# Reinstall — submodule MUST re-execute
_load_node_pack(pack_name, pack_dir)
assert marker_file.read_text().count("exec") == 2, (
"Submodule was not re-executed on reinstall — the @invocation "
"decorators would not have re-registered the pack's nodes."
)
finally:
_purge_pack_modules(pack_name)