diff --git a/modules/impact/impact_server.py b/modules/impact/impact_server.py index d5c542d5..8e869679 100644 --- a/modules/impact/impact_server.py +++ b/modules/impact/impact_server.py @@ -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): diff --git a/modules/impact/wildcards.py b/modules/impact/wildcards.py index d2beb7ed..cc8b5475 100644 --- a/modules/impact/wildcards.py +++ b/modules/impact/wildcards.py @@ -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() diff --git a/tests/integration_wildcard_cache.py b/tests/integration_wildcard_cache.py index 794b6370..d5c3f182 100644 --- a/tests/integration_wildcard_cache.py +++ b/tests/integration_wildcard_cache.py @@ -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): diff --git a/tests/test_e2e_wildcard_cache.py b/tests/test_e2e_wildcard_cache.py index acc178bb..313e6ec0 100644 --- a/tests/test_e2e_wildcard_cache.py +++ b/tests/test_e2e_wildcard_cache.py @@ -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): diff --git a/tests/test_e2e_wildcard_controls.py b/tests/test_e2e_wildcard_controls.py new file mode 100644 index 00000000..f1173ebe --- /dev/null +++ b/tests/test_e2e_wildcard_controls.py @@ -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) diff --git a/tests/wildcards/conftest.py b/tests/wildcards/conftest.py index ce7e9740..f9ad6618 100644 --- a/tests/wildcards/conftest.py +++ b/tests/wildcards/conftest.py @@ -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)