Files
ComfyUI/tests-unit/comfy_api_nodes_test/comfy_cloud_test.py
2026-08-08 17:32:46 +00:00

1078 lines
48 KiB
Python

import asyncio
from io import BytesIO
from typing import get_args
from unittest.mock import AsyncMock, Mock
import aiohttp
import pytest
import torch
from comfy.cli_args import args
if not torch.cuda.is_available():
args.cpu = True
from comfy_api_nodes.apis.comfy_cloud import (
ComfyCloudGenerateRequest,
ComfyCloudGenerateResponse,
ComfyCloudStatusResponse,
ComfyCloudWorkflow,
ComfyCloudWorkflowInputs,
)
from comfy_api_nodes import nodes_comfy_cloud
from comfy_api_nodes.util import conversions, download_helpers
@pytest.mark.parametrize(
("node", "workflow", "returns_video", "requires_image"),
[
(nodes_comfy_cloud.ComfyCloudTextToImageNode, "text-to-image", False, False),
(nodes_comfy_cloud.ComfyCloudTextToVideoNode, "text-to-video", True, False),
(nodes_comfy_cloud.ComfyCloudImageToVideoNode, "image-to-video", True, True),
(nodes_comfy_cloud.ComfyCloudImageEditNode, "image-edit", False, True),
],
)
def test_workflow_submission_polling_and_download(monkeypatch, node, workflow, returns_video, requires_image):
sync = AsyncMock(
return_value=ComfyCloudGenerateResponse(
task_id="task-1",
status="queued",
polling_url="/proxy/comfy-cloud/workflow/tasks/task-1",
cancel_url="/proxy/comfy-cloud/workflow/tasks/task-1/cancel",
)
)
poll = AsyncMock(
return_value=ComfyCloudStatusResponse(
task_id="task-1",
status="completed",
progress=100,
output_url="/proxy/comfy-cloud/results/task-1/output",
)
)
upload = AsyncMock(return_value="https://example.com/input.png")
image_download = AsyncMock(return_value="image-output")
video_download = AsyncMock(return_value="video-output")
monkeypatch.setattr(nodes_comfy_cloud, "sync_op", sync)
monkeypatch.setattr(nodes_comfy_cloud, "poll_op", poll)
monkeypatch.setattr(nodes_comfy_cloud, "upload_image_to_comfyapi", upload)
monkeypatch.setattr(nodes_comfy_cloud, "download_url_to_image_tensor", image_download)
monkeypatch.setattr(nodes_comfy_cloud, "download_url_to_video_output", video_download)
monkeypatch.setattr(nodes_comfy_cloud, "get_number_of_images", lambda image: 1)
image = object() if requires_image else None
output = asyncio.run(node.execute("A tiny fennec fox", image))
endpoint = sync.call_args.args[1]
request = sync.call_args.kwargs["data"]
assert endpoint.path == "/proxy/comfy-cloud/workflow/generate"
assert endpoint.method == "POST"
assert request == ComfyCloudGenerateRequest(
workflow=workflow,
inputs=ComfyCloudWorkflowInputs(
prompt="A tiny fennec fox",
image_url="https://example.com/input.png" if requires_image else None,
),
)
assert upload.await_count == int(requires_image)
poll_endpoint = poll.call_args.args[1]
cancel_endpoint = poll.call_args.kwargs["cancel_endpoint"]
assert poll_endpoint.path == "/proxy/comfy-cloud/workflow/tasks/task-1"
assert cancel_endpoint.path == "/proxy/comfy-cloud/workflow/tasks/task-1/cancel"
assert cancel_endpoint.method == "POST"
assert output[0] == ("video-output" if returns_video else "image-output")
@pytest.mark.parametrize(
"node",
[nodes_comfy_cloud.ComfyCloudImageToVideoNode, nodes_comfy_cloud.ComfyCloudImageEditNode],
)
def test_image_workflows_reject_batches(monkeypatch, node):
upload = AsyncMock()
monkeypatch.setattr(nodes_comfy_cloud, "get_number_of_images", lambda image: 2)
monkeypatch.setattr(nodes_comfy_cloud, "upload_image_to_comfyapi", upload)
with pytest.raises(ValueError, match="Exactly one input image"):
asyncio.run(node.execute("Animate this", object()))
upload.assert_not_awaited()
def test_contract_omits_optional_status_fields():
request = ComfyCloudGenerateRequest(
workflow="text-to-image",
inputs=ComfyCloudWorkflowInputs(prompt="A lighthouse"),
)
status = ComfyCloudStatusResponse(task_id="task-1", status="queued")
assert request.model_dump(exclude_none=True) == {
"workflow": "text-to-image",
"inputs": {"prompt": "A lighthouse"},
}
assert status.model_dump(exclude_none=True) == {"task_id": "task-1", "status": "queued"}
def test_status_progress_is_clamped_for_display():
assert nodes_comfy_cloud._progress(ComfyCloudStatusResponse(task_id="task-1", status="running", progress=100.5)) == 100
assert nodes_comfy_cloud._progress(ComfyCloudStatusResponse(task_id="task-1", status="running", progress=-1)) == 0
def test_poll_failure_cancels_submitted_task(monkeypatch):
poll = AsyncMock(side_effect=ValueError("invalid status response"))
cancel = AsyncMock(return_value={"status": "cancellation_requested"})
monkeypatch.setattr(nodes_comfy_cloud, "poll_op", poll)
monkeypatch.setattr(nodes_comfy_cloud, "sync_op_raw", cancel)
with pytest.raises(ValueError, match="invalid status response"):
asyncio.run(nodes_comfy_cloud._poll_task(nodes_comfy_cloud.ComfyCloudTextToImageNode, "task/1"))
cancel.assert_awaited_once()
assert cancel.call_args.args[1].path == "/proxy/comfy-cloud/workflow/tasks/task%2F1/cancel"
assert cancel.call_args.kwargs["max_retries"] == 0
@pytest.mark.parametrize("response_model", [ComfyCloudGenerateResponse, ComfyCloudStatusResponse])
@pytest.mark.parametrize("task_id", ["", " "])
def test_contract_rejects_empty_task_ids(response_model, task_id):
values = {"task_id": task_id, "status": "queued"}
if response_model is ComfyCloudGenerateResponse:
values.update(polling_url="/poll", cancel_url="/cancel")
with pytest.raises(ValueError, match="task_id"):
response_model(**values)
@pytest.mark.parametrize(
"url",
[
"http://example.com/output.png",
"http://127.0.0.1/output.png",
"//169.254.169.254/latest/meta-data",
"/unrelated/path/output.png",
"https://user@example.com/output.png",
"/proxy/comfy-cloud/../../v1/users/me",
"/proxy/comfy-cloud/%2e%2e/%2e%2e/v1/users/me",
"https://127.0.0.1/output.png",
"https://169.254.169.254/latest/meta-data",
"https://attacker.example/output.png",
],
)
def test_cloud_workflows_reject_untrusted_output_urls(monkeypatch, url):
sync = AsyncMock(
return_value=ComfyCloudGenerateResponse(
task_id="task-1",
status="queued",
polling_url="/poll",
cancel_url="/cancel",
)
)
poll = AsyncMock(
return_value=ComfyCloudStatusResponse(task_id="task-1", status="completed", output_url=url)
)
download = AsyncMock()
monkeypatch.setattr(nodes_comfy_cloud, "sync_op", sync)
monkeypatch.setattr(nodes_comfy_cloud, "poll_op", poll)
monkeypatch.setattr(nodes_comfy_cloud, "download_url_to_image_tensor", download)
with pytest.raises(RuntimeError, match="invalid output URL"):
asyncio.run(nodes_comfy_cloud.ComfyCloudTextToImageNode.execute("prompt"))
download.assert_not_awaited()
def test_cloud_workflows_accept_signed_https_output_urls(monkeypatch):
sync = AsyncMock(
return_value=ComfyCloudGenerateResponse(
task_id="task-1",
status="queued",
polling_url="/poll",
cancel_url="/cancel",
)
)
poll = AsyncMock(
return_value=ComfyCloudStatusResponse(
task_id="task-1",
status="completed",
output_url="https://storage.googleapis.com/comfy-cloud/output.png?signature=example",
)
)
download = AsyncMock(return_value="image-output")
monkeypatch.setattr(nodes_comfy_cloud, "sync_op", sync)
monkeypatch.setattr(nodes_comfy_cloud, "poll_op", poll)
monkeypatch.setattr(nodes_comfy_cloud, "download_url_to_image_tensor", download)
output = asyncio.run(nodes_comfy_cloud.ComfyCloudTextToImageNode.execute("prompt"))
assert output[0] == "image-output"
download.assert_awaited_once_with(
"https://storage.googleapis.com/comfy-cloud/output.png?signature=example",
timeout=nodes_comfy_cloud._OUTPUT_DOWNLOAD_TIMEOUT,
cls=nodes_comfy_cloud.ComfyCloudTextToImageNode,
allow_redirects=False,
)
@pytest.mark.parametrize(
"node",
[
nodes_comfy_cloud.ComfyCloudTextToImageNode,
nodes_comfy_cloud.ComfyCloudTextToVideoNode,
nodes_comfy_cloud.ComfyCloudImageToVideoNode,
nodes_comfy_cloud.ComfyCloudImageEditNode,
],
)
def test_legacy_nodes_reject_oversized_prompts(monkeypatch, node):
sync = AsyncMock()
monkeypatch.setattr(nodes_comfy_cloud, "sync_op", sync)
with pytest.raises(Exception, match="4096"):
asyncio.run(node.execute("x" * 4097, object()))
sync.assert_not_awaited()
def test_legacy_nodes_strip_prompts_before_submission(monkeypatch):
run = AsyncMock(return_value=("output",))
monkeypatch.setattr(nodes_comfy_cloud.ComfyCloudTextToImageNode, "_run", run)
asyncio.run(nodes_comfy_cloud.ComfyCloudTextToImageNode.execute(" prompt "))
assert run.call_args.args[0].prompt == "prompt"
def test_poc_nodes_strip_prompt_fields_before_submission(monkeypatch):
run = AsyncMock(return_value=("output",))
monkeypatch.setattr(nodes_comfy_cloud.ComfyCloudMageFlowImageNode, "_run", run)
asyncio.run(
nodes_comfy_cloud.ComfyCloudMageFlowImageNode.execute(
" prompt ", " avoid this ", "1:1", 0
)
)
inputs = run.call_args.args[0]
assert inputs.prompt == "prompt"
assert inputs.negative_prompt == "avoid this"
def test_task_routes_ignore_response_urls_and_errors_hide_task_token(monkeypatch):
sync = AsyncMock(
return_value=ComfyCloudGenerateResponse(
task_id="secret/task-token",
status="queued",
polling_url="https://attacker.example/poll",
cancel_url="https://attacker.example/cancel",
)
)
poll = AsyncMock(
return_value=ComfyCloudStatusResponse(
task_id="secret/task-token",
status="completed",
error="provider details with secret/task-token",
)
)
monkeypatch.setattr(nodes_comfy_cloud, "sync_op", sync)
monkeypatch.setattr(nodes_comfy_cloud, "poll_op", poll)
with pytest.raises(RuntimeError) as error:
asyncio.run(nodes_comfy_cloud.ComfyCloudTextToVideoNode.execute("A prompt"))
assert poll.call_args.args[1].path == "/proxy/comfy-cloud/workflow/tasks/secret%2Ftask-token"
assert poll.call_args.kwargs["cancel_endpoint"].path == "/proxy/comfy-cloud/workflow/tasks/secret%2Ftask-token/cancel"
assert "task-token" not in str(error.value)
assert "provider details" not in str(error.value)
IMAGE_POC_NODES = [
(
nodes_comfy_cloud.ComfyCloudIdeogram4DesignNode,
"image.ideogram-4-design.v1",
["prompt", "aspect_ratio", "quality_mode", "seed"],
{
"prompt": "A geometric fox logo",
"aspect_ratio": "21:9",
"quality_mode": "fast",
"seed": 11,
},
),
(
nodes_comfy_cloud.ComfyCloudKrea2CreativeImageNode,
"image.krea-2-creative-image.v1",
["prompt", "prompt_enhance", "aspect_ratio", "seed"],
{"prompt": "A glass forest", "prompt_enhance": False, "aspect_ratio": "16:9", "seed": 12},
),
(
nodes_comfy_cloud.ComfyCloudMageFlowImageNode,
"image.mage-flow-image.v1",
["prompt", "negative_prompt", "aspect_ratio", "seed"],
{"prompt": "A moonlit lake", "negative_prompt": "fog", "aspect_ratio": "3:2", "seed": 13},
),
(
nodes_comfy_cloud.ComfyCloudFlux2ReferenceEditNode,
"image.flux-2-reference-edit.v1",
["image", "instruction", "guidance", "quality_mode", "seed"],
{"image": object(), "instruction": "Make it winter", "guidance": 5.5, "quality_mode": "fast", "seed": 14},
),
(
nodes_comfy_cloud.ComfyCloudQwenImageEdit2511Node,
"image.qwen-image-edit-2511.v1",
["image", "instruction", "quality_mode", "seed"],
{"image": object(), "instruction": "Remove the sign", "quality_mode": "fast", "seed": 15},
),
(
nodes_comfy_cloud.ComfyCloudSeedVR2ImageUpscaleNode,
"image.seedvr2-image-upscale.v1",
["image", "scale"],
{"image": object(), "scale": "2x"},
),
]
@pytest.mark.parametrize(("node", "workflow", "input_names", "arguments"), IMAGE_POC_NODES)
def test_image_poc_node_schema_and_request_mapping(monkeypatch, node, workflow, input_names, arguments):
sync = AsyncMock(
return_value=ComfyCloudGenerateResponse(
task_id="task-poc",
status="queued",
polling_url="/tasks/task-poc",
cancel_url="/tasks/task-poc/cancel",
)
)
poll = AsyncMock(
return_value=ComfyCloudStatusResponse(
task_id="task-poc",
status="completed",
output_url="/proxy/comfy-cloud/results/task-poc/image.png",
)
)
upload = AsyncMock(return_value="/uploads/input.png")
download = AsyncMock(return_value="image-output")
monkeypatch.setattr(nodes_comfy_cloud, "sync_op", sync)
monkeypatch.setattr(nodes_comfy_cloud, "poll_op", poll)
monkeypatch.setattr(nodes_comfy_cloud, "upload_image_to_comfyapi", upload)
monkeypatch.setattr(nodes_comfy_cloud, "download_url_to_image_tensor", download)
monkeypatch.setattr(nodes_comfy_cloud, "get_number_of_images", lambda image: 1)
schema = node.define_schema()
assert schema.node_id == node.node_id
assert schema.display_name == node.display_name
assert schema.is_api_node is True
assert [input.id for input in schema.inputs] == input_names
assert len(schema.outputs) == 1
assert schema.outputs[0].get_io_type() == "IMAGE"
output = asyncio.run(node.execute(**arguments))
request = sync.call_args.kwargs["data"]
expected_inputs = {key: value for key, value in arguments.items() if key != "image"}
if "image" in arguments:
expected_inputs["assets"] = {"image": {"type": "IMAGE", "url": "/uploads/input.png"}}
assert request.workflow == workflow
assert request.inputs.model_dump(exclude_none=True) == expected_inputs
assert "asset_id" not in request.model_dump_json()
assert '"id"' not in request.model_dump_json()
assert upload.await_count == int("image" in arguments)
if "image" in arguments:
assert upload.call_args.kwargs == {"total_pixels": None}
download.assert_awaited_once_with(
"/proxy/comfy-cloud/results/task-poc/image.png",
timeout=30 * 60,
cls=node,
allow_redirects=False,
)
assert output[0] == "image-output"
def test_image_poc_schema_defaults_ranges_and_enums():
schemas = {
node.workflow: {input.id: input for input in node.define_schema().inputs}
for node, _, _, _ in IMAGE_POC_NODES
}
aspect_ratios = ["1:1", "3:4", "2:3", "3:2", "4:3", "16:9", "9:16", "21:9"]
for workflow in [
"image.ideogram-4-design.v1",
"image.krea-2-creative-image.v1",
"image.mage-flow-image.v1",
]:
assert schemas[workflow]["aspect_ratio"].options == aspect_ratios
assert schemas[workflow]["aspect_ratio"].default == "1:1"
seed = schemas[workflow]["seed"]
assert (seed.default, seed.min, seed.max) == (0, 0, 0xFFFFFFFFFFFFFFFF)
assert schemas["image.ideogram-4-design.v1"]["quality_mode"].options == ["quality", "balanced", "fast"]
assert schemas["image.ideogram-4-design.v1"]["quality_mode"].default == "balanced"
assert schemas["image.krea-2-creative-image.v1"]["prompt_enhance"].default is True
assert schemas["image.mage-flow-image.v1"]["negative_prompt"].default == ""
guidance = schemas["image.flux-2-reference-edit.v1"]["guidance"]
assert (guidance.default, guidance.min, guidance.max, guidance.step) == (4.0, 1.0, 10.0, 0.1)
for workflow in ["image.flux-2-reference-edit.v1", "image.qwen-image-edit-2511.v1"]:
assert schemas[workflow]["quality_mode"].options == ["quality", "fast"]
assert schemas[workflow]["quality_mode"].default == "quality"
seed = schemas[workflow]["seed"]
assert (seed.default, seed.min, seed.max) == (0, 0, 0xFFFFFFFFFFFFFFFF)
scale = schemas["image.seedvr2-image-upscale.v1"]["scale"]
assert scale.options == ["2x", "4x"]
assert scale.default == "4x"
def test_image_poc_api_declarations_and_extension_registration():
workflows = {workflow for _, workflow, _, _ in IMAGE_POC_NODES}
registered = set(asyncio.run(nodes_comfy_cloud.ComfyCloudExtension().get_node_list()))
assert workflows <= set(get_args(ComfyCloudWorkflow))
assert {node for node, _, _, _ in IMAGE_POC_NODES} <= registered
def test_cloud_workflow_controls_have_connection_sockets():
nodes = asyncio.run(nodes_comfy_cloud.ComfyCloudExtension().get_node_list())
for node in nodes:
for input_spec in node.define_schema().inputs:
if isinstance(input_spec, nodes_comfy_cloud.IO.WidgetInput):
assert input_spec.socketless is False, f"{node.__name__}.{input_spec.id}"
def test_cloud_workflow_schemas_have_descriptions():
nodes = asyncio.run(nodes_comfy_cloud.ComfyCloudExtension().get_node_list())
for node in nodes:
assert node.define_schema().description.strip(), node.__name__
def test_cloud_workflow_schemas_share_exact_estimated_rate_metadata():
nodes = asyncio.run(nodes_comfy_cloud.ComfyCloudExtension().get_node_list())
assert nodes_comfy_cloud.COMFY_CLOUD_GPU_SECOND_USD == 0.001295
assert nodes_comfy_cloud.COMFY_CLOUD_CREDITS_PER_USD == 211
assert nodes_comfy_cloud.COMFY_CLOUD_GPU_SECOND_CREDITS == pytest.approx(0.273245)
assert nodes_comfy_cloud.COMFY_CLOUD_GPU_HOUR_USD == pytest.approx(4.662)
assert nodes_comfy_cloud.COMFY_CLOUD_GPU_HOUR_CREDITS == pytest.approx(983.682)
for node in nodes:
schema = node.define_schema()
badge = schema.price_badge.as_dict(schema.inputs)
assert badge["expr"] == (
'{"type":"usd","usd":0.001295,"format":{"suffix":"/GPU-second","approximate":true}}'
)
assert "Estimated compute rate" in schema.description
assert "Actual final cost depends on GPU runtime" in schema.description
def test_all_linkable_widget_constraints_are_validated():
nodes = asyncio.run(nodes_comfy_cloud.ComfyCloudExtension().get_node_list())
for node in nodes:
for input_spec in node.define_schema().inputs:
if not isinstance(input_spec, nodes_comfy_cloud.IO.WidgetInput):
continue
io_type = input_spec.get_io_type()
if io_type == "COMBO":
invalid = "not-an-option"
elif io_type == "BOOLEAN":
invalid = "true"
elif io_type == "INT":
invalid = (input_spec.min - 1) if input_spec.min is not None else 1.5
elif io_type == "FLOAT":
invalid = float("nan")
elif io_type == "STRING" and nodes_comfy_cloud._TEXT_LIMITS.get(input_spec.id, (0,))[0]:
invalid = " "
else:
continue
with pytest.raises((ValueError, Exception), match=input_spec.id):
nodes_comfy_cloud._validate_node_inputs(node, {input_spec.id: invalid})
def test_linked_values_are_validated_before_upload(monkeypatch):
upload = AsyncMock()
monkeypatch.setattr(nodes_comfy_cloud, "upload_image_to_comfyapi", upload)
monkeypatch.setattr(nodes_comfy_cloud, "get_number_of_images", lambda image: 1)
with pytest.raises(ValueError, match="guidance"):
asyncio.run(
nodes_comfy_cloud.ComfyCloudFlux2ReferenceEditNode.execute(
object(), "instruction", float("nan"), "quality", 0
)
)
upload.assert_not_awaited()
def test_upload_inputs_have_decoded_resource_limits():
oversized_image = torch.empty((1, 8193, 1, 3), device="meta")
oversized_audio = {
"waveform": torch.empty((1, 2, nodes_comfy_cloud._MAX_DECODED_AUDIO_BYTES // 8 + 1), device="meta"),
"sample_rate": 48000,
}
with pytest.raises(ValueError, match="32-megapixel"):
nodes_comfy_cloud._validate_image_upload(oversized_image)
with pytest.raises(ValueError, match="256 MiB"):
nodes_comfy_cloud._validate_audio_upload(oversized_audio)
@pytest.mark.parametrize(
("node", "input_names"),
[
(nodes_comfy_cloud.ComfyCloudMiniMaxH3TextSoundNode, ["prompt", "aspect_ratio", "duration_seconds", "seed"]),
(nodes_comfy_cloud.ComfyCloudMiniMaxH3ImageSoundNode, ["image", "prompt", "aspect_ratio", "duration_seconds", "seed"]),
(nodes_comfy_cloud.ComfyCloudLTX23ImageAudioPerformanceNode, ["image", "audio", "prompt", "enhance_prompt", "duration_seconds", "seed"]),
(nodes_comfy_cloud.ComfyCloudLTX23FirstLastFrameNode, ["first_frame", "last_frame", "prompt", "duration_seconds", "seed"]),
(nodes_comfy_cloud.ComfyCloudWan22FirstLastFrameNode, ["first_frame", "last_frame", "prompt", "negative_prompt", "duration_seconds", "seed"]),
(nodes_comfy_cloud.ComfyCloudSCAIL2CharacterReplacementNode, ["reference_character", "driving_video", "scene_prompt", "driving_subject", "reference_subject", "seed"]),
],
)
def test_video_node_schemas_expose_only_manifest_inputs(node, input_names):
schema = node.define_schema()
assert schema.is_api_node
assert [input.id for input in schema.inputs] == input_names
assert len(schema.outputs) == 1
assert schema.outputs[0].get_io_type() == "VIDEO"
def test_ltx_performance_stages_image_and_audio(monkeypatch):
run = AsyncMock(return_value=("video-output",))
image_upload = AsyncMock(return_value="https://example.com/image.png")
audio_upload = AsyncMock(return_value="https://example.com/audio.mp4")
monkeypatch.setattr(nodes_comfy_cloud, "_run_video_workflow", run)
monkeypatch.setattr(nodes_comfy_cloud, "upload_image_to_comfyapi", image_upload)
monkeypatch.setattr(nodes_comfy_cloud, "upload_audio_to_comfyapi", audio_upload)
monkeypatch.setattr(nodes_comfy_cloud, "get_number_of_images", lambda image: 1)
audio = {"waveform": torch.zeros(1, 1, 480000), "sample_rate": 48000}
asyncio.run(nodes_comfy_cloud.ComfyCloudLTX23ImageAudioPerformanceNode.execute(object(), audio, "sing", True, 9, 7))
inputs = run.call_args.args[2]
assert inputs.image_url == "https://example.com/image.png"
assert inputs.audio_url == "https://example.com/audio.mp4"
assert inputs.duration_seconds == 9
def test_scail_stages_reference_image_and_driving_video(monkeypatch):
run = AsyncMock(return_value=("video-output",))
image_upload = AsyncMock(return_value="https://example.com/character.png")
video_upload = AsyncMock(return_value="https://example.com/driving.mp4")
video = Mock()
video.get_frame_count.return_value = 100
monkeypatch.setattr(nodes_comfy_cloud, "_run_video_workflow", run)
monkeypatch.setattr(nodes_comfy_cloud, "upload_image_to_comfyapi", image_upload)
monkeypatch.setattr(nodes_comfy_cloud, "upload_video_to_comfyapi", video_upload)
monkeypatch.setattr(nodes_comfy_cloud, "get_number_of_images", lambda image: 1)
asyncio.run(nodes_comfy_cloud.ComfyCloudSCAIL2CharacterReplacementNode.execute(object(), video, "park", "woman", "human", 1))
inputs = run.call_args.args[2]
assert inputs.reference_character_url == "https://example.com/character.png"
assert inputs.driving_video_url == "https://example.com/driving.mp4"
video.get_frame_count.assert_called_once()
def test_scail_defaults_and_frame_count_fail_closed_before_upload(monkeypatch):
schema = {input.id: input for input in nodes_comfy_cloud.ComfyCloudSCAIL2CharacterReplacementNode.define_schema().inputs}
image_upload = AsyncMock()
video_upload = AsyncMock()
video = Mock()
video.get_frame_count.side_effect = RuntimeError("decode failed")
monkeypatch.setattr(nodes_comfy_cloud, "upload_image_to_comfyapi", image_upload)
monkeypatch.setattr(nodes_comfy_cloud, "upload_video_to_comfyapi", video_upload)
monkeypatch.setattr(nodes_comfy_cloud, "get_number_of_images", lambda image: 1)
assert schema["driving_subject"].default == "human"
with pytest.raises(ValueError, match="Unable to determine video frame count"):
asyncio.run(nodes_comfy_cloud.ComfyCloudSCAIL2CharacterReplacementNode.execute(object(), video, "park", "human", "human", 1))
image_upload.assert_not_awaited()
video_upload.assert_not_awaited()
def test_scail_rejects_missing_frame_count_metadata_before_upload(monkeypatch):
upload = AsyncMock()
video = Mock()
video.get_frame_count.return_value = None
monkeypatch.setattr(nodes_comfy_cloud, "upload_image_to_comfyapi", upload)
monkeypatch.setattr(nodes_comfy_cloud, "upload_video_to_comfyapi", upload)
monkeypatch.setattr(nodes_comfy_cloud, "get_number_of_images", lambda image: 1)
with pytest.raises(ValueError, match="Unable to determine video frame count"):
asyncio.run(
nodes_comfy_cloud.ComfyCloudSCAIL2CharacterReplacementNode.execute(
object(), video, "park", "human", "human", 1
)
)
upload.assert_not_awaited()
@pytest.mark.parametrize("frame_count", [80, 158])
def test_scail_rejects_out_of_range_frames_before_upload(monkeypatch, frame_count):
upload = AsyncMock()
video = Mock()
video.get_frame_count.return_value = frame_count
monkeypatch.setattr(nodes_comfy_cloud, "upload_image_to_comfyapi", upload)
monkeypatch.setattr(nodes_comfy_cloud, "upload_video_to_comfyapi", upload)
monkeypatch.setattr(nodes_comfy_cloud, "get_number_of_images", lambda image: 1)
with pytest.raises(ValueError, match="frame count"):
asyncio.run(nodes_comfy_cloud.ComfyCloudSCAIL2CharacterReplacementNode.execute(object(), video, "park", "human", "human", 1))
upload.assert_not_awaited()
def test_in_memory_download_resets_retry_and_enforces_stream_limit(monkeypatch):
class Content:
def __init__(self, chunks):
self.chunks = iter(chunks)
self.finished = False
async def read(self, size):
chunk = next(self.chunks)
if isinstance(chunk, Exception):
raise chunk
if not chunk:
self.finished = True
return chunk
def at_eof(self):
return self.finished
class Response:
status = 200
headers = {}
def __init__(self, chunks, content_length=None):
self.content = Content(chunks)
self.content_length = content_length
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return False
responses = [Response([b"partial", aiohttp.ClientPayloadError("retry")]), Response([b"final", b""])]
class Session:
def __init__(self, timeout):
pass
async def get(self, url, headers, allow_redirects=True):
return responses.pop(0)
async def close(self):
pass
monkeypatch.setattr(download_helpers.aiohttp, "ClientSession", Session)
monkeypatch.setattr(download_helpers, "sleep_with_interrupt", AsyncMock())
destination = BytesIO()
asyncio.run(download_helpers.download_url_to_bytesio("https://example.com/result", destination))
assert destination.read() == b"final"
monkeypatch.setattr(download_helpers, "_MAX_IN_MEMORY_DOWNLOAD_BYTES", 4)
responses.append(Response([b"12345", b""]))
with pytest.raises(ValueError, match="in-memory limit"):
asyncio.run(download_helpers.download_url_to_bytesio("https://example.com/result", BytesIO()))
responses.append(Response([], content_length=5))
with pytest.raises(ValueError, match="in-memory limit"):
asyncio.run(download_helpers.download_url_to_bytesio("https://example.com/result", BytesIO()))
def test_file_object_download_resets_retry_and_enforces_stream_limit(monkeypatch, tmp_path):
destination = (tmp_path / "result.bin").open("w+b")
destination.write(b"stale")
class Content:
def __init__(self, chunks):
self.chunks = iter(chunks)
self.finished = False
async def read(self, size):
chunk = next(self.chunks)
if isinstance(chunk, Exception):
raise chunk
if not chunk:
self.finished = True
return chunk
def at_eof(self):
return self.finished
class Response:
status = 200
headers = {}
content_length = None
def __init__(self, chunks):
self.content = Content(chunks)
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return False
responses = [Response([b"partial", aiohttp.ClientPayloadError("retry")]), Response([b"done", b""])]
class Session:
def __init__(self, timeout):
pass
async def get(self, url, headers, allow_redirects=True):
return responses.pop(0)
async def close(self):
pass
monkeypatch.setattr(download_helpers.aiohttp, "ClientSession", Session)
monkeypatch.setattr(download_helpers, "sleep_with_interrupt", AsyncMock())
monkeypatch.setattr(download_helpers, "_MAX_IN_MEMORY_DOWNLOAD_BYTES", 10)
asyncio.run(download_helpers.download_url_to_bytesio("https://example.com/result", destination))
assert destination.read() == b"done"
monkeypatch.setattr(download_helpers, "_MAX_IN_MEMORY_DOWNLOAD_BYTES", 4)
responses.append(Response([b"12345", b""]))
with pytest.raises(ValueError, match="in-memory limit"):
asyncio.run(download_helpers.download_url_to_bytesio("https://example.com/result", destination))
destination.close()
def test_download_cloud_audio_url_to_audio_input(monkeypatch):
node = nodes_comfy_cloud.ComfyCloudTextToImageNode
downloaded = b"encoded audio"
expected = {"waveform": torch.ones(1, 2, 3), "sample_rate": 48000}
download_call = Mock()
async def download(url, dest, **kwargs):
download_call(url=url, dest=dest, **kwargs)
dest.write(downloaded)
dest.seek(0)
audio_decode = Mock(return_value=expected)
monkeypatch.setattr(download_helpers, "download_url_to_bytesio", download)
monkeypatch.setattr(download_helpers, "audio_bytes_to_audio_input", audio_decode)
output = asyncio.run(
download_helpers.download_url_to_audio_input(
"/proxy/comfy-cloud/results/task-1/audio.flac",
timeout=30,
max_retries=2,
cls=node,
)
)
assert output is expected
download_call.assert_called_once()
assert download_call.call_args.kwargs["url"] == "/proxy/comfy-cloud/results/task-1/audio.flac"
assert isinstance(download_call.call_args.kwargs["dest"], BytesIO)
assert download_call.call_args.kwargs["timeout"] == 30
assert download_call.call_args.kwargs["max_retries"] == 2
audio_decode.assert_called_once()
assert isinstance(audio_decode.call_args.args[0], BytesIO)
assert audio_decode.call_args.kwargs == {}
assert download_call.call_args.kwargs["cls"] is node
assert download_call.call_args.kwargs["allow_redirects"] is True
def test_audio_decode_stops_before_exceeding_budget(monkeypatch):
class Frame:
def to_ndarray(self):
return torch.ones(2, 8).numpy()
stream = type(
"Stream",
(),
{"codec_context": type("Codec", (), {"sample_rate": 48000})(), "channels": 2, "index": 0},
)()
class AudioFile:
streams = type("Streams", (), {"audio": [stream]})()
def __enter__(self):
return self
def __exit__(self, *args):
return False
def decode(self, streams):
yield Frame()
yield Frame()
monkeypatch.setattr(conversions.av, "open", lambda source: AudioFile())
monkeypatch.setattr(conversions, "_MAX_DECODED_AUDIO_BYTES", 64)
with pytest.raises(ValueError, match="Decoded audio exceeds"):
conversions.audio_bytes_to_audio_input(BytesIO(b"encoded"))
AUDIO_POC_NODES = [
(nodes_comfy_cloud.ComfyCloudACEStep15XLTurboNode, "audio.ace-step-1-5-xl-turbo.v1", ["style_prompt", "lyrics", "duration_seconds", "seed", "bpm", "time_signature", "language", "key"]),
(nodes_comfy_cloud.ComfyCloudStableAudio3MediumNode, "audio.stable-audio-3-medium.v1", ["prompt", "duration_seconds", "seed", "expand_prompt", "category"]),
(nodes_comfy_cloud.ComfyCloudChatterboxMultilingualVoiceCloneNode, "audio.chatterbox-multilingual-voice-clone.v1", ["text", "voice_reference", "language", "exaggeration", "cfg_weight", "temperature", "seed"]),
(nodes_comfy_cloud.ComfyCloudChatterboxDialogueNode, "audio.chatterbox-dialogue.v1", ["script", "speaker_a_reference", "speaker_b_reference", "exaggeration", "cfg_weight", "temperature", "seed"]),
(nodes_comfy_cloud.ComfyCloudChatterboxVoiceConversionNode, "audio.chatterbox-voice-conversion.v1", ["source_audio", "target_voice_reference", "seed"]),
(nodes_comfy_cloud.ComfyCloudMelBandRoFormerStemSeparationNode, "audio.melbandroformer-stem-separation.v1", ["audio"]),
]
@pytest.mark.parametrize(("node", "workflow", "input_names"), AUDIO_POC_NODES)
def test_audio_poc_schemas_and_registration(node, workflow, input_names):
schema = node.define_schema()
registered = asyncio.run(nodes_comfy_cloud.ComfyCloudExtension().get_node_list())
assert schema.is_api_node
assert schema.category == "partner/audio/Comfy Cloud"
assert [input.id for input in schema.inputs] == input_names
assert all(output.get_io_type() == "AUDIO" for output in schema.outputs)
assert workflow in get_args(ComfyCloudWorkflow)
assert node in registered
def test_audio_poc_schema_defaults_ranges_and_enums():
schemas = {workflow: {input.id: input for input in node.define_schema().inputs} for node, workflow, _ in AUDIO_POC_NODES}
ace = schemas["audio.ace-step-1-5-xl-turbo.v1"]
assert (ace["duration_seconds"].default, ace["duration_seconds"].min, ace["duration_seconds"].max, ace["duration_seconds"].step) == (120, 10, 300, 0.1)
assert (ace["bpm"].default, ace["bpm"].min, ace["bpm"].max) == (120, 10, 300)
assert ace["time_signature"].options == ["2", "3", "4", "6"]
assert ace["language"].default == "en"
assert ace["key"].default == "E minor"
stable = schemas["audio.stable-audio-3-medium.v1"]
assert stable["category"].options == ["Music", "Instrument", "SFX", "One-shot"]
assert stable["expand_prompt"].default is True
for workflow in [
"audio.chatterbox-multilingual-voice-clone.v1",
"audio.chatterbox-dialogue.v1",
"audio.chatterbox-voice-conversion.v1",
]:
assert schemas[workflow]["seed"].max == 0xFFFFFFFF
mel_schema = nodes_comfy_cloud.ComfyCloudMelBandRoFormerStemSeparationNode.define_schema()
assert [output.id for output in mel_schema.outputs] == ["vocals", "instruments"]
def test_audio_poc_request_mapping_and_named_result_decoding(monkeypatch):
sync = AsyncMock(return_value=ComfyCloudGenerateResponse(task_id="task-audio", status="queued", polling_url="/tasks/task-audio", cancel_url="/tasks/task-audio/cancel"))
poll = AsyncMock(return_value=ComfyCloudStatusResponse(task_id="task-audio", status="completed", output_urls={"vocals": "/proxy/comfy-cloud/results/vocals.mp3", "instruments": "/proxy/comfy-cloud/results/instruments.mp3"}))
upload = AsyncMock(return_value="/uploads/song.m4a")
download = AsyncMock(side_effect=["vocals-audio", "instruments-audio"])
monkeypatch.setattr(nodes_comfy_cloud, "sync_op", sync)
monkeypatch.setattr(nodes_comfy_cloud, "poll_op", poll)
monkeypatch.setattr(nodes_comfy_cloud, "upload_audio_to_comfyapi", upload)
monkeypatch.setattr(nodes_comfy_cloud, "download_url_to_audio_input", download)
audio = {"waveform": torch.zeros(1, 2, 48000), "sample_rate": 48000}
output = asyncio.run(nodes_comfy_cloud.ComfyCloudMelBandRoFormerStemSeparationNode.execute(audio))
request = sync.call_args.kwargs["data"]
assert request.workflow == "audio.melbandroformer-stem-separation.v1"
assert request.inputs.model_dump(exclude_none=True) == {"assets": {"audio": {"type": "AUDIO", "url": "/uploads/song.m4a"}}}
assert [call.args[0] for call in download.await_args_list] == [
"/proxy/comfy-cloud/results/vocals.mp3",
"/proxy/comfy-cloud/results/instruments.mp3",
]
assert all(call.kwargs["timeout"] == 30 * 60 for call in download.await_args_list)
assert all(call.kwargs["allow_redirects"] is False for call in download.await_args_list)
assert poll.call_args.kwargs["cancel_endpoint"].path == "/proxy/comfy-cloud/workflow/tasks/task-audio/cancel"
assert tuple(output) == ("vocals-audio", "instruments-audio")
def test_chatterbox_audio_inputs_use_named_staged_assets(monkeypatch):
run = AsyncMock(return_value=("audio-output",))
upload = AsyncMock(side_effect=["/uploads/source.m4a", "/uploads/target.m4a"])
monkeypatch.setattr(nodes_comfy_cloud, "_run_audio_workflow", run)
monkeypatch.setattr(nodes_comfy_cloud, "upload_audio_to_comfyapi", upload)
source = {"waveform": torch.zeros(1, 1, 48000), "sample_rate": 48000}
target = {"waveform": torch.zeros(1, 1, 96000), "sample_rate": 48000}
asyncio.run(nodes_comfy_cloud.ComfyCloudChatterboxVoiceConversionNode.execute(source, target, 7))
inputs = run.call_args.args[2]
assert inputs.model_dump(exclude_none=True) == {
"assets": {
"source_audio": {"type": "AUDIO", "url": "/uploads/source.m4a"},
"target_voice_reference": {"type": "AUDIO", "url": "/uploads/target.m4a"},
},
"seed": 7,
}
assert "audio_url" not in inputs.model_dump(exclude_none=True)
def test_chatterbox_dialogue_rejects_invalid_speaker_labels(monkeypatch):
upload = AsyncMock()
monkeypatch.setattr(nodes_comfy_cloud, "upload_audio_to_comfyapi", upload)
audio = {"waveform": torch.zeros(1, 1, 48000), "sample_rate": 48000}
with pytest.raises(ValueError, match="only speakers A and B"):
asyncio.run(nodes_comfy_cloud.ComfyCloudChatterboxDialogueNode.execute("NARRATOR: Hello", audio, audio, 0.5, 0.5, 0.8, 0))
upload.assert_not_awaited()
def test_chatterbox_dialogue_normalizes_labels_and_continuations(monkeypatch):
run = AsyncMock(return_value=("audio-output",))
monkeypatch.setattr(nodes_comfy_cloud, "_run_audio_workflow", run)
monkeypatch.setattr(nodes_comfy_cloud, "upload_audio_to_comfyapi", AsyncMock(side_effect=["/a", "/b"]))
audio = {"waveform": torch.zeros(1, 1, 48000), "sample_rate": 48000}
asyncio.run(nodes_comfy_cloud.ComfyCloudChatterboxDialogueNode.execute("a: Hello\ncontinued\n\nSpeaker B: Hi", audio, audio, 0.5, 0.5, 0.8, 0))
assert run.call_args.args[2].script == "SPEAKER A: Hello continued\nSPEAKER B: Hi"
def test_chatterbox_dialogue_accepts_colons_and_label_only_lines():
assert nodes_comfy_cloud._normalize_dialogue("SPEAKER A :\nMeet at 10:30\nhttps://example.com\nB: Done") == (
"SPEAKER A: Meet at 10:30 https://example.com\nSPEAKER B: Done"
)
@pytest.mark.parametrize("script", ["SPEAKER A:", "just a continuation", " "])
def test_chatterbox_dialogue_rejects_blank_or_unattributed_text(monkeypatch, script):
upload = AsyncMock()
monkeypatch.setattr(nodes_comfy_cloud, "upload_audio_to_comfyapi", upload)
audio = {"waveform": torch.zeros(1, 1, 48000), "sample_rate": 48000}
with pytest.raises(Exception):
asyncio.run(nodes_comfy_cloud.ComfyCloudChatterboxDialogueNode.execute(script, audio, audio, 0.5, 0.5, 0.8, 0))
upload.assert_not_awaited()
def test_audio_duration_tolerates_one_sample_and_rejects_invalid_sample_rate():
one_sample_over = {"waveform": torch.zeros(1, 1, 48001), "sample_rate": 48000}
nodes_comfy_cloud._validate_audio_duration("Audio", one_sample_over, 0.5, 1)
with pytest.raises(ValueError, match="sample rate"):
nodes_comfy_cloud._validate_audio_duration("Audio", {"waveform": torch.zeros(1, 1, 1), "sample_rate": 0}, 0.5, 1)
with pytest.raises(ValueError, match="between"):
nodes_comfy_cloud._validate_audio_duration("Audio", {"waveform": torch.zeros(1, 1, 31), "sample_rate": 1}, 1, 30)
@pytest.mark.parametrize(("file_format", "expected_format"), [(".GLB", "glb"), ("SPZ", "spz")])
def test_download_cloud_3d_url_to_file_3d(monkeypatch, file_format, expected_format):
node = nodes_comfy_cloud.ComfyCloudTextToImageNode
downloaded = b"3d result"
calls = []
async def download(url, dest, **kwargs):
calls.append((url, dest, kwargs))
dest.write(downloaded)
dest.seek(0)
monkeypatch.setattr(download_helpers, "download_url_to_bytesio", download)
output = asyncio.run(
download_helpers.download_url_to_file_3d(
f"/proxy/comfy-cloud/results/task-1/model.{expected_format}",
file_format,
timeout=45,
max_retries=3,
cls=node,
)
)
assert output.format == expected_format
assert output.get_bytes() == downloaded
assert calls[0][0] == f"/proxy/comfy-cloud/results/task-1/model.{expected_format}"
assert isinstance(calls[0][1], BytesIO)
assert calls[0][2] == {"timeout": 45, "max_retries": 3, "cls": node, "allow_redirects": True}
THREE_D_POC_NODES = [
(nodes_comfy_cloud.ComfyCloudTripoSplatImageToGaussianSplatNode, "3d.triposplat-image-to-gaussian-splat.v1", ["image", "remove_background", "seed", "gaussian_count"], {"image": object(), "remove_background": False, "seed": 7, "gaussian_count": 32768}, "FILE_3D_SPZ", "spz"),
(nodes_comfy_cloud.ComfyCloudHunyuan3D21ImageTo3DNode, "3d.hunyuan3d-2-1-image-to-3d.v1", ["image", "seed"], {"image": object(), "seed": 8}, "FILE_3D_GLB", "glb"),
(nodes_comfy_cloud.ComfyCloudHunyuan3DMultiViewTo3DNode, "3d.hunyuan3d-multiview-to-3d.v1", ["front_image", "back_image", "seed"], {"front_image": object(), "back_image": object(), "seed": 9}, "FILE_3D_GLB", "glb"),
(nodes_comfy_cloud.ComfyCloudMoGe2PhotoToTexturedMeshNode, "3d.moge-2-photo-to-textured-mesh.v1", ["image", "fov_degrees", "detail", "mesh_decimation", "gap_threshold", "texture"], {"image": object(), "fov_degrees": 45.5, "detail": 8, "mesh_decimation": 2, "gap_threshold": 0.05, "texture": False}, "FILE_3D_GLB", "glb"),
(nodes_comfy_cloud.ComfyCloudMoGe2PanoramaTo3DSceneNode, "3d.moge-2-panorama-to-3d-scene.v1", ["panorama", "detail", "split_resolution", "merge_resolution", "mesh_decimation", "gap_threshold", "texture"], {"panorama": object(), "detail": 6, "split_resolution": 768, "merge_resolution": 2048, "mesh_decimation": 3, "gap_threshold": 0.06, "texture": False}, "FILE_3D_GLB", "glb"),
]
@pytest.mark.parametrize(("node", "workflow", "input_names", "arguments", "output_type", "file_format"), THREE_D_POC_NODES)
def test_3d_poc_node_schema_request_mapping_and_registration(monkeypatch, node, workflow, input_names, arguments, output_type, file_format):
run = AsyncMock(return_value=("3d-output",))
upload = AsyncMock(side_effect=["/uploads/front.png", "/uploads/back.png"])
monkeypatch.setattr(nodes_comfy_cloud, "_run_3d_workflow", run)
monkeypatch.setattr(nodes_comfy_cloud, "upload_image_to_comfyapi", upload)
monkeypatch.setattr(nodes_comfy_cloud, "get_number_of_images", lambda image: 1)
schema = node.define_schema()
assert schema.is_api_node
assert schema.category == "partner/3d/Comfy Cloud"
assert [input.id for input in schema.inputs] == input_names
assert schema.outputs[0].get_io_type() == output_type
assert workflow in get_args(ComfyCloudWorkflow)
assert node in asyncio.run(nodes_comfy_cloud.ComfyCloudExtension().get_node_list())
output = asyncio.run(node.execute(**arguments))
assert output[0] == "3d-output"
assert run.call_args.args[1] == workflow
assert run.call_args.args[3] == file_format
request_inputs = run.call_args.args[2].model_dump(exclude_none=True)
image_names = [name for name in ("image", "front_image", "back_image", "panorama") if name in arguments]
expected_inputs = {name: value for name, value in arguments.items() if name not in image_names}
expected_inputs["assets"] = {
name: {"type": "IMAGE", "url": f"/uploads/{'front' if index == 0 else 'back'}.png"}
for index, name in enumerate(image_names)
}
assert request_inputs == expected_inputs
assert '"id"' not in run.call_args.args[2].model_dump_json()
def test_3d_poc_schema_defaults_and_ranges():
schemas = {workflow: {input.id: input for input in node.define_schema().inputs} for node, workflow, _, _, _, _ in THREE_D_POC_NODES}
tripo = schemas["3d.triposplat-image-to-gaussian-splat.v1"]
assert tripo["remove_background"].default is True
assert (tripo["seed"].default, tripo["seed"].min, tripo["seed"].max) == (46, 0, 0xFFFFFFFFFFFFFFFF)
assert (tripo["gaussian_count"].default, tripo["gaussian_count"].min, tripo["gaussian_count"].max) == (262144, 32768, 262144)
assert "application/octet-stream" in nodes_comfy_cloud.ComfyCloudTripoSplatImageToGaussianSplatNode.define_schema().outputs[0].tooltip
assert schemas["3d.hunyuan3d-2-1-image-to-3d.v1"]["seed"].default == 952805179515179
assert schemas["3d.hunyuan3d-multiview-to-3d.v1"]["seed"].default == 502126049100058
photo = schemas["3d.moge-2-photo-to-textured-mesh.v1"]
assert (photo["fov_degrees"].default, photo["fov_degrees"].min, photo["fov_degrees"].max, photo["fov_degrees"].step) == (0, 0, 170, 0.1)
assert (photo["detail"].default, photo["detail"].min, photo["detail"].max) == (9, 0, 9)
panorama = schemas["3d.moge-2-panorama-to-3d-scene.v1"]
assert (panorama["split_resolution"].default, panorama["split_resolution"].min, panorama["split_resolution"].max) == (512, 256, 1024)
assert (panorama["merge_resolution"].default, panorama["merge_resolution"].min, panorama["merge_resolution"].max) == (1024, 256, 8192)
def test_hunyuan_multiview_validates_both_images_before_upload(monkeypatch):
upload = AsyncMock()
monkeypatch.setattr(nodes_comfy_cloud, "upload_image_to_comfyapi", upload)
monkeypatch.setattr(nodes_comfy_cloud, "get_number_of_images", lambda image: image)
with pytest.raises(ValueError, match="Exactly one front image and one back image"):
asyncio.run(nodes_comfy_cloud.ComfyCloudHunyuan3DMultiViewTo3DNode.execute(1, 2, 9))
upload.assert_not_awaited()
def test_extension_preserves_all_23_poc_node_registrations():
legacy_nodes = {
nodes_comfy_cloud.ComfyCloudTextToImageNode,
nodes_comfy_cloud.ComfyCloudTextToVideoNode,
nodes_comfy_cloud.ComfyCloudImageToVideoNode,
nodes_comfy_cloud.ComfyCloudImageEditNode,
}
registered = set(asyncio.run(nodes_comfy_cloud.ComfyCloudExtension().get_node_list()))
assert len(registered - legacy_nodes) == 23
def test_3d_workflow_submission_polling_cancel_and_download(monkeypatch):
sync = AsyncMock(return_value=ComfyCloudGenerateResponse(task_id="task-3d", status="queued", polling_url="/tasks/task-3d", cancel_url="/tasks/task-3d/cancel"))
poll = AsyncMock(return_value=ComfyCloudStatusResponse(task_id="task-3d", status="completed", output_url="/proxy/comfy-cloud/results/model.spz"))
download = AsyncMock(return_value="spz-output")
monkeypatch.setattr(nodes_comfy_cloud, "sync_op", sync)
monkeypatch.setattr(nodes_comfy_cloud, "poll_op", poll)
monkeypatch.setattr(nodes_comfy_cloud, "download_url_to_file_3d", download)
output = asyncio.run(nodes_comfy_cloud._run_3d_workflow(nodes_comfy_cloud.ComfyCloudTripoSplatImageToGaussianSplatNode, "3d.triposplat-image-to-gaussian-splat.v1", ComfyCloudWorkflowInputs(seed=46), "spz"))
request = sync.call_args.kwargs["data"]
assert request.workflow == "3d.triposplat-image-to-gaussian-splat.v1"
assert request.inputs.model_dump(exclude_none=True) == {"seed": 46}
assert poll.call_args.args[1].path == "/proxy/comfy-cloud/workflow/tasks/task-3d"
assert poll.call_args.kwargs["cancel_endpoint"].path == "/proxy/comfy-cloud/workflow/tasks/task-3d/cancel"
assert poll.call_args.kwargs["cancel_endpoint"].method == "POST"
download.assert_awaited_once_with(
"/proxy/comfy-cloud/results/model.spz",
"spz",
timeout=30 * 60,
cls=nodes_comfy_cloud.ComfyCloudTripoSplatImageToGaussianSplatNode,
allow_redirects=False,
)
assert output[0] == "spz-output"