Old project/workflow names and browser settings migrate on load; /funpack redirects to /fun. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016acRss69eH1235eRdFTZHY
173 lines
5.6 KiB
Python
173 lines
5.6 KiB
Python
"""Announcing ComfyUI nodes.
|
|
|
|
Core registers whatever modules declared and nothing it names itself. These tests
|
|
use stub node classes rather than real comfy_api ones on purpose: `collect()` only
|
|
ever asks a node for its schema, so it stays provable without ComfyUI installed --
|
|
the same property the rest of the suite has.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from core import nodes as nodes_mod
|
|
from core import registry as registry_mod
|
|
from core.schema import SchemaError, validate
|
|
|
|
FIXTURES = Path(__file__).parent / "fixtures" / "fixture_modules"
|
|
PACKAGE = "core.tests.fixtures.fixture_modules"
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def collected():
|
|
registry = registry_mod.scan(FIXTURES, package=PACKAGE)
|
|
return registry, nodes_mod.collect(registry)
|
|
|
|
|
|
def _ids(nodes):
|
|
return [n.GET_SCHEMA().node_id for n in nodes]
|
|
|
|
|
|
def _good():
|
|
"""The real GoodNode class, by identity."""
|
|
from core.tests.fixtures.fixture_modules.nodes.good import GoodNode
|
|
return GoodNode
|
|
|
|
|
|
def test_an_announced_node_is_collected(collected):
|
|
_, (nodes, _) = collected
|
|
# By identity. Asserting on the id alone let a DIFFERENT fixture module that
|
|
# claims the same id satisfy this, so the test passed without the module
|
|
# under test contributing anything.
|
|
assert _good() in nodes
|
|
|
|
|
|
def test_a_node_outside_the_funui_namespace_is_refused(collected):
|
|
_, (nodes, rejected) = collected
|
|
# ComfyUI would let this silently replace core's own VAELoader.
|
|
assert "VAELoader" not in _ids(nodes)
|
|
assert any("VAELoader" in why for _, why in rejected)
|
|
|
|
|
|
def test_two_modules_cannot_claim_one_node_id(collected):
|
|
_, (nodes, rejected) = collected
|
|
assert _ids(nodes).count("FunUIGood") == 1
|
|
# And the one that survived is the first module to claim it, not whichever
|
|
# happened to be reached last.
|
|
assert _good() in nodes
|
|
assert any("duplicate node_id" in why and "nodes_zz_clashes" in where
|
|
for where, why in rejected)
|
|
|
|
|
|
def test_something_that_is_not_a_comfy_node_is_refused(collected):
|
|
_, (nodes, rejected) = collected
|
|
assert any("not a comfy_api" in why for _, why in rejected)
|
|
# And it did not stop the good one being collected.
|
|
assert _good() in nodes
|
|
|
|
|
|
def test_a_node_whose_schema_raises_does_not_take_the_others_down():
|
|
class Exploding:
|
|
@classmethod
|
|
def define_schema(cls):
|
|
raise RuntimeError("boom")
|
|
|
|
@classmethod
|
|
def execute(cls):
|
|
return None
|
|
|
|
@classmethod
|
|
def GET_SCHEMA(cls):
|
|
raise RuntimeError("boom")
|
|
|
|
registry = registry_mod.scan(FIXTURES, package=PACKAGE)
|
|
spec = registry.specs["nodes_good"]
|
|
registry.specs["nodes_good"] = type(spec)(
|
|
**{**spec.__dict__, "nodes": [Exploding, *spec.nodes]}
|
|
)
|
|
|
|
nodes, rejected = nodes_mod.collect(registry)
|
|
assert _good() in nodes, "a raising sibling took the good node with it"
|
|
assert any("boom" in why for _, why in rejected)
|
|
|
|
|
|
def test_a_node_only_module_needs_no_mount(collected):
|
|
registry, _ = collected
|
|
assert registry.specs["nodes_good"].mount == ""
|
|
|
|
|
|
def test_a_module_with_settings_still_needs_a_mount():
|
|
with pytest.raises(SchemaError, match="mount"):
|
|
validate({
|
|
"id": "m", "title": "M",
|
|
"settings": {"x": {"type": "bool", "default": True, "label": "X"}},
|
|
})
|
|
|
|
|
|
@pytest.mark.parametrize("bad", [
|
|
"not a list",
|
|
["not a class"],
|
|
[object], # a class, but with no define_schema/execute
|
|
])
|
|
def test_a_malformed_nodes_declaration_refuses_the_module(bad):
|
|
with pytest.raises(SchemaError):
|
|
validate({"id": "m", "title": "M", "nodes": bad})
|
|
|
|
|
|
def test_a_node_that_cannot_be_named_does_not_take_the_others_down():
|
|
"""`getattr(node, "__name__", repr(node))` evaluates repr() eagerly, so a
|
|
class whose metaclass raises there threw outside the guard and killed the
|
|
whole collection -- and ComfyUI reports that as the entire pack failing."""
|
|
|
|
class Meta(type):
|
|
def __repr__(cls):
|
|
raise RuntimeError("unreprable")
|
|
|
|
def __getattribute__(cls, item):
|
|
if item == "__name__":
|
|
raise RuntimeError("unnameable")
|
|
return type.__getattribute__(cls, item)
|
|
|
|
class Hostile(metaclass=Meta):
|
|
@classmethod
|
|
def define_schema(cls):
|
|
return None
|
|
|
|
@classmethod
|
|
def execute(cls):
|
|
return None
|
|
|
|
@classmethod
|
|
def GET_SCHEMA(cls):
|
|
return None
|
|
|
|
registry = registry_mod.scan(FIXTURES, package=PACKAGE)
|
|
spec = registry.specs["nodes_good"]
|
|
registry.specs["nodes_good"] = type(spec)(
|
|
**{**spec.__dict__, "nodes": [Hostile, *spec.nodes]}
|
|
)
|
|
|
|
nodes, rejected = nodes_mod.collect(registry)
|
|
assert _good() in nodes
|
|
assert any("unnameable" in why or "unreprable" in why for _, why in rejected)
|
|
|
|
|
|
@pytest.mark.parametrize("falsy", [0, False, ""])
|
|
def test_a_falsy_but_wrong_nodes_declaration_is_refused_not_repaired(falsy):
|
|
"""`announcement.get("nodes") or []` quietly turned a typo into "this module
|
|
ships no nodes". This file refuses rather than repairs, and 0 is not []."""
|
|
with pytest.raises(SchemaError, match="must be a list"):
|
|
validate({"id": "m", "title": "M", "nodes": falsy})
|
|
|
|
|
|
@pytest.mark.parametrize("field", ["requires", "after", "before"])
|
|
def test_a_falsy_but_wrong_id_list_is_refused_too(field):
|
|
with pytest.raises(SchemaError, match="must be a list"):
|
|
validate({"id": "m", "title": "M", field: 0})
|
|
|
|
|
|
def test_an_absent_declaration_is_still_fine():
|
|
# Absent means "none", which is different from a typo.
|
|
spec = validate({"id": "m", "title": "M"})
|
|
assert spec.nodes == [] and spec.requires == []
|