Files
ComfyAssets-ComfyUI_PromptM…/tests/test_validators.py
T
Vito aa768f57dd fix: release 3.2.3 — metadata parsing, null-safe saves, auto-hide, tag search (#151)
* fix: release 3.2.3 — metadata parsing, null-safe saves, auto-hide, tag search

- fix(#75): stop SaveImage patch from writing the last-run prompt into every
  PromptManager node (negative prompts were saved as copies of the positive)
- fix(#75): replace four duplicated metadata parsers with a shared
  web/js/comfy-metadata.js that traces sampler links (KSampler, CFGGuider /
  SamplerCustomAdvanced, LoRA chains, ControlNet pass-through, any checkpoint
  loader) and recovers per-node text for images saved by older versions
- fix(#143): treat null JSON body fields as empty in prompt API handlers
- fix(#149): keep auto-hide timeout 0 (disabled) instead of falling back to
  the default; validate result_timeout on save
- feat(#150): support tag:name filters in the node search box
- docs(#142): scan dialog mentions A1111/Forge metadata support
- docs(#144): fix ComfyUI-Lora-Manager links
- ci: run JS tests with node --test

* fix: bound metadata graph traversal to prevent tab freeze on crafted PNGs

Link resolution tried every linked input recursively with only a depth cap,
so a PNG whose prompt graph has cycles and fan-out could force ~inputs^25
paths and hang the gallery/metadata page. Each lookup now shares a visited
set (each node expanded once) with a hard cap of 500 visits.

* fix: read the matching output of multi-output settings nodes

KSampler settings linked to a node like SamplerCombo all resolved to its
first value, so steps, cfg and sampler showed the sampler name. Scalar
resolution now prefers the linked node's input with the same field name.

* fix: address review — trust API text, restore workflow-only parsing

- Workflow widget text now only replaces API text for metadata showing the
  pre-3.2.3 corruption signature (2+ PromptManager nodes with identical API
  text but different widgets). Otherwise the API graph wins, so expanded
  dynamic prompts and linked text display what actually ran.
- Images with only a UI workflow are converted to the API graph shape and
  traced the same way: prompts, KSamplerAdvanced, UNET/checkpoint loaders,
  KJNodes Set/Get, Reroute, bypassed and muted nodes.
2026-10-07 11:27:40 -07:00

334 lines
10 KiB
Python

"""
Comprehensive tests for input validation utilities.
Tests all 7 validator functions in utils/validators.py including
edge cases, boundary conditions, and security-related inputs.
"""
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from utils.validators import (
validate_prompt_text,
validate_rating,
validate_tags,
validate_category,
validate_workflow_name,
sanitize_input,
parse_tags_string,
validate_result_timeout,
)
class TestValidateCategory(unittest.TestCase):
"""Test validate_category function."""
def test_none_is_valid(self):
self.assertTrue(validate_category(None))
def test_empty_string_is_valid(self):
self.assertTrue(validate_category(""))
def test_whitespace_only_is_valid(self):
self.assertTrue(validate_category(" "))
def test_normal_category(self):
self.assertTrue(validate_category("landscape"))
def test_category_with_spaces(self):
self.assertTrue(validate_category("nature photos"))
def test_category_with_hyphens_underscores(self):
self.assertTrue(validate_category("sci-fi_art"))
def test_unicode_category(self):
self.assertTrue(validate_category("日本語カテゴリ"))
def test_max_length_boundary(self):
self.assertTrue(validate_category("x" * 100))
def test_exceeds_max_length(self):
with self.assertRaises(ValueError) as ctx:
validate_category("x" * 101)
self.assertIn("100", str(ctx.exception))
def test_non_string_raises(self):
with self.assertRaises(ValueError):
validate_category(123)
def test_list_raises(self):
with self.assertRaises(ValueError):
validate_category(["category"])
def test_control_characters_rejected(self):
with self.assertRaises(ValueError) as ctx:
validate_category("bad\x00category")
self.assertIn("control characters", str(ctx.exception))
def test_tab_character_rejected(self):
with self.assertRaises(ValueError):
validate_category("bad\tcategory")
def test_newline_rejected(self):
with self.assertRaises(ValueError):
validate_category("bad\ncategory")
class TestValidateWorkflowName(unittest.TestCase):
"""Test validate_workflow_name function."""
def test_none_is_valid(self):
self.assertTrue(validate_workflow_name(None))
def test_empty_string_is_valid(self):
self.assertTrue(validate_workflow_name(""))
def test_whitespace_only_is_valid(self):
self.assertTrue(validate_workflow_name(" "))
def test_normal_name(self):
self.assertTrue(validate_workflow_name("My Workflow"))
def test_name_with_special_chars(self):
self.assertTrue(validate_workflow_name("workflow-v2_final (copy)"))
def test_max_length_boundary(self):
self.assertTrue(validate_workflow_name("x" * 200))
def test_exceeds_max_length(self):
with self.assertRaises(ValueError) as ctx:
validate_workflow_name("x" * 201)
self.assertIn("200", str(ctx.exception))
def test_non_string_raises(self):
with self.assertRaises(ValueError):
validate_workflow_name(42)
def test_dict_raises(self):
with self.assertRaises(ValueError):
validate_workflow_name({"name": "workflow"})
class TestSanitizeInput(unittest.TestCase):
"""Test sanitize_input function."""
def test_normal_text_unchanged(self):
self.assertEqual(sanitize_input("hello world"), "hello world")
def test_strips_outer_whitespace(self):
self.assertEqual(sanitize_input(" hello "), "hello")
def test_removes_null_bytes(self):
self.assertEqual(sanitize_input("hello\x00world"), "helloworld")
def test_normalizes_windows_line_endings(self):
self.assertEqual(sanitize_input("line1\r\nline2"), "line1\nline2")
def test_normalizes_old_mac_line_endings(self):
self.assertEqual(sanitize_input("line1\rline2"), "line1\nline2")
def test_strips_whitespace_per_line(self):
result = sanitize_input(" line1 \n line2 ")
self.assertEqual(result, "line1\nline2")
def test_limits_consecutive_empty_lines(self):
text = "a\n\n\n\n\nb"
result = sanitize_input(text)
# Max 2 consecutive empty lines = 3 newlines between content
# 4+ newlines (3+ empty lines) should never appear
self.assertNotIn("\n\n\n\n", result)
self.assertIn("a", result)
self.assertIn("b", result)
def test_non_string_returns_empty(self):
self.assertEqual(sanitize_input(None), "")
self.assertEqual(sanitize_input(123), "")
self.assertEqual(sanitize_input([]), "")
def test_empty_string(self):
self.assertEqual(sanitize_input(""), "")
def test_preserves_single_newlines(self):
result = sanitize_input("line1\nline2\nline3")
self.assertEqual(result, "line1\nline2\nline3")
def test_mixed_line_endings(self):
result = sanitize_input("a\r\nb\rc\nd")
self.assertEqual(result, "a\nb\nc\nd")
class TestParseTagsString(unittest.TestCase):
"""Test parse_tags_string function."""
def test_simple_comma_separated(self):
result = parse_tags_string("tag1, tag2, tag3")
self.assertEqual(result, ["tag1", "tag2", "tag3"])
def test_extra_whitespace(self):
result = parse_tags_string(" tag1 , tag2 ")
self.assertEqual(result, ["tag1", "tag2"])
def test_empty_string_returns_empty_list(self):
self.assertEqual(parse_tags_string(""), [])
def test_none_returns_empty_list(self):
self.assertEqual(parse_tags_string(None), [])
def test_non_string_returns_empty_list(self):
self.assertEqual(parse_tags_string(123), [])
def test_single_tag(self):
self.assertEqual(parse_tags_string("solo"), ["solo"])
def test_deduplication(self):
result = parse_tags_string("tag1, tag1, tag2, tag1")
self.assertEqual(result, ["tag1", "tag2"])
def test_empty_segments_skipped(self):
result = parse_tags_string("tag1,,tag2,,,tag3")
self.assertEqual(result, ["tag1", "tag2", "tag3"])
def test_max_20_tags(self):
tags = ", ".join(f"tag{i}" for i in range(25))
result = parse_tags_string(tags)
self.assertEqual(len(result), 20)
def test_strips_null_bytes_from_tags(self):
result = parse_tags_string("clean\x00tag, normal")
self.assertIn("cleantag", result)
self.assertIn("normal", result)
class TestValidatePromptTextEdgeCases(unittest.TestCase):
"""Additional edge cases beyond test_basic.py coverage."""
def test_exactly_max_length(self):
self.assertTrue(validate_prompt_text("x" * 10000))
def test_one_over_max_length(self):
with self.assertRaises(ValueError):
validate_prompt_text("x" * 10001)
def test_single_character(self):
self.assertTrue(validate_prompt_text("a"))
def test_unicode_text(self):
self.assertTrue(validate_prompt_text("日本語プロンプト"))
def test_multiline_text(self):
self.assertTrue(validate_prompt_text("line1\nline2\nline3"))
def test_boolean_raises(self):
with self.assertRaises(ValueError):
validate_prompt_text(True)
def test_none_raises(self):
with self.assertRaises(ValueError):
validate_prompt_text(None)
class TestValidateRatingEdgeCases(unittest.TestCase):
"""Additional edge cases beyond test_basic.py coverage."""
def test_float_raises(self):
with self.assertRaises(ValueError):
validate_rating(3.5)
def test_negative_raises(self):
with self.assertRaises(ValueError):
validate_rating(-1)
def test_zero_raises(self):
with self.assertRaises(ValueError):
validate_rating(0)
def test_boolean_true_is_int(self):
# In Python, bool is a subclass of int: True == 1
# This tests the actual behavior
self.assertTrue(validate_rating(True))
class TestValidateTagsEdgeCases(unittest.TestCase):
"""Additional edge cases beyond test_basic.py coverage."""
def test_empty_list_is_valid(self):
self.assertTrue(validate_tags([]))
def test_comma_string_parsed(self):
self.assertTrue(validate_tags("tag1, tag2"))
def test_control_char_in_tag_rejected(self):
with self.assertRaises(ValueError) as ctx:
validate_tags(["bad\x00tag"])
self.assertIn("control characters", str(ctx.exception))
def test_tab_in_tag_rejected(self):
with self.assertRaises(ValueError):
validate_tags(["bad\ttag"])
def test_non_string_tag_in_list_rejected(self):
with self.assertRaises(ValueError):
validate_tags([123])
def test_dict_input_rejected(self):
with self.assertRaises(ValueError):
validate_tags({"tag": "value"})
def test_exactly_20_tags(self):
self.assertTrue(validate_tags([f"tag{i}" for i in range(20)]))
def test_exactly_50_char_tag(self):
self.assertTrue(validate_tags(["x" * 50]))
def test_whitespace_only_tag_rejected(self):
with self.assertRaises(ValueError):
validate_tags([" "])
class TestValidateResultTimeout(unittest.TestCase):
"""Test validate_result_timeout function (#149)."""
def test_zero_disables_auto_hide(self):
self.assertEqual(validate_result_timeout(0), 0)
def test_typical_value(self):
self.assertEqual(validate_result_timeout(30), 30)
def test_max_boundary(self):
self.assertEqual(validate_result_timeout(300), 300)
def test_returns_int(self):
self.assertIsInstance(validate_result_timeout(5), int)
def test_none_rejected(self):
with self.assertRaises(ValueError):
validate_result_timeout(None)
def test_garbage_string_rejected(self):
with self.assertRaises(ValueError):
validate_result_timeout("soon")
def test_numeric_string_coerced(self):
self.assertEqual(validate_result_timeout(" 10 "), 10)
def test_fractional_values_rejected(self):
for value in (1.5, "1.5"):
with self.assertRaises(ValueError):
validate_result_timeout(value)
def test_out_of_range_rejected_not_clamped(self):
for value in (-5, 301, 9999):
with self.assertRaises(ValueError):
validate_result_timeout(value)
def test_bool_rejected(self):
with self.assertRaises(ValueError):
validate_result_timeout(True)
if __name__ == "__main__":
unittest.main()