feat(ml): comfyui_export_skill_template — skills (recetas) como grafos cargables en el navegador

Cierra el gap receta->grafo del grupo comfyui-skill. La función impura
comfyui_export_skill_template compila una skill a template API format
(exports/<slug>.template.json) y, con ui_graph=True, genera el UI graph
posicionado vía CDP (load_workflow_ui + export_workflow_ui) en la carpeta
nativa de la UI (~/ComfyUI/user/default/workflows/<slug>.json), de modo que la
skill aparece en el menú Workflows del navegador y se abre como grafo visual.
Sin navegador, deja el template API y reporta el fallback (no falla).

- 4 tests offline (golden + edge + 2 error paths).
- Página madre comfyui-skill.md: fila en la tabla del grupo + sección
  "Skills como grafos en el navegador".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-24 16:58:11 +02:00
parent 46954d8584
commit 04ecf9f394
4 changed files with 428 additions and 0 deletions
@@ -0,0 +1,113 @@
"""Tests de comfyui_export_skill_template (función impura, sin ui_graph -> offline).
Verifica el camino headless (sin navegador), que es el que se puede testear sin GPU ni CDP:
- golden: una skill txt2img+facedetailer válida en un library_dir temporal -> ok True, escribe
exports/<slug>.template.json con API format válido (class_type/inputs) y el subject placeholder
presente en el prompt,
- edge: respeta export_template_path declarado en la receta,
- error: slug inexistente (sin recipe.json) -> ok False,
- error: receta con base_workflow desconocido -> ok False.
El camino ui_graph=True (carga/extrae el grafo en el navegador vía CDP) se valida en vivo, no
aquí: depende de una pestaña de ComfyUI abierta y queda fuera del alcance de un test offline.
"""
import json
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
import pytest # noqa: E402
from ml.comfyui_export_skill_template import export_skill_template # noqa: E402
from _comfyui_wf_assert import assert_api_format, class_types # noqa: E402
def _write_skill(library_dir, slug, recipe, *, export_template_path=None):
skill_dir = os.path.join(library_dir, slug)
os.makedirs(skill_dir, exist_ok=True)
if export_template_path is not None:
recipe = dict(recipe, export_template_path=export_template_path)
with open(os.path.join(skill_dir, "recipe.json"), "w", encoding="utf-8") as fh:
json.dump(recipe, fh)
return skill_dir
def _recipe_txt2img(**over):
r = {
"schema_version": 1,
"slug": "demo_skill",
"version": "1.0.0",
"base_workflow": "txt2img",
"checkpoint": "dreamshaper_8.safetensors",
"loras": [],
"params": {"steps": 28, "cfg": 6.0, "width": 512, "height": 768},
"prompt_scaffold": {
"positive": "cinematic portrait of {subject}, sharp focus",
"negative": "blurry, lowres",
"trigger_words": [],
},
"blocks": [{"type": "facedetailer", "params": {"denoise": 0.4}}],
}
r.update(over)
return r
def test_golden_headless_writes_api_template(tmp_path):
lib = str(tmp_path / "skills_library")
_write_skill(lib, "demo_skill", _recipe_txt2img())
out = export_skill_template("demo_skill", library_dir=lib) # ui_graph=False por defecto
assert out["ok"] is True
assert out["ui_workflow_path"] is None # sin ui_graph no toca la carpeta nativa
path = out["template_path"]
assert os.path.isfile(path)
workflow = json.load(open(path, encoding="utf-8"))
assert_api_format(workflow) # dict {node_id: {class_type, inputs}}
cts = class_types(workflow)
assert "CheckpointLoaderSimple" in cts
assert "FaceDetailer" in cts # el block facedetailer se aplicó
# el subject placeholder se mantiene literal en algún CLIPTextEncode
assert any("{subject}" in json.dumps(node.get("inputs", {})) for node in workflow.values())
def test_edge_respects_export_template_path(tmp_path):
lib = str(tmp_path / "skills_library")
_write_skill(lib, "demo_skill", _recipe_txt2img(),
export_template_path="exports/custom_name.template.json")
out = export_skill_template("demo_skill", library_dir=lib)
assert out["ok"] is True
assert out["template_path"].endswith(os.path.join("exports", "custom_name.template.json"))
assert os.path.isfile(out["template_path"])
def test_error_missing_skill(tmp_path):
lib = str(tmp_path / "skills_library")
os.makedirs(lib, exist_ok=True)
out = export_skill_template("no_existe", library_dir=lib)
assert out["ok"] is False
assert out["template_path"] is None
assert "recipe.json" in out["error"]
def test_error_unknown_base_workflow(tmp_path):
lib = str(tmp_path / "skills_library")
_write_skill(lib, "bad_skill", _recipe_txt2img(base_workflow="totally_unknown"))
out = export_skill_template("bad_skill", library_dir=lib)
assert out["ok"] is False
assert out["template_path"] is None
assert "build_skill_workflow" in out["error"]
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v"]))