- L2 连接核查: /object_info preflight (node existence + combo values) with
5-min cache + manual refresh; analyze accepts endpoint_id, saved workflows
get POST /{id}/check; unreachable degrades to 无法核查, never blocks saving
- 测试工作流: jobs.origin='workflow_test' rides the real queue → ComfyUI →
outputs pipeline; only a downloadable, correctly-typed output counts as a
pass; dry-run refused; repeat clicks reuse the in-flight job; artifacts
isolated under assets/_workflow_test/{id}; retention keeps newest 5;
clear-tests + delete-workflow cascade cleanup; tasks page shows a 测试 badge
- Media groups: Autogrow detection generalized to video/audio lists
(ref_videos/ref_audios etc.), patcher clones/rewires per media family,
validation per list kind; entries appear only for actually-wired slots;
MiniMax H3 Ref2VA official-limit hints keyed off the node class
- 25 new backend tests (preflight / workflow-test / media-groups) against the
fake ComfyUI; fake server gains /object_info + drop_outputs knobs
Co-authored-by: multica-agent <github@multica.ai>
399 lines
15 KiB
Python
399 lines
15 KiB
Python
"""独立生图/生视频 full-chain tests (HEL-612): studio window jobs run
|
|
through the real playground → queue → ComfyUI HTTP → downloaded-output
|
|
path against a fake ComfyUI server (real HTTP round trip), plus the
|
|
source label, upload slot flow, retry and delete behaviours."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import json
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from contextlib import contextmanager
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from calliope.config import settings
|
|
|
|
PNG_1PX = bytes.fromhex(
|
|
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489"
|
|
"0000000d4944415478da63f8cfc0f01f0005000105ab5e30760000000049454e44ae426082"
|
|
)
|
|
|
|
|
|
class _FakeComfyHandler(BaseHTTPRequestHandler):
|
|
"""Minimal ComfyUI: /system_stats, /prompt, /history/{id}, /view, /upload/image.
|
|
|
|
History outputs are keyed by the prompt's real output node id (what
|
|
ComfyUI does); a known output class that is absent falls back to
|
|
``server.forced_output_node`` (default "9"). Scripted tests can pin
|
|
``server.forced_output_node`` or drop entries via ``server.drop_outputs``.
|
|
"""
|
|
|
|
server_version = "FakeComfy/1.0"
|
|
KNOWN_OUTPUT_CLASSES = {
|
|
"SaveImage", "PreviewImage", "SaveImageAdvanced", "SaveImageWebsocket",
|
|
"SaveVideo", "VideoOutput", "VHS_VideoCombine", "AnimateDiffCombine",
|
|
}
|
|
|
|
def do_GET(self):
|
|
self.server.requests.append(self.path)
|
|
if self.path == "/system_stats":
|
|
self._json(
|
|
200,
|
|
{
|
|
"system": {"comfyui_version": "0.3.72-fake", "os": "linux"},
|
|
"devices": [{"name": "FakeGPU", "type": "cuda"}],
|
|
},
|
|
)
|
|
return
|
|
if self.path == "/object_info":
|
|
self._json(200, dict(getattr(self.server, "object_info", None) or {}))
|
|
return
|
|
if self.path.startswith("/history/"):
|
|
pid = self.path[len("/history/") :]
|
|
entry = self.server.history.get(pid)
|
|
if entry is None:
|
|
self._json(200, {})
|
|
return
|
|
self._json(200, {pid: entry})
|
|
return
|
|
if self.path.startswith("/view"):
|
|
self._raw(200, PNG_1PX, "image/png")
|
|
return
|
|
self._json(404, {"error": "not found"})
|
|
|
|
def do_POST(self):
|
|
length = int(self.headers.get("Content-Length") or 0)
|
|
body = self.rfile.read(length) if length else b""
|
|
self.server.requests.append(self.path)
|
|
if self.path == "/prompt":
|
|
if self.server.fail_prompt:
|
|
self._json(500, {"error": "boom", "node_errors": []})
|
|
return
|
|
prompt_id = uuid.uuid4().hex
|
|
outputs_key = self.server.outputs_key
|
|
out_entry = {
|
|
outputs_key: [
|
|
{"filename": self.server.out_name, "subfolder": "", "type": "output"}
|
|
]
|
|
}
|
|
outputs = (
|
|
{} if getattr(self.server, "drop_outputs", False) else
|
|
{self._output_node(body): out_entry}
|
|
)
|
|
self.server.history[prompt_id] = {
|
|
"status": {"status_str": "success", "completed": True},
|
|
"outputs": outputs,
|
|
"prompt": [],
|
|
}
|
|
self.server.prompts.append(json.loads(body.decode("utf-8") or "{}"))
|
|
self._json(200, {"prompt_id": prompt_id})
|
|
return
|
|
if self.path == "/upload/image":
|
|
self.server.uploads.append(body)
|
|
self._json(200, {"name": "uploaded-input.png", "subfolder": "calliope"})
|
|
return
|
|
self._json(404, {"error": "not found"})
|
|
|
|
def _output_node(self, body: bytes) -> str:
|
|
forced = getattr(self.server, "forced_output_node", None)
|
|
if forced:
|
|
return forced
|
|
try:
|
|
prompt = json.loads(body.decode("utf-8") or "{}").get("prompt") or {}
|
|
except json.JSONDecodeError:
|
|
prompt = {}
|
|
for nid, node in prompt.items():
|
|
if isinstance(node, dict) and node.get("class_type") in self.KNOWN_OUTPUT_CLASSES:
|
|
return str(nid)
|
|
return "9"
|
|
|
|
def _json(self, status: int, payload: dict) -> None:
|
|
data = json.dumps(payload).encode("utf-8")
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(data)))
|
|
self.end_headers()
|
|
self.wfile.write(data)
|
|
|
|
def _raw(self, status: int, data: bytes, ctype: str) -> None:
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", ctype)
|
|
self.send_header("Content-Length", str(len(data)))
|
|
self.end_headers()
|
|
self.wfile.write(data)
|
|
|
|
def log_message(self, format, *args):
|
|
pass
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_comfy():
|
|
server = ThreadingHTTPServer(("127.0.0.1", 0), _FakeComfyHandler)
|
|
server.requests = []
|
|
server.prompts = []
|
|
server.uploads = []
|
|
server.history = {}
|
|
server.fail_prompt = False
|
|
server.out_name = "out-1.png"
|
|
server.outputs_key = "images"
|
|
server.forced_output_node = None
|
|
server.object_info = {}
|
|
server.drop_outputs = False
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
try:
|
|
yield server
|
|
finally:
|
|
server.shutdown()
|
|
server.server_close()
|
|
|
|
|
|
def _url(server) -> str:
|
|
return f"http://127.0.0.1:{server.server_address[1]}"
|
|
|
|
|
|
@contextmanager
|
|
def _real_run(client, server, endpoint_name="测试桩连接"):
|
|
"""One registered connection pointing at the fake server; dry_run off."""
|
|
prev_dry = settings.dry_run
|
|
prev_eps = [dict(ep) for ep in (settings.comfyui_endpoints or [])]
|
|
prev_active = settings.comfyui_active_id
|
|
settings.dry_run = False
|
|
r = client.post(
|
|
"/api/comfyui/endpoints",
|
|
json={"name": endpoint_name, "base_url": _url(server), "auth_type": "none"},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
endpoints = r.json()["endpoints"]
|
|
# keep only the new endpoint for deterministic snapshot resolution
|
|
settings.comfyui_endpoints = [dict(ep) for ep in endpoints if ep["base_url"] == _url(server)]
|
|
settings.comfyui_active_id = settings.comfyui_endpoints[0]["id"]
|
|
settings.apply_active_comfyui()
|
|
try:
|
|
yield settings.comfyui_endpoints[0]
|
|
finally:
|
|
settings.dry_run = prev_dry
|
|
settings.comfyui_endpoints = prev_eps
|
|
settings.comfyui_active_id = prev_active
|
|
settings.apply_active_comfyui()
|
|
|
|
|
|
IMAGE_WORKFLOW = {
|
|
"1": {
|
|
"class_type": "CLIPTextEncode",
|
|
"inputs": {"text": "默认提示词"},
|
|
"_meta": {"title": "(Input:prompt) 正向提示词"},
|
|
},
|
|
"2": {
|
|
"class_type": "PrimitiveInt",
|
|
"inputs": {"value": 42},
|
|
"_meta": {"title": "(Input:seed) 种子"},
|
|
},
|
|
"9": {
|
|
"class_type": "SaveImage",
|
|
"inputs": {"images": "x"},
|
|
"_meta": {"title": "(Output:image) 图片输出"},
|
|
},
|
|
}
|
|
|
|
|
|
def _video_workflow(first_last: bool) -> dict:
|
|
wf = {
|
|
"1": {
|
|
"class_type": "CLIPTextEncode",
|
|
"inputs": {"text": "镜头缓慢推近"},
|
|
"_meta": {"title": "(Input:prompt) 正向提示词"},
|
|
},
|
|
"2": {
|
|
"class_type": "PrimitiveFloat",
|
|
"inputs": {"value": 6},
|
|
"_meta": {"title": "(Input:duration) 时长"},
|
|
},
|
|
}
|
|
if first_last:
|
|
wf["3"] = {
|
|
"class_type": "LoadImage",
|
|
"inputs": {"image": ""},
|
|
"_meta": {"title": "(Input:image) 首帧"},
|
|
}
|
|
wf["4"] = {
|
|
"class_type": "LoadImage",
|
|
"inputs": {"image": ""},
|
|
"_meta": {"title": "(Input:image) 尾帧"},
|
|
}
|
|
wf["9"] = {
|
|
"class_type": "VHS_VideoCombine",
|
|
"inputs": {},
|
|
"_meta": {"title": "(Output:video) 视频输出"},
|
|
}
|
|
return wf
|
|
|
|
|
|
def _import_workflow(client, name: str, kind: str, workflow: dict) -> int:
|
|
r = client.post(
|
|
"/api/workflows",
|
|
json={"name": name, "kind": kind, "workflow_json": workflow},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
return r.json()["id"]
|
|
|
|
|
|
def _wait_done(client, job_id: int, timeout: float = 25.0) -> dict:
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
jobs = client.get("/api/playground/jobs").json()
|
|
for job in jobs:
|
|
if job["id"] == job_id:
|
|
if job["status"] in ("done", "failed"):
|
|
return job
|
|
time.sleep(0.4)
|
|
pytest.fail(f"job {job_id} did not finish in {timeout}s")
|
|
|
|
|
|
def test_studio_image_full_chain(client, fake_comfy):
|
|
"""独立生图: upload → generate(source=studio-image) → worker → output file."""
|
|
with _real_run(client, fake_comfy):
|
|
wf_id = _import_workflow(client, "假·文生图", "image", IMAGE_WORKFLOW)
|
|
r = client.post(
|
|
"/api/playground/generate",
|
|
json={
|
|
"workflow_id": wf_id,
|
|
"input_values": {"1": "霓虹灯下的雨夜街道", "2": 7},
|
|
"source": "studio-image",
|
|
"endpoint_id": settings.comfyui_active_id,
|
|
},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
job_id = r.json()["job"]["id"]
|
|
|
|
job = _wait_done(client, job_id)
|
|
assert job["status"] == "done", job.get("error")
|
|
assert job["kind"] == "image"
|
|
assert job["payload"]["source"] == "studio-image"
|
|
|
|
# endpoint snapshot: id/name/url only, never a token
|
|
snap = job["payload"]["endpoint"]
|
|
assert snap["id"] == settings.comfyui_active_id
|
|
assert snap["base_url"] == _url(fake_comfy)
|
|
assert "token" not in json.dumps(snap)
|
|
|
|
# output downloaded into assets and served via /api/file
|
|
assert len(job["output_paths"]) == 1
|
|
out = Path(job["output_paths"][0])
|
|
assert out.exists() and out.read_bytes() == PNG_1PX
|
|
r = client.get("/api/file", params={"path": str(out)})
|
|
assert r.status_code == 200
|
|
|
|
# the workflow actually reached the fake ComfyUI /prompt
|
|
assert any(p.startswith("/prompt") for p in map(str, fake_comfy.requests))
|
|
queued = fake_comfy.prompts[-1]["prompt"]
|
|
assert queued["1"]["inputs"]["text"] == "霓虹灯下的雨夜街道"
|
|
assert queued["2"]["inputs"]["value"] == 7
|
|
|
|
|
|
def test_studio_video_full_chain_with_frame_uploads(client, fake_comfy):
|
|
"""独立生视频: first/last frame slots upload to ComfyUI before /prompt."""
|
|
fake_comfy.outputs_key = "videos"
|
|
fake_comfy.out_name = "out-clip.mp4"
|
|
with _real_run(client, fake_comfy):
|
|
wf_id = _import_workflow(client, "假·首尾帧生视频", "video", _video_workflow(True))
|
|
|
|
# two upload-slot files via the playground upload API
|
|
paths = []
|
|
for _ in range(2):
|
|
r = client.post(
|
|
"/api/playground/uploads",
|
|
files={"file": ("frame.png", io.BytesIO(PNG_1PX), "image/png")},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
paths.append(r.json()["path"])
|
|
|
|
r = client.post(
|
|
"/api/playground/generate",
|
|
json={
|
|
"workflow_id": wf_id,
|
|
"input_values": {"1": "海上日出延时", "2": 6, "3": paths[0], "4": paths[1]},
|
|
"source": "studio-video",
|
|
"endpoint_id": settings.comfyui_active_id,
|
|
},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
job = _wait_done(client, r.json()["job"]["id"])
|
|
assert job["status"] == "done", job.get("error")
|
|
assert job["payload"]["source"] == "studio-video"
|
|
assert job["output_paths"][0].endswith(".mp4")
|
|
|
|
# both frames were uploaded to ComfyUI's /upload/image (media slots)
|
|
assert len(fake_comfy.uploads) == 2
|
|
queued = fake_comfy.prompts[-1]["prompt"]
|
|
assert queued["3"]["inputs"]["image"] == "calliope/uploaded-input.png"
|
|
assert queued["4"]["inputs"]["image"] == "calliope/uploaded-input.png"
|
|
|
|
|
|
def test_studio_retry_after_failure(client, fake_comfy):
|
|
"""失败原因进历史 → retry re-queues → success."""
|
|
fake_comfy.fail_prompt = True
|
|
with _real_run(client, fake_comfy):
|
|
wf_id = _import_workflow(client, "假·文生图(重试)", "image", IMAGE_WORKFLOW)
|
|
r = client.post(
|
|
"/api/playground/generate",
|
|
json={"workflow_id": wf_id, "input_values": {"1": "x"}, "source": "studio-image"},
|
|
)
|
|
job_id = r.json()["job"]["id"]
|
|
job = _wait_done(client, job_id)
|
|
assert job["status"] == "failed"
|
|
assert job["error"]
|
|
|
|
fake_comfy.fail_prompt = False
|
|
r = client.post(f"/api/jobs/{job_id}/retry")
|
|
assert r.status_code == 200, r.text
|
|
job = _wait_done(client, job_id)
|
|
assert job["status"] == "done"
|
|
assert job["retry_count"] >= 1
|
|
|
|
|
|
def test_studio_delete_removes_outputs(client, fake_comfy):
|
|
with _real_run(client, fake_comfy):
|
|
wf_id = _import_workflow(client, "假·文生图(删除)", "image", IMAGE_WORKFLOW)
|
|
r = client.post(
|
|
"/api/playground/generate",
|
|
json={"workflow_id": wf_id, "input_values": {"1": "y"}, "source": "studio-image"},
|
|
)
|
|
job_id = r.json()["job"]["id"]
|
|
job = _wait_done(client, job_id)
|
|
assert job["status"] == "done"
|
|
out = Path(job["output_paths"][0])
|
|
assert out.exists()
|
|
|
|
r = client.delete(f"/api/playground/jobs/{job_id}")
|
|
assert r.status_code == 200, r.text
|
|
assert not out.exists()
|
|
jobs = client.get("/api/playground/jobs").json()
|
|
assert all(j["id"] != job_id for j in jobs)
|
|
|
|
|
|
def test_studio_jobs_listed_on_tasks_api(client, fake_comfy):
|
|
"""/api/jobs (生成任务页) carries both studio kinds with payload.source."""
|
|
with _real_run(client, fake_comfy):
|
|
img_wf = _import_workflow(client, "假·文生图A", "image", IMAGE_WORKFLOW)
|
|
fake_comfy.outputs_key = "videos"
|
|
fake_comfy.out_name = "v.mp4"
|
|
vid_wf = _import_workflow(client, "假·生视频A", "video", _video_workflow(False))
|
|
for wf_id, source in ((img_wf, "studio-image"), (vid_wf, "studio-video")):
|
|
r = client.post(
|
|
"/api/playground/generate",
|
|
json={"workflow_id": wf_id, "input_values": {"1": "z"}, "source": source},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
_wait_done(client, r.json()["job"]["id"])
|
|
|
|
jobs = client.get("/api/jobs").json()
|
|
sources = {j["payload"].get("source"): j["kind"] for j in jobs}
|
|
assert sources.get("studio-image") == "image"
|
|
assert sources.get("studio-video") == "video"
|