From d246db9e2736a7ef0edfca50b521113fc021ea2d Mon Sep 17 00:00:00 2001 From: "Dr.Lt.Data" Date: Tue, 3 Nov 2026 20:45:28 +0900 Subject: [PATCH] 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. --- modules/impact/impact_server.py | 36 ++- modules/impact/wildcards.py | 23 +- tests/integration_wildcard_cache.py | 252 +++++++++++++++---- tests/test_e2e_wildcard_cache.py | 252 ++++++++++++++++++- tests/test_e2e_wildcard_controls.py | 374 ++++++++++++++++++++++++++++ tests/wildcards/conftest.py | 6 + 6 files changed, 869 insertions(+), 74 deletions(-) create mode 100644 tests/test_e2e_wildcard_controls.py 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)