Support constant wildcard inputs and preserve list seeds

Resolve direct string constants before execution and retain requested seeds
for list-producing inputs. Preserve native widget values and scalar reuse.

Cover cold queues, mapped lists and Control combinations with actual core
cache checks and Chrome tests. Limit the Bypass xfail to the known Processor
case so Encode failures remain visible.
This commit is contained in:
Dr.Lt.Data
2026-11-03 20:46:14 +09:00
parent 609e411e13
commit d246db9e27
6 changed files with 869 additions and 74 deletions
+27 -9
View File
@@ -16,6 +16,7 @@ import nodes
import numpy as np
import torchvision
from aiohttp import web
from comfy_execution.graph_utils import is_link
from impact.utils import to_tensor
from PIL import Image
from segment_anything import SamPredictor, sam_model_registry
@@ -510,6 +511,19 @@ def find_input_value(input_node, prompt, input_type=int, input_keys=('value',)):
return input_val
def _constant_text(value, prompt):
"""Read literals and direct core string constants without executing upstream nodes."""
if isinstance(value, str):
return value
if not is_link(value) or type(value[1]) is not int or value[1] != 0:
return None
source = prompt.get(value[0], {})
if source.get('class_type') not in ('PrimitiveString', 'PrimitiveStringMultiline'):
return None
text = source.get('inputs', {}).get('value')
return text if isinstance(text, str) else None
def onprompt_populate_wildcards(json_data):
prompt = json_data['prompt']
@@ -528,17 +542,19 @@ def onprompt_populate_wildcards(json_data):
inputs['mode'] = new_mode
if inputs['mode'] == 'populate' and isinstance(inputs['populated_text'], str):
wildcard_text = _constant_text(inputs['wildcard_text'], prompt)
if wildcard_text is None:
logging.info("[Impact Pack] Node %s: wildcard_text requires text or a core string constant. "
"Connect runtime text to populated_text.", k)
continue
if isinstance(inputs['seed'], list):
try:
input_node = prompt[inputs['seed'][0]]
if input_node['class_type'] == 'ImpactInt':
input_seed = int(input_node['inputs']['value'])
if not isinstance(input_seed, int):
continue
elif input_node['class_type'] == 'Seed (rgthree)':
input_seed = int(input_node['inputs']['seed'])
if not isinstance(input_seed, int):
continue
else:
input_seed = find_input_value(input_node, prompt=prompt, input_type=int, input_keys=('int', 'seed', 'value'))
if input_seed is None:
@@ -549,19 +565,18 @@ def onprompt_populate_wildcards(json_data):
else:
input_seed = int(inputs['seed'])
inputs['populated_text'] = impact.wildcards.process(inputs['wildcard_text'], input_seed)
inputs['populated_text'] = impact.wildcards.process(wildcard_text, input_seed)
inputs['mode'] = 'reproduce'
PromptServer.instance.send_sync("impact-node-feedback", {"node_id": k, "widget_name": "populated_text", "type": "STRING", "value": inputs['populated_text']})
updated_widget_values[k] = inputs['populated_text']
# Static text can share cache entries; preserve seeds for syntax markers and links.
populated_text = inputs['populated_text']
if isinstance(inputs['seed'], int) and isinstance(populated_text, str):
# Static text can share cache entries; preserve seeds for syntax and unknown outputs.
populated_text = _constant_text(inputs['populated_text'], prompt)
if isinstance(inputs['seed'], int) and populated_text is not None:
if impact.wildcards.is_static_text(populated_text):
inputs['seed'] = 0
elif isinstance(inputs['seed'], int):
# Linked text that the last finished run received as static text reuses that run's seed.
seed = impact.wildcards.recorded_static_seed(prompt, k)
if seed is not None:
inputs['seed'] = seed
@@ -580,6 +595,9 @@ def onprompt_populate_wildcards(json_data):
if key in updated_widget_values:
widgets_values[1] = updated_widget_values[key]
widgets_values[2] = "reproduce"
if "widgets_values_named" in node:
node["widgets_values_named"].update(
populated_text=updated_widget_values[key], mode="reproduce")
def onprompt_for_remote(json_data):
+13 -10
View File
@@ -10,6 +10,7 @@ import folder_paths
import nodes
import numpy as np
import yaml
from comfy_execution.graph_utils import is_link
from impact import config, utils
wildcards_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "wildcards"))
@@ -838,10 +839,6 @@ def _may_change_with_same_inputs(class_def):
return hasattr(class_def, 'IS_CHANGED')
def _is_link(value):
return isinstance(value, list) and len(value) == 2 and isinstance(value[0], str) and isinstance(value[1], int)
def _validated_input(value):
# The hook sees the prompt before validation, nodes see it after. Validation unwraps
# list widgets and casts FLOAT widgets, which JSON sends as 1 for 1.0.
@@ -856,21 +853,27 @@ def linked_text_fingerprint(prompt, node_id):
# Like ComfyUI's cache, equal upstream inputs are trusted to produce equal text unless
# an upstream node declares IS_CHANGED/fingerprint_inputs.
link = prompt[node_id]['inputs']['populated_text']
if not _is_link(link):
if not is_link(link):
return None
upstream = {}
pending = [link[0]]
pending = [link]
while pending:
upstream_id = pending.pop()
if upstream_id in upstream:
continue
upstream_id, slot = pending.pop()
node = prompt.get(upstream_id)
class_def = nodes.NODE_CLASS_MAPPINGS.get(node['class_type']) if node is not None else None
if class_def is None or _may_change_with_same_inputs(class_def):
return None
if type(slot) is not int or slot < 0:
return None
# One mapped item cannot establish that every text in a list is static.
output_is_list = getattr(class_def, 'OUTPUT_IS_LIST', ())
if slot < len(output_is_list) and output_is_list[slot]:
return None
if upstream_id in upstream:
continue
upstream[upstream_id] = [node['class_type'], {k: _validated_input(v) for k, v in node['inputs'].items()}]
pending += [v[0] for v in node['inputs'].values() if _is_link(v)]
pending += [v for v in node['inputs'].values() if is_link(v)]
return hashlib.sha256(json.dumps([link, upstream], sort_keys=True).encode()).hexdigest()
+210 -42
View File
@@ -7,6 +7,7 @@ and the CLIP model are substituted; this does not test neural encoding quality.
import argparse
import asyncio
from copy import deepcopy
from itertools import product
import json
from pathlib import Path
import sys
@@ -33,15 +34,15 @@ def main():
send_sync=lambda event, data: feedback.append((event, data)),
)
import nodes
from comfy_api.latest import io
from comfy_execution.caching import CacheKeySetInputSignature
from comfy_execution.graph import DynamicPrompt
from comfy_api.latest import io
from comfy_extras.nodes_primitive import Float, String, StringMultiline
from comfy_extras.nodes_preview_any import PreviewAny
from comfy_extras.nodes_primitive import Float, StringMultiline
from comfy_extras.nodes_string import StringConcatenate
from execution import IsChangedCache, validate_prompt
from execution import IsChangedCache, _async_map_node_over_list, validate_prompt
# Match server startup order; importing impact_pack first creates a cycle.
from impact import impact_server, impact_pack, logics, wildcards
from impact import impact_server, impact_pack, logics, util_nodes, wildcards
# Text providers whose output may change while their inputs stay equal, like file loaders.
class ChangingTextSource:
@@ -71,12 +72,16 @@ def main():
classes = {name: getattr(impact_pack, name) for name in (
"ImpactWildcardProcessor", "ImpactWildcardEncode",
)}
nodes.NODE_CLASS_MAPPINGS.update(classes | {
"PreviewAny": PreviewAny, "PrimitiveStringMultiline": StringMultiline, "StringConcatenate": StringConcatenate,
constants = {"PrimitiveString": String, "PrimitiveStringMultiline": StringMultiline}
nodes.NODE_CLASS_MAPPINGS.update(classes | constants | {
"PreviewAny": PreviewAny, "StringConcatenate": StringConcatenate,
"ChangingTextSource": ChangingTextSource, "ChangingTextSourceV3": ChangingTextSourceV3,
"UndeclaredTextSource": UndeclaredTextSource, "PrimitiveFloat": Float,
"ImpactConvertDataType": logics.ImpactConvertDataType,
"ImpactConvertDataType": logics.ImpactConvertDataType, "ImpactMakeAnyList": util_nodes.MakeAnyList,
})
widget_defaults = {node["type"]: {w["name"]: w["value"] for w in node["widgets"]}
for node in json.loads((root / "tests/workflows/all-impact-nodes.widgets.json").read_text())["nodes"]
if node["type"] in classes}
wildcards.wildcard_dict = {"colors": ["red", "green", "blue"]}
wildcards._on_demand_mode = False
@@ -103,16 +108,13 @@ def main():
inputs = dict(wildcard_text=source, populated_text=text, seed=seed, mode=mode)
prompt = deepcopy(upstream or {}) | {"1": {"class_type": name, "inputs": inputs}}
# The saved native workflow must keep the user's seed and widget slots.
widgets = [source, text, mode]
widgets += ([seed, "increment", "Select the Wildcard to add to the text"]
if name == "ImpactWildcardProcessor" else [
"Select the LoRA to add to the text", "Select the Wildcard to add to the text", seed, "increment"])
widgets = list(dict(widget_defaults[name], wildcard_text=source, populated_text=text,
mode=mode, seed=seed, control_after_generate="increment").values())
workflow_node = {"id": 1, "widgets_values": deepcopy(widgets)}
data = {"prompt": prompt, "extra_data": {"extra_pnginfo": {"workflow": {"nodes": [workflow_node]}}}}
feedback.clear()
impact_server.onprompt_populate_wildcards(data)
expected_widgets = deepcopy(widgets)
# Linked populated_text cannot be populated before execution.
populated = mode in ("populate", True) and isinstance(text, str)
if populated:
expected_widgets[1:3] = [inputs["populated_text"], "reproduce"]
@@ -131,7 +133,6 @@ def main():
if name == "ImpactWildcardEncode":
values.update(model=object(), clip=Clip())
if prompt is not None:
# Hidden inputs supplied by ComfyUI during execution.
values.update(prompt=prompt, unique_id="1")
return classes[name]().doit(**values)[-1]
@@ -149,6 +150,9 @@ def main():
return [keys.get_data_key(node) for node in ("1", "2")]
class WildcardCacheTests(unittest.TestCase):
def setUp(self):
wildcards.static_linked_text_seeds.clear()
def test_file_loading_modes_preserve_seeded_output(self):
# Exercise the real startup loader, lazy TXT lookup and preloaded
# YAML through the hook, not just an injected wildcard dictionary.
@@ -218,37 +222,32 @@ def main():
self.assertEqual(output(name, inputs, combined), selection)
def test_linked_static_text_reuses_learned_seed(self):
sources = (
{"class_type": "PrimitiveStringMultiline", "inputs": {"value": "plain α"}},
{"class_type": "StringConcatenate", "inputs": {"string_a": "plain", "string_b": "α", "delimiter": " "}},
)
for name in classes:
for mode in ("populate", "fixed", "reproduce"):
for source in sources:
with self.subTest(node=name, mode=mode, source=source["class_type"]):
wildcards.static_linked_text_seeds.clear()
source = {"class_type": "StringConcatenate", "inputs": {"string_a": "plain", "string_b": "α", "delimiter": " "}}
for name, mode in product(classes, ("populate", "fixed", "reproduce")):
with self.subTest(node=name, mode=mode):
wildcards.static_linked_text_seeds.clear()
def linked(seed, upstream=source):
return prepare(name, seed, ["text", 0], mode, upstream={"text": upstream})
def linked(seed, upstream=source):
return prepare(name, seed, ["text", 0], mode, upstream={"text": upstream})
# Nothing is known about the text before the first run.
first = linked(731)
self.assertEqual(first["1"]["inputs"]["seed"], 731)
self.assertEqual(output(name, first["1"]["inputs"], resolve(source), prompt=first), resolve(source))
# Nothing is known about the text before the first run.
first = linked(731)
self.assertEqual(first["1"]["inputs"]["seed"], 731)
self.assertEqual(output(name, first["1"]["inputs"], resolve(source), prompt=first), resolve(source))
second = linked(732)
self.assertEqual(second["1"]["inputs"]["seed"], 731)
self.assertEqual(asyncio.run(cache_keys(first)), asyncio.run(cache_keys(second)))
second = linked(732)
self.assertEqual(second["1"]["inputs"]["seed"], 731)
self.assertEqual(asyncio.run(cache_keys(first)), asyncio.run(cache_keys(second)))
changed = deepcopy(source)
changed["inputs"][next(iter(changed["inputs"]))] = "changed"
third = linked(733, changed)
self.assertEqual(third["1"]["inputs"]["seed"], 733)
self.assertNotEqual(asyncio.run(cache_keys(first)), asyncio.run(cache_keys(third)))
changed = deepcopy(source)
changed["inputs"]["string_a"] = "changed"
third = linked(733, changed)
self.assertEqual(third["1"]["inputs"]["seed"], 733)
self.assertNotEqual(asyncio.run(cache_keys(first)), asyncio.run(cache_keys(third)))
# Only the last run is kept; ComfyUI's cache drops the earlier text too.
output(name, third["1"]["inputs"], resolve(changed), prompt=third)
self.assertEqual(linked(734)["1"]["inputs"]["seed"], 734)
# Only the last run is kept; ComfyUI's cache drops the earlier text too.
output(name, third["1"]["inputs"], resolve(changed), prompt=third)
self.assertEqual(linked(734)["1"]["inputs"]["seed"], 734)
def test_linked_dynamic_text_keeps_seeds_of_queued_runs(self):
sources = (
@@ -324,6 +323,59 @@ def main():
prompt = prepare(name, 731, ["text", 0], upstream=upstream)
self.assertIsNotNone(wildcards.linked_text_fingerprint(prompt, "1"))
def test_mapped_text_lists_keep_requested_seeds(self):
for name, texts in product(classes, (("{red|green|blue}", "plain"),
("plain", "{red|green|blue}"), ("one", "two"))):
with self.subTest(node=name, texts=texts):
wildcards.static_linked_text_seeds.clear()
upstream = {"list": {"class_type": "ImpactMakeAnyList", "inputs": {
"value1": texts[0], "value2": texts[1]}}}
mapped_text = util_nodes.MakeAnyList().doit(**upstream["list"]["inputs"])[0]
original = classes[name].doit
def execute(instance, **values):
result = original(instance, **values)
# A request may arrive between mapped calls, before the list finishes.
queued = prepare(name, 42, ["list", 0], upstream=upstream)
self.assertEqual(queued["1"]["inputs"]["seed"], 42)
return result
for seed, color in ((2, "red"), (5, "blue")):
prompt = prepare(name, seed, ["list", 0], upstream=upstream)
self.assertEqual(prompt["1"]["inputs"]["seed"], seed)
values = {k: [v] for k, v in prompt["1"]["inputs"].items()}
values.update(populated_text=mapped_text, prompt=[prompt], unique_id=["1"])
if name == "ImpactWildcardEncode":
values.update(model=[object()], clip=[Clip()])
with patch.object(classes[name], "doit", execute):
results = asyncio.run(_async_map_node_over_list("test", "1", classes[name](), values, "doit"))
self.assertEqual([result[-1] for result in results],
[color if text.startswith("{") else text for text in texts])
self.assertEqual(wildcards.static_linked_text_seeds, {})
def test_list_output_detection_follows_selected_slots_and_ancestors(self):
class TextOutputs(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(node_id="TextOutputs", outputs=[
io.String.Output(), io.String.Output(is_output_list=True)])
@classmethod
def execute(cls):
return io.NodeOutput("plain", ["plain", "{red|green|blue}"])
with patch.dict(nodes.NODE_CLASS_MAPPINGS, TextOutputs=TextOutputs):
for source_type, slot, allowed in (("ImpactMakeAnyList", 0, False),
("TextOutputs", 1, False), ("TextOutputs", 0, True)):
with self.subTest(source=source_type, slot=slot):
upstream = {
"source": {"class_type": source_type, "inputs": {}},
"text": {"class_type": "StringConcatenate", "inputs": {
"string_a": ["source", slot], "string_b": "plain", "delimiter": " "}},
}
prompt = prepare("ImpactWildcardProcessor", 731, ["text", 0], upstream=upstream)
self.assertEqual(wildcards.linked_text_fingerprint(prompt, "1") is not None, allowed)
def test_recorded_seed_is_dropped_when_text_gains_syntax(self):
upstream = {"text": {"class_type": "UndeclaredTextSource", "inputs": {"path": "prompt.txt"}}}
for name in classes:
@@ -338,14 +390,130 @@ def main():
self.assertEqual(prepare(name, 733, ["text", 0], upstream=upstream)["1"]["inputs"]["seed"], 733)
def test_linked_seed_is_never_replaced(self):
for name in classes:
for name, (source_type, field) in product(classes, (("ImpactInt", "value"), ("Seed (rgthree)", "seed"))):
for mode in ("populate", "fixed", "reproduce"):
with self.subTest(node=name, mode=mode):
upstream = {"seed": {"class_type": "ImpactInt", "inputs": {"value": 5}}}
with self.subTest(node=name, mode=mode, source=source_type):
upstream = {"seed": {"class_type": source_type, "inputs": {field: 5}}}
inputs = prepare(name, ["seed", 0], "__colors__", mode, "__colors__", upstream)["1"]["inputs"]
self.assertEqual(inputs["seed"], ["seed", 0])
self.assertEqual(output(name, inputs, resolved_seed=5), "blue")
def test_constant_text_links_reuse_cache(self):
cases = (("populated_text", "fixed"), ("populated_text", "populate"),
("populated_text", "reproduce"), ("wildcard_text", "populate"))
for name, source_type, (field, mode), text in product(classes, constants, cases, ("", "plain α")):
with self.subTest(node=name, source=source_type, field=field, mode=mode, text=text):
keys = []
for seed, value in ((731, text), (732, text), (733, text + " changed")):
upstream = {"source": {"class_type": source_type, "inputs": {"value": value}}}
link = ["source", 0]
prompt = prepare(name, seed, link if field == "populated_text" else "old",
mode, link if field == "wildcard_text" else "unused", upstream)
inputs = prompt["1"]["inputs"]
self.assertEqual(inputs[field], link)
self.assertEqual(prompt["source"], upstream["source"])
self.assertEqual(inputs["seed"], 0)
resolved = constants[source_type].execute(value)[0] if field == "populated_text" else None
self.assertEqual(output(name, inputs, resolved), value)
keys.append(asyncio.run(cache_keys(prompt)))
self.assertEqual(keys[0], keys[1])
self.assertNotEqual(keys[0], keys[2])
def test_constant_text_links_preserve_seeded_output(self):
for name, source_type, field, text in product(classes, constants, ("populated_text", "wildcard_text"), selections):
with self.subTest(node=name, source=source_type, field=field, text=text):
for seed, selection in zip(seeds, selections[text]):
upstream = {"source": {"class_type": source_type, "inputs": {"value": text}}}
link = ["source", 0]
inputs = prepare(name, seed, link if field == "populated_text" else "old",
"populate", link if field == "wildcard_text" else "unused", upstream)["1"]["inputs"]
resolved = constants[source_type].execute(text)[0] if field == "populated_text" else None
self.assertEqual(output(name, inputs, resolved), selection)
self.assertEqual(inputs[field], link)
self.assertEqual(inputs["seed"], seed if field == "populated_text" else 0)
for name, source_type, field, text in product(
classes, constants, ("populated_text", "wildcard_text"), ("plain", "__colors__")):
with self.subTest(node=name, source=source_type, field=field, text=text, linked_seed=True):
for seed, expected in ((2, "red"), (5, "blue")):
upstream = {"source": {"class_type": source_type, "inputs": {"value": text}},
"seed": {"class_type": "ImpactInt", "inputs": {"value": seed}}}
link = ["source", 0]
inputs = prepare(name, ["seed", 0], link if field == "populated_text" else "old",
"populate", link if field == "wildcard_text" else "unused", upstream)["1"]["inputs"]
self.assertEqual(inputs["seed"], ["seed", 0])
self.assertEqual(inputs[field], link)
resolved = text if field == "populated_text" else None
self.assertEqual(output(name, inputs, resolved, seed), "plain" if text == "plain" else expected)
def test_only_direct_constant_text_links_are_resolved(self):
cases = [
(["source", 0], {"class_type": "PrimitiveString", "inputs": {"value": ["literal", 0]}}),
(["source", 0], {"class_type": "StringConcatenate", "inputs": {"string_a": "plain", "string_b": " text", "delimiter": ""}}),
(["source", 0], {"class_type": "ExternalTextReader", "inputs": {"value": "plain"}}),
]
constant = {"class_type": "PrimitiveString", "inputs": {"value": "plain"}}
cases += [(link, constant) for link in (
["source", 1], ["source", -1], ["source", False], ["source", 0.0],
["source"], ["source", 0, 1], [1, 0], ["missing", 0])]
cases += [(["source", 0], {"class_type": source_type, "inputs": inputs})
for source_type, inputs in product(constants, ({}, {"value": None}, {"value": 0}, {"value": True}))]
for name, (link, source), field in product(classes, cases, ("populated_text", "wildcard_text")):
with self.subTest(node=name, link=link, source=source, field=field):
data = {"prompt": {
"source": source, "literal": constant,
"1": {"class_type": name, "inputs": {
"populated_text": link if field == "populated_text" else "old",
"wildcard_text": link if field == "wildcard_text" else "unused",
"mode": "populate", "seed": 731,
}},
}}
before = deepcopy(data)
feedback.clear()
impact_server.onprompt_populate_wildcards(data)
self.assertEqual(data, before)
self.assertEqual(feedback, [])
def test_unsupported_population_does_not_abort_other_nodes_or_hooks(self):
for name, named in product(classes, (False, True)):
with self.subTest(node=name, named=named):
values = dict(widget_defaults[name], wildcard_text="saved source", populated_text="old",
mode="populate", seed=2)
saved = [{"id": i, "widgets_values": list(values.values())} for i in (1, 2)]
if named:
for node in saved:
node["widgets_values_named"] = dict(values)
inputs = dict(wildcard_text=["runtime", 0], populated_text="old", mode="populate", seed=2)
data = {"prompt": {
"runtime": {"class_type": "StringConcatenate", "inputs": {
"string_a": "{red|", "string_b": "green|blue}", "delimiter": ""}},
"constant": {"class_type": "PrimitiveString", "inputs": {"value": "{red|green|blue}"}},
"1": {"class_type": name, "inputs": inputs},
"2": {"class_type": name, "inputs": dict(inputs, wildcard_text=["constant", 0])},
"receiver": {"class_type": "ImageReceiver", "inputs": {
"save_to_workflow": True, "image": "previous image"}},
}, "extra_data": {"extra_pnginfo": {"workflow": {"nodes": saved}}}}
skipped = deepcopy(saved[0])
skipped_inputs = deepcopy(inputs)
feedback.clear()
self.assertIs(impact_server.onprompt(data), data)
self.assertEqual(data["prompt"]["1"]["inputs"], skipped_inputs)
self.assertEqual(saved[0], skipped)
self.assertEqual(data["prompt"]["2"]["inputs"], dict(
wildcard_text=["constant", 0], populated_text="red", mode="reproduce", seed=0))
expected = dict(values, populated_text="red", mode="reproduce")
self.assertEqual(saved[1]["widgets_values"], list(expected.values()))
if named:
self.assertEqual(saved[1]["widgets_values_named"], expected)
else:
self.assertNotIn("widgets_values_named", saved[1])
self.assertEqual([(event, item["node_id"], item["widget_name"], item["value"])
for event, item in feedback], [
("impact-node-feedback", "2", "populated_text", "red"),
("impact-node-feedback", "2", "mode", "populate"),
])
self.assertEqual(data["prompt"]["receiver"]["inputs"]["image"], "#DATA")
def test_unresolved_syntax_after_population_keeps_seed(self):
for name in classes:
with self.subTest(node=name):
+239 -13
View File
@@ -49,8 +49,9 @@ def graph(ui, node_type):
def cached(entry):
return {str(node) for event, data in entry["status"]["messages"]
if event == "execution_cached" for node in data["nodes"]}
events = [data["nodes"] for event, data in entry["status"]["messages"] if event == "execution_cached"]
assert len(events) == 1, entry["status"]
return set(map(str, events[0]))
def run(ui, preview_id, expected, label):
@@ -61,32 +62,45 @@ def run(ui, preview_id, expected, label):
return entry
def native_roundtrip(ui, node_id):
def set_text(ui, node_type, widget, value):
ui.page.mouse.dblclick(*ui.point(node_type, widget=widget), delay=100)
editor = ui.page.locator("input.value:visible")
editor.fill(value)
editor.press("Enter")
def native_roundtrip(ui, node_id, *other_ids):
"""Compare rendered widget names/order/values and the native serialization."""
show_node(ui, node_id)
before = widget_state(ui, node_id)
trusted = json.loads((ROOT / "tests/workflows/all-impact-nodes.widgets.json").read_text())
reference = next(node for node in trusted["nodes"] if node["type"] == before["type"])
assert [w["name"] for w in before["widgets"]] == [w["name"] for w in reference["widgets"]]
states = {}
for target_id in (node_id, *other_ids):
show_node(ui, target_id)
state = widget_state(ui, target_id)
reference = next(node for node in trusted["nodes"] if node["type"] == state["type"])
assert [w["name"] for w in state["widgets"]] == [w["name"] for w in reference["widgets"]]
states[target_id] = state
workflow = ui.artifacts / "saved.json"
export_workflow(ui, workflow)
saved = json.loads(workflow.read_text())
ui.new_workflow()
ui.load_workflow(workflow, len(saved["nodes"]))
full_zoom(ui)
show_node(ui, node_id)
assert widget_state(ui, node_id) == before
for target_id, before in states.items():
show_node(ui, target_id)
assert json.dumps(widget_state(ui, target_id), sort_keys=True) == json.dumps(before, sort_keys=True)
roundtrip = ui.artifacts / "roundtrip.json"
export_workflow(ui, roundtrip)
restored = json.loads(roundtrip.read_text())
assert {str(n["id"]): n.get("widgets_values", []) for n in restored["nodes"]} == {
str(n["id"]): n.get("widgets_values", []) for n in saved["nodes"]}
for field, default in (("widgets_values", []), ("widgets_values_named", {})):
assert json.dumps({str(n["id"]): n.get(field, default) for n in restored["nodes"]}, sort_keys=True) == json.dumps({
str(n["id"]): n.get(field, default) for n in saved["nodes"]}, sort_keys=True)
# Native loading can reorder link records; their IDs and endpoints must
# remain identical. Widget arrays above deliberately retain their order.
assert sorted(restored["links"]) == sorted(saved["links"])
assert restored.get("definitions") == saved.get("definitions")
(ui.artifacts / "widget-state.json").write_text(json.dumps(before, indent=2) + "\n")
return before
(ui.artifacts / "widget-state.json").write_text(json.dumps(states, indent=2) + "\n")
show_node(ui, node_id)
return states[node_id]
def input_slot(ui, node_type, name):
@@ -177,6 +191,66 @@ def test_populate_reuses_cache_only_for_same_expansion(ui, node_type):
assert saved_node["widgets_values"][seed_index] == seed
@pytest.mark.parametrize("restore_by_name", [False, True], ids=["positional", "named"])
@pytest.mark.parametrize("node_type", NODE_TYPES)
@pytest.mark.parametrize("syntax_first", [True, False], ids=["syntax-first", "syntax-last"])
def test_mapped_list_preserves_each_seed(ui, node_type, syntax_first, restore_by_name):
setting(ui, "Restore widget values", "Comfy.Workflow.NamedValuesRestore", restore_by_name)
node_id, preview_id = graph(ui, node_type)
ui.choose(node_type, "mode", "fixed")
text_id = ui.add_node("Text", "PrimitiveString", 100, 650)
set_text(ui, "PrimitiveString", "value", "{red|green|blue}")
plain = f"plain {uuid4().hex}"
other_id = ui.add_node("Text (Multiline)", "PrimitiveStringMultiline", 100, 850)
ui.page.get_by_placeholder("value", exact=True).fill(plain)
list_id = ui.add_node("Make List (Any)", "ImpactMakeAnyList", 700, 800)
sources = ("PrimitiveString", "PrimitiveStringMultiline") if syntax_first else (
"PrimitiveStringMultiline", "PrimitiveString")
for index, source in enumerate(sources, 1):
ui.connect(source, "ImpactMakeAnyList", target_slot=input_slot(ui, "ImpactMakeAnyList", f"value{index}"))
ui.connect("ImpactMakeAnyList", node_type, target_slot=input_slot(ui, node_type, "populated_text"))
native_roundtrip(ui, node_id, list_id)
for seed, color in ((2, "red"), (5, "blue")):
show_node(ui, node_id)
ui.set_number(node_type, "seed", seed)
entry = ui.run()
(ui.artifacts / f"list-{seed}.json").write_text(json.dumps(entry, indent=2) + "\n")
expected = [color, plain] if syntax_first else [plain, color]
assert entry["outputs"][preview_id]["text"] == expected
show_node(ui, preview_id)
expect(ui.page.get_by_role("textbox", name="preview_text", exact=True)).to_have_value("\n\n".join(expected))
assert entry["prompt"][2][node_id]["inputs"]["seed"] == seed
assert {node_id, preview_id}.isdisjoint(cached(entry))
if seed == 5:
assert {text_id, other_id, list_id} <= cached(entry)
@pytest.mark.parametrize("node_type", NODE_TYPES)
def test_scalar_runtime_text_still_reuses_learned_seed(ui, node_type):
node_id, preview_id = graph(ui, node_type)
ui.choose(node_type, "mode", "fixed")
source_id = ui.add_node("Concatenate Text", "StringConcatenate", 100, 650)
text = f"learned {uuid4().hex}"
ui.page.get_by_placeholder("string_a", exact=True).fill(text)
ui.page.get_by_placeholder("string_b", exact=True).fill(" text")
ui.connect("StringConcatenate", node_type, target_slot=input_slot(ui, node_type, "populated_text"))
for seed in (731, 732):
show_node(ui, node_id)
ui.set_number(node_type, "seed", seed)
entry = run(ui, preview_id, text + " text", f"learned-{seed}")
assert entry["prompt"][2][node_id]["inputs"]["seed"] == 731
assert (node_id in cached(entry)) is (seed == 732)
show_node(ui, source_id)
ui.page.get_by_placeholder("string_a", exact=True).fill(f"{text} {{red|")
ui.page.get_by_placeholder("string_b", exact=True).fill("green|blue}")
for seed, expected in ((2, "red"), (5, "blue")):
show_node(ui, node_id)
ui.set_number(node_type, "seed", seed)
entry = run(ui, preview_id, f"{text} {expected}", f"learned-to-syntax-{seed}")
assert entry["prompt"][2][node_id]["inputs"]["seed"] == seed
assert {node_id, preview_id}.isdisjoint(cached(entry))
@pytest.mark.parametrize("restore_by_name", [False, True], ids=["positional", "named"])
@pytest.mark.parametrize("node_type", NODE_TYPES)
def test_reproduce_native_workflow_then_populate(ui, node_type, restore_by_name):
@@ -226,6 +300,158 @@ def test_plain_text_reuses_downstream_cache(ui, node_type):
assert {node_id, preview_id}.isdisjoint(cached(changed))
@pytest.mark.parametrize("restore_by_name", [False, True], ids=["positional", "named"])
@pytest.mark.parametrize("node_type", NODE_TYPES)
@pytest.mark.parametrize("source_type", ["PrimitiveString", "PrimitiveStringMultiline"])
@pytest.mark.parametrize("field", ["populated_text", "wildcard_text"])
def test_constant_text_link_cache_and_native_restore(ui, node_type, source_type, field, restore_by_name):
setting(ui, "Restore widget values", "Comfy.Workflow.NamedValuesRestore", restore_by_name)
node_id, preview_id = graph(ui, node_type)
ui.choose(node_type, "control_after_generate", "increment")
ui.set_number(node_type, "seed", 731)
title = "Text" if source_type == "PrimitiveString" else "Text (Multiline)"
source_id = ui.add_node(title, source_type, 100, 650)
def set_source_text(value):
show_node(ui, source_id)
if source_type == "PrimitiveStringMultiline":
ui.page.get_by_placeholder("value", exact=True).fill(value)
else:
set_text(ui, source_type, "value", value)
text = f"constant prompt {uuid4().hex}"
set_source_text(text)
show_node(ui, node_id)
ui.connect(source_type, node_type, target_slot=input_slot(ui, node_type, field))
native_roundtrip(ui, node_id)
for index in range(2):
entry = run(ui, preview_id, text, f"constant-{index}")
inputs = entry["prompt"][2][node_id]["inputs"]
assert inputs[field] == [source_id, 0]
assert inputs["seed"] == 0
if index:
assert {node_id, preview_id} <= cached(entry)
else:
assert {node_id, preview_id}.isdisjoint(cached(entry))
assert next(w["value"] for w in widget_state(ui, node_id)["widgets"] if w["name"] == "seed") == 733
set_source_text(text + " changed")
changed = run(ui, preview_id, text + " changed", "constant-changed")
assert {node_id, preview_id}.isdisjoint(cached(changed))
# LRU can retain an identical syntax graph from another parameterized case.
syntax = f"{text} {{red|green|blue}}"
set_source_text(syntax)
show_node(ui, node_id)
ui.choose(node_type, "control_after_generate", "fixed")
for seed, expected in ((2, "red"), (5, "blue")):
ui.set_number(node_type, "seed", seed)
entry = run(ui, preview_id, f"{text} {expected}", f"constant-syntax-{seed}")
assert entry["prompt"][2][node_id]["inputs"]["seed"] == (seed if field == "populated_text" else 0)
seed_id = ui.add_node("ImpactInt", "ImpactInt", 850, 650)
ui.set_number("ImpactInt", "value", 2)
ui.connect("ImpactInt", node_type, target_slot=input_slot(ui, node_type, "seed"))
before = native_roundtrip(ui, node_id)
for source_text, label in ((syntax, "syntax"), (text + " linked seed", "plain")):
set_source_text(source_text)
for seed, expected in ((2, "red"), (5, "blue")):
show_node(ui, seed_id)
ui.set_number("ImpactInt", "value", seed)
show_node(ui, node_id)
entry = run(ui, preview_id, f"{text} {expected}" if label == "syntax" else source_text, f"linked-seed-{label}-{seed}")
inputs = entry["prompt"][2][node_id]["inputs"]
assert inputs["seed"] == [seed_id, 0]
assert inputs[field] == [source_id, 0]
assert {node_id, preview_id}.isdisjoint(cached(entry))
show_node(ui, node_id)
assert next(w for w in widget_state(ui, node_id)["widgets"] if w["name"] == "seed") == next(
w for w in before["widgets"] if w["name"] == "seed")
@pytest.mark.parametrize("restore_by_name", [False, True], ids=["positional", "named"])
@pytest.mark.parametrize("node_type", NODE_TYPES)
def test_runtime_population_source_preserves_saved_text(ui, node_type, restore_by_name):
setting(ui, "Restore widget values", "Comfy.Workflow.NamedValuesRestore", restore_by_name)
node_id, preview_id = graph(ui, node_type)
previous = f"saved text {uuid4().hex}"
ui.choose(node_type, "mode", "fixed")
ui.page.get_by_placeholder("Populated Prompt (Will be generated automatically)", exact=True).fill(previous)
ui.choose(node_type, "mode", "populate")
ui.set_number(node_type, "seed", 2)
source_id = ui.add_node("Concatenate Text", "StringConcatenate", 100, 650)
show_node(ui, source_id)
ui.page.get_by_placeholder("string_a", exact=True).fill("{red|")
ui.page.get_by_placeholder("string_b", exact=True).fill("green|blue}")
show_node(ui, node_id)
ui.connect("StringConcatenate", node_type, target_slot=input_slot(ui, node_type, "wildcard_text"))
before = native_roundtrip(ui, node_id)
entry = run(ui, preview_id, previous, "unsupported-population")
inputs = entry["prompt"][2][node_id]["inputs"]
assert (inputs["wildcard_text"], inputs["populated_text"], inputs["mode"], inputs["seed"]) == (
[source_id, 0], previous, "populate", 2)
assert widget_state(ui, node_id) == before
native_roundtrip(ui, node_id)
constant_id = ui.add_node("Text", "PrimitiveString", 850, 750)
show_node(ui, constant_id)
set_text(ui, "PrimitiveString", "value", "{red|green|blue}")
show_node(ui, node_id)
ui.connect("PrimitiveString", node_type, target_slot=input_slot(ui, node_type, "wildcard_text"))
entry = run(ui, preview_id, "red", "supported-population")
inputs = entry["prompt"][2][node_id]["inputs"]
assert (inputs["wildcard_text"], inputs["populated_text"], inputs["mode"], inputs["seed"]) == (
[constant_id, 0], "red", "reproduce", 0)
@pytest.mark.parametrize("restore_by_name", [False, True], ids=["positional", "named"])
@pytest.mark.parametrize("node_type", NODE_TYPES)
@pytest.mark.parametrize("source_type", [None, "PrimitiveString", "PrimitiveStringMultiline"],
ids=["literal", "string", "multiline"])
def test_execution_metadata_restores_populated_text(ui, node_type, source_type, restore_by_name):
setting(ui, "Restore widget values", "Comfy.Workflow.NamedValuesRestore", restore_by_name)
node_id, preview_id = graph(ui, node_type)
ui.choose(node_type, "mode", "fixed")
ui.page.get_by_placeholder("Populated Prompt (Will be generated automatically)", exact=True).fill("previous text")
ui.choose(node_type, "mode", "populate")
ui.set_number(node_type, "seed", 2)
if source_type:
title = "Text" if source_type == "PrimitiveString" else "Text (Multiline)"
source_id = ui.add_node(title, source_type, 100, 650)
ui.connect(source_type, node_type, target_slot=input_slot(ui, node_type, "wildcard_text"))
def set_source(value):
show_node(ui, source_id if source_type else node_id)
if source_type == "PrimitiveString":
set_text(ui, source_type, "value", value)
else:
placeholder = "value" if source_type else "Wildcard Prompt (User input)"
ui.page.get_by_placeholder(placeholder, exact=True).fill(value)
set_source("{red|green|blue}")
show_node(ui, node_id)
before = native_roundtrip(ui, node_id)
entry = run(ui, preview_id, "red", "generated")
# Load the native workflow captured by the server, before UI feedback can
# repair stale metadata through another Export.
workflow = entry["prompt"][3]["extra_pnginfo"]["workflow"]
path = ui.artifacts / "execution-workflow.json"
path.write_text(json.dumps(workflow, indent=2) + "\n")
ui.new_workflow()
ui.load_workflow(path, len(workflow["nodes"]))
full_zoom(ui)
show_node(ui, node_id)
expected = [dict(w, value="red") if w["name"] == "populated_text" else
dict(w, value="reproduce") if w["name"] == "mode" else w for w in before["widgets"]]
assert widget_state(ui, node_id)["widgets"] == expected
saved_node = next(n for n in workflow["nodes"] if str(n["id"]) == node_id)
assert saved_node["widgets_values"] == [w["value"] for w in expected]
assert saved_node["widgets_values_named"] == {w["name"]: w["value"] for w in expected}
set_source("changed after loading")
show_node(ui, node_id)
run(ui, preview_id, "red", "reproduced")
run(ui, preview_id, "changed after loading", "next-populate")
@pytest.mark.parametrize("node_type", NODE_TYPES)
@pytest.mark.parametrize("text", ["{red|\ngreen|blue}", "{red|{green}|blue}"], ids=["multiline", "nested"])
def test_fixed_syntax_preserves_seed(ui, node_type, text):
+374
View File
@@ -0,0 +1,374 @@
"""Chrome queue/control interactions for constant-linked wildcard prompts.
Create and edit every graph through the UI; inspect real execution history and
websocket feedback. Encode cases use the checkpoint from the cache E2E suite.
"""
import json
import time
from urllib.parse import urlsplit
from uuid import uuid4
import pytest
from test_e2e_widget_catalog import export_workflow, full_zoom, setting, show_node, widget_state
from test_e2e_wildcard_cache import (
NODE_TYPES, cached, graph, input_slot, native_roundtrip, set_text, run as run_preview,
)
from test_e2e_wildcard_cache import ui as ui # shared Chrome fixture
def run(ui, preview_id, expected, label):
show_node(ui, preview_id)
return run_preview(ui, preview_id, expected, label)
def toggle(ui, node_type, widget, value):
current = ui.app.evaluate("""(app, {type, widget}) =>
app.graph._nodes.find(n => n.type === type).widgets.find(w => w.name === widget).value
""", {"type": node_type, "widget": widget})
if current != value:
ui.page.mouse.click(*ui.point(node_type, widget=widget))
ui.page.wait_for_function("""({app, type, widget, value}) =>
app.graph._nodes.find(n => n.type === type).widgets.find(w => w.name === widget).value === value
""", arg={"app": ui.app, "type": node_type, "widget": widget, "value": value})
def constant_graph(ui, node_type, text):
node_id, preview_id = graph(ui, node_type)
ui.choose(node_type, "mode", "fixed")
source_id = ui.add_node("Text", "PrimitiveString", 100, 650)
set_text(ui, "PrimitiveString", "value", text)
ui.connect("PrimitiveString", node_type, target_slot=input_slot(ui, node_type, "populated_text"))
return node_id, preview_id, source_id
@pytest.mark.parametrize("node_type", NODE_TYPES)
@pytest.mark.parametrize("field", ["populated_text", "wildcard_text"])
def test_cold_batch_reuses_constant_cache(ui, node_type, field):
node_id, preview_id = graph(ui, node_type)
ui.choose(node_type, "control_after_generate", "increment")
ui.set_number(node_type, "seed", 731)
text = f"cold batch {uuid4().hex}"
ui.add_node("Text (Multiline)", "PrimitiveStringMultiline", 100, 650)
ui.page.get_by_placeholder("value", exact=True).fill(text)
ui.connect("PrimitiveStringMultiline", node_type, target_slot=input_slot(ui, node_type, field))
native_roundtrip(ui, node_id)
workflow = ui.artifacts / "saved.json"
# Occupy the execution worker so both native batch requests arrive cold.
ui.new_workflow()
full_zoom(ui)
ui.add_node("Text", "PrimitiveString", 100, 250)
set_text(ui, "PrimitiveString", "value", uuid4().hex)
ui.add_node("Sleep", "ImpactSleep", 650, 250)
ui.set_number("ImpactSleep", "seconds", 20)
ui.connect("PrimitiveString", "ImpactSleep")
with ui.page.expect_response(lambda r: urlsplit(r.url).path in ("/prompt", "/api/prompt")
and r.request.method == "POST") as submitted:
ui.page.get_by_test_id("queue-button").click()
assert submitted.value.ok
blocker = submitted.value.json()["prompt_id"]
ui.new_workflow()
ui.load_workflow(workflow, len(json.loads(workflow.read_text())["nodes"]))
full_zoom(ui)
batch = ui.page.get_by_role("textbox", name="Batch Count", exact=True)
batch.fill("2")
batch.press("Enter")
pending = []
def observe_queue(response):
if urlsplit(response.url).path in ("/prompt", "/api/prompt") and response.request.method == "POST":
queue = ui.page.request.get(f"{ui.base_url}/queue").json()
pending.append({"request": response.request.post_data_json, "queue": queue})
assert queue["queue_running"][0][1] == blocker
assert response.json()["prompt_id"] in [item[1] for item in queue["queue_pending"]]
ui.page.on("response", observe_queue)
try:
entries = queue_runs(ui, 2, "cold-batch")
finally:
ui.page.remove_listener("response", observe_queue)
(ui.artifacts / "cold-queue.json").write_text(json.dumps(pending, indent=2) + "\n")
assert len(pending) == 2
assert [item["request"]["prompt"][node_id]["inputs"]["seed"] for item in pending] == [731, 732]
for entry in entries:
assert entry["status"]["status_str"] == "success"
assert entry["outputs"][preview_id]["text"] == [text]
assert entry["prompt"][2][node_id]["inputs"]["seed"] == 0
assert {node_id, preview_id}.isdisjoint(cached(entries[0]))
assert {node_id, preview_id} <= cached(entries[1])
export_workflow(ui, ui.artifacts / "after-batch.json")
@pytest.mark.parametrize("restore_by_name", [False, True], ids=["positional", "named"])
@pytest.mark.parametrize("node_type", NODE_TYPES)
@pytest.mark.parametrize("upstream", [True, False], ids=["before_wildcard", "after_wildcard"])
def test_control_bridge_stop_with_constant(ui, node_type, upstream, restore_by_name):
setting(ui, "Restore widget values", "Comfy.Workflow.NamedValuesRestore", restore_by_name)
text = f"bridge {uuid4().hex}"
node_id, preview_id, source_id = constant_graph(ui, node_type, text)
ui.choose(node_type, "control_after_generate", "increment")
ui.set_number(node_type, "seed", 731)
bridge_id = ui.add_node("Control Bridge", "ImpactControlBridge", 950, 700)
if upstream:
ui.connect("PrimitiveString", "ImpactControlBridge")
ui.connect("ImpactControlBridge", node_type, target_slot=input_slot(ui, node_type, "populated_text"))
else:
ui.connect(node_type, "ImpactControlBridge", source_slot=3 if node_type == "ImpactWildcardEncode" else 0)
ui.connect("ImpactControlBridge", "PreviewAny")
native_roundtrip(ui, node_id, bridge_id)
for index in range(2):
entry = run(ui, preview_id, text, f"active-{index}")
inputs = entry["prompt"][2][node_id]["inputs"]
assert inputs["seed"] == (731 + index if upstream else 0)
assert inputs["populated_text"] == [bridge_id if upstream else source_id, 0]
if index:
assert (node_id in cached(entry)) is not upstream
show_node(ui, bridge_id)
toggle(ui, "ImpactControlBridge", "mode", False)
for label in ("stopped", "stopped-new-text"):
if label == "stopped-new-text":
show_node(ui, source_id)
set_text(ui, "PrimitiveString", "value", text + " changed")
entry = ui.run()
(ui.artifacts / f"{label}.json").write_text(json.dumps(entry, indent=2) + "\n")
assert preview_id not in entry["outputs"]
assert preview_id not in cached(entry)
native_roundtrip(ui, node_id, bridge_id)
show_node(ui, bridge_id)
toggle(ui, "ImpactControlBridge", "mode", True)
run(ui, preview_id, text + " changed", "reactivated")
@pytest.mark.parametrize("restore_by_name", [False, True], ids=["positional", "named"])
@pytest.mark.parametrize("node_type", NODE_TYPES)
def test_switch_constant_is_not_folded(ui, node_type, restore_by_name):
setting(ui, "Restore widget values", "Comfy.Workflow.NamedValuesRestore", restore_by_name)
text = f"switch {uuid4().hex}"
node_id, preview_id, _ = constant_graph(ui, node_type, text)
other_id = ui.add_node("Text (Multiline)", "PrimitiveStringMultiline", 100, 850)
show_node(ui, other_id)
ui.page.get_by_placeholder("value", exact=True).fill("{red|green|blue}")
show_node(ui, node_id)
switch_id = ui.add_node("Switch (Any)", "ImpactSwitch", 950, 700)
ui.connect("PrimitiveString", "ImpactSwitch", target_slot=input_slot(ui, "ImpactSwitch", "input1"))
ui.connect("PrimitiveStringMultiline", "ImpactSwitch", target_slot=input_slot(ui, "ImpactSwitch", "input2"))
ui.connect("ImpactSwitch", node_type, target_slot=input_slot(ui, node_type, "populated_text"))
for select_on_prompt in (False, True):
show_node(ui, switch_id)
toggle(ui, "ImpactSwitch", "sel_mode", select_on_prompt)
native_roundtrip(ui, node_id, switch_id)
for select, seed, expected in ((1, 2, text), (2, 2, "red"), (2, 5, "blue"), (1, 5, text)):
show_node(ui, switch_id)
ui.set_number("ImpactSwitch", "select", select)
show_node(ui, node_id)
ui.set_number(node_type, "seed", seed)
entry = run(ui, preview_id, expected, f"switch-{select_on_prompt}-{select}-{seed}")
assert entry["prompt"][2][node_id]["inputs"]["seed"] == seed
assert entry["prompt"][2][node_id]["inputs"]["populated_text"] == [switch_id, 0]
@pytest.mark.parametrize("restore_by_name", [False, True], ids=["positional", "named"])
@pytest.mark.parametrize("node_type", NODE_TYPES)
def test_remote_seed_precedes_constant_check(ui, node_type, restore_by_name):
setting(ui, "Restore widget values", "Comfy.Workflow.NamedValuesRestore", restore_by_name)
node_id, preview_id, source_id = constant_graph(ui, node_type, "{red|green|blue}")
remote_id = ui.add_node("Remote Int (on prompt)", "ImpactRemoteInt", 950, 700)
ui.set_number("ImpactRemoteInt", "node_id", int(node_id))
set_text(ui, "ImpactRemoteInt", "widget_name", "seed")
for seed, expected in ((2, "red"), (5, "blue")):
show_node(ui, remote_id)
ui.set_number("ImpactRemoteInt", "value", seed)
entry = run(ui, preview_id, expected, f"remote-syntax-{seed}")
assert entry["prompt"][2][node_id]["inputs"]["seed"] == seed
text = f"remote {uuid4().hex}"
show_node(ui, source_id)
set_text(ui, "PrimitiveString", "value", text)
native_roundtrip(ui, node_id, remote_id)
for index, seed in enumerate((731, 732)):
show_node(ui, remote_id)
ui.set_number("ImpactRemoteInt", "value", seed)
entry = run(ui, preview_id, text, f"remote-plain-{seed}")
assert entry["prompt"][2][node_id]["inputs"]["seed"] == 0
show_node(ui, node_id)
assert next(w["value"] for w in widget_state(ui, node_id)["widgets"] if w["name"] == "seed") == seed
if index:
assert {node_id, preview_id} <= cached(entry)
def queue_runs(ui, count, label):
"""Click Run once and observe the frontend's bounded automatic requeues."""
requests = []
prompt_ids = []
history = {}
def is_submission(request):
return urlsplit(request.url).path in ("/prompt", "/api/prompt") and request.method == "POST"
def requested(request):
if is_submission(request):
requests.append(request)
def submitted(response):
if is_submission(response.request):
assert response.ok, response.text()
prompt_ids.append(response.json()["prompt_id"])
ui.page.on("request", requested)
ui.page.on("response", submitted)
try:
ui.page.get_by_test_id("queue-button").click()
deadline = time.monotonic() + ui.timeout
while time.monotonic() < deadline:
ui.page.wait_for_timeout(100)
for prompt_id in tuple(prompt_ids):
if prompt_id not in history:
response = ui.page.request.get(f"{ui.base_url}/history/{prompt_id}")
assert response.ok, response.text()
history.update(response.json())
queue = ui.page.request.get(f"{ui.base_url}/queue").json()
frontend_idle = ui.app.evaluate("app => app.processingQueue === false && app.queueItems.length === 0")
assert len(requests) <= count, f"Expected {count} submissions, observed {len(requests)}"
if (len(prompt_ids) == count and all(key in history for key in prompt_ids)
and not any(queue.values()) and frontend_idle):
entries = [history[key] for key in prompt_ids]
(ui.artifacts / f"{label}.json").write_text(json.dumps(entries, indent=2) + "\n")
return entries
raise AssertionError(f"Expected {count} completed frontend submissions, got {prompt_ids}")
finally:
ui.page.remove_listener("request", requested)
ui.page.remove_listener("response", submitted)
def test_queue_runs_rejects_extra_submission_before_response(ui):
ui.add_node("Queue Trigger (Countdown)", "ImpactQueueTriggerCountdown", 400, 300)
ui.set_number("ImpactQueueTriggerCountdown", "total", 4)
requests = []
held = []
def delay_fourth(route):
if route.request.method != "POST":
route.continue_()
return
requests.append(route.request)
if len(requests) == 4:
held.append(route)
else:
route.continue_()
ui.page.route("**/prompt", delay_fourth)
try:
with pytest.raises(AssertionError, match="Expected 3 submissions, observed 4"):
queue_runs(ui, 3, "unexpected-fourth")
assert len(held) == 1
finally:
for route in held:
route.abort()
ui.page.unroute("**/prompt", delay_fourth)
@pytest.mark.parametrize("restore_by_name", [False, True], ids=["positional", "named"])
@pytest.mark.parametrize("node_type", NODE_TYPES)
@pytest.mark.parametrize("behavior,node_mode,bypass_input", [
pytest.param("Mute", 2, False, id="Mute"),
pytest.param("Bypass", 4, False, id="Bypass-populated-only"),
pytest.param("Bypass", 4, True, id="Bypass-connected-first-input"),
])
def test_control_bridge_mode_change_requeues(ui, node_type, behavior, node_mode, bypass_input, restore_by_name):
setting(ui, "Restore widget values", "Comfy.Workflow.NamedValuesRestore", restore_by_name)
node_id, preview_id, _ = constant_graph(ui, node_type, "{red|green|blue}")
ui.set_number(node_type, "seed", 2)
bridge_id = ui.add_node("Control Bridge", "ImpactControlBridge", 950, 700)
ui.choose("ImpactControlBridge", "behavior", behavior)
ui.connect("PrimitiveString", "ImpactControlBridge")
ui.connect("ImpactControlBridge", node_type, target_slot=input_slot(ui, node_type, "populated_text"))
if bypass_input:
# Native Bypass forwards the first matching input, wildcard_text.
ui.connect("PrimitiveString", node_type, target_slot=input_slot(ui, node_type, "wildcard_text"))
native_roundtrip(ui, node_id, bridge_id)
run(ui, preview_id, "red", "active")
show_node(ui, bridge_id)
toggle(ui, "ImpactControlBridge", "mode", False)
entries = queue_runs(ui, 2, "deactivate")
assert any(event == "execution_interrupted" for event, _ in entries[0]["status"]["messages"])
assert entries[1]["status"]["status_str"] == "success"
assert node_id not in entries[1]["prompt"][2]
assert ui.app.evaluate("(app, id) => app.graph.getNodeById(id).mode", node_id) == node_mode
if behavior == "Mute":
assert preview_id not in entries[1]["outputs"]
else:
if (node_type == "ImpactWildcardProcessor" and not bypass_input
and entries[1]["prompt"][2][preview_id]["inputs"] == {}
and preview_id not in entries[1]["outputs"]):
pytest.xfail("Pre-existing on Main + #1269: Processor Bypass drops PreviewAny.source when only populated_text is linked")
assert entries[1]["outputs"][preview_id]["text"] == ["{red|green|blue}"]
native_roundtrip(ui, node_id, bridge_id)
assert ui.app.evaluate("(app, id) => app.graph.getNodeById(id).mode", node_id) == node_mode
show_node(ui, bridge_id)
toggle(ui, "ImpactControlBridge", "mode", True)
entries = queue_runs(ui, 2, "reactivate")
assert node_id not in entries[0]["prompt"][2]
# With a muted target, the bridge can be the last executable node: the
# requeue still happens, but no following node polls the interrupt flag.
if entries[0]["status"]["status_str"] != "success":
assert any(event == "execution_interrupted" for event, _ in entries[0]["status"]["messages"])
assert entries[1]["status"]["status_str"] == "success"
assert entries[1]["outputs"][preview_id]["text"] == ["red"]
assert entries[1]["prompt"][2][node_id]["inputs"]["seed"] == 2
assert ui.app.evaluate("(app, id) => app.graph.getNodeById(id).mode", node_id) == 0
@pytest.mark.parametrize("restore_by_name", [False, True], ids=["positional", "named"])
@pytest.mark.parametrize("node_type", NODE_TYPES)
def test_widget_feedback_and_countdown_requeue(ui, node_type, restore_by_name):
setting(ui, "Restore widget values", "Comfy.Workflow.NamedValuesRestore", restore_by_name)
text = f"feedback {uuid4().hex}"
node_id, preview_id, source_id = constant_graph(ui, node_type, text)
ui.choose(node_type, "control_after_generate", "increment")
ui.set_number(node_type, "seed", 731)
replacement_id = ui.add_node("Text (Multiline)", "PrimitiveStringMultiline", 100, 850)
show_node(ui, replacement_id)
ui.page.get_by_placeholder("value", exact=True).fill(text + " changed")
show_node(ui, node_id)
setter_id = ui.add_node("Set Widget Value", "ImpactSetWidgetValue", 950, 650)
ui.set_number("ImpactSetWidgetValue", "node_id", int(source_id))
set_text(ui, "ImpactSetWidgetValue", "widget_name", "value")
ui.connect(node_type, "ImpactSetWidgetValue", source_slot=3 if node_type == "ImpactWildcardEncode" else 0)
ui.connect("PrimitiveStringMultiline", "ImpactSetWidgetValue",
target_slot=input_slot(ui, "ImpactSetWidgetValue", "string_value"))
countdown_id = ui.add_node("Queue Trigger (Countdown)", "ImpactQueueTriggerCountdown", 1300, 700)
ui.set_number("ImpactQueueTriggerCountdown", "total", 3)
ui.connect("ImpactSetWidgetValue", "ImpactQueueTriggerCountdown",
target_slot=input_slot(ui, "ImpactQueueTriggerCountdown", "signal"))
native_roundtrip(ui, node_id, setter_id, countdown_id)
entries = queue_runs(ui, 3, "feedback-countdown")
for index, (entry, expected) in enumerate(zip(entries, (text, text + " changed", text + " changed"))):
assert entry["status"]["status_str"] == "success", entry["status"]
assert entry["outputs"][preview_id]["text"] == [expected]
assert entry["prompt"][2][source_id]["inputs"]["value"] == expected
assert entry["prompt"][2][node_id]["inputs"]["seed"] == 0
assert entry["prompt"][2][countdown_id]["inputs"]["count"] == index
assert (node_id in cached(entry)) == (index == 2)
assert (preview_id in cached(entry)) == (index == 2)
for target, widget, expected in ((source_id, "value", text + " changed"), (countdown_id, "count", 0)):
show_node(ui, target)
assert next(w["value"] for w in widget_state(ui, target)["widgets"] if w["name"] == widget) == expected
# A runtime widget update can introduce wildcard syntax. The next queued
# prompt must keep its requested seed instead of reusing the old static key.
syntax = text + " {red|green|blue}"
show_node(ui, replacement_id)
ui.page.get_by_placeholder("value", exact=True).fill(syntax)
show_node(ui, node_id)
ui.choose(node_type, "control_after_generate", "fixed")
ui.set_number(node_type, "seed", 2)
entries = queue_runs(ui, 3, "feedback-syntax-countdown")
for index, entry in enumerate(entries):
assert entry["status"]["status_str"] == "success", entry["status"]
assert entry["outputs"][preview_id]["text"] == [text + (" changed" if index == 0 else " red")]
assert entry["prompt"][2][source_id]["inputs"]["value"] == (text + " changed" if index == 0 else syntax)
assert entry["prompt"][2][node_id]["inputs"]["seed"] == (0 if index == 0 else 2)
assert entry["prompt"][2][countdown_id]["inputs"]["count"] == index
assert (node_id in cached(entry)) == (index != 1)
native_roundtrip(ui, node_id, setter_id, countdown_id)
+6
View File
@@ -1,6 +1,12 @@
"""Stub dependencies unused by wildcard parsing; pytest.ini handles import paths."""
import sys
import types
from unittest.mock import Mock
for name in ("folder_paths", "nodes", "impact.utils"):
sys.modules.setdefault(name, types.ModuleType(name))
graph_utils = types.ModuleType("comfy_execution.graph_utils")
graph_utils.is_link = Mock(side_effect=AssertionError("Run cache tests with real ComfyUI using integration_wildcard_cache.py"))
sys.modules.setdefault("comfy_execution.graph_utils", graph_utils)