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:
@@ -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"]))
|
||||
Reference in New Issue
Block a user