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:
bigcat88
2026-02-20 16:07:09 +02:00
parent 3d870ff51f
commit 9a98cdc389
4 changed files with 334 additions and 5 deletions

View File

@@ -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)

View File

@@ -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",
}