799 lines
30 KiB
Python
799 lines
30 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_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"])
|