Files
malkuthro-ComfyUI-Koolook/tests/server/test_published_setup_registry.py
T
6f158c9020 fix(nodes): Easy_LoadVideo empty-branch degrade (bundles #243 publish-tab cleanup) (#244)
* docs(setups): add setup runner quickstart runbook

Standalone reference for opening the published-setup mockup frontend
(setup runner simulator): the `<comfy-address>/koolook/setup_runner_simulator.html`
URL pattern, the live-registry vs setup-file (inspect-only) load paths,
and demo mode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(sidebar): close throwaway workflow tabs left by publish capture

Publishing a setup captures ComfyUI's API-format prompt by loading the
graph onto the canvas (graphToPrompt) and reloading the prior canvas.
In current Comfy each loadGraphData spawns an "Unsaved Workflow" tab, so
every publish left two stray tabs behind.

captureWorkflowApiPrompt now snapshots the open workflows before capture
and, afterward, re-activates the user's original tab and closes whatever
the capture opened, via app.extensionManager.workflow. All of it is
guarded and best-effort: the workflow-store API is Comfy-version
specific, so if it is absent capture still succeeds and behavior falls
back to today (no crash, no broken publish).

Covered by a source-pattern test. Needs live verification against the
1.45 frontend (dev-sync) to confirm the closeWorkflow API shape.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(nodes): Easy_LoadVideo degrades to empty on an unselected folder branch

When an upstream mode switch routes a path into Easy_LoadVideo, both the
EXR sequence loader and the video loader execute eagerly. On the EXR
branch the video loader was handed a folder (no filename) and raised
ValueError, crashing the whole prompt -- even though that branch is
discarded downstream. Lenient sequence loaders just ignore a wrong-type
path; the video loader was the only one that hard-failed.

_compose_input_video_path now returns EMPTY_BRANCH_SENTINEL instead of
raising when input_path resolves to an existing directory. load_video
turns that into a correctly-shaped empty result (IMAGE zeros,
frame_count 0, empty AUDIO, empty video_info) matching VHS_LoadVideoPath
returns, so the dead branch no longer crashes the prompt. VALIDATE_INPUTS
and IS_CHANGED handle the sentinel too. A path that does not exist is
still a genuine mistake and keeps raising.

torch is imported lazily in the empty-result builder so the path logic
stays importable/testable without torch.

Covered by unit tests for the sentinel (absolute + relative dirs), the
still-raises case, and a load_video integration test that asserts the
empty result and that the loader never runs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(setups): expose prompt as an always-on field in the publish frontend

The publish surface modelled `prompt` as a 4th source mode (mutually
exclusive with EXR/QT/Img) and hid it (visible:false), so a published
setup couldn't offer "pick a source AND describe the shot". This makes
prompt a standalone, always-on input.

- published_surface.js: prompt becomes visible + standalone + multiline,
  rendered independently of the source switch; the author's prompt-widget
  text is surfaced as a `placeholder` hint (submitted default is empty so
  an untouched hint is never sent), with a `help` line. The switch
  excludes standalone fields from its source options.
- setup_runner_simulator.js: renders always-on standalone inputs alongside
  the selected source (textarea + placeholder + help), includes them in the
  run payload, and updates the demo setup.
- setup_runner_simulator.html: .field-help styling.
- contract doc: documents the always-on prompt behaviour.
- launch.json: portability fix (hardcoded C:\Python313 -> python).

Contract test updated + a new test asserting prompt is always-on, carries
the placeholder, and is excluded from the switch source options (runs on
CI where Node is available). Behaviour verified in the runner simulator
(demo mode) in a browser: textarea + hint, EXR/QT/Img-only switch, prompt
stays visible across source changes, payload includes the typed prompt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(setups): mirror always-on prompt field in the Python publish inference

The publish surface is inferred server-side in Python (koolook_setups.py
_infer_app_surface, called by publishSetup), which overrides the client.
The earlier change only updated the JS (published_surface.js), so
re-publishing still produced the old surface with a hidden prompt.

Mirror the JS here: prompt is a standalone, always-on input (visible +
multiline) with the author's prompt-widget text as a placeholder hint and
an empty submitted default; it is excluded from the source switch options.
Keeps koolook_setups.py and published_surface.js in sync.

Registry test updated to the new shape. Server + publish-contract suites
green (115); full non-sidebar suite green (530).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(setups): keep the runner simulator polling through long renders

The poller used a fixed 30s total budget, so a multi-minute render (e.g.
428s) timed out mid-run with "Timed out waiting for run" even though
ComfyUI was still rendering and the run later landed as succeeded.

timeoutMs is now a "no-progress" window: while ComfyUI keeps reporting the
run as queued/running, the deadline is pushed forward, so a long-but-active
render never spuriously times out; only a stalled/vanished run gives up.
Default raised 30s -> 120s. The run status line shows elapsed seconds while
active so a long wait visibly ticks instead of looking frozen.

Happy path (queued -> running -> succeeded) unchanged; verified in the
runner simulator (demo mode) in a browser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(setups): serve simulator assets with no-store so dev-syncs take effect

The runner-simulator HTML/JS route returned no cache headers, so browsers
cached the assets and kept running stale JS after a dev-sync -- e.g. the
old 30s poller persisted client-side even though the server was serving
the long-render fix, which looked like "the fix didn't land". Serve these
dev-synced assets with Cache-Control: no-store so a normal reload always
picks up the current code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(setups): add live run/poll smoke check for the published-setup API

tools/check_setup_run.py queues a published setup through the same routes
the runner simulator uses and polls the run endpoint with the same
deadline-extension logic, printing the status timeline. Lets us self-verify
end-to-end run tracking against a live ComfyUI (e.g. "does a long render
reach succeeded?") without driving the browser UI.

Used it to confirm the run/poll server flow is correct (terminal status
detected in ~2s; run-000001 reported succeeded), isolating the simulator's
earlier "Timed out" to stale browser cache rather than a server bug.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(nodes): Koolook_NextVersion auto-detects the next output version

Adds filesystem-aware version auto-detection so a re-run lands on a fresh
version instead of failing with "Output file already exists and overwrite
is disabled" (the real EasyAIPipeline failure seen on the RMGB setup).

- koolook_versioning.next_version_token(directory, name, ...): scans for
  existing <name>_vNNN files AND sequence-folders (handles name_v002.png,
  name_v002.0001.exr, name_v003/), returns highest+1 zero-padded; missing/
  empty dir -> start (v001); exact base-name match so other shots never
  bump this one; empty name matches bare vNNN; configurable prefix/padding.
- Koolook_NextVersion node (Koolook/Publish): folder + name -> next version
  token. Wire once into both the EXR and video version inputs so branches
  stay in sync. IS_CHANGED returns NaN to re-scan every run.

Tested: helper edge cases + node registration/run. Preflight clean (20
node IDs). Full non-sidebar suite green (539).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(nodes): 'auto' version keyword for EasyAIPipeline + Easy_VideoCombine

No new node or wiring needed: type 'auto' (or 'next') in the version field
and the node scans its own output folder for existing <name>_vNNN entries
and writes the next free version -- fixing "Output file already exists and
overwrite is disabled" without manual version bumps.

- koolook_versioning.is_auto_version(): detects the auto/next keyword.
- EasyAIPipeline (build_pipeline_outputs): on auto, scans the version-folder
  parent (base, or base/shot/method when subfolders are on) for vNNN dirs and
  picks the next; version computed after path segments so the scan dir is known.
- Easy_VideoCombine: on auto, resolves the token after the output dir + name
  root are composed, scanning that dir for <name>_vNNN outputs.
- Non-auto/typed/wired tokens and disable_versioning are unchanged.

Tests: is_auto_version keywords + EasyAIPipeline auto (v003 after v001/v002,
v001 first run). Full non-sidebar suite green (542), preflight clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(nodes): drop redundant Koolook_NextVersion node

The 'auto' version keyword on EasyAIPipeline + Easy_VideoCombine covers
auto-detection without any new node or wiring, so the standalone
Koolook_NextVersion node is redundant. Remove it (and its node test) to keep
the node list clean; the shared next_version_token helper stays (the 'auto'
keyword uses it).

Node-ID count back to 19; full non-sidebar suite green (541), preflight clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(tools): satisfy security gate for setup smoke check

* fix(pr244): address verified review findings

* fix(video): fail if relative auto-version scan root is unavailable

---------

Co-authored-by: Cristian Predut <cristian.predut@x.baconcph.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: AI Machine <ai.machine@x.baconcph.com>
2026-06-19 00:10:59 +02:00

1656 lines
59 KiB
Python

from __future__ import annotations
from copy import deepcopy
from datetime import datetime
import json
import pytest
from koolook_setups import (
FileSetupStorage,
PublishedSetupRegistry,
StaticSetupStorage,
validate_setup,
)
def _valid_setup() -> dict:
return {
"schemaVersion": 1,
"id": "ltx-director-demo",
"version": 1,
"updatedAt": "2026-06-06T08:00:00Z",
"metadata": {
"title": "LTX Director Demo",
"description": "A tiny published setup fixture.",
"category": "Video",
"tags": ["demo", "ltx"],
"previewImage": "koolook://preview/ltx-director-demo.png",
},
"visualGraph": {
"nodes": [
{
"id": 12,
"type": "Text Multiline",
"inputs": [{"name": "text", "type": "STRING", "widget": {"name": "text"}}],
"widgets_values": ["demo prompt"],
}
],
"links": [],
},
"apiPrompt": {
"12": {
"class_type": "Text Multiline",
"inputs": {"text": "demo prompt"},
}
},
"inputContract": {
"inputs": [
{
"key": "prompt",
"label": "Prompt",
"type": "text",
"required": True,
"target": {"node": "12", "input": "text"},
}
]
},
"outputContract": {
"outputs": [
{
"key": "video",
"label": "Video",
"type": "video",
}
]
},
"source": {
"kind": "sidebar-workflow",
"path": "Demos/LTX Director Demo",
},
"validation": {
"status": "valid",
"diagnostics": [],
},
}
def test_validate_setup_accepts_complete_published_setup() -> None:
result = validate_setup(_valid_setup())
assert result.valid is True
assert result.setup["id"] == "ltx-director-demo"
assert result.diagnostics == []
def test_validate_setup_rejects_valid_status_without_api_prompt() -> None:
setup = deepcopy(_valid_setup())
setup["apiPrompt"] = None
result = validate_setup(setup)
assert result.valid is False
assert "validation.status valid requires apiPrompt" in result.diagnostics
@pytest.mark.parametrize("missing_key", ["id", "metadata", "visualGraph", "inputContract"])
def test_validate_setup_rejects_missing_required_fields(missing_key: str) -> None:
setup = deepcopy(_valid_setup())
setup.pop(missing_key)
result = validate_setup(setup)
assert result.valid is False
assert any(missing_key in diagnostic for diagnostic in result.diagnostics)
@pytest.mark.parametrize(
("mutator", "expected"),
[
(lambda setup: setup["metadata"].pop("title"), "metadata.title"),
(lambda setup: setup["inputContract"]["inputs"][0].pop("target"), "inputContract.inputs[0].target"),
(lambda setup: setup["validation"].__setitem__("status", "unknown"), "validation.status"),
(lambda setup: setup.__setitem__("updatedAt", "06/06/2026"), "updatedAt"),
(lambda setup: setup.__setitem__("schemaVersion", 2), "unsupported schemaVersion: 2"),
],
)
def test_validate_setup_rejects_malformed_schema_fields(mutator, expected: str) -> None:
setup = deepcopy(_valid_setup())
mutator(setup)
result = validate_setup(setup)
assert result.valid is False
assert any(expected in diagnostic for diagnostic in result.diagnostics)
def test_registry_lists_summaries_and_fetches_full_setup_without_invalid_records() -> None:
invalid = deepcopy(_valid_setup())
invalid["id"] = "broken"
invalid.pop("metadata")
registry = PublishedSetupRegistry(StaticSetupStorage([invalid, _valid_setup()]))
rows = registry.listSetups()
detail = registry.getSetup("ltx-director-demo")
assert [row["id"] for row in rows] == ["ltx-director-demo"]
assert rows[0] == {
"id": "ltx-director-demo",
"version": 1,
"updatedAt": "2026-06-06T08:00:00Z",
"metadata": {
"title": "LTX Director Demo",
"description": "A tiny published setup fixture.",
"category": "Video",
"tags": ["demo", "ltx"],
"previewImage": "koolook://preview/ltx-director-demo.png",
},
"validation": {"status": "valid", "diagnostics": []},
"inputSummary": [{"key": "prompt", "label": "Prompt", "type": "text", "required": True}],
"outputSummary": [{"key": "video", "label": "Video", "type": "video"}],
}
assert detail["visualGraph"] == _valid_setup()["visualGraph"]
assert detail["apiPrompt"] == _valid_setup()["apiPrompt"]
assert registry.getSetup("broken") is None
assert registry.diagnostics == ["broken: missing required field: metadata"]
def test_registry_preserves_supplied_api_prompt_that_differs_from_fallback_conversion() -> None:
stale = deepcopy(_valid_setup())
stale["apiPrompt"] = {
"1": {
"class_type": "Different Node",
"inputs": {},
}
}
registry = PublishedSetupRegistry(StaticSetupStorage([stale]))
assert registry.listSetups()[0]["id"] == "ltx-director-demo"
setup = registry.getSetup("ltx-director-demo")
assert setup is not None
assert setup["apiPrompt"] == stale["apiPrompt"]
assert registry.diagnostics == []
def test_registry_normalizes_legacy_visual_artifacts_in_api_prompt() -> None:
setup = deepcopy(_valid_setup())
setup["apiPrompt"]["99"] = {"class_type": "Label (rgthree)", "inputs": {}}
registry = PublishedSetupRegistry(StaticSetupStorage([setup]))
normalized = registry.getSetup("ltx-director-demo")
assert normalized is not None
assert normalized["apiPrompt"] == _valid_setup()["apiPrompt"]
assert registry.diagnostics == []
def test_registry_omits_group_first_setup_missing_persisted_surface() -> None:
setup = deepcopy(_valid_setup())
setup["id"] = "missing-surface"
setup["inputContract"] = {"inputs": []}
setup["outputContract"] = {"outputs": []}
registry = PublishedSetupRegistry(StaticSetupStorage([setup]))
assert registry.getSetup("missing-surface") is None
assert registry.diagnostics == [
"missing-surface: setupSurface must be a JSON object for group-authored setups"
]
def test_registry_omits_malformed_persisted_setup_surface() -> None:
setup = deepcopy(_valid_setup())
setup["setupSurface"] = {
"sourceInputs": [{"group": "Koolook Input", "nodes": [{"id": "12"}]}],
"outputs": "not-list",
"controls": [],
}
registry = PublishedSetupRegistry(StaticSetupStorage([setup]))
assert registry.getSetup("ltx-director-demo") is None
assert "ltx-director-demo: setupSurface.outputs must be a list" in registry.diagnostics
def test_registry_omits_malformed_persisted_setup_surface_app() -> None:
setup = deepcopy(_valid_setup())
setup["inputContract"] = {"inputs": []}
setup["outputContract"] = {"outputs": []}
setup["setupSurface"] = {
"sourceInputs": [
{
"group": "Koolook Input",
"nodes": [{"id": "12", "type": "Koolook_PublishInput", "title": "Input"}],
}
],
"outputs": [
{
"group": "Koolook Output",
"nodes": [{"id": "20", "type": "Koolook_PublishOutput", "title": "Output"}],
}
],
"controls": [],
"app": "not-object",
}
registry = PublishedSetupRegistry(StaticSetupStorage([setup]))
assert registry.getSetup("ltx-director-demo") is None
assert "ltx-director-demo: setupSurface.app must be a JSON object" in registry.diagnostics
def test_registry_omits_malformed_persisted_setup_surface_app_fields() -> None:
setup = deepcopy(_valid_setup())
setup["inputContract"] = {"inputs": []}
setup["outputContract"] = {"outputs": []}
setup["setupSurface"] = {
"sourceInputs": [
{
"group": "Koolook Input",
"nodes": [{"id": "12", "type": "Koolook_PublishInput", "title": "Input"}],
}
],
"outputs": [
{
"group": "Koolook Output",
"nodes": [{"id": "20", "type": "Koolook_PublishOutput", "title": "Output"}],
}
],
"controls": [],
"app": {
"inputs": [
{
"key": "single_file",
"label": "Single file",
"visible": True,
"target": {"node": "12"},
"default": "/image.png",
}
],
"outputs": "not-list",
"results": [],
"switch": {
"key": "switch",
"label": "Input type",
"visible": True,
"target": {"node": "12", "input": "mode"},
"default": 2,
"options": [{"value": True, "label": "Img", "visible": True, "input": "single_file"}],
},
},
}
registry = PublishedSetupRegistry(StaticSetupStorage([setup]))
assert registry.getSetup("ltx-director-demo") is None
assert "ltx-director-demo: setupSurface.app.outputs must be a list" in registry.diagnostics
assert (
"ltx-director-demo: setupSurface.app.inputs[0].target.input must be non-empty text"
in registry.diagnostics
)
assert (
"ltx-director-demo: setupSurface.app.switch.options[0].value must be an integer"
in registry.diagnostics
)
def test_registry_omits_persisted_setup_surface_app_targets_missing_from_graph() -> None:
setup = deepcopy(_valid_setup())
setup["inputContract"] = {"inputs": []}
setup["outputContract"] = {"outputs": []}
setup["setupSurface"] = {
"sourceInputs": [
{
"group": "Koolook Input",
"nodes": [{"id": "12", "type": "Koolook_PublishInput", "title": "Input"}],
}
],
"outputs": [
{
"group": "Koolook Output",
"nodes": [{"id": "20", "type": "Koolook_PublishOutput", "title": "Output"}],
}
],
"controls": [],
"app": {
"inputs": [
{
"key": "single_file",
"label": "Single file",
"visible": True,
"target": {"node": "999", "input": "missing"},
"default": "/image.png",
}
],
"outputs": [],
"results": [],
"switch": {
"key": "switch",
"label": "Input type",
"visible": True,
"target": {"node": "12", "input": "mode"},
"default": 2,
"options": [{"value": 2, "label": "Img", "visible": True, "input": "missing_key"}],
},
},
}
registry = PublishedSetupRegistry(StaticSetupStorage([setup]))
assert registry.getSetup("ltx-director-demo") is None
assert (
"ltx-director-demo: setupSurface.app.inputs[0].target.node not found in visualGraph"
in registry.diagnostics
)
assert (
"ltx-director-demo: setupSurface.app.switch.options[0].input must match a setupSurface.app.inputs key"
in registry.diagnostics
)
def test_file_storage_loads_setups_object_and_uses_sample_fallback(tmp_path) -> None:
primary = tmp_path / "setups.json"
fallback = tmp_path / "sample.json"
fallback.write_text('{"setups": [%s]}' % _json(_valid_setup()), encoding="utf-8")
storage = FileSetupStorage(primary, fallback_path=fallback)
assert storage.load_setups()[0]["id"] == "ltx-director-demo"
primary.write_text('{"setups": []}', encoding="utf-8")
assert storage.load_setups() == []
def test_file_storage_reports_corrupt_primary_without_falling_back(tmp_path) -> None:
primary = tmp_path / "setups.json"
fallback = tmp_path / "sample.json"
primary.write_text("{not json", encoding="utf-8")
fallback.write_text('{"setups": [%s]}' % _json(_valid_setup()), encoding="utf-8")
storage = FileSetupStorage(primary, fallback_path=fallback)
registry = PublishedSetupRegistry(storage)
assert registry.listSetups() == []
assert len(registry.diagnostics) == 1
assert str(primary) in registry.diagnostics[0]
assert "could not read published setups" in registry.diagnostics[0]
def test_publish_setup_does_not_copy_sample_fallback_into_primary(tmp_path) -> None:
primary = tmp_path / "user" / "setups.json"
fallback = tmp_path / "sample.json"
fallback.write_text(json.dumps({"setups": [_valid_setup()]}), encoding="utf-8")
registry = PublishedSetupRegistry(FileSetupStorage(primary, fallback_path=fallback))
result = registry.publishSetup(
visualGraph={
"nodes": [
{
"id": 12,
"type": "Text Multiline",
"inputs": [{"name": "text", "widget": {"name": "text"}}],
"widgets_values": ["first prompt"],
}
],
"links": [],
},
metadata={
"id": "first-curated-setup",
"title": "First Curated Setup",
"description": "The first real user-published setup.",
},
inputContract={
"inputs": [
{
"key": "prompt",
"type": "text",
"target": {"node": "12", "input": "text"},
}
]
},
outputContract={"outputs": [{"key": "preview", "type": "image"}]},
source={"kind": "sidebar-workflow", "path": "Demos/First"},
)
assert result.valid is True
stored = json.loads(primary.read_text(encoding="utf-8"))
assert [setup["id"] for setup in stored["setups"]] == ["first-curated-setup"]
def test_publish_setup_converts_supported_visual_graph_to_api_prompt() -> None:
storage = StaticSetupStorage([])
registry = PublishedSetupRegistry(storage)
visual_graph = {
"nodes": [
{
"id": 12,
"type": "Text Multiline",
"inputs": [{"name": "text", "type": "STRING", "widget": {"name": "text"}}],
"widgets_values": ["default prompt"],
},
{
"id": 20,
"type": "Text Concatenate",
"inputs": [
{"name": "text_a", "type": "STRING", "link": 101},
{"name": "delimiter", "type": "STRING", "widget": {"name": "delimiter"}},
],
"widgets_values": ["_"],
}
],
"links": [[101, 12, 0, 20, 0, "STRING"]],
}
result = registry.publishSetup(
visualGraph=visual_graph,
metadata={
"id": "my-callable-flow",
"title": "My Callable Flow",
"description": "External users can run this curated setup.",
"category": "Video",
"tags": ["demo", "publish"],
"previewImage": "koolook://preview/card.png",
},
inputContract={
"inputs": [
{
"key": "prompt",
"label": "Prompt",
"type": "text",
"required": True,
"target": {"node": "12", "input": "text"},
}
]
},
outputContract={"outputs": [{"key": "preview", "label": "Preview", "type": "image"}]},
source={"kind": "sidebar-workflow", "path": "Demos/My Callable Flow"},
)
assert result.valid is True
setup = registry.getSetup("my-callable-flow")
assert setup is not None
assert setup["visualGraph"] == visual_graph
assert setup["apiPrompt"] == {
"12": {
"class_type": "Text Multiline",
"inputs": {"text": "default prompt"},
},
"20": {
"class_type": "Text Concatenate",
"inputs": {"text_a": ["12", 0], "delimiter": "_"},
}
}
assert setup["source"] == {"kind": "sidebar-workflow", "path": "Demos/My Callable Flow"}
assert setup["updatedAt"].endswith("Z")
assert "+00:00" in datetime.fromisoformat(setup["updatedAt"].replace("Z", "+00:00")).isoformat()
assert setup["validation"] == {"status": "valid", "diagnostics": []}
assert registry.listSetups()[0]["id"] == "my-callable-flow"
def test_publish_setup_allows_empty_description() -> None:
registry = PublishedSetupRegistry(StaticSetupStorage([]))
result = registry.publishSetup(
visualGraph={
"nodes": [
{
"id": 12,
"type": "Text Multiline",
"inputs": [{"name": "text", "type": "STRING", "widget": {"name": "text"}, "link": None}],
"widgets_values": ["default prompt"],
}
],
"links": [],
},
metadata={"id": "empty-description-flow", "title": "Empty Description", "description": ""},
inputContract={"inputs": []},
outputContract={"outputs": [{"key": "preview", "type": "text"}]},
source={"kind": "sidebar-workflow", "path": "Demos/Empty Description"},
)
assert result.valid is True
setup = registry.getSetup("empty-description-flow")
assert setup is not None
assert setup["metadata"]["description"] == ""
def test_publish_setup_converts_text_multiline_without_serialized_inputs() -> None:
registry = PublishedSetupRegistry(StaticSetupStorage([]))
visual_graph = {
"nodes": [
{
"id": 3,
"type": "Text Multiline",
"pos": [40, 40],
"size": [300, 100],
"inputs": [],
"widgets_values": ["W:/projects/example/frames"],
},
{
"id": 1,
"type": "SaveEXRFrames",
"pos": [420, 40],
"size": [240, 180],
"inputs": [],
"widgets_values": ["path/to/frame%04d.exr", "linear", 1001, False, "ui"],
},
],
"links": [],
"groups": [
{"title": "Koolook Input", "bounding": [20, 20, 340, 160]},
{"title": "Koolook Output", "bounding": [400, 20, 300, 240]},
],
}
result = registry.publishSetup(
visualGraph=visual_graph,
metadata={
"id": "text-multiline-simple-setup",
"title": "Text Multiline Simple Setup",
"description": "A simple setup with widget-only source text.",
},
inputContract={"inputs": []},
outputContract={"outputs": []},
source={"kind": "sidebar-workflow", "path": "Demos/Text Multiline"},
)
assert result.valid is True
setup = registry.getSetup("text-multiline-simple-setup")
assert setup is not None
assert setup["apiPrompt"]["3"]["inputs"] == {"text": "W:/projects/example/frames"}
def test_publish_setup_rejects_text_multiline_without_widget_value() -> None:
registry = PublishedSetupRegistry(StaticSetupStorage([]))
result = registry.publishSetup(
visualGraph={
"nodes": [
{
"id": 3,
"type": "Text Multiline",
"pos": [40, 40],
"size": [300, 100],
"inputs": [],
},
{"id": 1, "type": "Preview Image", "pos": [420, 40], "size": [240, 180], "inputs": []},
],
"links": [],
"groups": [
{"title": "Koolook Input", "bounding": [20, 20, 340, 160]},
{"title": "Koolook Output", "bounding": [400, 20, 300, 240]},
],
},
metadata={
"id": "missing-text-widget",
"title": "Missing Text Widget",
"description": "Missing widget-backed text.",
},
inputContract={"inputs": []},
outputContract={"outputs": []},
source={"kind": "sidebar-workflow", "path": "Demos/Missing Text"},
)
assert result.valid is False
assert (
"visualGraph.nodes[0].widgets_values is missing a value for widget-only input text"
in result.diagnostics
)
def test_publish_setup_converts_easy_ai_pipeline_widget_only_values() -> None:
registry = PublishedSetupRegistry(StaticSetupStorage([]))
visual_graph = {
"nodes": [
{
"id": 6,
"type": "EasyAIPipeline",
"pos": [40, 40],
"size": [420, 700],
"inputs": [],
"widgets_values": [
81,
453453453,
"Place your base folder path in the FIELD below",
"W:/projects/example",
".%04d.exr",
"msk",
"",
"1",
False,
False,
"W:/preview/path/frame.%04d.exr",
"",
None,
None,
],
},
{"id": 1, "type": "SaveEXRFrames", "pos": [620, 40], "size": [240, 180], "inputs": []},
],
"links": [],
"groups": [
{"title": "Koolook Input", "bounding": [20, 20, 500, 760]},
{"title": "Koolook Output", "bounding": [600, 20, 300, 240]},
],
}
result = registry.publishSetup(
visualGraph=visual_graph,
metadata={
"id": "easy-ai-pipeline-simple-setup",
"title": "Easy AI Pipeline Simple Setup",
"description": "A simple setup with EasyAIPipeline widget-only values.",
},
inputContract={"inputs": []},
outputContract={"outputs": []},
source={"kind": "sidebar-workflow", "path": "Demos/EasyAIPipeline"},
)
assert result.valid is True
setup = registry.getSetup("easy-ai-pipeline-simple-setup")
assert setup is not None
assert setup["apiPrompt"]["6"]["inputs"] == {
"shot_duration": 81,
"seed_value": 453453453,
"instruction": "Place your base folder path in the FIELD below",
"base_directory_path": "W:/projects/example",
"extension": ".%04d.exr",
"shot_name": "msk",
"ai_method": "",
"version": "1",
"disable_versioning": False,
"enable_overwrite": False,
"no_subfolders": False,
}
def test_publish_setup_rejects_partial_easy_ai_pipeline_widget_only_values() -> None:
registry = PublishedSetupRegistry(StaticSetupStorage([]))
result = registry.publishSetup(
visualGraph={
"nodes": [
{
"id": 6,
"type": "EasyAIPipeline",
"pos": [40, 40],
"size": [420, 700],
"inputs": [],
"widgets_values": [81],
},
{"id": 1, "type": "SaveEXRFrames", "pos": [620, 40], "size": [240, 180], "inputs": []},
],
"links": [],
"groups": [
{"title": "Koolook Input", "bounding": [20, 20, 500, 760]},
{"title": "Koolook Output", "bounding": [600, 20, 300, 240]},
],
},
metadata={
"id": "partial-easy-ai-pipeline",
"title": "Partial Easy AI Pipeline",
"description": "Missing widget-backed pipeline values.",
},
inputContract={"inputs": []},
outputContract={"outputs": []},
source={"kind": "sidebar-workflow", "path": "Demos/Partial EasyAIPipeline"},
)
assert result.valid is False
assert (
"visualGraph.nodes[0].widgets_values is missing a value for widget-only input seed_value"
in result.diagnostics
)
assert (
"visualGraph.nodes[0].widgets_values is missing a value for widget-only input base_directory_path"
in result.diagnostics
)
assert (
"visualGraph.nodes[0].widgets_values is missing a value for widget-only input no_subfolders"
not in result.diagnostics
)
def test_publish_setup_converts_subgraph_proxy_widget_values() -> None:
registry = PublishedSetupRegistry(StaticSetupStorage([]))
visual_graph = {
"nodes": [
{
"id": 10,
"type": "sam3-subgraph",
"inputs": [
{"name": "text", "type": "STRING", "widget": {"name": "text"}, "link": None},
{"name": "threshold", "type": "FLOAT", "widget": {"name": "threshold"}, "link": None},
{"name": "image", "type": "IMAGE", "link": 42},
],
"properties": {
"proxyWidgets": [
["135", "text"],
["133", "threshold"],
],
},
"widgets_values": [],
},
{"id": 20, "type": "LoadImage", "inputs": []},
],
"links": [
[42, 20, 0, 10, 2, "IMAGE"],
],
"definitions": {
"subgraphs": [
{
"id": "sam3-subgraph",
"inputs": [
{"name": "text", "type": "STRING"},
{"name": "threshold", "type": "FLOAT"},
{"name": "image", "type": "IMAGE"},
],
"nodes": [
{
"id": 135,
"type": "CLIPTextEncode",
"inputs": [
{"name": "clip", "type": "CLIP", "link": None},
{"name": "text", "type": "STRING", "widget": {"name": "text"}, "link": 2},
],
"widgets_values": ["bear"],
},
{
"id": 133,
"type": "SAM3_Detect",
"inputs": [
{"name": "image", "type": "IMAGE", "link": 3},
{"name": "threshold", "type": "FLOAT", "widget": {"name": "threshold"}, "link": 4},
],
"widgets_values": [0.5],
},
],
"links": [
{"id": 2, "origin_id": -10, "origin_slot": 0, "target_id": 135, "target_slot": 1},
{"id": 3, "origin_id": -10, "origin_slot": 2, "target_id": 133, "target_slot": 0},
{"id": 4, "origin_id": -10, "origin_slot": 1, "target_id": 133, "target_slot": 1},
],
},
],
},
}
result = registry.publishSetup(
visualGraph=visual_graph,
metadata={
"id": "sam3-proxy-widget-setup",
"title": "SAM3 Proxy Widget Setup",
"description": "A subgraph wrapper with proxy widget defaults.",
},
inputContract={"inputs": []},
outputContract={"outputs": [{"key": "mask", "type": "mask"}]},
source={"kind": "sidebar-workflow", "path": "Demos/SAM3"},
)
assert result.valid is True
setup = registry.getSetup("sam3-proxy-widget-setup")
assert setup is not None
assert "10" not in setup["apiPrompt"]
assert setup["apiPrompt"]["10:135"]["inputs"] == {"text": "bear"}
assert setup["apiPrompt"]["10:133"]["inputs"] == {
"threshold": 0.5,
"image": ["20", 0],
}
def test_publish_setup_converts_widget_values_saved_as_mapping() -> None:
registry = PublishedSetupRegistry(StaticSetupStorage([]))
visual_graph = {
"nodes": [
{
"id": 142,
"type": "VHS_LoadVideo",
"inputs": [
{"name": "video", "type": "COMBO", "widget": {"name": "video"}, "link": None},
{"name": "force_rate", "type": "FLOAT", "widget": {"name": "force_rate"}, "link": None},
{"name": "frame_load_cap", "type": "INT", "widget": {"name": "frame_load_cap"}, "link": None},
],
"widgets_values": {
"video": "example.mp4",
"force_rate": 0,
"frame_load_cap": 41,
},
},
],
"links": [],
}
result = registry.publishSetup(
visualGraph=visual_graph,
metadata={
"id": "mapping-widget-values",
"title": "Mapping Widget Values",
"description": "A node saved with named widget values.",
},
inputContract={"inputs": []},
outputContract={"outputs": [{"key": "preview", "type": "video"}]},
source={"kind": "sidebar-workflow", "path": "Demos/Mapping Widgets"},
)
assert result.valid is True
setup = registry.getSetup("mapping-widget-values")
assert setup is not None
assert setup["apiPrompt"]["142"]["inputs"] == {
"video": "example.mp4",
"force_rate": 0,
"frame_load_cap": 41,
}
def test_publish_setup_converts_reroute_with_unnamed_input() -> None:
registry = PublishedSetupRegistry(StaticSetupStorage([]))
visual_graph = {
"nodes": [
{
"id": 1,
"type": "Text Multiline",
"inputs": [],
"outputs": [{"name": "STRING", "type": "STRING", "links": [10]}],
"widgets_values": ["example"],
},
{
"id": 170,
"type": "Reroute",
"inputs": [{"name": "", "type": "*", "link": 10}],
"outputs": [{"name": "", "type": "*", "links": [11]}],
},
{
"id": 2,
"type": "Text Concatenate",
"inputs": [{"name": "text_a", "type": "STRING", "link": 11}],
},
],
"links": [[10, 1, 0, 170, 0, "*"], [11, 170, 0, 2, 0, "*"]],
}
result = registry.publishSetup(
visualGraph=visual_graph,
metadata={
"id": "reroute-flow",
"title": "Reroute Flow",
"description": "A workflow with an unnamed reroute input.",
},
inputContract={"inputs": []},
outputContract={"outputs": [{"key": "preview", "type": "text"}]},
source={"kind": "sidebar-workflow", "path": "Demos/Reroute"},
)
assert result.valid is True
setup = registry.getSetup("reroute-flow")
assert setup is not None
assert "170" not in setup["apiPrompt"]
assert setup["apiPrompt"]["2"]["inputs"] == {"text_a": ["1", 0]}
def test_publish_setup_infers_app_surface_from_koolook_groups() -> None:
storage = StaticSetupStorage([])
registry = PublishedSetupRegistry(storage)
visual_graph = {
"nodes": [
{
"id": 12,
"type": "Load Image",
"title": "Source image",
"pos": [40, 40],
"size": [180, 80],
"inputs": [],
},
{
"id": 20,
"type": "Preview Image",
"title": "Preview",
"pos": [420, 40],
"size": [180, 80],
"inputs": [],
},
],
"links": [],
"groups": [
{"title": "Koolook Input", "bounding": [20, 20, 240, 140]},
{"title": "Koolook Output", "pos": [400, 20], "size": [240, 140]},
],
}
result = registry.publishSetup(
visualGraph=visual_graph,
metadata={
"id": "group-surface-flow",
"title": "Group Surface Flow",
"description": "A setup authored with ComfyUI groups.",
},
inputContract={"inputs": []},
outputContract={"outputs": []},
source={"kind": "sidebar-workflow", "path": "Demos/Group Surface"},
)
assert result.valid is True
setup = registry.getSetup("group-surface-flow")
assert setup is not None
assert setup["setupSurface"] == {
"sourceInputs": [
{
"group": "Koolook Input",
"nodes": [{"id": "12", "type": "Load Image", "title": "Source image"}],
}
],
"outputs": [
{
"group": "Koolook Output",
"nodes": [{"id": "20", "type": "Preview Image", "title": "Preview"}],
}
],
"controls": [],
"app": {"inputs": [], "outputs": [], "results": []},
}
def test_publish_setup_infers_app_contract_from_publish_nodes() -> None:
storage = StaticSetupStorage([])
registry = PublishedSetupRegistry(storage)
visual_graph = {
"nodes": [
{
"id": 100,
"type": "Koolook_PublishInput",
"title": "Koolook Publish Input",
"pos": [40, 60],
"size": [360, 320],
"inputs": [],
"widgets_values": [
"Img",
"/shots/example/frames",
"/shots/example/movie.mov",
"/shots/example/image.png",
"unused prompt",
],
"outputs": [
{"name": "sequence_folder", "type": "STRING", "links": []},
{"name": "qt_file", "type": "STRING", "links": []},
{"name": "single_file", "type": "STRING", "links": []},
{"name": "prompt", "type": "STRING", "links": []},
{"name": "switch", "type": "INT", "links": []},
],
},
{
"id": 200,
"type": "Koolook_PublishOutput",
"title": "Koolook Publish Output",
"pos": [520, 60],
"size": [360, 240],
"inputs": [],
"widgets_values": [
"/shots/example/output",
"publish-OUT",
"1",
],
"outputs": [
{"name": "folder", "type": "STRING", "links": []},
{"name": "name", "type": "STRING", "links": []},
{"name": "version", "type": "STRING", "links": []},
],
},
{
"id": 300,
"type": "Koolook_PublishResult",
"title": "Koolook Publish Result",
"pos": [520, 340],
"size": [360, 160],
"inputs": [
{
"name": "result",
"type": "STRING",
"widget": {"name": "result"},
"link": None,
}
],
"widgets_values": [
"/shots/example/output/publish-OUT_v001.mov",
],
"outputs": [
{"name": "result", "type": "STRING", "links": []},
],
},
],
"links": [],
"groups": [
{"title": "Koolook Input", "bounding": [20, 20, 440, 420]},
{"title": "Koolook Output", "bounding": [500, 20, 360, 560]},
],
}
result = registry.publishSetup(
visualGraph=visual_graph,
metadata={
"id": "app-contract-flow",
"title": "App Contract Flow",
"description": "A setup authored with Koolook publish nodes.",
},
inputContract={"inputs": []},
outputContract={"outputs": []},
source={"kind": "sidebar-workflow", "path": "Demos/App Contract"},
)
assert result.valid is True
setup = registry.getSetup("app-contract-flow")
assert setup is not None
assert setup["setupSurface"]["app"] == {
"inputs": [
{
"key": "sequence_folder",
"label": "Sequence folder",
"visible": True,
"target": {"node": "100", "input": "sequence_folder"},
"default": "/shots/example/frames",
},
{
"key": "qt_file",
"label": "QT file",
"visible": True,
"target": {"node": "100", "input": "qt_file"},
"default": "/shots/example/movie.mov",
},
{
"key": "single_file",
"label": "Single file",
"visible": True,
"target": {"node": "100", "input": "single_file"},
"default": "/shots/example/image.png",
},
{
"key": "prompt",
"label": "Prompt",
"visible": True,
"standalone": True,
"multiline": True,
"target": {"node": "100", "input": "prompt"},
"default": "",
"placeholder": "unused prompt",
"help": "Describe the shot in one simple line: subject + action + setting.",
},
],
"outputs": [
{
"key": "folder",
"label": "Output folder",
"visible": True,
"target": {"node": "200", "input": "folder"},
"default": "/shots/example/output",
},
{
"key": "name",
"label": "Output name",
"visible": True,
"target": {"node": "200", "input": "name"},
"default": "publish-OUT",
},
{
"key": "version",
"label": "Version",
"visible": True,
"target": {"node": "200", "input": "version"},
"default": "1",
},
],
"results": [
{
"key": "result",
"label": "Result",
"visible": True,
"target": {"node": "300", "input": "result"},
"default": "/shots/example/output/publish-OUT_v001.mov",
},
],
"switch": {
"key": "switch",
"label": "Input type",
"visible": True,
"target": {"node": "100", "input": "mode"},
"default": 2,
"options": [
{"value": 0, "label": "EXR", "visible": True, "input": "sequence_folder"},
{"value": 1, "label": "QT", "visible": True, "input": "qt_file"},
{"value": 2, "label": "Img", "visible": True, "input": "single_file"},
{"value": 3, "label": "Prompt", "visible": False, "input": "prompt"},
],
},
}
def test_publish_setup_stores_execution_map_from_publish_router() -> None:
storage = StaticSetupStorage([])
registry = PublishedSetupRegistry(storage)
visual_graph = {
"nodes": [
{
"id": 100,
"type": "Koolook_PublishInput",
"title": "Koolook Publish Input",
"pos": [40, 60],
"size": [360, 320],
"inputs": [],
"widgets_values": ["Img", "/seq", "/movie.mov", "/image.png", "prompt"],
},
{
"id": 200,
"type": "Koolook_PublishOutput",
"title": "Koolook Publish Output",
"pos": [520, 60],
"size": [360, 240],
"inputs": [],
"widgets_values": ["/out", "mask", "1"],
},
{
"id": 400,
"type": "Koolook_PublishRouter",
"title": "Koolook Publish Router",
"pos": [520, 340],
"size": [360, 220],
"inputs": [],
"widgets_values": [],
},
{"id": 311, "type": "SaveEXRFrames", "title": "EXR writer", "pos": [920, 80], "size": [220, 120]},
{"id": 312, "type": "Easy_VideoCombine", "title": "QT writer", "pos": [920, 260], "size": [220, 120]},
{"id": 313, "type": "SaveImageAndPromptExact", "title": "Img writer", "pos": [920, 440], "size": [220, 120]},
],
"links": [],
"groups": [
{"title": "Koolook Input", "bounding": [20, 20, 420, 420]},
{"title": "Koolook Output", "bounding": [500, 20, 700, 620]},
],
}
result = registry.publishSetup(
visualGraph=visual_graph,
apiPrompt={
"100": {
"class_type": "Koolook_PublishInput",
"inputs": {
"mode": "Img",
"sequence_folder": "/seq",
"qt_file": "/movie.mov",
"single_file": "/image.png",
"prompt": "prompt",
},
},
"200": {"class_type": "Koolook_PublishOutput", "inputs": {"folder": "/out", "name": "mask", "version": "1"}},
"300": {"class_type": "RMBG", "inputs": {"image": ["100", 2]}},
"301": {"class_type": "EasyAIPipeline", "inputs": {"base_directory_path": ["200", 0], "extension": ".exr"}},
"302": {"class_type": "EasyAIPipeline", "inputs": {"base_directory_path": ["200", 0], "extension": ".mov"}},
"303": {"class_type": "EasyAIPipeline", "inputs": {"base_directory_path": ["200", 0], "extension": ".png"}},
"400": {"class_type": "Koolook_PublishRouter", "inputs": {"selector": ["100", 4], "payload": ["300", 0]}},
"311": {"class_type": "SaveEXRFrames", "inputs": {"filepath": ["301", 0], "images": ["400", 0]}},
"312": {"class_type": "Easy_VideoCombine", "inputs": {"filepath": ["302", 0], "images": ["400", 1]}},
"313": {"class_type": "SaveImageAndPromptExact", "inputs": {"filepath": ["303", 0], "image": ["400", 2]}},
},
metadata={
"id": "router-contract-flow",
"title": "Router Contract Flow",
"description": "A setup authored with a publish router.",
},
inputContract={"inputs": []},
outputContract={"outputs": []},
source={"kind": "sidebar-workflow", "path": "Demos/Router Contract"},
)
assert result.valid is True
setup = registry.getSetup("router-contract-flow")
assert setup is not None
assert setup["executionMap"] == {
"version": 1,
"routers": [
{
"node": "400",
"switchKey": "switch",
"selector": {"node": "100", "output": 4},
"payload": {"node": "300", "output": 0},
"branches": {
"0": {"label": "EXR", "output": 0, "writerNodes": ["311"]},
"1": {"label": "QT", "output": 1, "writerNodes": ["312"]},
"2": {"label": "Img", "output": 2, "writerNodes": ["313"]},
"3": {"label": "Prompt", "output": 3, "writerNodes": []},
},
}
],
}
@pytest.mark.parametrize(
("visual_graph", "expected"),
[
(
{
"nodes": [{"id": 12, "type": "Load Image", "pos": [40, 40], "inputs": []}],
"links": [],
"groups": [{"title": "Koolook Output", "bounding": [20, 20, 240, 140]}],
},
"setupSurface.sourceInputs requires a non-empty Koolook Input group",
),
(
{
"nodes": [{"id": 12, "type": "Load Image", "pos": [400, 40], "inputs": []}],
"links": [],
"groups": [
{"title": "Koolook Input", "bounding": [20, 20, 240, 140]},
{"title": "Koolook Output", "bounding": [20, 20, 240, 140]},
],
},
"setupSurface.sourceInputs requires a non-empty Koolook Input group",
),
(
{
"nodes": [{"id": 12, "type": "Load Image", "pos": [40, 40], "inputs": []}],
"links": [],
"groups": [{"title": "Koolook Input", "bounding": [20, 20, 240, 140]}],
},
"setupSurface.outputs requires a non-empty Koolook Output group",
),
],
)
def test_publish_setup_rejects_missing_or_empty_required_surface_groups(
visual_graph: dict,
expected: str,
) -> None:
registry = PublishedSetupRegistry(StaticSetupStorage([]))
result = registry.publishSetup(
visualGraph=visual_graph,
metadata={
"id": "bad-group-surface",
"title": "Bad Group Surface",
"description": "Missing required setup groups.",
},
inputContract={"inputs": []},
outputContract={"outputs": []},
source={"kind": "sidebar-workflow", "path": "Demos/Bad Group"},
)
assert result.valid is False
assert expected in result.diagnostics
assert registry.getSetup("bad-group-surface") is None
@pytest.mark.parametrize(
("visual_graph", "expected"),
[
(
{
"nodes": [{"id": 12, "type": "Load Image", "pos": [40, 40], "inputs": []}],
"links": [],
"groups": [
{"title": "Koolook Input", "bounding": ["bad", 20, 240, 140]},
{"title": "Koolook Output", "bounding": [20, 20, 240, 140]},
],
},
"visualGraph.groups[0].bounding must contain numeric x, y, width, height",
),
(
{
"nodes": [{"id": 12, "type": "Load Image", "pos": ["bad", 40], "inputs": []}],
"links": [],
"groups": [
{"title": "Koolook Input", "bounding": [20, 20, 240, 140]},
{"title": "Koolook Output", "bounding": [20, 20, 240, 140]},
],
},
"visualGraph.nodes[0].pos must contain numeric x and y for setup surface inference",
),
(
{
"nodes": [{"id": 12, "type": "Load Image", "pos": [True, 40], "inputs": []}],
"links": [],
"groups": [
{"title": "Koolook Input", "bounding": [20, 20, 240, 140]},
{"title": "Koolook Output", "bounding": [20, 20, 240, 140]},
],
},
"visualGraph.nodes[0].pos must contain numeric x and y for setup surface inference",
),
(
{
"nodes": [{"id": 12, "type": "Load Image", "pos": [40, 40], "inputs": []}],
"links": [],
"groups": [
{"title": "Koolook Input", "bounding": [20, 20, float("inf"), 140]},
{"title": "Koolook Output", "bounding": [20, 20, 240, 140]},
],
},
"visualGraph.groups[0].bounding must contain numeric x, y, width, height",
),
],
)
def test_publish_setup_rejects_malformed_surface_geometry(
visual_graph: dict,
expected: str,
) -> None:
registry = PublishedSetupRegistry(StaticSetupStorage([]))
result = registry.publishSetup(
visualGraph=visual_graph,
metadata={
"id": "bad-geometry",
"title": "Bad Geometry",
"description": "Malformed setup surface geometry.",
},
inputContract={"inputs": []},
outputContract={"outputs": []},
source={"kind": "sidebar-workflow", "path": "Demos/Bad Geometry"},
)
assert result.valid is False
assert expected in result.diagnostics
assert registry.getSetup("bad-geometry") is None
def test_publish_setup_rejects_missing_contract_target() -> None:
registry = PublishedSetupRegistry(StaticSetupStorage([]))
result = registry.publishSetup(
visualGraph={
"nodes": [
{
"id": 12,
"type": "Text Multiline",
"inputs": [{"name": "text", "widget": {"name": "text"}}],
"widgets_values": ["bad prompt"],
}
],
"links": [],
},
metadata={"id": "bad-flow", "title": "Bad", "description": "Bad target"},
inputContract={
"inputs": [
{
"key": "prompt",
"type": "text",
"target": {"node": "99", "input": "text"},
}
]
},
outputContract={"outputs": [{"key": "preview", "type": "image"}]},
source={"kind": "sidebar-workflow", "path": "Demos/Bad"},
)
assert result.valid is False
assert "inputContract.inputs[0].target.node not found in visualGraph" in result.diagnostics
assert registry.listSetups() == []
def test_publish_setup_rejects_visual_node_without_type() -> None:
registry = PublishedSetupRegistry(StaticSetupStorage([]))
result = registry.publishSetup(
visualGraph={"nodes": [{"id": 12, "inputs": [{"name": "text"}]}]},
metadata={"id": "bad-flow", "title": "Bad", "description": "Missing node type"},
inputContract={
"inputs": [
{
"key": "prompt",
"type": "text",
"target": {"node": "12", "input": "text"},
}
]
},
outputContract={"outputs": [{"key": "preview", "type": "image"}]},
source={"kind": "sidebar-workflow", "path": "Demos/Bad"},
)
assert result.valid is False
assert "visualGraph.nodes[0].type must be non-empty text" in result.diagnostics
assert registry.getSetup("bad-flow") is None
def test_publish_setup_rejects_contract_target_missing_from_generated_api_prompt() -> None:
registry = PublishedSetupRegistry(StaticSetupStorage([]))
result = registry.publishSetup(
visualGraph={
"nodes": [
{
"id": 12,
"type": "Text Multiline",
"inputs": [{"name": "text", "type": "STRING"}],
"widgets_values": ["default prompt"],
}
],
"links": [],
},
metadata={"id": "bad-flow", "title": "Bad", "description": "Not injectable"},
inputContract={
"inputs": [
{
"key": "prompt",
"type": "text",
"target": {"node": "12", "input": "text"},
}
]
},
outputContract={"outputs": [{"key": "preview", "type": "image"}]},
source={"kind": "sidebar-workflow", "path": "Demos/Bad"},
)
assert result.valid is False
assert (
"inputContract.inputs[0].target.input not found in generated apiPrompt"
in result.diagnostics
)
assert registry.getSetup("bad-flow") is None
def test_publish_setup_rejects_partial_module_graph_links() -> None:
registry = PublishedSetupRegistry(StaticSetupStorage([]))
result = registry.publishSetup(
visualGraph={
"nodes": [
{
"id": 12,
"type": "Text Multiline",
"inputs": [{"name": "text", "type": "STRING", "link": 101}],
"widgets_values": ["default prompt"],
}
],
"links": [[101, -10, 0, 12, 0, "STRING"]],
},
metadata={"id": "bad-flow", "title": "Bad", "description": "Partial module graph"},
inputContract={
"inputs": [
{
"key": "prompt",
"type": "text",
"target": {"node": "12", "input": "text"},
}
]
},
outputContract={"outputs": [{"key": "preview", "type": "image"}]},
source={"kind": "sidebar-workflow", "path": "Demos/Bad"},
)
assert result.valid is False
assert "visualGraph.links[101] uses unsupported module graph sentinel node" in result.diagnostics
assert registry.getSetup("bad-flow") is None
def test_publish_setup_rejects_link_with_missing_origin_node() -> None:
registry = PublishedSetupRegistry(StaticSetupStorage([]))
result = registry.publishSetup(
visualGraph={
"nodes": [
{
"id": 20,
"type": "Text Concatenate",
"inputs": [{"name": "text_a", "type": "STRING", "link": 101}],
}
],
"links": [[101, 12, 0, 20, 0, "STRING"]],
},
metadata={"id": "bad-flow", "title": "Bad", "description": "Missing origin"},
inputContract={"inputs": []},
outputContract={"outputs": [{"key": "preview", "type": "image"}]},
source={"kind": "sidebar-workflow", "path": "Demos/Bad"},
)
assert result.valid is False
assert "visualGraph.links[101].origin_id not found in visualGraph" in result.diagnostics
assert registry.getSetup("bad-flow") is None
def test_publish_setup_rejects_link_target_that_does_not_match_input() -> None:
registry = PublishedSetupRegistry(StaticSetupStorage([]))
result = registry.publishSetup(
visualGraph={
"nodes": [
{
"id": 12,
"type": "Text Multiline",
"inputs": [{"name": "text", "widget": {"name": "text"}}],
"widgets_values": ["default prompt"],
},
{
"id": 20,
"type": "Text Concatenate",
"inputs": [{"name": "text_a", "type": "STRING", "link": 101}],
},
],
"links": [[101, 12, 0, 999, 0, "STRING"]],
},
metadata={"id": "bad-flow", "title": "Bad", "description": "Mismatched link"},
inputContract={"inputs": []},
outputContract={"outputs": [{"key": "preview", "type": "image"}]},
source={"kind": "sidebar-workflow", "path": "Demos/Bad"},
)
assert result.valid is False
assert "visualGraph.links[101].target does not match visualGraph.nodes[1].inputs[0]" in result.diagnostics
assert registry.getSetup("bad-flow") is None
def test_publish_setup_rejects_link_with_malformed_origin_slot() -> None:
registry = PublishedSetupRegistry(StaticSetupStorage([]))
result = registry.publishSetup(
visualGraph={
"nodes": [
{"id": 12, "type": "Text Multiline", "inputs": [], "widgets_values": ["prompt"]},
{
"id": 20,
"type": "Text Concatenate",
"inputs": [{"name": "text_a", "type": "STRING", "link": 101}],
},
],
"links": [[101, 12, "zero", 20, 0, "STRING"]],
},
metadata={"id": "bad-flow", "title": "Bad", "description": "Malformed slot"},
inputContract={"inputs": []},
outputContract={"outputs": [{"key": "preview", "type": "image"}]},
source={"kind": "sidebar-workflow", "path": "Demos/Bad"},
)
assert result.valid is False
assert "visualGraph.links[101].origin_slot must be a non-negative integer" in result.diagnostics
assert registry.getSetup("bad-flow") is None
def test_publish_setup_converts_links_from_nodes_declared_later() -> None:
registry = PublishedSetupRegistry(StaticSetupStorage([]))
visual_graph = {
"nodes": [
{
"id": 20,
"type": "Text Concatenate",
"inputs": [{"name": "text_a", "type": "STRING", "link": 101}],
},
{"id": 12, "type": "Text Multiline", "inputs": [], "widgets_values": ["prompt"]},
],
"links": [[101, 12, 0, 20, 0, "STRING"]],
}
result = registry.publishSetup(
visualGraph=visual_graph,
metadata={"id": "good-flow", "title": "Good", "description": "Later origin"},
inputContract={"inputs": []},
outputContract={"outputs": [{"key": "preview", "type": "image"}]},
source={"kind": "sidebar-workflow", "path": "Demos/Good"},
)
assert result.valid is True
setup = registry.getSetup("good-flow")
assert setup is not None
assert setup["apiPrompt"]["20"]["inputs"]["text_a"] == ["12", 0]
def test_publish_setup_rejects_duplicate_node_ids() -> None:
registry = PublishedSetupRegistry(StaticSetupStorage([]))
result = registry.publishSetup(
visualGraph={
"nodes": [
{"id": 20, "type": "Text Multiline", "inputs": []},
{"id": 20, "type": "Text Concatenate", "inputs": []},
],
"links": [],
},
metadata={"id": "bad-flow", "title": "Bad", "description": "Duplicate nodes"},
inputContract={"inputs": []},
outputContract={"outputs": [{"key": "preview", "type": "image"}]},
source={"kind": "sidebar-workflow", "path": "Demos/Bad"},
)
assert result.valid is False
assert "visualGraph.nodes[1].id duplicates visualGraph.nodes[0].id" in result.diagnostics
assert registry.getSetup("bad-flow") is None
def test_publish_setup_rejects_duplicate_link_ids() -> None:
registry = PublishedSetupRegistry(StaticSetupStorage([]))
result = registry.publishSetup(
visualGraph={
"nodes": [
{"id": 12, "type": "Text Multiline", "inputs": []},
{
"id": 20,
"type": "Text Concatenate",
"inputs": [{"name": "text_a", "type": "STRING", "link": 101}],
},
],
"links": [
[101, 12, 0, 20, 0, "STRING"],
[101, 12, 0, 20, 0, "STRING"],
],
},
metadata={"id": "bad-flow", "title": "Bad", "description": "Duplicate links"},
inputContract={"inputs": []},
outputContract={"outputs": [{"key": "preview", "type": "image"}]},
source={"kind": "sidebar-workflow", "path": "Demos/Bad"},
)
assert result.valid is False
assert "visualGraph.links[1].id duplicates visualGraph.links[0].id" in result.diagnostics
assert registry.getSetup("bad-flow") is None
def test_publish_setup_rejects_present_non_list_node_inputs() -> None:
registry = PublishedSetupRegistry(StaticSetupStorage([]))
result = registry.publishSetup(
visualGraph={
"nodes": [{"id": 12, "type": "Text Multiline", "inputs": "not-list"}],
"links": [],
},
metadata={"id": "bad-flow", "title": "Bad", "description": "Malformed inputs"},
inputContract={"inputs": []},
outputContract={"outputs": [{"key": "preview", "type": "image"}]},
source={"kind": "sidebar-workflow", "path": "Demos/Bad"},
)
assert result.valid is False
assert "visualGraph.nodes[0].inputs must be a list when present" in result.diagnostics
assert registry.getSetup("bad-flow") is None
def _json(value: dict) -> str:
import json
return json.dumps(value)