Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions comfy_cli/cmdline.py
Original file line number Diff line number Diff line change
Expand Up @@ -929,7 +929,9 @@ def validate(
):
from pathlib import Path

from comfy_cli.command.run import is_ui_workflow
from comfy_cli.cql.engine import Graph, LoadError
from comfy_cli.workflow_to_api import WorkflowConversionError, convert_ui_to_api

renderer = get_renderer()

Expand Down Expand Up @@ -972,6 +974,43 @@ def validate(
)
raise typer.Exit(code=1) from e

# Detect a UI-export (frontend/canvas) workflow and lower it to API format
# before validating — exactly as `comfy run` does. Without this the wrapper
# keys (`nodes`, `links`, `groups`, `config`, …) each emit a `non_node_key`
# warning, zero nodes are checked, and the result is a vacuous `valid:true`.
# The converter reuses the object_info the graph was already built from
# (`graph.object_info`), so offline `--input` works and no second fetch happens.
converted_from_ui = False
if is_ui_workflow(wf_data):
if renderer.is_pretty():
rprint("[yellow]Detected UI-format workflow, converting to API format...[/yellow]")
try:
converted = convert_ui_to_api(wf_data, graph.object_info)
except WorkflowConversionError as e:
renderer.error(
code="workflow_not_api_format",
message=f"Workflow is a UI export that could not be converted to API format: {e}",
hint="use ComfyUI's 'File > Export (API)' to save as API format",
)
raise typer.Exit(code=1) from e
except Exception as e: # noqa: BLE001 — never leak a raw traceback to the agent flow
renderer.error(
code="conversion_crash",
message=f"Workflow conversion crashed unexpectedly: {type(e).__name__}: {e}",
Comment thread
mattmillerai marked this conversation as resolved.
hint="report this at https://github.com/Comfy-Org/comfy-cli/issues",
details={"exception_type": type(e).__name__},
)
raise typer.Exit(code=1) from e
if not converted:
renderer.error(
code="workflow_not_api_format",
message="Workflow is a UI export that converted to no executable nodes",
hint="use ComfyUI's 'File > Export (API)' to save as API format",
)
raise typer.Exit(code=1)
wf_data = converted
converted_from_ui = True

result = graph.validate_workflow(wf_data)

payload = {
Expand All @@ -982,6 +1021,11 @@ def validate(
"errors": result["errors"],
"warnings": result["warnings"],
}
if converted_from_ui:
# Signal that validation ran against the converted graph, not the file's
# literal bytes, and report how many nodes the conversion produced.
payload["converted_from_ui"] = True
payload["converted_node_count"] = len(wf_data)

if renderer.is_pretty():
if result["valid"]:
Expand Down
16 changes: 16 additions & 0 deletions comfy_cli/cql/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,21 @@ def __init__(self) -> None:
self._consumers: dict[str, list[Morphism]] = defaultdict(list)
self._types: set[str] = set()
self._annotated = False
# The raw ``/object_info`` payload this graph was built from. Retained
# verbatim so callers that also need to lower a UI-format workflow to
# API format (``convert_ui_to_api``) can reuse it without a second fetch.
self._raw: dict[str, Any] = {}

@property
def object_info(self) -> dict[str, Any]:
"""The raw ``/object_info`` dict this graph was built from (``{}`` if
the graph was constructed without one).

Read-only: this is the graph's live internal schema state, returned by
reference to avoid copying a large payload. Callers (e.g. the validate
command handing it to ``convert_ui_to_api``) must not mutate it.
"""
return self._raw

@classmethod
def from_object_info(cls, object_info: dict[str, Any]) -> Graph:
Expand All @@ -439,6 +454,7 @@ def from_object_info(cls, object_info: dict[str, Any]) -> Graph:
details={"top_level_type": type(object_info).__name__},
)
g = cls()
g._raw = object_info
Comment thread
mattmillerai marked this conversation as resolved.
for node_id, raw in object_info.items():
if not isinstance(raw, dict):
continue
Expand Down
140 changes: 140 additions & 0 deletions tests/comfy_cli/command/test_validate_command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
"""Tests for `comfy validate` — frontend-format (UI-export) auto-conversion.

`comfy validate --workflow <ui-export.json>` used to validate vacuously: a
UI-export file's wrapper keys (`nodes`, `links`, `groups`, `config`, …) each
emitted a `non_node_key` warning, zero nodes were checked, and the verdict was
`valid:true`. The command now detects UI format (`is_ui_workflow`) and lowers it
to API format with `convert_ui_to_api` — exactly as `comfy run` does — before
validating, so the verdict reflects the real graph and the payload carries
`converted_from_ui: true` plus the converted node count.

Offline mode (`--input <object_info.json>`) is used throughout so no server is
needed: the same file supplies both the graph and the converter's object_info.
"""

from __future__ import annotations

import json
from pathlib import Path

import pytest
from typer.testing import CliRunner

from comfy_cli.cmdline import app

FIXTURES = Path(__file__).parent.parent / "fixtures"
OBJECT_INFO = FIXTURES / "sd15_object_info.json"
UI_WORKFLOW = FIXTURES / "sd15_ui_workflow.json"


@pytest.fixture
def runner():
return CliRunner()


def _write(tmp_path: Path, name: str, obj) -> Path:
p = tmp_path / name
p.write_text(json.dumps(obj), encoding="utf-8")
return p


def _envelope(result) -> dict:
"""Parse the final JSON envelope line emitted in `--json` mode."""
return json.loads(result.stdout.strip().splitlines()[-1])


def _validate(runner: CliRunner, workflow: Path):
"""Invoke `comfy --json validate` offline against the sd15 object_info."""
return runner.invoke(
app,
["--json", "validate", "--workflow", str(workflow), "--input", str(OBJECT_INFO)],
env={"COMFY_WHERE": "local"},
)


def test_ui_export_is_converted_and_validated(runner):
"""A UI-export fixture validates against the CONVERTED graph: a truthful
verdict, `converted_from_ui: true`, the converted node count, and zero
`non_node_key` wrapper-key noise."""
result = _validate(runner, UI_WORKFLOW)

assert result.exit_code == 0, result.stdout
data = _envelope(result)["data"]
assert data["valid"] is True
assert data["converted_from_ui"] is True
# The sd15 UI workflow lowers to 7 API nodes.
assert data["converted_node_count"] == 7
# The wrapper keys (nodes/links/groups/config/…) are gone after conversion,
# so none of them can produce the old vacuous-pass warnings.
assert [w for w in data["warnings"] if w.get("code") == "non_node_key"] == []


def test_ui_export_surfaces_real_problems(runner, tmp_path):
"""Acceptance: the converted graph is really validated — an unknown node
type surfaces as `valid:false` (not a vacuous pass), while still flagging
the file as UI-converted."""
bad = {
"nodes": [{"id": 1, "type": "TotallyMadeUpNode", "mode": 0, "inputs": [], "outputs": [], "widgets_values": []}],
"links": [],
}
wf = _write(tmp_path, "bad_ui.json", bad)

result = _validate(runner, wf)

assert result.exit_code == 1
data = _envelope(result)["data"]
assert data["valid"] is False
assert data["converted_from_ui"] is True
assert any(e["code"] == "unknown_class_type" for e in data["errors"])


def test_ui_export_that_converts_to_nothing_is_rejected(runner, tmp_path):
"""A UI file whose nodes carry no usable `type` converts to zero executable
nodes → structured `workflow_not_api_format` error, exit 1, message naming
the conversion."""
empty_convert = {"nodes": [{"id": 1, "mode": 0, "inputs": [], "outputs": []}], "links": []}
wf = _write(tmp_path, "no_exec_ui.json", empty_convert)

result = _validate(runner, wf)

assert result.exit_code == 1
error = _envelope(result)["error"]
assert error["code"] == "workflow_not_api_format"
assert "convert" in error["message"].lower()


def test_api_format_unchanged(runner, tmp_path):
"""An API-format file behaves exactly as before: validated directly, no
`converted_from_ui` key in the payload."""
api = {"1": {"class_type": "EmptyLatentImage", "inputs": {"width": 64, "height": 64, "batch_size": 1}}}
wf = _write(tmp_path, "api.json", api)

result = _validate(runner, wf)

assert result.exit_code == 0
data = _envelope(result)["data"]
assert data["valid"] is True
assert "converted_from_ui" not in data


def test_non_dict_payload_unchanged(runner, tmp_path):
"""A non-dict JSON payload keeps its existing `workflow_not_api_format`
error (the UI-detection branch never runs for it)."""
wf = _write(tmp_path, "list.json", [1, 2, 3])

result = _validate(runner, wf)

assert result.exit_code == 1
assert _envelope(result)["error"]["code"] == "workflow_not_api_format"


def test_empty_dict_payload_unchanged(runner, tmp_path):
"""An empty dict is not UI format and is left to the existing validator
(no conversion, no `converted_from_ui` key)."""
wf = _write(tmp_path, "empty.json", {})

result = _validate(runner, wf)

assert result.exit_code == 0
data = _envelope(result)["data"]
assert "converted_from_ui" not in data
Loading