mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-29 15:04:09 -03:00
Add a "Save Recipe with Workflow" action next to "Save Recipe" in the LoRA
widget context menu. It posts the current UI-format graph alongside the save
request so the stored preview embeds it and the recipe can send the graph back
to ComfyUI. Embedding stays opt-in rather than folded into "Save Recipe": the
workflow is by far the largest metadata field and its widget values may carry
sensitive data.
- web/comfyui: new menu entry; saveRecipeDirectly({ embedWorkflow }) posts the
UI graph and reports the outcome (embedded / skipped) via toasts.
- save_recipe_from_widget handler: reads an optional JSON workflow field so the
long-standing body-less POST keeps working, including from cached clients.
- RecipePersistenceService.save_recipe_from_widget: embeds the graph through
the existing optimize_image workflow path, derives has_workflow by detection,
and skips graphs above MAX_WORKFLOW_EMBED_BYTES with workflow_skipped.
130 lines
3.7 KiB
Python
130 lines
3.7 KiB
Python
"""Handler tests for the widget "Save Recipe" endpoint.
|
|
|
|
Covers the opt-in workflow body: the endpoint historically received no body at
|
|
all, so a missing, empty or malformed body must degrade to "no workflow"
|
|
rather than failing the save.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from py.routes.handlers.recipe_handlers import RecipeManagementHandler
|
|
|
|
|
|
async def _noop_ensure() -> None:
|
|
return None
|
|
|
|
|
|
class FakeRequest:
|
|
"""Minimal request double exposing the optional-body contract."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
body: Any = None,
|
|
can_read_body: bool = True,
|
|
json_raises: bool = False,
|
|
) -> None:
|
|
self._body = body
|
|
self.can_read_body = can_read_body
|
|
self._json_raises = json_raises
|
|
|
|
async def json(self) -> Any:
|
|
if self._json_raises or self._body is None:
|
|
raise ValueError("no JSON body")
|
|
return self._body
|
|
|
|
|
|
class CapturingPersistence:
|
|
def __init__(self) -> None:
|
|
self.calls: list[dict[str, Any]] = []
|
|
|
|
async def save_recipe_from_widget(self, **kwargs: Any) -> SimpleNamespace:
|
|
self.calls.append(kwargs)
|
|
return SimpleNamespace(
|
|
payload={"success": True, "has_workflow": bool(kwargs.get("workflow"))},
|
|
status=200,
|
|
)
|
|
|
|
|
|
def _make_handler(persistence: CapturingPersistence) -> RecipeManagementHandler:
|
|
analysis_service = SimpleNamespace(
|
|
analyze_widget_metadata=lambda **kwargs: _analysis_result()
|
|
)
|
|
|
|
return RecipeManagementHandler(
|
|
ensure_dependencies_ready=_noop_ensure,
|
|
recipe_scanner_getter=lambda: object(),
|
|
logger=logging.getLogger(__name__),
|
|
persistence_service=persistence, # pyright: ignore[reportArgumentType]
|
|
analysis_service=analysis_service, # pyright: ignore[reportArgumentType]
|
|
downloader_factory=lambda: None,
|
|
civitai_client_getter=lambda: None,
|
|
)
|
|
|
|
|
|
async def _analysis_result() -> SimpleNamespace:
|
|
return SimpleNamespace(
|
|
payload={"metadata": {"loras": ""}, "image_bytes": b"image"}
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_widget_save_forwards_workflow_from_json_body():
|
|
persistence = CapturingPersistence()
|
|
handler = _make_handler(persistence)
|
|
workflow = {"nodes": [{"id": 1}]}
|
|
|
|
response = await handler.save_recipe_from_widget(
|
|
FakeRequest(body={"workflow": workflow}) # type: ignore[arg-type]
|
|
)
|
|
|
|
assert response.status == 200
|
|
assert persistence.calls[0]["workflow"] == workflow
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_widget_save_without_body_passes_no_workflow():
|
|
persistence = CapturingPersistence()
|
|
handler = _make_handler(persistence)
|
|
|
|
response = await handler.save_recipe_from_widget(
|
|
FakeRequest(can_read_body=False) # type: ignore[arg-type]
|
|
)
|
|
|
|
assert response.status == 200
|
|
assert persistence.calls[0]["workflow"] is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_widget_save_tolerates_malformed_body():
|
|
persistence = CapturingPersistence()
|
|
handler = _make_handler(persistence)
|
|
|
|
await handler.save_recipe_from_widget(
|
|
FakeRequest(json_raises=True) # type: ignore[arg-type]
|
|
)
|
|
await handler.save_recipe_from_widget(
|
|
FakeRequest(body=["not", "an", "object"]) # type: ignore[arg-type]
|
|
)
|
|
|
|
assert [call["workflow"] for call in persistence.calls] == [None, None]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_widget_save_reports_embedded_workflow_in_response():
|
|
persistence = CapturingPersistence()
|
|
handler = _make_handler(persistence)
|
|
|
|
response = await handler.save_recipe_from_widget(
|
|
FakeRequest(body={"workflow": {"nodes": []}}) # type: ignore[arg-type]
|
|
)
|
|
|
|
assert json.loads(response.text)["has_workflow"] is True
|