193 lines
9.0 KiB
Python
193 lines
9.0 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
from test_reuse_endpoint import ComfyUIError, new_plugin
|
|
|
|
|
|
WORKFLOW = {
|
|
"nodes": [
|
|
{"id": 1, "type": "LoadImage", "title": "Farm input"},
|
|
{"id": 2, "type": "SaveImage", "title": "Final render"},
|
|
{"id": 5, "type": "ImpactSwitch", "title": "Lighting choice"},
|
|
]
|
|
}
|
|
|
|
|
|
def object_info():
|
|
return {
|
|
"LoadImage": {
|
|
"input": {"required": {"image": [["a.png"], {"image_upload": True}]}, "hidden": {}},
|
|
"output_node": False,
|
|
},
|
|
"SaveImage": {
|
|
"input": {"required": {}, "hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"}},
|
|
"output_node": True,
|
|
},
|
|
"ImpactSwitch": {
|
|
"input": {"required": {}, "hidden": {"extra_pnginfo": "EXTRA_PNGINFO", "unique_id": "UNIQUE_ID"}},
|
|
"output_node": False,
|
|
},
|
|
"PrimitiveInt": {
|
|
"input": {"required": {"value": [["INT", {}]]}, "hidden": {}},
|
|
"output_node": False,
|
|
},
|
|
"FL_ImagePicker": {"input": {"required": {}, "hidden": {"unique_id": "UNIQUE_ID"}}, "output_node": False},
|
|
"Explode": {"input": {"required": {}, "hidden": {}}, "output_node": False},
|
|
"DeadlineWorkerRegistration": {
|
|
"input": {"required": {}, "optional": {}, "hidden": {}},
|
|
"output_node": True,
|
|
},
|
|
}
|
|
|
|
|
|
class HeadlessPreflightTests(unittest.TestCase):
|
|
def make_plugin(self, root):
|
|
return new_plugin(
|
|
object_info=object_info(),
|
|
comfyui_input_dir=str(root / "input"),
|
|
comfyui_output_dir=str(root / "output"),
|
|
comfyui_install_path=str(root / "install"),
|
|
)
|
|
|
|
def test_farm_safe_payload_passes_and_preserves_workflow_metadata(self):
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
root = Path(temp)
|
|
(root / "input").mkdir()
|
|
(root / "input" / "a.png").write_bytes(b"image")
|
|
plugin = self.make_plugin(root)
|
|
prompt = {
|
|
"1": {"class_type": "LoadImage", "inputs": {"image": "a.png"}},
|
|
"2": {"class_type": "SaveImage", "inputs": {"images": ["1", 0]}},
|
|
}
|
|
result, expected = plugin._preflight_worker_payload(prompt, WORKFLOW)
|
|
self.assertEqual(result, prompt)
|
|
self.assertEqual(expected, ["2"])
|
|
self.assertEqual(WORKFLOW["nodes"][1]["title"], "Final render")
|
|
|
|
def test_fixed_switch_is_removed_and_consumers_are_reconnected(self):
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
root = Path(temp)
|
|
plugin = self.make_plugin(root)
|
|
prompt = {
|
|
"3": {"class_type": "Explode", "inputs": {}},
|
|
"4": {"class_type": "Explode", "inputs": {}},
|
|
"5": {"class_type": "ImpactSwitch", "inputs": {"select": 2, "input1": ["3", 0], "input2": ["4", 0]}},
|
|
"2": {"class_type": "SaveImage", "inputs": {"images": ["5", 0], "label": ["5", 1]}},
|
|
}
|
|
result, _ = plugin._preflight_worker_payload(prompt, WORKFLOW)
|
|
self.assertNotIn("5", result)
|
|
self.assertEqual(result["2"]["inputs"]["images"], ["4", 0])
|
|
self.assertEqual(result["2"]["inputs"]["label"], "input2")
|
|
|
|
def test_switch_selection_from_fixed_primitive_is_resolved(self):
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
plugin = self.make_plugin(Path(temp))
|
|
prompt = {
|
|
"3": {"class_type": "Explode", "inputs": {}},
|
|
"4": {"class_type": "Explode", "inputs": {}},
|
|
"5": {
|
|
"class_type": "ImpactSwitch",
|
|
"inputs": {"select": ["6", 0], "input1": ["3", 0], "input2": ["4", 0]},
|
|
},
|
|
"6": {"class_type": "PrimitiveInt", "inputs": {"value": 2}},
|
|
"2": {"class_type": "SaveImage", "inputs": {"images": ["5", 0]}},
|
|
}
|
|
result, _ = plugin._preflight_worker_payload(prompt, WORKFLOW)
|
|
self.assertNotIn("5", result)
|
|
self.assertEqual(result["2"]["inputs"]["images"], ["4", 0])
|
|
|
|
def test_dynamic_switch_value_node_is_still_rejected(self):
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
plugin = self.make_plugin(Path(temp))
|
|
with self.assertRaisesRegex(ComfyUIError, "connected to a non-fixed value"):
|
|
plugin._preflight_worker_payload({
|
|
"3": {"class_type": "Explode", "inputs": {}},
|
|
"4": {"class_type": "Explode", "inputs": {}},
|
|
"5": {
|
|
"class_type": "ImpactSwitch",
|
|
"inputs": {"select": ["3", 0], "input1": ["3", 0], "input2": ["4", 0]},
|
|
},
|
|
"2": {"class_type": "SaveImage", "inputs": {"images": ["5", 0]}},
|
|
}, WORKFLOW)
|
|
|
|
def test_gui_dependent_node_is_rejected_with_context(self):
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
plugin = self.make_plugin(Path(temp))
|
|
with self.assertRaisesRegex(ComfyUIError, r"node 9 .*FL_ImagePicker.*worker M21"):
|
|
plugin._preflight_worker_payload({
|
|
"9": {"class_type": "FL_ImagePicker", "inputs": {}},
|
|
"2": {"class_type": "SaveImage", "inputs": {"images": ["9", 0]}},
|
|
}, WORKFLOW)
|
|
|
|
def test_missing_node_type_is_rejected_against_object_info(self):
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
plugin = self.make_plugin(Path(temp))
|
|
with self.assertRaisesRegex(ComfyUIError, r"MissingOnWorker.*not installed in /object_info"):
|
|
plugin._preflight_worker_payload({
|
|
"7": {"class_type": "MissingOnWorker", "inputs": {}},
|
|
"2": {"class_type": "SaveImage", "inputs": {"images": ["7", 0]}},
|
|
}, WORKFLOW)
|
|
|
|
def test_distributed_worker_registration_does_not_require_a_file_output(self):
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
plugin = self.make_plugin(Path(temp))
|
|
flags = {"WorkerMode": True, "DistributedMode": True}
|
|
plugin.GetBooleanPluginInfoEntryWithDefault = lambda key, default: flags.get(key, default)
|
|
prompt = {
|
|
"1": {"class_type": "DeadlineWorkerRegistration", "inputs": {}},
|
|
}
|
|
result, expected = plugin._preflight_worker_payload(prompt)
|
|
self.assertEqual(result, prompt)
|
|
self.assertEqual(expected, [])
|
|
|
|
def test_distributed_non_registration_prompt_still_requires_file_output(self):
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
plugin = self.make_plugin(Path(temp))
|
|
flags = {"WorkerMode": True, "DistributedMode": True}
|
|
plugin.GetBooleanPluginInfoEntryWithDefault = lambda key, default: flags.get(key, default)
|
|
with self.assertRaisesRegex(ComfyUIError, "no file-producing output node"):
|
|
plugin._preflight_worker_payload({
|
|
"1": {"class_type": "DeadlineWorkerRegistration", "inputs": {}},
|
|
"3": {"class_type": "Explode", "inputs": {}},
|
|
})
|
|
|
|
def test_invalid_staged_path_is_rejected_before_queue(self):
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
plugin = self.make_plugin(Path(temp))
|
|
with self.assertRaisesRegex(ComfyUIError, "invalid staged path"):
|
|
plugin._preflight_worker_payload({
|
|
"1": {"class_type": "LoadImage", "inputs": {"image": "missing.png"}},
|
|
"2": {"class_type": "SaveImage", "inputs": {"images": ["1", 0]}},
|
|
}, WORKFLOW)
|
|
|
|
def test_execution_exception_reports_node_title_type_worker_and_original(self):
|
|
plugin = new_plugin(
|
|
standard_workflow={"nodes": [{"id": 7, "type": "Explode", "title": "Broken sampler"}]},
|
|
current_prompt_payload={"7": {"class_type": "Explode", "inputs": {}}},
|
|
)
|
|
status = {"status_str": "error", "messages": [["execution_error", {
|
|
"node_id": "7", "node_type": "Explode", "exception_message": "synthetic boom"
|
|
}]]}
|
|
with self.assertRaisesRegex(ComfyUIError, r"node 7 .*Broken sampler.*Explode.*M21.*synthetic boom"):
|
|
plugin._handle_prompt_status(status)
|
|
|
|
def test_missing_expected_output_file_fails_successful_history(self):
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
root = Path(temp)
|
|
plugin = self.make_plugin(root)
|
|
plugin.prompt_id = "prompt-a"
|
|
plugin.expected_outputs_by_prompt = {"prompt-a": ["2"]}
|
|
plugin.current_prompt_payload = {"2": {"class_type": "SaveImage", "inputs": {}}}
|
|
with self.assertRaisesRegex(ComfyUIError, r"expected output file is missing.*node 2"):
|
|
plugin._validate_expected_outputs({"2": {"images": [{
|
|
"filename": "never-written.png", "subfolder": "", "type": "output"
|
|
}]}})
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main(verbosity=2)
|