Files
malkuthro-ComfyUI-Koolook/tests/scripts/test_validate_workflow.py
T
malkuthroandClaude Opus 4.7 9372032b46 feat(automations): workflow validator + LTX 2.3 stage-2-only docs (#170)
* feat(automations): workflow JSON validator + LTX 2.3 stage-2-only findings

Adds scripts/validate_workflow.py — a stdlib-only CLI that checks a
ComfyUI workflow JSON for internal consistency: link-table declarations
vs node-IO references, slot index bounds and types, endpoint cross-
checks, last_node_id/last_link_id bounds, and duplicate IDs.

Catches the class of bugs that come from hand-editing workflow JSONs
(e.g. when an agent re-wires a graph to build a slim variant from a
Director workflow). Comfy's loader silently drops or mis-wires bad
parts, so the cost of an undetected drift is "graph looks fine but
behaves wrong" — exactly the failure mode this script blocks.

Exit codes: 0 clean, 1 problems on stderr, 2 file missing or bad JSON.

Docs:
- docs/automations/CONVENTIONS.md §11 — when and how to run the
  validator, full list of checks performed.
- docs/automations/LTX-2.3/findings.md — two new sections covering the
  spatial upscaler's architectural facts (ltx-2.3-spatial-upscaler-x2
  is hardcoded x2, the brief enters only via Director > guide_data >
  DirectorGuide, attention scales (W * H * T)^2) plus three open
  hypotheses for a stage-2-only refine path.
- CHANGELOG.md [Unreleased] entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(scripts): make validate_workflow.py robust against malformed JSON

Addresses PR #170 review (Codex GPT-5.3) — the validator crashed with
AttributeError on malformed-but-valid JSON, defeating its goal as a safe
preflight tool. Two repros from the review:

    {"nodes":[1],"links":[],"last_node_id":0,"last_link_id":0}
    -> AttributeError: 'int' object has no attribute 'get'

    [1,2]
    -> AttributeError: 'list' object has no attribute 'get'

Both confirmed locally. Now every shape assumption is guarded:

- Top-level must be a JSON object; non-object short-circuits with one
  clear problem line.
- 'nodes' and 'links' must be lists; non-list reported and treated as
  empty so subsequent checks still run.
- Each node entry must be a dict; bad entries reported and dropped.
- Each input/output entry must be a dict (extracted into a
  _sanitize_slots helper, cached per node so duplicates aren't reported
  when the same node is touched by multiple links).
- Each link entry must be a 6-element list; malformed link entries
  reported and skipped without crashing later phases.
- node_by_id only keys on int node IDs (non-int IDs can't match a
  link's int src/dst, so we don't mask bad data by accepting them).

Adds tests/scripts/test_validate_workflow.py — 24 tests covering both
review repros, every new guard, and a sampling of the existing
consistency checks (link-to-missing-node, type mismatch, endpoint
desync, duplicate IDs, last_node_id bound, CLI exit codes). Full suite:
168 passed in 1.59s.

CHANGELOG entry updated to reflect the defensive behavior + test
coverage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:52:35 +02:00

181 lines
6.1 KiB
Python

"""Regression tests for scripts/validate_workflow.py.
Anchored by PR #170 review: the validator must report problems rather than
raise on malformed-but-valid JSON. These tests pin that behavior plus a
sampling of the existing happy-path and consistency checks so future edits
cannot silently regress the safety guarantees.
"""
from __future__ import annotations
import importlib.util
import json
from pathlib import Path
import pytest
# Load the script directly — it lives under scripts/, not under a package.
_SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "validate_workflow.py"
_spec = importlib.util.spec_from_file_location("validate_workflow", _SCRIPT)
assert _spec is not None and _spec.loader is not None
_module = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_module)
validate = _module.validate
main = _module.main
def _minimal_clean() -> dict:
"""Smallest workflow that should validate cleanly: two nodes, one link."""
return {
"last_node_id": 2,
"last_link_id": 1,
"nodes": [
{
"id": 1,
"type": "Src",
"inputs": [],
"outputs": [
{"name": "out", "type": "FOO", "links": [1]},
],
},
{
"id": 2,
"type": "Dst",
"inputs": [
{"name": "in", "type": "FOO", "link": 1},
],
"outputs": [],
},
],
"links": [[1, 1, 0, 2, 0, "FOO"]],
}
# --- Happy path -------------------------------------------------------------
def test_minimal_clean_workflow_has_no_problems() -> None:
assert validate(_minimal_clean()) == []
# --- PR #170 review repros: malformed-but-valid JSON must not crash --------
@pytest.mark.parametrize("data", [None, 42, 3.14, "string", [1, 2], (1, 2)])
def test_top_level_not_object_reports_does_not_crash(data) -> None:
problems = validate(data)
assert problems, "expected at least one problem"
assert "top-level JSON must be an object" in problems[0]
def test_node_entry_not_dict_reports_does_not_crash() -> None:
# Reviewer's repro 1: nodes contains an int.
data = {"nodes": [1], "links": [], "last_node_id": 0, "last_link_id": 0}
problems = validate(data)
assert any("nodes[0]" in p and "object" in p for p in problems), problems
def test_nodes_field_not_list_reports() -> None:
data = {"nodes": {"oops": "dict not list"}, "links": []}
problems = validate(data)
assert any("'nodes' must be a list" in p for p in problems), problems
def test_links_field_not_list_reports() -> None:
data = {"nodes": [], "links": "not-a-list"}
problems = validate(data)
assert any("'links' must be a list" in p for p in problems), problems
def test_inputs_field_not_list_reports() -> None:
data = _minimal_clean()
data["nodes"][1]["inputs"] = {"oops": "dict not list"}
problems = validate(data)
assert any("'inputs' must be a list" in p for p in problems), problems
def test_input_entry_not_dict_reports() -> None:
data = _minimal_clean()
data["nodes"][1]["inputs"] = ["string-not-dict"]
problems = validate(data)
assert any("inputs[0]" in p and "object" in p for p in problems), problems
def test_output_entry_not_dict_reports() -> None:
data = _minimal_clean()
data["nodes"][0]["outputs"] = [99]
problems = validate(data)
assert any("outputs[0]" in p and "object" in p for p in problems), problems
def test_malformed_link_not_list_reports() -> None:
data = _minimal_clean()
data["links"].append("not-a-list")
problems = validate(data)
assert any("links[1]" in p for p in problems), problems
def test_malformed_link_wrong_length_reports() -> None:
data = _minimal_clean()
data["links"].append([2, 1, 0]) # too short
problems = validate(data)
assert any("links[1]" in p and "6 elements" in p for p in problems), problems
# --- Existing consistency checks -------------------------------------------
def test_link_to_missing_src_node_reports() -> None:
data = _minimal_clean()
data["links"] = [[1, 99, 0, 2, 0, "FOO"]]
data["nodes"][0]["outputs"][0]["links"] = [] # not referenced from anywhere now
problems = validate(data)
assert any("src node 99 missing" in p for p in problems), problems
def test_type_mismatch_on_slot_reports() -> None:
data = _minimal_clean()
data["links"] = [[1, 1, 0, 2, 0, "BAR"]] # link declares BAR, slots are FOO
problems = validate(data)
assert any("type mismatch" in p for p in problems), problems
def test_duplicate_node_ids_report() -> None:
data = _minimal_clean()
data["nodes"][1]["id"] = 1 # collision with first node
problems = validate(data)
assert any("Duplicate node IDs" in p for p in problems), problems
def test_endpoint_ref_desync_reports() -> None:
data = _minimal_clean()
data["nodes"][0]["outputs"][0]["links"] = [] # source side forgot link 1
problems = validate(data)
assert any("missing from node 1 output[0]" in p for p in problems), problems
def test_last_node_id_too_low_reports() -> None:
data = _minimal_clean()
data["last_node_id"] = 0 # but max is 2
problems = validate(data)
assert any("last_node_id" in p for p in problems), problems
# --- CLI smoke (covers main() exit codes) ----------------------------------
def test_cli_returns_zero_on_clean_workflow(tmp_path: Path) -> None:
p = tmp_path / "clean.json"
p.write_text(json.dumps(_minimal_clean()), encoding="utf-8")
assert main([str(p), "--quiet"]) == 0
def test_cli_returns_one_on_problems(tmp_path: Path) -> None:
p = tmp_path / "broken.json"
p.write_text(json.dumps({"nodes": [1], "links": []}), encoding="utf-8")
assert main([str(p), "--quiet"]) == 1
def test_cli_returns_two_on_missing_file(tmp_path: Path) -> None:
assert main([str(tmp_path / "does-not-exist.json"), "--quiet"]) == 2
def test_cli_returns_two_on_invalid_json(tmp_path: Path) -> None:
p = tmp_path / "bad.json"
p.write_text("{not valid", encoding="utf-8")
assert main([str(p), "--quiet"]) == 2