cddb07a176
docs / deploy (push) Has been cancelled
docs / changes (push) Has been cancelled
docs / check-and-build (push) Has been cancelled
build container image / cpu (push) Has been cancelled
build container image / cuda (push) Has been cancelled
build container image / rocm (push) Has been cancelled
frontend checks / frontend-checks (push) Has been cancelled
frontend tests / frontend-tests (push) Has been cancelled
lfs checks / lfs-check (push) Has been cancelled
python checks / python-checks (push) Has been cancelled
python tests / py3.12: macos-default (push) Has been cancelled
python tests / py3.11: windows-cpu (push) Has been cancelled
python tests / py3.12: windows-cpu (push) Has been cancelled
python tests / py3.11: linux-cpu (push) Has been cancelled
typegen checks / typegen-checks (push) Has been cancelled
uv lock checks / uv-lock-checks (push) Has been cancelled
openapi checks / openapi-checks (push) Has been cancelled
python tests / py3.11: macos-default (push) Has been cancelled
python tests / py3.12: linux-cpu (push) Has been cancelled
422 lines
14 KiB
Python
422 lines
14 KiB
Python
from types import SimpleNamespace
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
|
|
from invokeai.app.services.users.users_common import UserDTO
|
|
from invokeai.app.services.workflow_records.workflow_records_common import (
|
|
Workflow,
|
|
WorkflowCategory,
|
|
WorkflowMeta,
|
|
WorkflowNotFoundError,
|
|
WorkflowRecordDTO,
|
|
WorkflowWithoutIDValidator,
|
|
)
|
|
|
|
|
|
def build_workflow_record_dto(
|
|
*,
|
|
workflow_id: str = "workflow-123",
|
|
user_id: str = "owner-1",
|
|
category: WorkflowCategory = WorkflowCategory.User,
|
|
is_public: bool = False,
|
|
) -> WorkflowRecordDTO:
|
|
workflow = Workflow(
|
|
id=workflow_id,
|
|
name="Saved Workflow",
|
|
author="Tester",
|
|
description="",
|
|
version="1.0.0",
|
|
contact="",
|
|
tags="",
|
|
notes="",
|
|
exposedFields=[],
|
|
meta=WorkflowMeta(version="1.0.0", category=category),
|
|
nodes=[],
|
|
edges=[],
|
|
form=None,
|
|
)
|
|
|
|
return WorkflowRecordDTO(
|
|
workflow_id=workflow_id,
|
|
workflow=workflow,
|
|
name=workflow.name,
|
|
created_at="2026-04-08T00:00:00Z",
|
|
updated_at="2026-04-08T00:00:00Z",
|
|
opened_at=None,
|
|
user_id=user_id,
|
|
is_public=is_public,
|
|
)
|
|
|
|
|
|
def build_user_dto(*, user_id: str = "user-1", is_admin: bool = False) -> UserDTO:
|
|
return UserDTO(
|
|
user_id=user_id,
|
|
email=f"{user_id}@example.test",
|
|
display_name=user_id,
|
|
is_admin=is_admin,
|
|
is_active=True,
|
|
created_at="2026-04-08T00:00:00Z",
|
|
updated_at="2026-04-08T00:00:00Z",
|
|
last_login_at=None,
|
|
)
|
|
|
|
|
|
def build_context(
|
|
*,
|
|
workflow_record: WorkflowRecordDTO | None = None,
|
|
queue_user_id: str = "owner-1",
|
|
multiuser: bool = False,
|
|
user_is_admin: bool = False,
|
|
user_exists: bool = True,
|
|
workflow_not_found: bool = False,
|
|
):
|
|
services = SimpleNamespace(
|
|
configuration=SimpleNamespace(multiuser=multiuser),
|
|
users=Mock(),
|
|
workflow_records=Mock(),
|
|
)
|
|
services.users.get.return_value = (
|
|
build_user_dto(user_id=queue_user_id, is_admin=user_is_admin) if user_exists else None
|
|
)
|
|
|
|
if workflow_not_found:
|
|
services.workflow_records.get.side_effect = WorkflowNotFoundError("missing")
|
|
else:
|
|
services.workflow_records.get.return_value = workflow_record or build_workflow_record_dto()
|
|
|
|
context = Mock()
|
|
context._services = services
|
|
context._data = SimpleNamespace(queue_item=SimpleNamespace(user_id=queue_user_id))
|
|
return context
|
|
|
|
|
|
def test_call_saved_workflow_invocation_contract():
|
|
from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
|
|
from invokeai.app.invocations.workflow_return import WorkflowReturnOutput
|
|
|
|
invocation = CallSavedWorkflowInvocation(id="test-node", workflow_id="workflow-123")
|
|
|
|
assert invocation.get_type() == "call_saved_workflow"
|
|
assert invocation.workflow_id == "workflow-123"
|
|
|
|
output = invocation.invoke(build_context())
|
|
|
|
assert isinstance(output, WorkflowReturnOutput)
|
|
assert output.values == {}
|
|
|
|
|
|
def test_call_saved_workflow_invocation_raises_when_workflow_id_is_empty():
|
|
from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
|
|
|
|
invocation = CallSavedWorkflowInvocation(id="test-node")
|
|
|
|
with pytest.raises(ValueError, match="saved workflow must be selected"):
|
|
invocation.invoke(build_context())
|
|
|
|
|
|
def test_call_saved_workflow_invocation_raises_when_workflow_does_not_exist():
|
|
from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
|
|
|
|
invocation = CallSavedWorkflowInvocation(id="test-node", workflow_id="missing-workflow")
|
|
|
|
with pytest.raises(ValueError, match="could not be found"):
|
|
invocation.invoke(build_context(workflow_not_found=True))
|
|
|
|
|
|
def test_call_saved_workflow_invocation_raises_when_workflow_is_not_accessible():
|
|
from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
|
|
|
|
invocation = CallSavedWorkflowInvocation(id="test-node", workflow_id="private-workflow")
|
|
|
|
with pytest.raises(ValueError, match="is not accessible"):
|
|
invocation.invoke(
|
|
build_context(
|
|
workflow_record=build_workflow_record_dto(
|
|
workflow_id="private-workflow",
|
|
user_id="owner-1",
|
|
category=WorkflowCategory.User,
|
|
is_public=False,
|
|
),
|
|
queue_user_id="other-user",
|
|
multiuser=True,
|
|
user_is_admin=False,
|
|
)
|
|
)
|
|
|
|
|
|
def test_call_saved_workflow_invocation_allows_shared_workflow_for_non_owner():
|
|
from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
|
|
|
|
invocation = CallSavedWorkflowInvocation(id="test-node", workflow_id="shared-workflow")
|
|
|
|
output = invocation.invoke(
|
|
build_context(
|
|
workflow_record=build_workflow_record_dto(
|
|
workflow_id="shared-workflow",
|
|
user_id="owner-1",
|
|
category=WorkflowCategory.User,
|
|
is_public=True,
|
|
),
|
|
queue_user_id="other-user",
|
|
multiuser=True,
|
|
user_is_admin=False,
|
|
)
|
|
)
|
|
|
|
assert output.values == {}
|
|
|
|
|
|
def test_call_saved_workflow_invocation_allows_default_workflow_for_non_owner():
|
|
from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
|
|
|
|
invocation = CallSavedWorkflowInvocation(id="test-node", workflow_id="default-workflow")
|
|
|
|
output = invocation.invoke(
|
|
build_context(
|
|
workflow_record=build_workflow_record_dto(
|
|
workflow_id="default-workflow",
|
|
user_id="system",
|
|
category=WorkflowCategory.Default,
|
|
is_public=False,
|
|
),
|
|
queue_user_id="other-user",
|
|
multiuser=True,
|
|
user_is_admin=False,
|
|
)
|
|
)
|
|
|
|
assert output.values == {}
|
|
|
|
|
|
def test_call_saved_workflow_invocation_allows_admin_to_access_private_workflow():
|
|
from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
|
|
|
|
invocation = CallSavedWorkflowInvocation(id="test-node", workflow_id="private-workflow")
|
|
|
|
output = invocation.invoke(
|
|
build_context(
|
|
workflow_record=build_workflow_record_dto(
|
|
workflow_id="private-workflow",
|
|
user_id="owner-1",
|
|
category=WorkflowCategory.User,
|
|
is_public=False,
|
|
),
|
|
queue_user_id="admin-user",
|
|
multiuser=True,
|
|
user_is_admin=True,
|
|
)
|
|
)
|
|
|
|
assert output.values == {}
|
|
|
|
|
|
def test_call_saved_workflow_invocation_raises_when_private_workflow_user_record_is_missing():
|
|
from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
|
|
|
|
invocation = CallSavedWorkflowInvocation(id="test-node", workflow_id="private-workflow")
|
|
|
|
with pytest.raises(ValueError, match="is not accessible"):
|
|
invocation.invoke(
|
|
build_context(
|
|
workflow_record=build_workflow_record_dto(
|
|
workflow_id="private-workflow",
|
|
user_id="owner-1",
|
|
category=WorkflowCategory.User,
|
|
is_public=False,
|
|
),
|
|
queue_user_id="other-user",
|
|
multiuser=True,
|
|
user_exists=False,
|
|
)
|
|
)
|
|
|
|
|
|
def test_call_saved_workflow_invocation_schema_declares_saved_workflow_ui_type():
|
|
from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
|
|
|
|
schema = CallSavedWorkflowInvocation.model_json_schema()
|
|
workflow_id = schema["properties"]["workflow_id"]
|
|
workflow_inputs = schema["properties"]["workflow_inputs"]
|
|
|
|
assert workflow_id["default"] == ""
|
|
assert workflow_id["input"] == "any"
|
|
assert workflow_id["ui_type"] == "SavedWorkflowField"
|
|
assert workflow_inputs["default"] == {}
|
|
assert workflow_inputs["ui_hidden"] is True
|
|
|
|
|
|
def test_workflow_return_invocation_contract():
|
|
from invokeai.app.invocations.workflow_return import (
|
|
WorkflowReturnInvocation,
|
|
WorkflowReturnOutput,
|
|
WorkflowReturnValueField,
|
|
)
|
|
|
|
invocation = WorkflowReturnInvocation(
|
|
id="return-node",
|
|
values=[
|
|
WorkflowReturnValueField(key="prompt", value="a"),
|
|
WorkflowReturnValueField(key="count", value=1),
|
|
WorkflowReturnValueField(key="metadata", value={"x": True}),
|
|
],
|
|
)
|
|
|
|
assert invocation.get_type() == "workflow_return"
|
|
|
|
output = invocation.invoke(build_context())
|
|
|
|
assert isinstance(output, WorkflowReturnOutput)
|
|
assert output.values == {"prompt": "a", "count": 1, "metadata": {"x": True}}
|
|
assert not hasattr(output, "collection")
|
|
|
|
|
|
def test_workflow_return_invocation_accepts_single_return_value():
|
|
from invokeai.app.invocations.workflow_return import WorkflowReturnInvocation, WorkflowReturnValueField
|
|
|
|
invocation = WorkflowReturnInvocation(id="return-node", values=WorkflowReturnValueField(key="sum", value=3))
|
|
|
|
output = invocation.invoke(build_context())
|
|
|
|
assert output.values == {"sum": 3}
|
|
|
|
|
|
def test_workflow_return_values_schema_preserves_single_or_list_cardinality():
|
|
from invokeai.app.invocations.workflow_return import WorkflowReturnInvocation
|
|
|
|
values_schema = WorkflowReturnInvocation.model_json_schema()["properties"]["values"]
|
|
|
|
assert values_schema["anyOf"] == [
|
|
{"$ref": "#/$defs/WorkflowReturnValueField"},
|
|
{"items": {"$ref": "#/$defs/WorkflowReturnValueField"}, "type": "array"},
|
|
]
|
|
assert values_schema.get("ui_type") != "CollectionField"
|
|
|
|
|
|
def test_workflow_return_value_invocation_contract():
|
|
from invokeai.app.invocations.workflow_return import WorkflowReturnValueField, WorkflowReturnValueInvocation
|
|
|
|
invocation = WorkflowReturnValueInvocation(id="return-value-node", key="image", value={"image_name": "image-a"})
|
|
|
|
output = invocation.invoke(build_context())
|
|
|
|
assert output.value == WorkflowReturnValueField(key="image", value={"image_name": "image-a"})
|
|
|
|
|
|
def test_workflow_return_value_field_survives_exclude_none_session_roundtrip():
|
|
from invokeai.app.invocations.workflow_return import (
|
|
WorkflowReturnInvocation,
|
|
WorkflowReturnValueField,
|
|
WorkflowReturnValueOutput,
|
|
)
|
|
from invokeai.app.services.shared.graph import Graph, GraphExecutionState
|
|
|
|
graph = Graph()
|
|
graph.add_node(
|
|
WorkflowReturnInvocation(id="return-node", values=WorkflowReturnValueField(key="nullable", value=None))
|
|
)
|
|
session = GraphExecutionState(graph=graph)
|
|
session.execution_graph.add_node(
|
|
WorkflowReturnInvocation(id="return-node", values=WorkflowReturnValueField(key="nullable", value=None))
|
|
)
|
|
session.results["return-value-node"] = WorkflowReturnValueOutput(
|
|
value=WorkflowReturnValueField(key="nullable", value=None)
|
|
)
|
|
|
|
reloaded = GraphExecutionState.model_validate_json(session.model_dump_json(warnings=False, exclude_none=True))
|
|
|
|
assert reloaded.execution_graph.nodes["return-node"].values == WorkflowReturnValueField(key="nullable", value=None)
|
|
assert reloaded.results["return-value-node"].value == WorkflowReturnValueField(key="nullable", value=None)
|
|
|
|
|
|
def test_workflow_return_invocation_rejects_duplicate_keys():
|
|
from invokeai.app.invocations.workflow_return import WorkflowReturnInvocation, WorkflowReturnValueField
|
|
|
|
invocation = WorkflowReturnInvocation(
|
|
id="return-node",
|
|
values=[
|
|
WorkflowReturnValueField(key="image", value="image-a"),
|
|
WorkflowReturnValueField(key="image", value="image-b"),
|
|
],
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="Duplicate workflow return key 'image'"):
|
|
invocation.invoke(build_context())
|
|
|
|
|
|
def test_workflow_return_get_invocation_contract():
|
|
from invokeai.app.invocations.workflow_return import WorkflowReturnGetInvocation
|
|
|
|
invocation = WorkflowReturnGetInvocation(id="return-get-node", values={"image": "image-a"}, key="image")
|
|
|
|
output = invocation.invoke(build_context())
|
|
|
|
assert output.value == "image-a"
|
|
|
|
|
|
def test_workflow_return_get_invocation_rejects_missing_key():
|
|
from invokeai.app.invocations.workflow_return import WorkflowReturnGetInvocation
|
|
|
|
invocation = WorkflowReturnGetInvocation(id="return-get-node", values={"image": "image-a"}, key="mask")
|
|
|
|
with pytest.raises(ValueError, match="Workflow return key 'mask' was not found"):
|
|
invocation.invoke(build_context())
|
|
|
|
|
|
def test_workflow_return_get_invocation_rejects_empty_key():
|
|
from invokeai.app.invocations.workflow_return import WorkflowReturnGetInvocation
|
|
|
|
invocation = WorkflowReturnGetInvocation(id="return-get-node", values={"image": "image-a"}, key=" ")
|
|
|
|
with pytest.raises(ValueError, match="Workflow return key must not be empty"):
|
|
invocation.invoke(build_context())
|
|
|
|
|
|
def test_workflow_return_invocation_schema_declares_named_values_contract():
|
|
from invokeai.app.invocations.workflow_return import WorkflowReturnGetInvocation, WorkflowReturnInvocation
|
|
|
|
schema = WorkflowReturnInvocation.model_json_schema()
|
|
assert "collection" not in schema["properties"]
|
|
values = schema["properties"]["values"]
|
|
|
|
assert values["input"] == "connection"
|
|
assert "ui_type" not in values
|
|
|
|
get_schema = WorkflowReturnGetInvocation.model_json_schema()
|
|
get_values = get_schema["properties"]["values"]
|
|
assert get_values["input"] == "connection"
|
|
assert get_values["ui_type"] == "AnyField"
|
|
|
|
|
|
def test_workflow_without_id_validator_rejects_duplicate_workflow_return_nodes():
|
|
with pytest.raises(ValueError, match="workflow_return"):
|
|
WorkflowWithoutIDValidator.validate_python(
|
|
{
|
|
"name": "Workflow With Duplicate Returns",
|
|
"author": "Tester",
|
|
"description": "",
|
|
"version": "1.0.0",
|
|
"contact": "",
|
|
"tags": "",
|
|
"notes": "",
|
|
"exposedFields": [],
|
|
"meta": {"version": "1.0.0", "category": "user"},
|
|
"nodes": [
|
|
{
|
|
"id": "return-1",
|
|
"type": "invocation",
|
|
"data": {"id": "return-1", "type": "workflow_return"},
|
|
"position": {"x": 0, "y": 0},
|
|
},
|
|
{
|
|
"id": "return-2",
|
|
"type": "invocation",
|
|
"data": {"id": "return-2", "type": "workflow_return"},
|
|
"position": {"x": 100, "y": 0},
|
|
},
|
|
],
|
|
"edges": [],
|
|
"form": None,
|
|
}
|
|
)
|