mirror of
https://github.com/Comfy-Org/ComfyUI.git
synced 2026-08-26 02:42:36 +08:00
feat: declarative input validation with opt-in runtime enforcement
- Add `minLength`/`maxLength` to `IO.String.Input`, mirroring existing `min`/`max` for `Int`/`Float`. - Add `runtime_input_validation` to V3 `Schema` (and `RUNTIME_INPUT_VALIDATION` class attribute for V1 nodes). Default `False` Signed-off-by: bigcat88 <bigcat88@icloud.com>
This commit is contained in:
@@ -1011,3 +1011,124 @@ class TestExecution:
|
||||
"""Test getting a non-existent job returns 404"""
|
||||
job = client.get_job("nonexistent-job-id")
|
||||
assert job is None, "Non-existent job should return None"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text, expect_error", [
|
||||
("hello", False), # 5 chars, within [3, 10]
|
||||
("abc", False), # 3 chars, exact min boundary
|
||||
("abcdefghij", False), # 10 chars, exact max boundary
|
||||
("ab", True), # 2 chars, below min
|
||||
("abcdefghijk", True), # 11 chars, above max
|
||||
("", True), # 0 chars, below min
|
||||
])
|
||||
def test_string_length_widget_validation(self, text, expect_error, client: ComfyClient, builder: GraphBuilder):
|
||||
"""Test minLength/maxLength validation for direct widget values (validate_inputs path)."""
|
||||
g = builder
|
||||
node = g.node("StubStringWithLength", text=text)
|
||||
g.node("SaveImage", images=node.out(0))
|
||||
if expect_error:
|
||||
with pytest.raises(urllib.error.HTTPError) as exc_info:
|
||||
client.run(g)
|
||||
assert exc_info.value.code == 400
|
||||
else:
|
||||
client.run(g)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text, expect_error", [
|
||||
("hello", False), # within bounds
|
||||
("ab", True), # below min
|
||||
("abcdefghijk", True), # above max
|
||||
])
|
||||
def test_string_length_linked_validation(self, text, expect_error, client: ComfyClient, builder: GraphBuilder):
|
||||
"""Test minLength/maxLength validation for linked inputs when node opts in via RUNTIME_INPUT_VALIDATION=True."""
|
||||
g = builder
|
||||
str_node = g.node("StubStringOutput", value=text)
|
||||
node = g.node("StubStringWithLength", text=str_node.out(0))
|
||||
g.node("SaveImage", images=node.out(0))
|
||||
|
||||
if expect_error:
|
||||
try:
|
||||
client.run(g)
|
||||
assert False, "Should have raised an error"
|
||||
except Exception as e:
|
||||
assert 'prompt_id' in e.args[0], f"Did not get proper error message: {e}"
|
||||
else:
|
||||
client.run(g)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", [
|
||||
"ab", # below declared minLength
|
||||
"abcdefghijk", # above declared maxLength
|
||||
"", # empty
|
||||
"hello", # within bounds
|
||||
])
|
||||
def test_string_length_linked_skipped_without_flag(self, text, client: ComfyClient, builder: GraphBuilder):
|
||||
"""Without RUNTIME_INPUT_VALIDATION=True, declared bounds must NOT be enforced for linked values.
|
||||
|
||||
Preserves V1 behavior: many existing workflows rely on out-of-bounds values passing
|
||||
through links. Adding declared bounds without the flag must not break them.
|
||||
"""
|
||||
g = builder
|
||||
str_node = g.node("StubStringOutput", value=text)
|
||||
node = g.node("StubStringWithLengthNoFlag", text=str_node.out(0))
|
||||
g.node("SaveImage", images=node.out(0))
|
||||
client.run(g)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value, expect_error", [
|
||||
(5, False), # within [1, 10]
|
||||
(1, False), # exact min boundary
|
||||
(10, False), # exact max boundary
|
||||
(0, True), # below min
|
||||
(11, True), # above max
|
||||
(-7, True), # well below min
|
||||
])
|
||||
def test_int_bounds_linked_validation(self, value, expect_error, client: ComfyClient, builder: GraphBuilder):
|
||||
"""min/max validation for linked INT inputs when node opts in via RUNTIME_INPUT_VALIDATION=True.
|
||||
|
||||
Direct widget INT values are already validated pre-execution. This test exercises the
|
||||
symmetric runtime path for values arriving through a connection.
|
||||
"""
|
||||
g = builder
|
||||
int_node = g.node("StubInt", value=value)
|
||||
node = g.node("StubIntWithBounds", value=int_node.out(0))
|
||||
g.node("SaveImage", images=node.out(0))
|
||||
|
||||
if expect_error:
|
||||
try:
|
||||
client.run(g)
|
||||
assert False, "Should have raised an error"
|
||||
except Exception as e:
|
||||
assert 'prompt_id' in e.args[0], f"Did not get proper error message: {e}"
|
||||
else:
|
||||
client.run(g)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("choice, expect_error", [
|
||||
("RED", False),
|
||||
("GREEN", False),
|
||||
("BLUE", False),
|
||||
("PURPLE", True),
|
||||
("", True),
|
||||
("red", True), # case-sensitive
|
||||
])
|
||||
def test_combo_membership_linked_validation(self, choice, expect_error, client: ComfyClient, builder: GraphBuilder):
|
||||
"""COMBO option membership for linked values when node opts in via RUNTIME_INPUT_VALIDATION=True.
|
||||
|
||||
StubComboWithOptions declares ``input_types`` in VALIDATE_INPUTS to bypass the engine's
|
||||
link-type compatibility check, so we can feed a STRING into a COMBO and verify the
|
||||
runtime membership check fires.
|
||||
"""
|
||||
g = builder
|
||||
str_node = g.node("StubStringOutput", value=choice)
|
||||
node = g.node("StubComboWithOptions", choice=str_node.out(0))
|
||||
g.node("SaveImage", images=node.out(0))
|
||||
|
||||
if expect_error:
|
||||
try:
|
||||
client.run(g)
|
||||
assert False, "Should have raised an error"
|
||||
except Exception as e:
|
||||
assert 'prompt_id' in e.args[0], f"Did not get proper error message: {e}"
|
||||
else:
|
||||
client.run(g)
|
||||
|
||||
@@ -113,12 +113,117 @@ class StubFloat:
|
||||
def stub_float(self, value):
|
||||
return (value,)
|
||||
|
||||
class StubStringOutput:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"value": ("STRING", {"default": ""}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
FUNCTION = "stub_string"
|
||||
|
||||
CATEGORY = "Testing/Stub Nodes"
|
||||
|
||||
def stub_string(self, value):
|
||||
return (value,)
|
||||
|
||||
class StubStringWithLength:
|
||||
"""STRING input with declared bounds AND opted in to runtime validation (RUNTIME_INPUT_VALIDATION = True)."""
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"text": ("STRING", {"default": "hello", "minLength": 3, "maxLength": 10}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "stub_string_with_length"
|
||||
RUNTIME_INPUT_VALIDATION = True
|
||||
|
||||
CATEGORY = "Testing/Stub Nodes"
|
||||
|
||||
def stub_string_with_length(self, text):
|
||||
return (torch.zeros(1, 64, 64, 3),)
|
||||
|
||||
class StubStringWithLengthNoFlag:
|
||||
"""Same bounds as StubStringWithLength but NOT opted in - linked values must flow through unchecked."""
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"text": ("STRING", {"default": "hello", "minLength": 3, "maxLength": 10}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "stub_string_with_length_no_flag"
|
||||
|
||||
CATEGORY = "Testing/Stub Nodes"
|
||||
|
||||
def stub_string_with_length_no_flag(self, text):
|
||||
return (torch.zeros(1, 64, 64, 3),)
|
||||
|
||||
class StubIntWithBounds:
|
||||
"""INT input with min/max bounds AND opted in to runtime validation."""
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"value": ("INT", {"default": 5, "min": 1, "max": 10}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "stub_int_with_bounds"
|
||||
RUNTIME_INPUT_VALIDATION = True
|
||||
|
||||
CATEGORY = "Testing/Stub Nodes"
|
||||
|
||||
def stub_int_with_bounds(self, value):
|
||||
return (torch.zeros(1, 64, 64, 3),)
|
||||
|
||||
class StubComboWithOptions:
|
||||
"""COMBO input opted in to runtime validation.
|
||||
|
||||
Declares ``input_types`` in VALIDATE_INPUTS to bypass the engine's link-type compatibility
|
||||
check, allowing tests to link a STRING into a COMBO and exercise the runtime membership check.
|
||||
"""
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"choice": (["RED", "GREEN", "BLUE"],),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "stub_combo"
|
||||
RUNTIME_INPUT_VALIDATION = True
|
||||
|
||||
CATEGORY = "Testing/Stub Nodes"
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(cls, input_types):
|
||||
return True
|
||||
|
||||
def stub_combo(self, choice):
|
||||
return (torch.zeros(1, 64, 64, 3),)
|
||||
|
||||
TEST_STUB_NODE_CLASS_MAPPINGS = {
|
||||
"StubImage": StubImage,
|
||||
"StubConstantImage": StubConstantImage,
|
||||
"StubMask": StubMask,
|
||||
"StubInt": StubInt,
|
||||
"StubFloat": StubFloat,
|
||||
"StubStringOutput": StubStringOutput,
|
||||
"StubStringWithLength": StubStringWithLength,
|
||||
"StubStringWithLengthNoFlag": StubStringWithLengthNoFlag,
|
||||
"StubIntWithBounds": StubIntWithBounds,
|
||||
"StubComboWithOptions": StubComboWithOptions,
|
||||
}
|
||||
TEST_STUB_NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"StubImage": "Stub Image",
|
||||
@@ -126,4 +231,9 @@ TEST_STUB_NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"StubMask": "Stub Mask",
|
||||
"StubInt": "Stub Int",
|
||||
"StubFloat": "Stub Float",
|
||||
"StubStringOutput": "Stub String Output",
|
||||
"StubStringWithLength": "Stub String With Length",
|
||||
"StubStringWithLengthNoFlag": "Stub String With Length (No Flag)",
|
||||
"StubIntWithBounds": "Stub Int With Bounds",
|
||||
"StubComboWithOptions": "Stub Combo With Options",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user