Files
ComfyAssets-ComfyUI_PromptM…/tests/test_generation_params_fuzz.py
T
Vito 36564b4767 feat(parse): one generation-parameter parser in Python; scan links what the resolver cannot decide (#253)
* feat(parse): port the browser generation-parameter parser to Python

Adds utils/generation_params.py, a line-for-line port of
extractGenerationParams in web/js/comfy-metadata.js, the parser behind
the gallery, admin and metadata pages. The scan and live monitor can use
it so every surface reads prompts the same way. The JavaScript parser is
unchanged.

The port reproduces the JavaScript value semantics the result depends on
(property order, truthiness, String(), trim(), strict equality, Map keys,
JSON.parse number rounding, and the inputs on which it throws), and runs
its recursive tracing on an explicit stack so crafted graphs cannot
exhaust the Python stack.

Tests:
- behaviour tests for each tracing rule, with JavaScript results as
  expected values, including the visit-limit boundary;
- golden vectors of the JavaScript parser's results for every named case
  and every PNG fixture, so the comparison runs without Node;
- a live comparison against the JavaScript parser when Node is present;
- a randomized differential test over 9,000 generated graphs and
  workflows, coherent and corrupted, including inputs where the
  JavaScript throws.

Five deliberate porting mistakes (trim, truthiness, prepend handling,
visit limit, pair slot) each fail the tests. On a 38,602-file output
folder, all 18,491 files with a workflow give identical results.

* fix(scan): link the traced prompt when the pair resolver cannot decide

The output scan skipped every file the pair resolver called ambiguous or
unresolved, so workflows where several samplers share one prompt (a base
pass, an upscale pass and a detailer) never reached the Library from a
scan. It now links the positive prompt the shared parser traces for
those files, the prompt the inspector and the legacy pages show. Files
the resolver decides are stored as before, and graphs without a
recognized sampler keep the legacy extractor (#252).

Read-only scans of a 38,602-file output folder into temporary databases:
- links: 1,500 before, 7,954 after; none of the 1,500 dropped or changed;
- of the added links that differ from the pre-redesign scan, 1,275
  replace a number, a LoRA tag, a fragment or a truncated prompt that
  scan stored.

* refactor(parse): return a uniform pair from the SameValueZero key
2026-10-19 20:56:51 -07:00

638 lines
19 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Randomized differential test: the Python parser equals the browser parser.
Generates seeded random API graphs and UI workflows, from realistic to
malformed, and requires utils.generation_params to return exactly what
web/js/comfy-metadata.js extractGenerationParams returns, or to fail where it
throws. Runs only where Node is available. Set PM_PARAMS_FUZZ_CASES to run more.
"""
import json
import os
import random
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
try:
from tests.test_generation_params_js_parity import (
NODE,
js_equal,
run_oracle,
run_python,
) # noqa: E402
except ImportError: # discovered with tests/ as the top-level directory
from test_generation_params_js_parity import (
NODE,
js_equal,
run_oracle,
run_python,
) # noqa: E402
CASE_COUNT = int(os.environ.get("PM_PARAMS_FUZZ_CASES", "3000"))
SEEDS = (20261020, 7, 424242)
NODE_IDS = [
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"9",
"10",
"12",
"25",
"26",
"100",
"1300:5",
"1300:8",
"x",
"05",
"4294967295",
"-1",
"constructor",
]
CLASS_TYPES = [
"KSampler",
"KSamplerAdvanced",
"SamplerCustomAdvanced",
"CFGGuider",
"BasicGuider",
"CLIPTextEncode",
"PromptManager",
"PromptManagerPair",
"PromptManagerText",
"StringConcatenate",
"PrimitiveString",
"ControlNetApplyAdvanced",
"ConditioningCombine",
"CheckpointLoaderSimple",
"UNETLoader",
"LoraLoader",
"RandomNoise",
"KSamplerSelect",
"BasicScheduler",
"Reroute",
"MyKSamplerThing",
"SaveImage",
"FaceDetailer",
"toString",
"",
]
INPUT_KEYS = [
"text",
"positive",
"negative",
"model",
"conditioning",
"conditioning_1",
"conditioning_to",
"guider",
"sigmas",
"noise",
"sampler",
"seed",
"noise_seed",
"steps",
"cfg",
"sampler_name",
"ckpt_name",
"unet_name",
"model_name",
"prepend_text",
"append_text",
"negative_text",
"delimiter",
"string_a",
"string_b",
"value",
"text_g",
"prompt",
"0",
"10",
"clip",
]
TEXTS = [
"a cat",
" padded text ",
"",
" ",
"bom text ",
"line one\nline two",
"masterpiece",
"masterpiece, a dog",
"4k",
"a dog 4k",
"ünïcödé ✓",
", ",
]
WORKFLOW_TYPES = [
"KSampler",
"KSamplerAdvanced",
"CLIPTextEncode",
"PromptManager",
"PromptManagerPair",
"CheckpointLoaderSimple",
"CheckpointLoader",
"UNETLoader",
"MyUnetLoader",
"Reroute",
"GetNode",
"SetNode",
"Note",
"toString",
"valueOf",
]
class Generator:
def __init__(self, seed):
self.random = random.Random(seed)
def pick(self, items):
return self.random.choice(items)
def chance(self, probability):
return self.random.random() < probability
def number(self):
return self.pick(
[
0,
1,
2,
3,
5,
20,
7,
1.0,
1.5,
6.5,
0.45,
1.5e-7,
2.5e21,
-3,
9007199254740991,
]
)
def link(self):
return [
self.pick(NODE_IDS + [3, 12, 5.0]),
self.pick([0, 1, 2, 3, 1.0, 0, 0, 0, -1]),
]
def value(self, key):
roll = self.random.random()
if (
key
in (
"positive",
"negative",
"model",
"conditioning",
"conditioning_1",
"conditioning_to",
"guider",
"sigmas",
"noise",
"sampler",
"clip",
)
and roll < 0.8
):
return self.link()
if roll < 0.3:
return self.link()
if roll < 0.6:
return self.pick(TEXTS)
if roll < 0.85:
return self.number()
return self.pick(
[True, False, None, [1, 2, 3], {"nested": "x"}, [], "randomize"]
)
def inputs(self):
if self.chance(0.04):
return self.pick([[], ["text"], "text", 3, None, True])
keys = self.random.sample(INPUT_KEYS, self.random.randint(0, 9))
return {key: self.value(key) for key in keys}
def node(self):
if self.chance(0.03):
return self.pick([None, "node", 5, [1, 2]])
node = {"class_type": self.pick(CLASS_TYPES), "inputs": self.inputs()}
if self.chance(0.05):
del node["class_type"]
return node
def graph(self):
ids = self.random.sample(NODE_IDS, self.random.randint(1, 12))
if self.chance(0.03):
return [self.node() for _ in ids]
return {node_id: self.node() for node_id in ids}
def workflow_node(self, node_id):
if self.chance(0.02):
return self.pick([None, 3, "node"])
node = {
"id": node_id,
"type": self.pick(WORKFLOW_TYPES),
"mode": self.pick([0, 0, 0, 2, 4]),
}
inputs = []
for _ in range(self.random.randint(0, 4)):
item = {
"name": self.pick(INPUT_KEYS),
"link": self.pick([None, 1, 2, 3, 4, 5, 6, 7, 8, "2"]),
"type": self.pick(["CONDITIONING", "MODEL", "CLIP", "*", "STRING"]),
}
if self.chance(0.2):
item["widget"] = {"name": self.pick(INPUT_KEYS)}
inputs.append(item)
node["inputs"] = (
inputs if not self.chance(0.03) else self.pick([{"a": 1}, "abc", None])
)
node["widgets_values"] = [
self.value(self.pick(INPUT_KEYS)) for _ in range(self.random.randint(0, 6))
]
if self.chance(0.1):
node["widgets_values"].insert(1, "randomize")
if self.chance(0.05):
node["widgets_values_named"] = {
"text": self.pick(TEXTS),
"seed": self.number(),
}
return node
def workflow(self):
if self.chance(0.03):
return self.pick([[], "workflow", {"nodes": "x"}, {"links": []}])
ids = self.random.sample(
[1, 2, 3, 4, 5, 6, 7, 8, "9", 10.0], self.random.randint(1, 8)
)
links = []
for link_id in range(1, self.random.randint(1, 9)):
origin, slot = self.pick(ids), self.pick([0, 1, 2])
if self.chance(0.5):
links.append(
[
link_id,
origin,
slot,
self.pick(ids),
0,
self.pick(["CONDITIONING", "MODEL", "*", "STRING"]),
]
)
else:
links.append(
{
"id": link_id,
"origin_id": origin,
"origin_slot": slot,
"type": self.pick(["CONDITIONING", "MODEL", "*"]),
}
)
return {
"nodes": [self.workflow_node(node_id) for node_id in ids],
"links": links,
}
# --- coherent pipelines, then corrupted ------------------------------------
def text_source(self, graph, ids, depth=0):
"""A node id producing text: an encoder, PromptManager, pair or a text chain."""
node_id = ids.pop()
kind = self.pick(
["encoder", "manager", "pair", "concat", "primitive"]
if depth < 2
else ["encoder", "manager"]
)
if kind == "encoder":
text = (
self.pick(TEXTS)
if self.chance(0.7)
else [self.text_source(graph, ids, depth + 1), 0]
)
graph[node_id] = {
"class_type": "CLIPTextEncode",
"inputs": {"text": text, "clip": ["4", 1]},
}
elif kind == "manager":
graph[node_id] = {
"class_type": self.pick(["PromptManager", "PromptManagerText"]),
"inputs": {
"text": self.pick(TEXTS),
"prepend_text": self.pick(TEXTS + ["masterpiece"]),
"append_text": self.pick(TEXTS + ["4k"]),
"clip": ["4", 1],
},
}
elif kind == "pair":
graph[node_id] = {
"class_type": "PromptManagerPair",
"inputs": {
"text": self.pick(TEXTS),
"negative_text": self.pick(TEXTS),
"prepend_text": "",
"append_text": "",
},
}
elif kind == "concat":
graph[node_id] = {
"class_type": "StringConcatenate",
"inputs": {
"string_a": (
[self.text_source(graph, ids, depth + 1), 0] if ids else "x"
),
"string_b": self.pick(TEXTS),
"delimiter": self.pick([", ", " ", 3, "|"]),
},
}
else:
graph[node_id] = {
"class_type": "PrimitiveString",
"inputs": {"value": self.pick(TEXTS)},
}
return node_id
def conditioning(self, graph, ids, role):
source = self.text_source(graph, ids)
slot = (
self.pick([0, 1, 2, 3])
if graph[source]["class_type"] == "PromptManagerPair"
else 0
)
link = [source, slot]
for _ in range(self.pick([0, 0, 1, 2, 3])):
if not ids:
break
node_id = ids.pop()
key = self.pick([role, "conditioning", "conditioning_to", "conditioning_1"])
graph[node_id] = {
"class_type": self.pick(
[
"ControlNetApplyAdvanced",
"ConditioningCombine",
"ConditioningSetArea",
]
),
"inputs": {key: link, "strength": 1.0},
}
link = [node_id, 0]
return link
def pipeline(self):
ids = [str(i) for i in self.random.sample(range(1, 400), 120)] + [
"1300:7",
"x",
"constructor",
]
self.random.shuffle(ids)
graph = {
"4": {
"class_type": self.pick(["CheckpointLoaderSimple", "UNETLoader"]),
"inputs": {
self.pick(
["ckpt_name", "unet_name", "gguf_name"]
): "model.safetensors"
},
}
}
for _ in range(self.pick([1, 1, 2, 3])):
positive, negative = self.conditioning(
graph, ids, "positive"
), self.conditioning(graph, ids, "negative")
settings = {
"seed": self.number() if self.chance(0.8) else [ids.pop(), 0],
"steps": self.number(),
"cfg": self.number(),
"sampler_name": self.pick(["euler", "lcm", "dpmpp_2m"]),
}
if settings["seed"] and isinstance(settings["seed"], list):
graph[settings["seed"][0]] = {
"class_type": "Seed",
"inputs": {"seed": self.number(), "other": "x"},
}
if self.chance(0.25):
guider = ids.pop()
graph[guider] = {
"class_type": "CFGGuider",
"inputs": {
"model": ["4", 0],
"positive": positive,
"negative": negative,
"cfg": self.number(),
},
}
noise, chooser, sigmas = ids.pop(), ids.pop(), ids.pop()
graph[noise] = {
"class_type": "RandomNoise",
"inputs": {"noise_seed": self.number()},
}
graph[chooser] = {
"class_type": "KSamplerSelect",
"inputs": {"sampler_name": "euler"},
}
graph[sigmas] = {
"class_type": "BasicScheduler",
"inputs": {"steps": self.number()},
}
graph[ids.pop()] = {
"class_type": "SamplerCustomAdvanced",
"inputs": {
"noise": [noise, 0],
"guider": [guider, 0],
"sampler": [chooser, 0],
"sigmas": [sigmas, 0],
},
}
else:
graph[ids.pop()] = {
"class_type": self.pick(
["KSampler", "KSamplerAdvanced", "FaceDetailer"]
),
"inputs": {
"model": ["4", 0],
"positive": positive,
"negative": negative,
**settings,
},
}
for _ in range(self.pick([0, 0, 1, 3])): # corruption
node_id = self.pick(list(graph))
node = graph[node_id]
if (
self.chance(0.5)
and isinstance(node, dict)
and isinstance(node.get("inputs"), dict)
and node["inputs"]
):
key = self.pick(list(graph[node_id]["inputs"]))
graph[node_id]["inputs"][key] = self.value(key)
else:
graph[node_id] = self.node()
return graph
def pipeline_workflow(self):
"""A connected workflow: loader, encoders, Reroute or Set/Get hops, sampler."""
nodes, links = [], []
def link(origin, slot, kind):
links.append(
[len(links) + 1, origin, slot, 0, 0, kind]
if self.chance(0.6)
else {
"id": len(links) + 1,
"origin_id": origin,
"origin_slot": slot,
"type": kind,
}
)
return len(links)
nodes.append(
{
"id": 4,
"type": self.pick(
["CheckpointLoaderSimple", "UNETLoader", "MyUnetLoader"]
),
"mode": 0,
"inputs": [],
"widgets_values": ["model.safetensors"],
}
)
refs = []
for index, role in enumerate(("positive", "negative")):
encoder = 10 + index
nodes.append(
{
"id": encoder,
"type": self.pick(
["CLIPTextEncode", "PromptManager", "PromptManagerText"]
),
"mode": self.pick([0, 0, 0, 4]),
"inputs": [
{"name": "clip", "link": link(4, 1, "CLIP"), "type": "CLIP"}
],
"widgets_values": [
self.pick(TEXTS),
"",
"",
"",
self.pick(TEXTS),
self.pick(TEXTS),
],
}
)
ref = link(encoder, 0, "CONDITIONING")
hop = self.pick(["none", "none", "reroute", "setget"])
if hop == "reroute":
nodes.append(
{
"id": 20 + index,
"type": "Reroute",
"mode": 0,
"inputs": [{"name": "", "link": ref}],
}
)
ref = link(20 + index, 0, "*")
elif hop == "setget":
name = self.pick(["cond_" + role, "shared"])
nodes.append(
{
"id": 30 + index,
"type": "SetNode",
"mode": 0,
"inputs": [{"name": "c", "link": ref}],
"widgets_values": [name],
}
)
nodes.append(
{
"id": 40 + index,
"type": "GetNode",
"mode": 0,
"inputs": [],
"widgets_values": [name],
}
)
ref = link(40 + index, 0, "CONDITIONING")
refs.append(ref)
widgets = [
self.number(),
self.pick(["randomize", "fixed", "x"]),
self.number(),
self.number(),
"euler",
"normal",
1,
]
nodes.append(
{
"id": 3,
"type": self.pick(["KSampler", "KSamplerAdvanced"]),
"mode": 0,
"inputs": [
{"name": "model", "link": link(4, 0, "MODEL"), "type": "MODEL"},
{"name": "positive", "link": refs[0], "type": "CONDITIONING"},
{"name": "negative", "link": refs[1], "type": "CONDITIONING"},
],
"widgets_values": widgets,
}
)
if self.chance(0.3):
nodes[self.random.randrange(len(nodes))] = self.workflow_node(
self.pick([1, 2, 99])
)
return {"nodes": nodes, "links": links}
def case(self):
roll = self.random.random()
structured = self.chance(0.7)
prompt = (
json.dumps(self.pipeline() if structured else self.graph())
if roll < 0.75
else None
)
workflow = (
json.dumps(self.pipeline_workflow() if structured else self.workflow())
if roll > 0.45
else None
)
if self.chance(0.02):
prompt = self.pick(['{"1": NaN}', "{", "null", "[]", '"text"', "12"])
return {"prompt": prompt, "workflow": workflow}
@unittest.skipUnless(NODE, "Node is required to run the browser parser")
class RandomGraphs(unittest.TestCase):
def test_random_graphs_match_the_browser_parser(self):
for seed in SEEDS:
generator = Generator(seed)
cases = [generator.case() for _ in range(CASE_COUNT)]
expected = run_oracle(cases)
self.assertEqual(len(expected), len(cases))
mismatches = []
for index, (case, result) in enumerate(zip(cases, expected)):
actual = run_python(case)
if not js_equal(actual, result):
mismatches.append((index, case, actual, result))
with self.subTest(seed=seed):
outcomes = {
"ok": sum(r["ok"] for r in expected),
"throws": sum(not r["ok"] for r in expected),
}
self.assertEqual(
mismatches[:3],
[],
f"{len(mismatches)} of {len(cases)} differ; outcomes {outcomes}",
)
if __name__ == "__main__":
unittest.main()