v4 could not remove a built-in node from the pipeline and the reason was structural: the wiring lived in code as a CORE dict nobody could reach, so "remove this" had nothing to delete and "use a different one" had nothing to change. Code is not a thing a user can edit. The pipeline is now data. Three operations over it, each refusing with a reason rather than half-doing the job: build turns slots into the prompt ComfyUI queues, checking every link's type and refusing a graph that cannot work. replace points a slot at any node producing what its consumers read -- the built-in ones have no special standing, which is what makes them replaceable rather than merely present. remove takes a slot out and rewires what it fed to what fed it, when the node has exactly one input matching each CONSUMED output; v4 required one for every output a class could produce, so a spare FLOAT nobody wired made a node unremovable. Demonstrated against real schemas: FunPack's VAE loader swapped for core's VAELoader, the modifiers node removed with the sampler rewired to read the model directly, and removing the empty latent refused because nothing upstream could feed what it fed. Then run for real. The default pipeline, with three loader slots replaced by one checkpoint slot, built from data, accepted by ComfyUI's own validate_prompt, and executed by its own engine to execution_success and a picture. Every wiring and type validated on the first attempt; the only things ComfyUI objected to in the untouched default were the three unset file pickers, which is correct -- a fresh install cannot know which model you own. Added a checkpoint loader, because the split loaders are right for LTX and H3 and cannot read an SD-family single file at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
101 lines
3.5 KiB
Python
101 lines
3.5 KiB
Python
"""The nodes FunPack actually ships, checked against ComfyUI itself.
|
|
|
|
Needs a ComfyUI source tree, because a node's schema is only meaningful in terms
|
|
of comfy_api's own types. Skipped where there is none.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def shipped(comfyui):
|
|
from core import nodes as nodes_mod
|
|
from core import registry as registry_mod
|
|
|
|
registry = registry_mod.scan()
|
|
nodes, rejected = nodes_mod.collect(registry)
|
|
return registry, nodes, rejected
|
|
|
|
|
|
def _schema(node):
|
|
return node.GET_SCHEMA()
|
|
|
|
|
|
def _input_ids(schema):
|
|
return [getattr(i, "id", None) for i in (schema.inputs or [])]
|
|
|
|
|
|
def test_nothing_shipped_is_rejected(shipped):
|
|
_, _, rejected = shipped
|
|
assert rejected == [], f"a shipped node did not register: {rejected}"
|
|
|
|
|
|
def test_every_shipped_node_is_a_real_comfy_node(shipped, comfyui):
|
|
from comfy_api.latest import io
|
|
_, nodes, _ = shipped
|
|
for node in nodes:
|
|
assert issubclass(node, io.ComfyNode), f"{node.__name__} is not an io.ComfyNode"
|
|
assert isinstance(_schema(node), io.Schema)
|
|
|
|
|
|
# Each entry is a promise to everyone's saved workflows: `node_id` is stored as
|
|
# `class_type`, and input ids are stored as key names. Changing either silently
|
|
# breaks graphs that already exist, so this test is meant to FAIL on a rename and
|
|
# make that a decision rather than an accident.
|
|
FROZEN = {
|
|
"FunPackCheckpointLoader": ["ckpt_name"],
|
|
"FunPackCLIPLoader": [
|
|
"clip_name1", "clip_name2", "clip_name3", "clip_name4", "type", "device",
|
|
],
|
|
"FunPackSampler": [
|
|
"model", "positive", "negative", "latent", "seed", "steps", "cfg",
|
|
"sampler_name", "scheduler", "denoise", "settings",
|
|
],
|
|
"FunPackLoadModifiers": ["model", "settings"],
|
|
"FunPackModifierSettings": ["settings"],
|
|
"FunPackDecode": ["samples", "vae", "model", "audio_vae"],
|
|
"FunPackEmptyLatent": ["model", "width", "height", "length", "batch_size"],
|
|
"FunPackDiffusionModelLoader": [
|
|
"model_name", "weight_dtype", "compute_dtype", "attention", "fp16_accumulation",
|
|
],
|
|
"FunPackLoraLoader": [
|
|
"model", "lora_name", "strength_model", "clip", "strength_clip",
|
|
],
|
|
"FunPackVAELoader": ["vae_name", "dtype"],
|
|
}
|
|
|
|
|
|
def test_shipped_node_ids_and_inputs_are_frozen(shipped):
|
|
_, nodes, _ = shipped
|
|
actual = {_schema(n).node_id: _input_ids(_schema(n)) for n in nodes}
|
|
assert actual == FROZEN, (
|
|
"a shipped node's id or input names changed. Every saved workflow refers "
|
|
"to these by name; update FROZEN only if you intend to break them."
|
|
)
|
|
|
|
|
|
def test_a_setting_never_becomes_a_node_socket(shipped):
|
|
"""The rule that keeps the sampler from growing 70 widgets, and keeps a new
|
|
setting from rotting saved graphs: settings are a payload, not sockets."""
|
|
registry, _, _ = shipped
|
|
for spec in registry.specs.values():
|
|
keys = set(spec.settings)
|
|
for node in spec.nodes:
|
|
ids = set(_input_ids(_schema(node))) - {None}
|
|
overlap = keys & ids
|
|
assert not overlap, (
|
|
f"{spec.id}'s node {node.__name__} exposes settings as sockets: "
|
|
f"{sorted(overlap)}"
|
|
)
|
|
|
|
|
|
def test_the_extension_hands_comfyui_exactly_what_was_collected(shipped, comfyui):
|
|
import asyncio
|
|
from core import nodes as nodes_mod
|
|
|
|
extension = nodes_mod.extension()
|
|
assert extension is not None
|
|
listed = asyncio.run(extension.get_node_list())
|
|
_, nodes, _ = shipped
|
|
assert {_schema(n).node_id for n in listed} == {_schema(n).node_id for n in nodes}
|