diff --git a/README.md b/README.md
index f1d31b8..e0d0886 100644
--- a/README.md
+++ b/README.md
@@ -366,7 +366,7 @@ Restart ComfyUI. No further dependency installation is required.
## ▓ Changelog
### v2.15.0 — 2026-09-08
-- **Restructured the pack layout + unified the node category.** All node definitions now live in a dedicated `nodes/` folder (`nodes/dype.py`, `sega.py`, `spa.py`, `hap.py`, `hap_calibrate.py`, `freescale.py`, `pixelrush.py`, `hiflow.py`); `src/` holds engines/implementation only and the pack `__init__.py` just registers the extension. All 8 nodes moved to the single **`WMNodes/image`** menu category (previously split across `model_patches/position_encoding` and `image/upscaling`). No node ids, inputs, defaults, or behavior changed — workflows keep loading. Also merges PR #41 (FreeScale fp16 antialiased-bicubic crash fix).
+- **Restructured the pack layout + unified the node category.** All node definitions now live in a dedicated `nodes/` folder (`nodes/dype.py`, `sega.py`, `spa.py`, `hap.py`, `hap_calibrate.py`, `freescale.py`, `pixelrush.py`, `hiflow.py`); `src/` holds engines/implementation only and the pack `__init__.py` just registers the extension. All 8 nodes moved to the single **`WMNodes/image`** menu category (previously split across two menu paths). No node ids, inputs, defaults, or behavior changed — workflows keep loading. Also merges PR #41 (FreeScale fp16 antialiased-bicubic crash fix).
### v2.14.1 — 2026-09-07
- **Fixed HiFlow Krea2/Qwen-Image noising crash** (user-reported `torch.cat` size mismatch, "Expected size 1 but got size 16"): the v2.12.1 model-space noising called the model's `process_latent_in` on the 4D core tensor, but Wan21's per-channel mean/std stats are shaped `[1,C,1,1,1]` — a 4D tensor against 5D stats **broadcasts silently to `[B,C,C,H,W]` garbage** (the model reads T=16=channels). The node now wraps the noising conversions ndim-transparently: unsqueeze → convert in true 5D model space → squeeze back, so the cascade's σ-mix runs on 4D tensors with correctly-normalized values. The node-test mock now uses Wan21-faithful stats (replicating the broadcast hazard — the earlier affine mock masked the bug class).
diff --git a/tests/test_structure.py b/tests/test_structure.py
index 270a03a..014b06a 100644
--- a/tests/test_structure.py
+++ b/tests/test_structure.py
@@ -7,13 +7,20 @@ Entry `__init__.py` — extension registration only.
These guards are text/AST-based where the entry module cannot be imported in
the GPU-free test env, and runtime-based where the mock-comfy conftest makes
live imports possible (same split as test_hap_node.py / test_hiflow_node.py).
+The loader regression test replicates ComfyUI's ``load_custom_node``
+(``spec_from_file_location`` on the pack ``__init__.py``, executed as a
+module in ``sys.modules``) — the mechanism verified empirically 2026-09-08.
Markers: @pytest.mark.unit
"""
from __future__ import annotations
+import asyncio
+import importlib.util
import pathlib
+import sys
+import types
import pytest
@@ -21,23 +28,74 @@ PROJECT_ROOT = pathlib.Path(__file__).parent.parent
ENTRY = PROJECT_ROOT / "__init__.py"
NODES_DIR = PROJECT_ROOT / "nodes"
NODES_INIT = NODES_DIR / "__init__.py"
+SRC_DIR = PROJECT_ROOT / "src"
ALL_NODE_CLASSES = [
"DyPE_FLUX", "SEGA", "SPA", "HAP",
"HAPCalibrate", "PixelRushNode", "FreeScaleNode", "HiFlowNode",
]
-# Node modules that should exist once every layout-plan step has landed.
-# S1 ships dype/sega/spa/hap; the *_node moves append their entries (S2/S3).
+# Expected node modules once every layout-plan step has landed.
EXPECTED_NODE_MODULES = [
"dype", "sega", "spa", "hap",
+ "hap_calibrate", "freescale", "pixelrush", "hiflow",
]
+_OLD_CATEGORIES = ("model_patches/position_encoding", "image/upscaling")
+
def _entry_src() -> str:
return ENTRY.read_text(encoding="utf-8")
+def _fake_mod(name, **attrs):
+ """Additive-only mock module (pattern from tests/test_qwen2d_vae.py):
+ never replaces the root conftest's comfy mocks, registers in sys.modules
+ and wires the parent attribute."""
+ if name in sys.modules:
+ return sys.modules[name]
+ m = types.ModuleType(name)
+ for k, v in attrs.items():
+ setattr(m, k, v)
+ sys.modules[name] = m
+ parent, _, child = name.rpartition(".")
+ if parent and parent in sys.modules:
+ setattr(sys.modules[parent], child, m)
+ return m
+
+
+def _ensure_entry_comfy_chain():
+ """The pack entry imports src.qwen2d_vae_patch, which imports the comfy
+ chain (comfy.sd, comfy.model_management, ...). Install additive mocks
+ so the loader replica can execute the entry in the GPU-free env."""
+ _fake_mod("comfy.sd", VAE=type("VAE", (), {}))
+ _fake_mod("comfy.model_management",
+ is_amd=lambda: False, dtype_size=lambda d: 4,
+ OOM_EXCEPTION=RuntimeError)
+ _fake_mod("comfy.utils")
+ _fake_mod("comfy.ops", disable_weight_init=object)
+ _fake_mod("comfy.ldm")
+ _fake_mod("comfy.ldm.modules")
+ _fake_mod("comfy.ldm.modules.diffusionmodules")
+ _fake_mod("comfy.ldm.modules.diffusionmodules.model",
+ vae_attention=lambda *a, **k: None)
+
+
+def _load_entry_via_comfy_loader():
+ """Replicate ComfyUI nodes.load_custom_node for a DIRECTORY pack:
+ spec_from_file_location(sys_module_name,
/__init__.py), module in
+ sys.modules, exec. Returns the loaded module (caller pops sys.modules)."""
+ sys_module_name = str(PROJECT_ROOT).replace(".", "_x_")
+ module_spec = importlib.util.spec_from_file_location(
+ sys_module_name, str(PROJECT_ROOT / "__init__.py")
+ )
+ _ensure_entry_comfy_chain()
+ module = importlib.util.module_from_spec(module_spec)
+ sys.modules[sys_module_name] = module
+ module_spec.loader.exec_module(module)
+ return module
+
+
@pytest.mark.unit
class TestStructureGuard:
def test_entry_imports_from_nodes_package(self):
@@ -90,3 +148,136 @@ class TestStructureGuard:
for name in EXPECTED_NODE_MODULES:
mod = NODES_DIR / f"{name}.py"
assert mod.exists(), f"nodes/{name}.py missing (layout plan step incomplete)"
+
+ def test_nodes_dir_exclusively_nodes(self):
+ """Every nodes/*.py module defines an io.ComfyNode subclass; src/
+ never defines a node schema."""
+ for name in EXPECTED_NODE_MODULES:
+ src = (NODES_DIR / f"{name}.py").read_text(encoding="utf-8")
+ assert "io.ComfyNode" in src or "_ComfyNodeBase = io.ComfyNode" in src, (
+ f"nodes/{name}.py must define an io.ComfyNode node class"
+ )
+ for py in SRC_DIR.rglob("*.py"):
+ src = py.read_text(encoding="utf-8")
+ assert "io.Schema(" not in src, (
+ f"engine file {py.name} must not define node schemas — "
+ f"node definitions live in nodes/ (layout plan 2026-09-08)"
+ )
+
+ def test_category_wmnodes_everywhere(self):
+ """Every node module pins category=\"WMNodes/image\"."""
+ for name in EXPECTED_NODE_MODULES:
+ src = (NODES_DIR / f"{name}.py").read_text(encoding="utf-8")
+ assert 'category="WMNodes/image"' in src, (
+ f"nodes/{name}.py must use category=\"WMNodes/image\""
+ )
+
+ def test_no_category_strings_in_engines(self):
+ """No category strings (old or new) may appear in src/."""
+ for py in SRC_DIR.rglob("*.py"):
+ src = py.read_text(encoding="utf-8")
+ for cat in _OLD_CATEGORIES + ("WMNodes/image",):
+ assert cat not in src, f"{py.name} mentions category {cat!r}"
+
+ def test_no_legacy_node_files_in_src(self):
+ """src/ holds no *_node.py leftovers."""
+ leftovers = sorted(str(p.name) for p in SRC_DIR.glob("*_node.py"))
+ assert not leftovers, f"legacy node modules remain in src/: {leftovers}"
+
+
+@pytest.mark.unit
+class TestCategoryGrepGuard:
+ def test_no_old_category_strings_repo_wide(self):
+ """No tracked file mentions the old category paths. Uses git
+ ls-files so untracked/ignored files never mask a miss."""
+ import subprocess
+
+ out = subprocess.run(
+ ["git", "ls-files"], cwd=str(PROJECT_ROOT),
+ capture_output=True, text=True, check=True,
+ ).stdout.splitlines()
+ offenders = []
+ for rel in out:
+ if rel.replace("\\", "/") == "tests/test_structure.py":
+ continue # this guard holds the reference strings
+ if not rel.endswith((".py", ".md", ".toml", ".yml", ".json")):
+ continue
+ path = PROJECT_ROOT / rel
+ if not path.exists():
+ continue
+ text = path.read_text(encoding="utf-8", errors="replace")
+ for cat in _OLD_CATEGORIES:
+ if cat in text:
+ offenders.append(f"{rel}: {cat}")
+ assert not offenders, f"old category strings remain: {offenders}"
+
+
+@pytest.mark.unit
+class TestComfyLoaderRegression:
+ """Replicates ComfyUI's load_custom_node for a DIRECTORY pack and pins
+ that the pack keeps loading through that exact mechanism."""
+
+ def test_entry_exposes_v3_contract(self):
+ """Loader mechanics: module loads, exports comfy_entrypoint +
+ DyPEExtension."""
+ sys_module_name = str(PROJECT_ROOT).replace(".", "_x_")
+ try:
+ module = _load_entry_via_comfy_loader()
+ assert hasattr(module, "comfy_entrypoint"), (
+ "pack entry must export comfy_entrypoint() for the V3 loader"
+ )
+ assert hasattr(module, "DyPEExtension"), (
+ "pack entry must export the DyPEExtension class"
+ )
+ finally:
+ sys.modules.pop(sys_module_name, None)
+
+ def test_entrypoint_returns_8_node_extension(self):
+ """comfy_entrypoint() → get_node_list() yields the 8 node classes
+ (runtime import through loader mechanics, mock-comfy env)."""
+ sys_module_name = str(PROJECT_ROOT).replace(".", "_x_")
+ try:
+ module = _load_entry_via_comfy_loader()
+ ext = asyncio.run(module.comfy_entrypoint())
+ nodes_list = asyncio.run(ext.get_node_list())
+ names = sorted(c.__name__ for c in nodes_list)
+ assert names == sorted(ALL_NODE_CLASSES), (
+ f"loaded pack registers {names}, expected {sorted(ALL_NODE_CLASSES)}"
+ )
+ finally:
+ sys.modules.pop(sys_module_name, None)
+
+ def test_extension_on_load_installs_qwen2d_patch(self):
+ """on_load() must install the Qwen2D VAE patch (monkeypatched to
+ observe the call; the real patcher is engine-level and covered by
+ test_qwen2d_vae.py)."""
+ sys_module_name = str(PROJECT_ROOT).replace(".", "_x_")
+ try:
+ module = _load_entry_via_comfy_loader()
+ ext = asyncio.run(module.comfy_entrypoint())
+ called = {}
+
+ import src.qwen2d_vae_patch as qwen_patch
+ orig = qwen_patch.install_qwen2d_patch
+
+ def spy():
+ called["yes"] = True
+ return orig()
+
+ # The loaded entry module resolved install_qwen2d_patch into ITS
+ # namespace at import time (from .src.qwen2d_vae_patch import);
+ # on_load() looks the name up there. Patch BOTH namespaces.
+ qwen_patch.install_qwen2d_patch = spy
+ entry_ns_orig = getattr(module, "install_qwen2d_patch", None)
+ module.install_qwen2d_patch = spy
+ try:
+ asyncio.run(ext.on_load())
+ finally:
+ qwen_patch.install_qwen2d_patch = orig
+ if entry_ns_orig is not None:
+ module.install_qwen2d_patch = entry_ns_orig
+ assert called.get("yes") is True, (
+ "extension on_load() did not call install_qwen2d_patch"
+ )
+ finally:
+ sys.modules.pop(sys_module_name, None)