Files
JasonHoku-ComfyUI-Ultimate-…/tests/test_workflow_sweep_runner.py
T
Jason HokuandClaude Fable 5.1 968b0dfdfc feat(sweep): attach to a running identical sweep, job_prompt route, configs in sweep meta
Identity = session + ordered job fingerprints + start_at_job + overwrite_existing; anything
else gets a clear 409. GET /config_tester/sweep/job_prompt returns one job's API prompt.
Sweep manifests now save their workflow configs in meta.configs_json.
pause/resume emit a progress event.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-04 00:52:56 -08:00

774 lines
34 KiB
Python

"""Workflow Sweep runner: window, results, errors, breaker, interrupt/lost, cancel, resume, bridge mirror."""
import asyncio
import json
import os
import sys
import threading
import time
import types
import pytest
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
import workflow_sweep as wfs # noqa: E402
import workflow_sweep_runner as runner # noqa: E402
def _real_manifest_utils():
# conftest registers a stub manifest_utils; load the real file for update_meta
import importlib.util
spec = importlib.util.spec_from_file_location("manifest_utils_real_sweep", os.path.join(ROOT, "manifest_utils.py"))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def load(name):
with open(os.path.join(ROOT, "tests", "fixtures", name), "r", encoding="utf-8") as f:
return json.load(f)
def entry(values=(1, 2, 3, 4, 5, 6), **extra):
e = {"model_type": "workflow", "name": "c", "workflow": load("image_moss_image_t2i.json"),
"workflow_output": {"node_id": "38", "output_index": 0},
"workflow_bindings": [{"id": "b", "kind": "literal", "targets": [{"node_id": "32", "input": "seed"}],
"values": list(values)}]}
e.update(extra)
return e
class FakeBridge:
"""Synchronous stand-in for ComfyBridge with a hand-driven queue/history."""
def __init__(self):
self.queue, self.running, self.history, self.meta = [], [], {}, {}
self.enqueued, self.events, self.deleted, self.interrupted = [], [], [], []
self.reject = None # callable(prompt) -> error string | None
self.n = 0
def run_on_loop(self, coro):
return asyncio.run(coro)
async def validate(self, prompt, partial):
err = self.reject(prompt) if self.reject else None
if err:
return False, {"type": "prompt_outputs_failed_validation", "message": err}, \
{"29": {"class_type": "UNETLoader", "errors": [{"message": err, "details": "unet_name"}]}}
return True, None, {}
async def enqueue(self, prompt, partial, client_id):
ok, error, node_errors = await self.validate(prompt, partial)
if not ok:
return None, error, node_errors
self.n += 1
pid = f"pid-{self.n}"
collector = [n for n in prompt.values() if n["class_type"] == "USCGSweepCollect"][0]
self.meta[pid] = json.loads(collector["inputs"]["job_json"])
self.queue.append(pid)
self.enqueued.append((pid, prompt, partial, client_id))
return pid, None, {}
def queue_state(self):
return set(self.running), set(self.queue)
def history_entry(self, pid):
return self.history.get(pid)
def delete_queued(self, pid):
if pid in self.queue:
self.queue.remove(pid)
self.deleted.append(pid)
return True
return False
def interrupt(self, pid):
self.interrupted.append(pid)
return pid in self.running
def send_sync(self, event, data):
self.events.append((event, data))
# ---- test controls
def start(self, pid):
self.queue.remove(pid)
self.running.append(pid)
def finish(self, pid, kind="success", collect=True, ts=(1000, 3500)):
if pid in self.queue:
self.queue.remove(pid)
if pid in self.running:
self.running.remove(pid)
msgs = [["execution_start", {"prompt_id": pid, "timestamp": ts[0]}]]
if kind == "success":
msgs.append(["execution_success", {"prompt_id": pid, "timestamp": ts[1]}])
status = "success"
elif kind == "error":
msgs.append(["execution_error", {"prompt_id": pid, "node_id": "32", "node_type": "KSampler",
"exception_message": "CUDA out of memory", "timestamp": ts[1]}])
status = "error"
else:
msgs.append(["execution_interrupted", {"prompt_id": pid, "timestamp": ts[1]}])
status = "error"
self.history[pid] = {"status": {"status_str": status, "completed": kind == "success", "messages": msgs},
"outputs": {}}
if kind == "success" and collect:
meta = self.meta[pid]
runner.on_collected(meta["sweep_id"], meta["job_index"], [{"id": 1}])
def progress_events(self):
return [d for e, d in self.events if e == "ultimate_grid.progress"]
@pytest.fixture
def env(tmp_path, monkeypatch):
import folder_paths
monkeypatch.setattr(folder_paths, "get_output_directory", lambda: str(tmp_path), raising=False)
monkeypatch.setattr(runner, "manifest_utils", _real_manifest_utils(), raising=False)
runner.reset_registry()
bridge = FakeBridge()
yield types.SimpleNamespace(bridge=bridge, tmp=tmp_path)
runner.reset_registry()
def make(env, configs=None, settings=None, origin="node7", thread=False):
settings = {"session_name": "wf_test", **(settings or {})}
sw = runner.prepare_sweep(configs or [entry()], settings, origin, None, env.bridge, class_info=None)
runner.launch(sw, start_thread=thread)
return sw
def drive(sw, bridge, n=1):
for _ in range(n):
if not runner.tick(sw):
return False
return True
def complete_all(sw, bridge, limit=100):
for _ in range(limit):
for pid in list(bridge.queue):
bridge.start(pid)
for pid in list(bridge.running):
bridge.finish(pid)
if not runner.tick(sw):
return
raise AssertionError("sweep did not finish")
def test_window_respected_and_refilled(env):
sw = make(env)
drive(sw, env.bridge)
assert len(env.bridge.queue) == runner.WINDOW == 2
drive(sw, env.bridge, 3)
assert len(env.bridge.enqueued) == 2 # nothing new while two are in flight
pid = env.bridge.queue[0]
env.bridge.start(pid)
env.bridge.finish(pid)
drive(sw, env.bridge)
assert len(env.bridge.enqueued) == 3 # the freed slot is refilled
assert len(sw.in_flight()) == 2
def test_enqueued_prompt_shape_and_synthetic_client_id(env):
sw = make(env)
drive(sw, env.bridge)
pid, prompt, partial, client_id = env.bridge.enqueued[0]
collector_id = [k for k, v in prompt.items() if v["class_type"] == "USCGSweepCollect"][0]
assert partial == [collector_id] # the template's SaveImage / PreviewAny are not executed
assert client_id == f"uscg-sweep-{sw.id}"
job_json = json.loads(prompt[collector_id]["inputs"]["job_json"])
assert job_json["sweep_id"] == sw.id and job_json["job_index"] == 0
assert "class_type" not in json.dumps(job_json) # job conf only, never the template
assert prompt[collector_id]["inputs"]["session_name"] == "wf_test"
assert prompt["32"]["inputs"]["seed"] == 1
def test_run_other_outputs_executes_everything(env):
sw = make(env, [entry(workflow_run_other_outputs=True)])
drive(sw, env.bridge)
assert env.bridge.enqueued[0][2] is None
def test_success_path_progress_events_and_completion(env):
sw = make(env, [entry((1, 2, 3))])
complete_all(sw, env.bridge)
assert sw.state == "completed"
assert [j["status"] for j in sw.jobs] == ["done"] * 3
assert sw.jobs[0]["duration"] == pytest.approx(2.5) # from ComfyUI's execution_start/success timestamps
events = env.bridge.progress_events()
assert events[0]["current_job"] == 0 and events[0]["total_jobs"] == 3 and events[0]["phase"] == "sweep"
assert all(e["node"] == "node7" and e["session_name"] == "wf_test" for e in events)
last = events[-1]
assert last["complete"] is True and last["total_generated"] == 3 and last["eta_str"] == "Done"
assert any(e["current_job"] == 2 and not e.get("complete") for e in events)
status = runner.get_status(sw.id)
assert status["done"] == 3 and status["errors"] == 0 and status["state"] == "completed"
assert runner.get_status(session="wf_test")["sweep_id"] == sw.id
def test_spec_file_written_without_secrets(env):
sw = make(env, [entry((1, 2))])
complete_all(sw, env.bridge)
path = env.tmp / "benchmarks" / "wf_test" / "workflow_sweeps" / f"{sw.id}.json"
spec = json.loads(path.read_text())
assert spec["state"] == "completed" and len(spec["jobs"]) == 2
assert spec["jobs"][0]["subs"] == [["32", "seed", 1]] and spec["jobs"][0]["prompt_id"] == "pid-1"
assert list(spec["templates"].values())[0]["output"] == ["38", 0]
def test_manifest_created_with_meta(env):
make(env)
data = json.loads((env.tmp / "benchmarks" / "wf_test" / "manifest.json").read_text())
assert data["session_name"] == "wf_test" and data["items"] == [] and data["meta"]["image_format"] == "webp"
def test_job_error_recorded_and_sweep_continues(env):
sw = make(env, [entry((1, 2, 3))])
drive(sw, env.bridge)
p1, p2 = env.bridge.queue[:2]
env.bridge.start(p1)
env.bridge.finish(p1, "error")
env.bridge.start(p2)
env.bridge.finish(p2)
complete_all(sw, env.bridge)
assert [j["status"] for j in sw.jobs] == ["error", "done", "done"]
assert "CUDA out of memory" in sw.jobs[0]["error"] and "KSampler" in sw.jobs[0]["error"]
assert sw.state == "completed" and "1 job" in sw.reason
status = runner.get_status(sw.id)
assert status["errors"] == 1 and status["last_errors"][0]["index"] == 0
assert sw.consecutive_errors == 0 # reset by the later success
def test_three_consecutive_errors_pause(env):
sw = make(env, [entry(range(1, 9))])
for _ in range(3):
drive(sw, env.bridge)
for pid in list(env.bridge.queue):
env.bridge.start(pid)
for pid in list(env.bridge.running):
env.bridge.finish(pid, "error")
break
drive(sw, env.bridge)
if sw.state == "paused":
break
drive(sw, env.bridge)
assert sw.state == "paused" and "in a row failed" in sw.reason and "CUDA" in sw.reason
def test_invalid_job_continues_and_first_invalid_fails_start(env):
env.bridge.reject = lambda prompt: "Value not in list: unet_name" if prompt["32"]["inputs"]["seed"] == 2 else None
sw = make(env, [entry((1, 2, 3))])
complete_all(sw, env.bridge)
assert [j["status"] for j in sw.jobs] == ["done", "invalid", "done"]
assert "node 29 (UNETLoader" in sw.jobs[1]["error"] and "Value not in list" in sw.jobs[1]["error"]
# job 1 invalid at start -> the sweep is refused with the node errors (route answers 400)
env.bridge.reject = lambda prompt: "model missing"
sw2 = runner.prepare_sweep([entry((1, 2))], {"session_name": "other"}, None, None, env.bridge, class_info=None)
runner.reset_registry()
with pytest.raises(ValueError, match="first job is not valid.*model missing"):
asyncio.run(runner.validate_first(sw2))
def test_interrupted_pauses_and_resume_requeues(env):
sw = make(env, [entry((1, 2, 3))])
drive(sw, env.bridge)
p1 = env.bridge.queue[0]
env.bridge.start(p1)
env.bridge.finish(p1, "interrupted")
drive(sw, env.bridge)
assert sw.state == "paused" and "interrupted" in sw.reason
assert sw.jobs[0]["status"] == "interrupted"
# the second (still queued) prompt is pulled while paused
drive(sw, env.bridge)
assert env.bridge.deleted and sw.jobs[1]["status"] == "pending"
ok, state = runner.resume(sw.id)
assert ok and state == "running" and sw.jobs[0]["status"] == "pending"
complete_all(sw, env.bridge)
assert [j["status"] for j in sw.jobs] == ["done"] * 3 and sw.state == "completed"
def test_lost_prompt_pauses(env, monkeypatch):
monkeypatch.setattr(runner, "LOST_GRACE", -1)
sw = make(env, [entry((1, 2, 3))])
drive(sw, env.bridge)
env.bridge.queue.clear() # user cleared ComfyUI's queue
drive(sw, env.bridge)
assert sw.state == "paused" and "removed from ComfyUI's queue" in sw.reason
assert {j["status"] for j in sw.jobs[:2]} == {"lost"}
runner.resume(sw.id)
assert [j["status"] for j in sw.jobs[:2]] == ["pending", "pending"]
def test_success_without_collected_item_is_an_error(env, monkeypatch):
monkeypatch.setattr(runner, "COLLECT_GRACE", -1)
sw = make(env, [entry((1,))])
drive(sw, env.bridge)
pid = env.bridge.queue[0]
env.bridge.start(pid)
env.bridge.finish(pid, collect=False)
drive(sw, env.bridge, 2)
assert sw.jobs[0]["status"] == "error" and "collector" in sw.jobs[0]["error"]
def test_cancel_deletes_queued_interrupts_running_and_marks_rest(env):
sw = make(env, [entry(range(1, 6))])
drive(sw, env.bridge)
p1, p2 = env.bridge.queue[:2]
env.bridge.start(p1)
drive(sw, env.bridge)
assert sw.jobs[0]["status"] == "running"
ok, state = runner.cancel(sw.id)
assert ok and state == "cancelled"
assert drive(sw, env.bridge) is False
assert env.bridge.deleted == [p2] and env.bridge.interrupted == [p1]
assert {j["status"] for j in sw.jobs} == {"cancelled"}
assert runner.cancel(sw.id)[0] is False # already finished
assert runner.get_status(sw.id)["state"] == "cancelled"
def test_pause_pulls_queued_but_lets_running_finish_then_resume(env):
sw = make(env, [entry((1, 2, 3))])
drive(sw, env.bridge)
p1, p2 = env.bridge.queue[:2]
env.bridge.start(p1)
drive(sw, env.bridge)
assert runner.pause(sw.id) == (True, "paused")
drive(sw, env.bridge)
assert env.bridge.deleted == [p2] and sw.jobs[0]["status"] == "running" and sw.jobs[1]["status"] == "pending"
env.bridge.finish(p1)
drive(sw, env.bridge)
assert sw.jobs[0]["status"] == "done" and sw.state == "paused" and not env.bridge.queue
assert runner.pause(sw.id)[0] is False
runner.resume(sw.id)
complete_all(sw, env.bridge)
assert sw.state == "completed"
def _write_manifest(env, items):
d = env.tmp / "benchmarks" / "wf_test"
d.mkdir(parents=True, exist_ok=True)
(d / "manifest.json").write_text(json.dumps({"session_name": "wf_test", "items": items, "meta": {"x": 1}}))
def test_resume_skips_fingerprints_in_manifest(env):
plan = wfs.plan_workflow_configs([entry((1, 2, 3))])
done_fps = [plan["jobs"][0]["wf_fingerprint"], plan["jobs"][2]["wf_fingerprint"]]
_write_manifest(env, [{"id": 11, "wf_fingerprint": done_fps[0], "gen_index": 4},
{"id": 12, "wf_fingerprint": done_fps[1]}, {"id": 13}])
sw = make(env, [entry((1, 2, 3))])
assert [j["status"] for j in sw.jobs] == ["skipped", "pending", "skipped"]
assert sw.skipped_existing == 2 and sw.to_run == 1
assert sw.jobs[1]["gen_index"] == 5 + 1 # continues after the highest existing gen_index
complete_all(sw, env.bridge)
assert len(env.bridge.enqueued) == 1 and env.bridge.enqueued[0][1]["32"]["inputs"]["seed"] == 2
first_event = env.bridge.progress_events()[0]
assert first_event["current_job"] == 2 and first_event["total_jobs"] == 3 # "2/3 already done"
def test_overwrite_existing_reruns_and_carries_replace_ids(env):
plan = wfs.plan_workflow_configs([entry((1, 2))])
_write_manifest(env, [{"id": 11, "wf_fingerprint": plan["jobs"][0]["wf_fingerprint"]}])
sw = make(env, [entry((1, 2))], {"overwrite_existing": True})
assert [j["status"] for j in sw.jobs] == ["pending", "pending"] and sw.jobs[0]["replace_ids"] == [11]
drive(sw, env.bridge)
jj = env.bridge.meta[env.bridge.enqueued[0][0]]
assert jj["replace_item_ids"] == [11]
def test_start_at_job_skips_first_n(env):
sw = make(env, [entry((1, 2, 3, 4))], {"start_at_job": 2})
assert [j["status"] for j in sw.jobs] == ["skipped", "skipped", "pending", "pending"]
assert sw.skipped_existing == 0
def test_all_done_sweep_completes_immediately(env):
plan = wfs.plan_workflow_configs([entry((1,))])
_write_manifest(env, [{"id": 11, "wf_fingerprint": plan["jobs"][0]["wf_fingerprint"]}])
sw = make(env, [entry((1,))])
assert drive(sw, env.bridge) is False and sw.state == "completed"
def test_one_active_sweep_and_409(env):
sw = make(env)
with pytest.raises(runner.SweepBusy):
runner.prepare_sweep([entry()], {"session_name": "x"}, None, None, env.bridge, class_info=None)
status, body = asyncio.run(runner.api_start(
{"configs_json": json.dumps({"configs": [entry()], "_session_settings": {"session_name": "x"}})},
bridge=env.bridge, class_info=None))
assert status == 409 and body["sweep_id"] == sw.id
runner.cancel(sw.id)
drive(sw, env.bridge)
runner.prepare_sweep([entry()], {"session_name": "x"}, None, None, env.bridge, class_info=None) # free again
def test_api_start_ok_and_bad_input(env):
body = {"configs_json": json.dumps({"configs": [entry((1, 2))], "_session_settings": {"session_name": "api"}}),
"node_id": "5"}
status, out = asyncio.run(runner.api_start(body, bridge=env.bridge, class_info=None))
assert status == 200 and out["total"] == 2 and out["to_run"] == 2
runner.cancel(out["sweep_id"])
time.sleep(runner.TICK * 3)
status, out = asyncio.run(runner.api_start({"configs_json": json.dumps({"configs": [{"model_type": "checkpoint"}]})},
bridge=env.bridge, class_info=None))
assert status == 400 and "cannot be mixed" in out["error"]
status, out = asyncio.run(runner.api_start({}, bridge=env.bridge, class_info=None))
assert status == 400
def test_start_sweep_returns_while_jobs_pending_and_never_blocks(env):
t0 = time.time()
sw = runner.start_sweep([entry(range(1, 50))], {"session_name": "nb"}, "n1", None, env.bridge, class_info=None)
assert time.time() - t0 < 2 and sw.state == "running"
time.sleep(runner.TICK * 3) # the feeder thread fills the window on its own
assert len(env.bridge.enqueued) == runner.WINDOW
assert runner.cancel(sw.id)[0]
sw.thread.join(5)
assert not sw.thread.is_alive() and sw.state == "cancelled"
def test_dry_run_shape_and_no_queueing(env):
body = {"configs_json": json.dumps({"configs": [entry((1, 2, 3))], "_session_settings": {"session_name": "dry"}}),
"limit": 2}
out = asyncio.run(runner.dry_run(body, bridge=env.bridge, class_info=None))
assert out["total"] == 3 and out["to_run"] == 3 and len(out["jobs"]) == 2
assert out["validation"]["ok"] is True and out["axes"][0]["count"] == 3
j = out["jobs"][0]
assert j["prompt"]["32"]["inputs"]["seed"] == 1 and j["partial_execution_targets"]
assert any(n["class_type"] == "USCGSweepCollect" for n in j["prompt"].values())
assert env.bridge.enqueued == [] and runner._active_sweep() is None
env.bridge.reject = lambda p: "bad model"
out = asyncio.run(runner.dry_run(body, bridge=env.bridge, class_info=None))
assert out["validation"]["ok"] is False and "bad model" in out["validation"]["error"]
def test_dry_run_allowed_while_a_sweep_is_active(env):
make(env)
body = {"configs_json": json.dumps({"configs": [entry((1,))], "_session_settings": {"session_name": "d2"}})}
assert asyncio.run(runner.dry_run(body, bridge=env.bridge, class_info=None))["total"] == 1
def test_configs_from_body_uses_builder_state_transformer():
state = {"session_name": "st", "config_arrays": [{
"name": "WF", "kind": "workflow",
"workflow": {"api_prompt": load("sdxl_default_api.json"), "output": {"node_id": "8", "output_index": 0},
"bindings": []}}]}
configs, settings = runner.configs_from_body({"state": state})
assert configs[0]["model_type"] == "workflow" and settings["session_name"] == "st"
with pytest.raises(ValueError):
runner.configs_from_body({})
def test_classify_history_entries():
ok = {"status": {"status_str": "success", "messages": [["execution_start", {"timestamp": 1}],
["execution_success", {"timestamp": 3}]]}}
assert runner._classify(ok)[0] == "success"
err = {"status": {"status_str": "error", "messages": [["execution_error", {"node_id": "3", "node_type": "K",
"exception_message": "boom"}]]}}
kind, detail = runner._classify(err)
assert kind == "error" and "boom" in detail["error"] and "K #3" in detail["error"]
assert runner._classify({"status": {"status_str": "error", "messages": [["execution_interrupted", {}]]}})[0] == "interrupted"
assert runner._classify({"status": None})[0] == "unknown"
assert runner._classify(None)[0] == "unknown"
def test_summarize_errors():
msg = runner.summarize_errors({"message": "Prompt outputs failed validation", "details": "x"},
{"29": {"class_type": "UNETLoader", "errors": [{"message": "Value not in list",
"details": "unet_name: 'a'"}]}},
load("image_moss_image_t2i.json"))
assert "Prompt outputs failed validation" in msg and "node 29 (UNETLoader / Load Diffusion Model)" in msg
# ------------------------------------------------------------------ ComfyBridge mirrors server.py post_prompt
class _Queue:
def __init__(self):
self.items = []
def put(self, item):
self.items.append(item)
def _fake_comfy(monkeypatch, with_sensitive=True, validate_args=3, valid=True, handlers=None, replace=True):
calls = {"validate": [], "replace": [], "handlers": []}
class PS:
number = 5.0
prompt_queue = _Queue()
loop = None
def trigger_on_prompt(self, json_data):
calls["handlers"].append(dict(json_data))
if handlers:
json_data = handlers(json_data)
return json_data
class node_replace_manager: # noqa: N801
@staticmethod
def apply_replacements(prompt):
calls["replace"].append(prompt)
prompt["9999"] = {"class_type": "Replaced", "inputs": {}}
ps = PS()
if not replace:
del PS.node_replace_manager
server_mod = sys.modules["server"]
monkeypatch.setattr(server_mod, "PromptServer", types.SimpleNamespace(instance=ps), raising=False)
monkeypatch.setattr(server_mod, "valid_workflow_metadata", lambda jd: jd.get("workflow_metadata"), raising=False)
monkeypatch.setattr(server_mod, "workflow_metadata_from_prompt", lambda ed: None, raising=False)
ex = types.ModuleType("execution")
if validate_args == 3:
async def validate_prompt(prompt_id, prompt, partial_execution_list):
calls["validate"].append((prompt_id, partial_execution_list))
return (valid, None if valid else {"message": "bad"}, ["38x"], {} if valid else {"3": {"errors": []}})
elif validate_args == 2:
def validate_prompt(prompt_id, prompt):
calls["validate"].append((prompt_id, None))
return (valid, None, ["38x"], {})
else:
def validate_prompt(prompt):
calls["validate"].append((None, None))
return (valid, None, ["38x"], {})
ex.validate_prompt = validate_prompt
if with_sensitive:
ex.SENSITIVE_EXTRA_DATA_KEYS = ("auth_token_comfy_org", "api_key_comfy_org")
monkeypatch.setitem(sys.modules, "execution", ex)
return ps, calls
def test_bridge_enqueue_mirrors_post_prompt(monkeypatch):
ps, calls = _fake_comfy(monkeypatch)
prompt = {"1": {"class_type": "X", "inputs": {}}}
pid, err, node_errors = asyncio.run(runner.ComfyBridge().enqueue(prompt, ["1"], "cid"))
assert err is None and node_errors == {}
(item,) = ps.prompt_queue.items
assert len(item) == 6 # (number, prompt_id, prompt, extra_data, outputs, sensitive)
number, prompt_id, queued_prompt, extra, outputs, sensitive = item
assert number == 5.0 and ps.number == 6.0 # number taken, then incremented
assert prompt_id == pid and len(pid) == 36
assert queued_prompt is prompt and "9999" in prompt # node replacements applied before validation
assert extra["client_id"] == "cid" and isinstance(extra["create_time"], int)
assert outputs == ["38x"] and sensitive == {}
assert calls["validate"] == [(pid, ["1"])]
assert calls["handlers"][0]["prompt"] is prompt and calls["handlers"][0]["partial_execution_targets"] == ["1"]
def test_bridge_enqueue_honours_on_prompt_handler_and_strips_sensitive(monkeypatch):
def handler(jd):
jd["extra_data"] = {"api_key_comfy_org": "SECRET", "other": 1}
jd["number"] = 2
return jd
ps, _ = _fake_comfy(monkeypatch, handlers=handler)
asyncio.run(runner.ComfyBridge().enqueue({"1": {"class_type": "X", "inputs": {}}}, None, "c"))
number, _, _, extra, _, sensitive = ps.prompt_queue.items[0]
assert number == 2.0 and ps.number == 5.0 # an explicit number does not consume the counter
assert sensitive == {"api_key_comfy_org": "SECRET"} and "api_key_comfy_org" not in extra and extra["other"] == 1
def test_bridge_enqueue_older_server_shapes(monkeypatch):
ps, calls = _fake_comfy(monkeypatch, with_sensitive=False, validate_args=2, replace=False)
pid, err, _ = asyncio.run(runner.ComfyBridge().enqueue({"1": {"class_type": "X", "inputs": {}}}, None, "c"))
assert err is None and len(ps.prompt_queue.items[0]) == 5 and calls["validate"][0][0] == pid
ps, calls = _fake_comfy(monkeypatch, validate_args=1)
asyncio.run(runner.ComfyBridge().enqueue({"1": {"class_type": "X", "inputs": {}}}, None, "c"))
assert len(ps.prompt_queue.items[0]) == 6
def test_bridge_enqueue_invalid_prompt_is_not_queued(monkeypatch):
ps, _ = _fake_comfy(monkeypatch, valid=False)
pid, err, node_errors = asyncio.run(runner.ComfyBridge().enqueue({"1": {"class_type": "X", "inputs": {}}}, None, "c"))
assert pid is None and err == {"message": "bad"} and "3" in node_errors and ps.prompt_queue.items == []
def test_bridge_validate_does_not_queue(monkeypatch):
ps, calls = _fake_comfy(monkeypatch)
ok, err, ne = asyncio.run(runner.ComfyBridge().validate({"1": {"class_type": "X", "inputs": {}}}, ["1"]))
assert ok and ps.prompt_queue.items == [] and len(calls["validate"]) == 1
def test_run_on_loop_refuses_the_loop_thread_and_works_from_others(monkeypatch):
loop = asyncio.new_event_loop()
t = threading.Thread(target=loop.run_forever, daemon=True)
t.start()
ps, _ = _fake_comfy(monkeypatch)
ps.loop = loop
bridge = runner.ComfyBridge()
async def double(x):
return x * 2
assert bridge.run_on_loop(double(21)) == 42
async def from_loop():
c = double(1)
with pytest.raises(RuntimeError, match="event loop thread"):
bridge.run_on_loop(c)
return "refused"
assert asyncio.run_coroutine_threadsafe(from_loop(), loop).result(5) == "refused"
loop.call_soon_threadsafe(loop.stop)
t.join(5)
def test_bridge_queue_helpers(monkeypatch):
removed = []
class Q:
def get_current_queue_volatile(self):
return [(0, "r1", {}, {}, [])], [(1, "q1", {}, {}, [], {}), (2, "q2", {}, {}, [], {})]
def get_history(self, pid, map_function=None):
return {pid: map_function({"status": {"status_str": "success"}, "outputs": {}, "prompt": "big"})} \
if pid == "h1" else {}
def delete_queue_item(self, fn):
removed.append(fn((1, "q1")))
return True
def interrupt_if_running(self, pid):
return pid == "r1"
ps, _ = _fake_comfy(monkeypatch)
ps.prompt_queue = Q()
b = runner.ComfyBridge()
assert b.queue_state() == ({"r1"}, {"q1", "q2"})
assert b.history_entry("h1") == {"status": {"status_str": "success"}, "outputs": {}} # no deep copy of prompt
assert b.history_entry("nope") is None
assert b.delete_queued("q1") is True and removed == [True]
assert b.interrupt("r1") is True and b.interrupt("q1") is False
# --------------------------------------------------------------------------- attach to the identical sweep
def _cfg_body(configs, session):
return {"configs_json": json.dumps({"configs": configs, "_session_settings": {"session_name": session}})}
def test_same_sweep_attaches_and_queues_nothing(env):
sw = runner.start_sweep([entry()], {"session_name": "wf_test"}, "n1", None, env.bridge, class_info=None)
n = len(env.bridge.enqueued)
again = runner.start_sweep([entry()], {"session_name": "wf_test"}, "n2", None, env.bridge, class_info=None)
assert again is sw
assert len(runner._sweeps) == 1 and len(env.bridge.enqueued) == n
runner.cancel(sw.id)
sw.thread.join(5)
def test_api_start_same_sweep_returns_attached_200(env):
sw = make(env)
assert runner.pause(sw.id)[0]
status, body = asyncio.run(runner.api_start(_cfg_body([entry()], "wf_test"), bridge=env.bridge, class_info=None))
assert status == 200 and body["attached"] is True and body["sweep_id"] == sw.id
assert body["state"] == "paused" and sw.state == "paused" # attach never changes the sweep state
assert len(runner._sweeps) == 1 and env.bridge.enqueued == []
def test_different_sweep_409_with_session_and_message(env):
sw = make(env)
# different values
status, body = asyncio.run(runner.api_start(_cfg_body([entry((7, 8))], "wf_test"), bridge=env.bridge, class_info=None))
assert status == 409 and body["sweep_id"] == sw.id and body["session_name"] == "wf_test"
assert "NOT started" in body["error"] and "already active" in body["error"]
# same workflow, different session
status, body = asyncio.run(runner.api_start(_cfg_body([entry()], "other_session"), bridge=env.bridge, class_info=None))
assert status == 409
# same workflow and session but a different start_at_job / overwrite_existing is NOT the same sweep
for extra in ({"start_at_job": 2}, {"overwrite_existing": True}):
with pytest.raises(runner.SweepBusy) as ei:
runner.prepare_sweep([entry()], {"session_name": "wf_test", **extra}, None, None, env.bridge, class_info=None)
assert not isinstance(ei.value, runner.SweepAlreadyRunning)
with pytest.raises(runner.SweepBusy) as ei:
runner.prepare_sweep([entry((7, 8))], {"session_name": "wf_test"}, None, None, env.bridge, class_info=None)
assert not isinstance(ei.value, runner.SweepAlreadyRunning) and "NOT started" in str(ei.value)
assert len(runner._sweeps) == 1
def test_random_extra_seeds_never_attach(env):
make(env)
raw = {"configs": [entry()], "_session_settings": {"session_name": "wf_test", "add_random_seeds_to_gens": 1}}
status, body = asyncio.run(runner.api_start({"configs_json": json.dumps(raw)}, bridge=env.bridge, class_info=None))
assert status == 409
def test_launch_race_same_sweep(env):
a = runner.prepare_sweep([entry()], {"session_name": "race"}, None, None, env.bridge, class_info=None)
b = runner.prepare_sweep([entry()], {"session_name": "race"}, None, None, env.bridge, class_info=None)
runner.launch(a, start_thread=False)
with pytest.raises(runner.SweepAlreadyRunning) as ei:
runner.launch(b, start_thread=False)
assert ei.value.sweep is a and b.id not in runner._sweeps
def test_pause_and_resume_emit_progress(env):
sw = make(env)
env.bridge.events.clear()
runner.pause(sw.id)
runner.resume(sw.id)
states = [d["state"] for d in env.bridge.progress_events()]
assert states == ["paused", "running"]
# --------------------------------------------------------------------------- job_prompt + sweep meta
def test_job_prompt_matches_apply_subs_and_has_no_collector(env):
sw = make(env, [entry((1, 2, 3))])
status, body = runner.job_prompt("wf_test", sw.id, 1)
job = sw.jobs[1]
tpl = sw.templates[job["conf"]["wf_template_hash"]]
assert status == 200 and body["prompt"] == wfs.apply_subs(tpl["prompt"], job["conf"]["wf_subs"])
assert "USCGSweepCollect" not in json.dumps(body["prompt"])
assert body["fingerprint"] == job["fingerprint"] and body["job_index"] == 1 and body["params"] is not None
# from the spec file, after the in-memory registry is gone
runner.reset_registry()
status2, body2 = runner.job_prompt("wf_test", sw.id, "1")
assert status2 == 200 and body2["prompt"] == body["prompt"] and body2["fingerprint"] == body["fingerprint"]
def test_job_prompt_validation_and_404(env):
sw = make(env)
for sid, idx in (("../x", 0), ("abc", 0), (sw.id, -1), (sw.id, "x"), (sw.id, "1.5"), ("", 0)):
assert runner.job_prompt("wf_test", sid, idx)[0] == 400, (sid, idx)
assert runner.job_prompt("wf_test", "deadbeef0000", 0)[0] == 404
assert runner.job_prompt("wf_test", sw.id, 99)[0] == 404
assert runner.job_prompt("some_other_session", sw.id, 0)[0] == 404 # wrong session never leaks another's job
def test_new_manifest_meta_carries_configs_json(env):
sw = make(env)
meta = wfs.read_manifest_items(wfs.session_paths("wf_test")["manifest"])["meta"]
parsed = json.loads(meta["configs_json"])
assert parsed["configs"][0]["model_type"] == "workflow" and parsed["_session_settings"]["session_name"] == "wf_test"
assert meta["workflow_sweep"] is True
def test_existing_sweep_manifest_meta_refreshed_but_standard_untouched(env):
paths = wfs.session_paths("wf_test")
os.makedirs(paths["images"], exist_ok=True)
old = {"session_name": "wf_test", "meta": {"workflow_sweep": True, "configs_json": ""}, "items": [{"id": 5, "file": "x"}]}
with open(paths["manifest"], "w") as f:
json.dump(old, f)
make(env)
data = wfs.read_manifest_items(paths["manifest"])
assert json.loads(data["meta"]["configs_json"])["configs"] and data["items"] == [{"id": 5, "file": "x"}]
runner.reset_registry()
std = {"session_name": "std_sess", "meta": {"configs_json": "{}"}, "items": []}
p2 = wfs.session_paths("std_sess")
os.makedirs(p2["images"], exist_ok=True)
with open(p2["manifest"], "w") as f:
json.dump(std, f)
before = open(p2["manifest"], "rb").read()
make(env, settings={"session_name": "std_sess"})
assert open(p2["manifest"], "rb").read() == before
def test_update_meta_preserves_items_written_since(tmp_path):
mu = _real_manifest_utils()
path = str(tmp_path / "manifest.json")
with open(path, "w") as f:
json.dump({"meta": {"a": 1}, "items": []}, f)
with open(path, "w") as f: # another writer adds an item after the caller last looked
json.dump({"meta": {"a": 1}, "items": [{"id": 9}]}, f)
mu.update_meta(path, {"configs_json": "z"})
data = json.load(open(path))
assert data["items"] == [{"id": 9}] and data["meta"] == {"a": 1, "configs_json": "z"}