_detect_comfy_root() located the ComfyUI install by walking up from the output directory (or this package's own file) looking for a folder containing both main.py and folder_paths.py side by side. Under non-standard install layouts that heuristic can silently fail and return None, which makes managed_workflow_root() return None too - so every workflow path check fails with "Workflow path is not allowed" regardless of the configured Workflow Roots setting. Add get_base_path() to the comfy_core adapter (folder_paths.base_path, the root ComfyUI itself resolves at startup) and try it first, falling back to the old heuristic only when it's unavailable.
834 lines
32 KiB
Python
834 lines
32 KiB
Python
import json
|
|
import sqlite3
|
|
|
|
from mjr_am_backend.features.workflows import (
|
|
delete_workflow,
|
|
diff_workflow_versions,
|
|
duplicate_workflow,
|
|
list_workflow_thumbnail_candidates,
|
|
list_workflow_versions,
|
|
list_workflows,
|
|
mark_workflow_loaded,
|
|
move_or_rename_workflow,
|
|
read_workflow_content,
|
|
save_workflow,
|
|
set_workflow_favorite,
|
|
set_workflow_tags,
|
|
set_workflow_thumbnail,
|
|
validate_workflow,
|
|
)
|
|
from mjr_am_backend.features.workflows import service as workflows_service
|
|
from mjr_am_backend.shared import Result
|
|
|
|
|
|
def test_detect_comfy_root_prefers_base_path_over_layout_heuristic(monkeypatch, tmp_path):
|
|
"""Regression for GH #222: ``get_base_path()`` (``folder_paths.base_path``,
|
|
the root ComfyUI itself resolved at startup) must win over the fallback
|
|
heuristic that walks up from the output directory looking for a folder
|
|
containing both main.py and folder_paths.py side by side - a heuristic
|
|
that silently returns None under non-standard install layouts and makes
|
|
every workflow path look "not allowed".
|
|
"""
|
|
real_root = tmp_path / "real_comfy_root"
|
|
real_root.mkdir()
|
|
(real_root / "main.py").write_text("", encoding="utf-8")
|
|
(real_root / "folder_paths.py").write_text("", encoding="utf-8")
|
|
|
|
unrelated_output = tmp_path / "unrelated" / "output"
|
|
unrelated_output.mkdir(parents=True)
|
|
|
|
monkeypatch.setattr(workflows_service, "get_base_path", lambda: str(real_root))
|
|
monkeypatch.setattr(workflows_service, "get_output_directory", lambda: str(unrelated_output))
|
|
|
|
assert workflows_service._detect_comfy_root() == real_root.resolve(strict=False)
|
|
|
|
|
|
def test_detect_comfy_root_falls_back_when_base_path_unavailable(monkeypatch, tmp_path):
|
|
comfy_root = tmp_path / "comfy"
|
|
output_dir = comfy_root / "output"
|
|
output_dir.mkdir(parents=True)
|
|
(comfy_root / "main.py").write_text("", encoding="utf-8")
|
|
(comfy_root / "folder_paths.py").write_text("", encoding="utf-8")
|
|
|
|
monkeypatch.setattr(workflows_service, "get_base_path", lambda: None)
|
|
monkeypatch.setattr(workflows_service, "get_output_directory", lambda: str(output_dir))
|
|
|
|
assert workflows_service._detect_comfy_root() == comfy_root.resolve(strict=False)
|
|
|
|
|
|
def test_list_workflows_discovers_env_directory(monkeypatch, tmp_path):
|
|
workflow_dir = tmp_path / "workflows"
|
|
workflow_dir.mkdir()
|
|
workflow_path = workflow_dir / "qwen_i2v_demo.json"
|
|
workflow_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"name": "Qwen I2V Demo",
|
|
"nodes": [
|
|
{"id": 1, "type": "LoadImage"},
|
|
{"id": 2, "type": "QwenImageToVideo"},
|
|
],
|
|
"links": [],
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setenv("MJR_AM_WORKFLOW_DIRECTORY", str(workflow_dir))
|
|
|
|
result = list_workflows(query="qwen_i2v_demo", limit=10)
|
|
|
|
assert result.ok
|
|
assets = result.data["assets"]
|
|
assert len(assets) == 1
|
|
assert assets[0]["kind"] == "workflow"
|
|
assert assets[0]["task"] == "I2V"
|
|
assert assets[0]["model_family"] == "Qwen"
|
|
assert assets[0]["source"] == "workflow"
|
|
|
|
|
|
def test_list_workflows_discovers_multiple_env_directories(monkeypatch, tmp_path):
|
|
workflow_dir_a = tmp_path / "workflows-a"
|
|
workflow_dir_b = tmp_path / "workflows-b"
|
|
workflow_dir_a.mkdir()
|
|
workflow_dir_b.mkdir()
|
|
(workflow_dir_a / "alpha.json").write_text(
|
|
json.dumps({"name": "Alpha", "nodes": [{"id": 1, "type": "KSampler"}]}),
|
|
encoding="utf-8",
|
|
)
|
|
(workflow_dir_b / "beta.json").write_text(
|
|
json.dumps({"name": "Beta", "nodes": [{"id": 1, "type": "LoadImage"}]}),
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.delenv("MJR_AM_WORKFLOW_DIRECTORY", raising=False)
|
|
monkeypatch.setenv(
|
|
"MJR_AM_WORKFLOW_DIRECTORIES",
|
|
f"{workflow_dir_a}{workflows_service.os.pathsep}{workflow_dir_b}",
|
|
)
|
|
|
|
result = list_workflows(query="*", limit=10)
|
|
|
|
assert result.ok
|
|
names = {asset["display_name"] for asset in result.data["assets"]}
|
|
assert {"Alpha", "Beta"}.issubset(names)
|
|
|
|
|
|
def test_read_workflow_content_rejects_paths_outside_workflow_roots(monkeypatch, tmp_path):
|
|
workflow_dir = tmp_path / "workflows"
|
|
workflow_dir.mkdir()
|
|
outside = tmp_path / "outside.json"
|
|
outside.write_text(json.dumps({"nodes": []}), encoding="utf-8")
|
|
monkeypatch.setenv("MJR_AM_WORKFLOW_DIRECTORY", str(workflow_dir))
|
|
|
|
result = read_workflow_content(outside)
|
|
|
|
assert not result.ok
|
|
assert result.code == "FORBIDDEN"
|
|
|
|
|
|
def test_read_workflow_content_returns_allowed_json(monkeypatch, tmp_path):
|
|
workflow_dir = tmp_path / "workflows"
|
|
workflow_dir.mkdir()
|
|
workflow_path = workflow_dir / "demo.json"
|
|
workflow_path.write_text(json.dumps({"nodes": [{"id": 1, "type": "KSampler"}]}), encoding="utf-8")
|
|
monkeypatch.setenv("MJR_AM_WORKFLOW_DIRECTORY", str(workflow_dir))
|
|
|
|
result = read_workflow_content(workflow_path)
|
|
|
|
assert result.ok
|
|
assert result.data["workflow"]["nodes"][0]["type"] == "KSampler"
|
|
|
|
|
|
def test_save_duplicate_move_delete_workflow(monkeypatch, tmp_path):
|
|
workflow_dir = tmp_path / "workflows"
|
|
monkeypatch.setenv("MJR_AM_WORKFLOW_DIRECTORY", str(workflow_dir))
|
|
|
|
saved = save_workflow(
|
|
workflow={"name": "Demo", "nodes": [{"id": 1, "type": "LoadImage"}]},
|
|
name="demo",
|
|
category="I2I/Qwen",
|
|
)
|
|
assert saved.ok
|
|
saved_path = workflow_dir / "I2I" / "Qwen" / "demo.json"
|
|
assert saved_path.exists()
|
|
|
|
duplicated = duplicate_workflow(saved_path)
|
|
assert duplicated.ok
|
|
duplicate_path = duplicated.data["filepath"]
|
|
assert duplicate_path.endswith(".json")
|
|
|
|
moved = move_or_rename_workflow(saved_path, name="renamed", category="T2V")
|
|
assert moved.ok
|
|
moved_path = workflow_dir / "T2V" / "renamed.json"
|
|
assert moved_path.exists()
|
|
assert not saved_path.exists()
|
|
|
|
deleted = delete_workflow(moved_path)
|
|
assert deleted.ok
|
|
assert not moved_path.exists()
|
|
|
|
|
|
def test_save_workflow_persists_user_info_and_notes(monkeypatch, tmp_path):
|
|
workflow_dir = tmp_path / "workflows"
|
|
monkeypatch.setenv("MJR_AM_WORKFLOW_DIRECTORY", str(workflow_dir))
|
|
|
|
saved = save_workflow(
|
|
workflow={"name": "Info Demo", "nodes": [{"id": 1, "type": "LoadImage"}]},
|
|
name="info-demo",
|
|
info={
|
|
"task": "Image Edit",
|
|
"model_family": "Flux",
|
|
"provider": "local",
|
|
"runs_on": "local",
|
|
"notes": "Use the high detail branch before export.",
|
|
},
|
|
)
|
|
|
|
assert saved.ok
|
|
workflow = saved.data["workflow"]
|
|
assert workflow["task"] == "Image Edit"
|
|
assert workflow["model_family"] == "Flux"
|
|
assert workflow["provider"] == "local"
|
|
assert workflow["runs_on"] == "local"
|
|
assert workflow["notes"] == "Use the high detail branch before export."
|
|
assert workflow["user_task"] == "Image Edit"
|
|
|
|
|
|
def test_save_workflow_uses_detected_info_when_user_info_is_empty(monkeypatch, tmp_path):
|
|
workflow_dir = tmp_path / "workflows"
|
|
monkeypatch.setenv("MJR_AM_WORKFLOW_DIRECTORY", str(workflow_dir))
|
|
|
|
saved = save_workflow(
|
|
workflow={
|
|
"name": "Detected Demo",
|
|
"task": "T2V",
|
|
"model_family": "Wan",
|
|
"provider": "Replicate",
|
|
"runs_on": "api",
|
|
"nodes": [{"id": 1, "type": "KSampler"}],
|
|
},
|
|
name="detected-demo",
|
|
info={"task": "", "model_family": "", "provider": "", "runs_on": "", "notes": ""},
|
|
)
|
|
|
|
assert saved.ok
|
|
workflow = saved.data["workflow"]
|
|
assert workflow["task"] == "T2V"
|
|
assert workflow["model_family"] == "Wan"
|
|
assert workflow["provider"] == "Replicate"
|
|
assert workflow["runs_on"] == "api"
|
|
assert workflow["user_task"] == "T2V"
|
|
assert workflow["user_model_family"] == "Wan"
|
|
|
|
|
|
def test_move_workflow_same_target_is_noop(monkeypatch, tmp_path):
|
|
workflow_dir = tmp_path / "workflows"
|
|
monkeypatch.setenv("MJR_AM_WORKFLOW_DIRECTORY", str(workflow_dir))
|
|
|
|
saved = save_workflow(
|
|
workflow={"name": "Noop", "nodes": [{"id": 1, "type": "LoadImage"}]},
|
|
name="noop",
|
|
category="I2I",
|
|
)
|
|
assert saved.ok
|
|
workflow_path = workflow_dir / "I2I" / "noop.json"
|
|
|
|
moved = move_or_rename_workflow(workflow_path, name="noop", category="I2I")
|
|
|
|
assert moved.ok
|
|
assert moved.data["moved"] is False
|
|
assert moved.data["filepath"] == str(workflow_path)
|
|
assert workflow_path.exists()
|
|
assert not list((workflow_dir / "I2I").glob("noop-*.json"))
|
|
|
|
|
|
def test_delete_workflow_rejects_read_only_template_root(monkeypatch, tmp_path):
|
|
managed_dir = tmp_path / "managed"
|
|
template_dir = tmp_path / "example_workflows"
|
|
template_dir.mkdir()
|
|
template = template_dir / "template.json"
|
|
template.write_text(json.dumps({"nodes": []}), encoding="utf-8")
|
|
monkeypatch.setenv("MJR_AM_WORKFLOW_DIRECTORY", str(managed_dir))
|
|
|
|
result = delete_workflow(template)
|
|
|
|
assert not result.ok
|
|
assert result.code == "FORBIDDEN"
|
|
assert template.exists()
|
|
|
|
|
|
def test_list_workflows_exposes_metadata_fields(monkeypatch, tmp_path):
|
|
workflow_dir = tmp_path / "workflows"
|
|
workflow_dir.mkdir()
|
|
workflow_path = workflow_dir / "with_meta.json"
|
|
workflow_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"name": "With Meta",
|
|
"nodes": [{"id": 1, "type": "KSampler"}],
|
|
"favorite": True,
|
|
"usage_count": 7,
|
|
"last_loaded_at": 1710001234,
|
|
"missing_nodes": ["NodeA"],
|
|
"missing_models": ["ModelA"],
|
|
"tags": ["cinematic", "seedance"],
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setenv("MJR_AM_WORKFLOW_DIRECTORY", str(workflow_dir))
|
|
|
|
result = list_workflows(query="with meta", limit=10)
|
|
|
|
assert result.ok
|
|
asset = result.data["assets"][0]
|
|
assert asset["favorite"] is True
|
|
assert asset["usage_count"] == 7
|
|
assert asset["last_loaded_at"] == 1710001234
|
|
assert asset["missing_nodes_count"] == 1
|
|
assert asset["missing_models_count"] == 1
|
|
assert set(asset["tags"]) == {"cinematic", "seedance"}
|
|
|
|
|
|
def test_list_workflows_supports_usage_and_last_loaded_sort(monkeypatch, tmp_path):
|
|
workflow_dir = tmp_path / "workflows"
|
|
workflow_dir.mkdir()
|
|
(workflow_dir / "a.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"name": "A",
|
|
"nodes": [{"id": 1, "type": "KSampler"}],
|
|
"usage_count": 2,
|
|
"last_loaded_at": 10,
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
(workflow_dir / "b.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"name": "B",
|
|
"nodes": [{"id": 1, "type": "KSampler"}],
|
|
"usage_count": 9,
|
|
"last_loaded_at": 30,
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
(workflow_dir / "c.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"name": "C",
|
|
"nodes": [{"id": 1, "type": "KSampler"}],
|
|
"usage_count": 1,
|
|
"last_loaded_at": 20,
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setenv("MJR_AM_WORKFLOW_DIRECTORY", str(workflow_dir))
|
|
monkeypatch.setattr(workflows_service, "workflow_roots", lambda: [workflow_dir])
|
|
|
|
by_usage = list_workflows(sort="usage_count", limit=10)
|
|
assert by_usage.ok
|
|
assert [row["display_name"] for row in by_usage.data["assets"]][:3] == ["B", "A", "C"]
|
|
|
|
by_last_loaded = list_workflows(sort="last_loaded_at", limit=10)
|
|
assert by_last_loaded.ok
|
|
assert [row["display_name"] for row in by_last_loaded.data["assets"]][:3] == ["B", "C", "A"]
|
|
|
|
|
|
def test_list_workflows_workflow_default_prioritizes_favorite(monkeypatch, tmp_path):
|
|
workflow_dir = tmp_path / "workflows"
|
|
workflow_dir.mkdir()
|
|
(workflow_dir / "fav.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"name": "Fav",
|
|
"nodes": [{"id": 1, "type": "KSampler"}],
|
|
"favorite": True,
|
|
"usage_count": 1,
|
|
"last_loaded_at": 1,
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
(workflow_dir / "regular.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"name": "Regular",
|
|
"nodes": [{"id": 1, "type": "KSampler"}],
|
|
"favorite": False,
|
|
"usage_count": 99,
|
|
"last_loaded_at": 99,
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setenv("MJR_AM_WORKFLOW_DIRECTORY", str(workflow_dir))
|
|
monkeypatch.setattr(workflows_service, "workflow_roots", lambda: [workflow_dir])
|
|
|
|
result = list_workflows(sort="workflow_default", limit=10)
|
|
|
|
assert result.ok
|
|
assert [row["display_name"] for row in result.data["assets"]][:2] == ["Fav", "Regular"]
|
|
|
|
|
|
def test_mark_workflow_loaded_increments_usage_and_timestamp(monkeypatch, tmp_path):
|
|
workflow_dir = tmp_path / "workflows"
|
|
index_db = tmp_path / "index.sqlite"
|
|
monkeypatch.setenv("MJR_AM_WORKFLOW_DIRECTORY", str(workflow_dir))
|
|
monkeypatch.setattr(workflows_service, "get_runtime_index_db_path", lambda: index_db)
|
|
|
|
saved = save_workflow(
|
|
workflow={"name": "Load Me", "nodes": [{"id": 1, "type": "KSampler"}]},
|
|
name="load-me",
|
|
)
|
|
assert saved.ok
|
|
workflow_path = workflow_dir / "load-me.json"
|
|
original = workflow_path.read_text(encoding="utf-8")
|
|
|
|
first = mark_workflow_loaded(workflow_path)
|
|
assert first.ok
|
|
assert first.data["usage_count"] == 1
|
|
assert first.data["last_loaded_at"] > 0
|
|
assert workflow_path.read_text(encoding="utf-8") == original
|
|
|
|
second = mark_workflow_loaded(workflow_path)
|
|
assert second.ok
|
|
assert second.data["usage_count"] == 2
|
|
assert second.data["last_loaded_at"] >= first.data["last_loaded_at"]
|
|
assert workflow_path.read_text(encoding="utf-8") == original
|
|
|
|
|
|
def test_mark_workflow_loaded_rejects_non_managed_path(monkeypatch, tmp_path):
|
|
managed_dir = tmp_path / "workflows"
|
|
outside = tmp_path / "outside.json"
|
|
outside.write_text(json.dumps({"nodes": []}), encoding="utf-8")
|
|
monkeypatch.setenv("MJR_AM_WORKFLOW_DIRECTORY", str(managed_dir))
|
|
|
|
result = mark_workflow_loaded(outside)
|
|
|
|
assert not result.ok
|
|
assert result.code == "FORBIDDEN"
|
|
|
|
|
|
def test_set_workflow_favorite_persists_flag(monkeypatch, tmp_path):
|
|
workflow_dir = tmp_path / "workflows"
|
|
index_db = tmp_path / "index.sqlite"
|
|
monkeypatch.setenv("MJR_AM_WORKFLOW_DIRECTORY", str(workflow_dir))
|
|
monkeypatch.setattr(workflows_service, "get_runtime_index_db_path", lambda: index_db)
|
|
|
|
saved = save_workflow(
|
|
workflow={"name": "Fav Me", "nodes": [{"id": 1, "type": "KSampler"}]},
|
|
name="fav-me",
|
|
)
|
|
assert saved.ok
|
|
workflow_path = workflow_dir / "fav-me.json"
|
|
original = workflow_path.read_text(encoding="utf-8")
|
|
|
|
fav_on = set_workflow_favorite(workflow_path, favorite=True)
|
|
assert fav_on.ok
|
|
assert fav_on.data["favorite"] is True
|
|
assert workflow_path.read_text(encoding="utf-8") == original
|
|
|
|
listed = list_workflows(query="fav me", limit=10)
|
|
assert listed.ok
|
|
assert listed.data["assets"][0]["favorite"] is True
|
|
|
|
fav_off = set_workflow_favorite(workflow_path, favorite=False)
|
|
assert fav_off.ok
|
|
assert fav_off.data["favorite"] is False
|
|
assert workflow_path.read_text(encoding="utf-8") == original
|
|
|
|
|
|
def test_set_workflow_favorite_rejects_non_managed_path(monkeypatch, tmp_path):
|
|
managed_dir = tmp_path / "workflows"
|
|
outside = tmp_path / "outside.json"
|
|
outside.write_text(json.dumps({"nodes": []}), encoding="utf-8")
|
|
monkeypatch.setenv("MJR_AM_WORKFLOW_DIRECTORY", str(managed_dir))
|
|
|
|
result = set_workflow_favorite(outside, favorite=True)
|
|
|
|
assert not result.ok
|
|
assert result.code == "FORBIDDEN"
|
|
|
|
|
|
def test_set_workflow_tags_persists_list(monkeypatch, tmp_path):
|
|
workflow_dir = tmp_path / "workflows"
|
|
index_db = tmp_path / "index.sqlite"
|
|
monkeypatch.setenv("MJR_AM_WORKFLOW_DIRECTORY", str(workflow_dir))
|
|
monkeypatch.setattr(workflows_service, "get_runtime_index_db_path", lambda: index_db)
|
|
|
|
saved = save_workflow(
|
|
workflow={"name": "Tag Me", "nodes": [{"id": 1, "type": "KSampler"}]},
|
|
name="tag-me",
|
|
)
|
|
assert saved.ok
|
|
workflow_path = workflow_dir / "tag-me.json"
|
|
original = workflow_path.read_text(encoding="utf-8")
|
|
|
|
tagged = set_workflow_tags(workflow_path, tags=["cinematic", "flux"])
|
|
assert tagged.ok
|
|
assert tagged.data["tags"] == ["cinematic", "flux"]
|
|
assert workflow_path.read_text(encoding="utf-8") == original
|
|
|
|
listed = list_workflows(query="tag me", limit=10)
|
|
assert listed.ok
|
|
assert listed.data["assets"][0]["tags"] == ["cinematic", "flux"]
|
|
|
|
|
|
def test_list_workflow_thumbnail_candidates_ignores_shared_workflow_id(monkeypatch, tmp_path):
|
|
workflow_dir = tmp_path / "workflows"
|
|
workflow_dir.mkdir()
|
|
workflow_path = workflow_dir / "candidate-workflow.json"
|
|
workflow_path.write_text(
|
|
json.dumps({"id": "wf-candidate-1", "name": "Candidate", "nodes": [{"id": 1, "type": "KSampler"}]}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
output_dir = tmp_path / "output"
|
|
output_dir.mkdir()
|
|
linked_image = output_dir / "candidate.png"
|
|
linked_image.write_bytes(b"PNG")
|
|
|
|
index_dir = tmp_path / "index"
|
|
index_dir.mkdir()
|
|
index_db = index_dir / "assets.sqlite"
|
|
with sqlite3.connect(index_db) as conn:
|
|
conn.execute("CREATE TABLE assets (id INTEGER PRIMARY KEY, filepath TEXT, filename TEXT, subfolder TEXT, mtime INTEGER, size INTEGER, kind TEXT, workflow_id TEXT)")
|
|
conn.execute("CREATE TABLE asset_metadata (asset_id INTEGER PRIMARY KEY, workflow_hash TEXT)")
|
|
conn.execute(
|
|
"INSERT INTO assets(id, filepath, filename, subfolder, mtime, size, kind, workflow_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
(1, str(linked_image), "candidate.png", "", 999, 3, "image", "wf-candidate-1"),
|
|
)
|
|
conn.execute("INSERT INTO asset_metadata(asset_id, workflow_hash) VALUES (?, ?)", (1, ""))
|
|
conn.commit()
|
|
|
|
monkeypatch.setenv("MJR_AM_WORKFLOW_DIRECTORY", str(workflow_dir))
|
|
monkeypatch.setattr("mjr_am_backend.features.workflows.service.get_runtime_index_db_path", lambda: index_db)
|
|
|
|
result = list_workflow_thumbnail_candidates(workflow_path, limit=10)
|
|
|
|
assert result.ok
|
|
assert result.data == []
|
|
|
|
|
|
def test_list_workflow_thumbnail_candidates_returns_hash_linked_outputs(monkeypatch, tmp_path):
|
|
workflow_dir = tmp_path / "workflows"
|
|
workflow_dir.mkdir()
|
|
workflow_path = workflow_dir / "candidate-workflow.json"
|
|
workflow_path.write_text(
|
|
json.dumps({"id": "wf-candidate-1", "name": "Candidate", "nodes": [{"id": 1, "type": "KSampler"}]}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
output_dir = tmp_path / "output"
|
|
output_dir.mkdir()
|
|
linked_image = output_dir / "candidate.png"
|
|
linked_image.write_bytes(b"PNG")
|
|
|
|
index_dir = tmp_path / "index"
|
|
index_dir.mkdir()
|
|
index_db = index_dir / "assets.sqlite"
|
|
with sqlite3.connect(index_db) as conn:
|
|
conn.execute("CREATE TABLE assets (id INTEGER PRIMARY KEY, filepath TEXT, filename TEXT, subfolder TEXT, mtime INTEGER, size INTEGER, kind TEXT, workflow_id TEXT)")
|
|
conn.execute("CREATE TABLE asset_metadata (asset_id INTEGER PRIMARY KEY, workflow_hash TEXT)")
|
|
conn.execute(
|
|
"INSERT INTO assets(id, filepath, filename, subfolder, mtime, size, kind, workflow_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
(1, str(linked_image), "candidate.png", "", 999, 3, "image", "wf-candidate-1"),
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO asset_metadata(asset_id, workflow_hash) VALUES (?, ?)",
|
|
(1, workflows_service._workflow_hash(workflow_path)),
|
|
)
|
|
conn.commit()
|
|
|
|
monkeypatch.setenv("MJR_AM_WORKFLOW_DIRECTORY", str(workflow_dir))
|
|
monkeypatch.setattr("mjr_am_backend.features.workflows.service.get_runtime_index_db_path", lambda: index_db)
|
|
|
|
result = list_workflow_thumbnail_candidates(workflow_path, limit=10)
|
|
|
|
assert result.ok
|
|
assert len(result.data) == 1
|
|
assert result.data[0]["filepath"] == str(linked_image)
|
|
assert result.data[0]["thumbnail_url"].startswith("/mjr/am/download?filepath=")
|
|
|
|
|
|
def test_set_workflow_thumbnail_copies_linked_asset(monkeypatch, tmp_path):
|
|
workflow_dir = tmp_path / "workflows"
|
|
workflow_dir.mkdir()
|
|
workflow_path = workflow_dir / "thumb-workflow.json"
|
|
workflow_path.write_text(json.dumps({"name": "Thumb Workflow", "nodes": [{"id": 1, "type": "KSampler"}]}), encoding="utf-8")
|
|
|
|
source = tmp_path / "source.png"
|
|
source.write_bytes(b"PNGDATA")
|
|
stale = workflow_path.with_suffix(".jpg")
|
|
stale.write_bytes(b"OLD")
|
|
|
|
index_db = tmp_path / "index.sqlite"
|
|
with sqlite3.connect(index_db) as conn:
|
|
conn.execute("CREATE TABLE assets (id INTEGER PRIMARY KEY, filepath TEXT, filename TEXT, subfolder TEXT, mtime INTEGER, size INTEGER, kind TEXT, workflow_id TEXT)")
|
|
conn.execute("CREATE TABLE asset_metadata (asset_id INTEGER PRIMARY KEY, workflow_hash TEXT)")
|
|
conn.execute(
|
|
"INSERT INTO assets(id, filepath, filename, subfolder, mtime, size, kind, workflow_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
(1, str(source), "source.png", "", 999, 7, "image", ""),
|
|
)
|
|
conn.execute("INSERT INTO asset_metadata(asset_id, workflow_hash) VALUES (?, ?)", (1, workflows_service._workflow_hash(workflow_path)))
|
|
conn.commit()
|
|
|
|
monkeypatch.setenv("MJR_AM_WORKFLOW_DIRECTORY", str(workflow_dir))
|
|
monkeypatch.setattr(workflows_service, "get_runtime_index_db_path", lambda: index_db)
|
|
|
|
result = set_workflow_thumbnail(workflow_path, source_filepath=str(source))
|
|
|
|
assert result.ok
|
|
target = workflow_path.with_suffix(".png")
|
|
assert target.exists()
|
|
assert target.read_bytes() == b"PNGDATA"
|
|
assert not stale.exists()
|
|
|
|
|
|
def test_set_workflow_thumbnail_rejects_unlinked_local_file(monkeypatch, tmp_path):
|
|
workflow_dir = tmp_path / "workflows"
|
|
workflow_dir.mkdir()
|
|
workflow_path = workflow_dir / "thumb-workflow.json"
|
|
workflow_path.write_text(json.dumps({"name": "Thumb Workflow", "nodes": [{"id": 1, "type": "KSampler"}]}), encoding="utf-8")
|
|
source = tmp_path / "secret.png"
|
|
source.write_bytes(b"PNGDATA")
|
|
index_db = tmp_path / "index.sqlite"
|
|
|
|
monkeypatch.setenv("MJR_AM_WORKFLOW_DIRECTORY", str(workflow_dir))
|
|
monkeypatch.setattr(workflows_service, "get_runtime_index_db_path", lambda: index_db)
|
|
|
|
result = set_workflow_thumbnail(workflow_path, source_filepath=str(source))
|
|
|
|
assert not result.ok
|
|
assert result.code == "FORBIDDEN"
|
|
assert not workflow_path.with_suffix(".png").exists()
|
|
|
|
|
|
def test_set_workflow_thumbnail_accepts_output_image_asset(monkeypatch, tmp_path):
|
|
workflow_dir = tmp_path / "workflows"
|
|
workflow_dir.mkdir()
|
|
workflow_path = workflow_dir / "output-thumb.json"
|
|
workflow_path.write_text(json.dumps({"name": "Output Thumb", "nodes": [{"id": 1, "type": "KSampler"}]}), encoding="utf-8")
|
|
output_dir = tmp_path / "output"
|
|
output_dir.mkdir()
|
|
source = output_dir / "selected.png"
|
|
source.write_bytes(b"OUTPUTPNG")
|
|
|
|
monkeypatch.setenv("MJR_AM_WORKFLOW_DIRECTORY", str(workflow_dir))
|
|
monkeypatch.setattr(workflows_service, "get_runtime_index_db_path", lambda: tmp_path / "index.sqlite")
|
|
monkeypatch.setattr(workflows_service, "get_output_directory", lambda: str(output_dir))
|
|
|
|
result = set_workflow_thumbnail(workflow_path, source_filepath=str(source))
|
|
|
|
assert result.ok
|
|
assert workflow_path.with_suffix(".png").read_bytes() == b"OUTPUTPNG"
|
|
assert result.data["converted"] is False
|
|
|
|
|
|
def test_set_workflow_thumbnail_converts_output_video_to_webp(monkeypatch, tmp_path):
|
|
workflow_dir = tmp_path / "workflows"
|
|
workflow_dir.mkdir()
|
|
workflow_path = workflow_dir / "video-thumb.json"
|
|
workflow_path.write_text(json.dumps({"name": "Video Thumb", "nodes": [{"id": 1, "type": "KSampler"}]}), encoding="utf-8")
|
|
output_dir = tmp_path / "output"
|
|
output_dir.mkdir()
|
|
source = output_dir / "selected.mp4"
|
|
source.write_bytes(b"VIDEO")
|
|
|
|
def fake_convert(_source, target):
|
|
target.write_bytes(b"WEBP")
|
|
return Result.Ok({"target": str(target)})
|
|
|
|
monkeypatch.setenv("MJR_AM_WORKFLOW_DIRECTORY", str(workflow_dir))
|
|
monkeypatch.setattr(workflows_service, "get_runtime_index_db_path", lambda: tmp_path / "index.sqlite")
|
|
monkeypatch.setattr(workflows_service, "get_output_directory", lambda: str(output_dir))
|
|
monkeypatch.setattr(workflows_service, "_convert_video_to_workflow_thumbnail", fake_convert)
|
|
|
|
result = set_workflow_thumbnail(workflow_path, source_filepath=str(source))
|
|
|
|
assert result.ok
|
|
assert workflow_path.with_suffix(".webp").read_bytes() == b"WEBP"
|
|
assert result.data["converted"] is True
|
|
assert result.data["max_video_seconds"] == 5
|
|
|
|
|
|
def test_list_workflows_does_not_use_linked_output_thumbnail_from_workflow_id_only(monkeypatch, tmp_path):
|
|
workflow_dir = tmp_path / "workflows"
|
|
workflow_dir.mkdir()
|
|
workflow_path = workflow_dir / "linked-thumb.json"
|
|
workflow_path.write_text(
|
|
json.dumps({"id": "wf-thumb-1", "name": "Linked Thumb", "nodes": [{"id": 1, "type": "KSampler"}]}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
output_dir = tmp_path / "output"
|
|
output_dir.mkdir()
|
|
linked_image = output_dir / "linked.png"
|
|
linked_image.write_bytes(b"PNG")
|
|
|
|
index_dir = tmp_path / "index"
|
|
index_dir.mkdir()
|
|
index_db = index_dir / "assets.sqlite"
|
|
with sqlite3.connect(index_db) as conn:
|
|
conn.execute("CREATE TABLE assets (id INTEGER PRIMARY KEY, filepath TEXT, workflow_id TEXT, mtime INTEGER)")
|
|
conn.execute("CREATE TABLE asset_metadata (asset_id INTEGER PRIMARY KEY, workflow_hash TEXT)")
|
|
conn.execute(
|
|
"INSERT INTO assets(id, filepath, workflow_id, mtime) VALUES (?, ?, ?, ?)",
|
|
(1, str(linked_image), "wf-thumb-1", 999),
|
|
)
|
|
conn.execute("INSERT INTO asset_metadata(asset_id, workflow_hash) VALUES (?, ?)", (1, ""))
|
|
conn.commit()
|
|
|
|
monkeypatch.setenv("MJR_AM_WORKFLOW_DIRECTORY", str(workflow_dir))
|
|
monkeypatch.setattr(workflows_service, "get_runtime_index_db_path", lambda: index_db)
|
|
|
|
result = list_workflows(query="linked thumb", limit=10)
|
|
|
|
assert result.ok
|
|
asset = result.data["assets"][0]
|
|
assert asset["thumbnail_path"] == ""
|
|
assert asset["thumbnail_url"] == ""
|
|
assert asset["graph_map_thumbnail_url"].startswith("/mjr/am/workflows/graph-map-thumbnail?filepath=")
|
|
|
|
|
|
def test_list_workflows_uses_linked_output_thumbnail_from_workflow_hash(monkeypatch, tmp_path):
|
|
workflow_dir = tmp_path / "workflows"
|
|
workflow_dir.mkdir()
|
|
workflow_path = workflow_dir / "hash-linked.json"
|
|
workflow_path.write_text(
|
|
json.dumps({"name": "Hash Linked", "nodes": [{"id": 1, "type": "KSampler"}]}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
monkeypatch.setenv("MJR_AM_WORKFLOW_DIRECTORY", str(workflow_dir))
|
|
|
|
first = list_workflows(query="hash linked", limit=10)
|
|
assert first.ok
|
|
workflow_hash = first.data["assets"][0]["workflow_hash"]
|
|
|
|
output_dir = tmp_path / "output"
|
|
output_dir.mkdir()
|
|
linked_image = output_dir / "hash-linked.jpg"
|
|
linked_image.write_bytes(b"JPG")
|
|
|
|
index_dir = tmp_path / "index"
|
|
index_dir.mkdir()
|
|
index_db = index_dir / "assets.sqlite"
|
|
with sqlite3.connect(index_db) as conn:
|
|
conn.execute("CREATE TABLE assets (id INTEGER PRIMARY KEY, filepath TEXT, workflow_id TEXT, mtime INTEGER)")
|
|
conn.execute("CREATE TABLE asset_metadata (asset_id INTEGER PRIMARY KEY, workflow_hash TEXT)")
|
|
conn.execute(
|
|
"INSERT INTO assets(id, filepath, workflow_id, mtime) VALUES (?, ?, ?, ?)",
|
|
(2, str(linked_image), "", 1001),
|
|
)
|
|
conn.execute("INSERT INTO asset_metadata(asset_id, workflow_hash) VALUES (?, ?)", (2, workflow_hash))
|
|
conn.commit()
|
|
|
|
monkeypatch.setattr(workflows_service, "get_runtime_index_db_path", lambda: index_db)
|
|
|
|
second = list_workflows(query="hash linked", limit=10)
|
|
|
|
assert second.ok
|
|
asset = second.data["assets"][0]
|
|
assert asset["thumbnail_path"] == str(linked_image)
|
|
assert asset["thumbnail_url"].startswith("/mjr/am/download?filepath=")
|
|
|
|
|
|
def test_list_workflows_exposes_graph_map_thumbnail_fallback(monkeypatch, tmp_path):
|
|
workflow_dir = tmp_path / "workflows"
|
|
workflow_dir.mkdir()
|
|
workflow_path = workflow_dir / "graph-map.json"
|
|
workflow_path.write_text(json.dumps({"name": "Graph Map", "nodes": [{"id": 1, "type": "KSampler"}]}), encoding="utf-8")
|
|
|
|
monkeypatch.setenv("MJR_AM_WORKFLOW_DIRECTORY", str(workflow_dir))
|
|
|
|
result = list_workflows(query="graph map", limit=10)
|
|
|
|
assert result.ok
|
|
asset = result.data["assets"][0]
|
|
assert asset["thumbnail_path"] == ""
|
|
assert asset["animated_thumbnail_path"] == ""
|
|
assert asset["graph_map_thumbnail_url"].startswith("/mjr/am/workflows/graph-map-thumbnail?filepath=")
|
|
|
|
|
|
def test_list_workflow_model_families_returns_indexed_families(monkeypatch, tmp_path):
|
|
workflow_dir = tmp_path / "workflows"
|
|
workflow_dir.mkdir()
|
|
(workflow_dir / "flux.json").write_text(
|
|
json.dumps({"name": "Flux Demo", "model_family": "Flux", "nodes": [{"id": 1, "type": "KSampler"}]}),
|
|
encoding="utf-8",
|
|
)
|
|
(workflow_dir / "wan.json").write_text(
|
|
json.dumps({"name": "Wan Demo", "model_family": "Wan", "nodes": [{"id": 1, "type": "KSampler"}]}),
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setenv("MJR_AM_WORKFLOW_DIRECTORY", str(workflow_dir))
|
|
monkeypatch.setattr(workflows_service, "workflow_roots", lambda: [workflow_dir])
|
|
|
|
result = workflows_service.list_workflow_model_families()
|
|
|
|
assert result.ok
|
|
values = [row["value"] for row in result.data["model_families"]]
|
|
assert values == ["Flux", "Wan"]
|
|
|
|
|
|
def test_validate_workflow_detects_missing_runtime_dependencies(monkeypatch, tmp_path):
|
|
workflow_dir = tmp_path / "workflows"
|
|
workflow_dir.mkdir()
|
|
workflow_path = workflow_dir / "deps.json"
|
|
workflow_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"name": "Deps",
|
|
"nodes": [
|
|
{"id": 1, "type": "CheckpointLoaderSimple", "widgets_values": ["missing.safetensors"]},
|
|
{"id": 2, "type": "MissingNode"},
|
|
],
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setenv("MJR_AM_WORKFLOW_DIRECTORY", str(workflow_dir))
|
|
monkeypatch.setattr(workflows_service, "get_available_node_types", lambda: {"CheckpointLoaderSimple"})
|
|
monkeypatch.setattr(workflows_service, "get_model_filenames", lambda: {"existing.safetensors"})
|
|
|
|
result = validate_workflow(workflow_path)
|
|
|
|
assert result.ok
|
|
assert result.data["missing_nodes"] == ["MissingNode"]
|
|
assert result.data["missing_models"] == ["missing.safetensors"]
|
|
assert result.data["dependency_checks"]["nodes"] == "runtime"
|
|
assert result.data["dependency_checks"]["models"] == "folder_paths"
|
|
|
|
|
|
def test_workflow_versions_and_diff_track_overwrite(monkeypatch, tmp_path):
|
|
workflow_dir = tmp_path / "workflows"
|
|
index_db = tmp_path / "index.sqlite"
|
|
monkeypatch.setenv("MJR_AM_WORKFLOW_DIRECTORY", str(workflow_dir))
|
|
monkeypatch.setattr(workflows_service, "get_runtime_index_db_path", lambda: index_db)
|
|
|
|
saved = save_workflow(
|
|
workflow={"name": "Versioned", "nodes": [{"id": 1, "type": "KSampler", "widgets_values": [1]}]},
|
|
name="versioned",
|
|
)
|
|
assert saved.ok
|
|
workflow_path = workflow_dir / "versioned.json"
|
|
|
|
updated = save_workflow(
|
|
workflow={"name": "Versioned", "nodes": [{"id": 1, "type": "KSampler", "widgets_values": [2]}]},
|
|
filepath=str(workflow_path),
|
|
overwrite=True,
|
|
)
|
|
assert updated.ok
|
|
|
|
versions = list_workflow_versions(workflow_path)
|
|
assert versions.ok
|
|
assert versions.data["count"] == 1
|
|
|
|
diff = diff_workflow_versions(workflow_path)
|
|
assert diff.ok
|
|
assert any("widgets_values" in item for item in diff.data["changed"])
|