"""E2E tests for cm-cli --uv-compile across all supported commands. Requires a pre-built E2E environment (from setup_e2e_env.sh). Set E2E_ROOT env var to point at it, or the tests will be skipped. Supply-chain safety policy: To prevent supply-chain attacks, E2E tests MUST only install node packs from verified, controllable authors (ltdrdata, comfyanonymous, etc.). Currently this suite uses only ltdrdata's dedicated test packs (nodepack-test1-do-not-install, nodepack-test2-do-not-install) which are intentionally designed for conflict testing and contain no executable code. Adding packs from unverified sources is prohibited. Usage: E2E_ROOT=/tmp/e2e_full_test pytest tests/e2e/test_e2e_uv_compile.py -v """ from __future__ import annotations import os import shutil import subprocess import sys import time import pytest E2E_ROOT = os.environ.get("E2E_ROOT", "") COMFYUI_PATH = os.path.join(E2E_ROOT, "comfyui") if E2E_ROOT else "" CUSTOM_NODES = os.path.join(COMFYUI_PATH, "custom_nodes") if COMFYUI_PATH else "" # Cross-platform: resolve cm-cli executable in venv if E2E_ROOT: if sys.platform == "win32": CM_CLI = os.path.join(E2E_ROOT, "venv", "Scripts", "cm-cli.exe") else: CM_CLI = os.path.join(E2E_ROOT, "venv", "bin", "cm-cli") else: CM_CLI = "" REPO_TEST1 = "https://github.com/ltdrdata/nodepack-test1-do-not-install" REPO_TEST2 = "https://github.com/ltdrdata/nodepack-test2-do-not-install" PACK_TEST1 = "nodepack-test1-do-not-install" PACK_TEST2 = "nodepack-test2-do-not-install" pytestmark = pytest.mark.skipif( not E2E_ROOT or not os.path.isfile(os.path.join(E2E_ROOT, ".e2e_setup_complete")), reason="E2E_ROOT not set or E2E environment not ready (run setup_e2e_env.sh first)", ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _run_cm_cli(*args: str, timeout: int = 180) -> subprocess.CompletedProcess: """Run cm-cli in the E2E environment. Uses file-based capture instead of pipes to avoid Windows pipe buffer loss when the subprocess exits via typer.Exit / sys.exit. """ env = { **os.environ, "COMFYUI_PATH": COMFYUI_PATH, "PYTHONUNBUFFERED": "1", } stdout_path = os.path.join(E2E_ROOT, f"_cm_stdout_{os.getpid()}.tmp") stderr_path = os.path.join(E2E_ROOT, f"_cm_stderr_{os.getpid()}.tmp") try: with open(stdout_path, "w", encoding="utf-8") as out_f, \ open(stderr_path, "w", encoding="utf-8") as err_f: r = subprocess.run( [CM_CLI, *args], stdout=out_f, stderr=err_f, timeout=timeout, env=env, ) with open(stdout_path, encoding="utf-8", errors="replace") as f: r.stdout = f.read() with open(stderr_path, encoding="utf-8", errors="replace") as f: r.stderr = f.read() finally: for p in (stdout_path, stderr_path): try: os.unlink(p) except OSError: pass return r def _remove_pack(name: str) -> None: """Remove a node pack from custom_nodes (if it exists). On Windows, file locks (antivirus, git handles) can prevent immediate deletion. Strategy: retry rmtree, then fall back to rename (moves the directory out of the resolver's scan path so stale deps don't leak). """ path = os.path.join(CUSTOM_NODES, name) if os.path.islink(path): os.unlink(path) return if not os.path.isdir(path): return # Try direct removal first for attempt in range(3): try: shutil.rmtree(path) return except OSError: if attempt < 2: time.sleep(1) # Fallback: rename out of custom_nodes so resolver won't scan it import uuid trash = os.path.join(CUSTOM_NODES, f".trash_{uuid.uuid4().hex[:8]}") try: os.rename(path, trash) shutil.rmtree(trash, ignore_errors=True) except OSError: shutil.rmtree(path, ignore_errors=True) def _pack_exists(name: str) -> bool: return os.path.isdir(os.path.join(CUSTOM_NODES, name)) # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- def _clean_trash() -> None: """Remove .trash_* directories left by rename-then-delete fallback.""" if not CUSTOM_NODES or not os.path.isdir(CUSTOM_NODES): return for name in os.listdir(CUSTOM_NODES): if name.startswith(".trash_"): shutil.rmtree(os.path.join(CUSTOM_NODES, name), ignore_errors=True) @pytest.fixture(autouse=True) def _clean_test_packs(): """Ensure test node packs are removed before and after each test.""" _remove_pack(PACK_TEST1) _remove_pack(PACK_TEST2) _clean_trash() yield _remove_pack(PACK_TEST1) _remove_pack(PACK_TEST2) _clean_trash() # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- class TestInstall: """cm-cli install --uv-compile""" def test_install_single_pack_resolves(self): """Install one test pack with --uv-compile → resolve succeeds.""" r = _run_cm_cli("install", "--uv-compile", REPO_TEST1) combined = r.stdout + r.stderr assert _pack_exists(PACK_TEST1) assert "Installation was successful" in combined assert "Resolved" in combined def test_install_conflicting_packs_shows_attribution(self): """Install two conflicting packs → conflict attribution output.""" # Install first (no conflict yet) r1 = _run_cm_cli("install", "--uv-compile", REPO_TEST1) assert _pack_exists(PACK_TEST1), f"test1 not installed (rc={r1.returncode})" assert r1.returncode == 0, f"test1 install failed (rc={r1.returncode})" # Install second → uv-compile detects conflict between # python-slugify==8.0.4 (test1) and text-unidecode==1.2 (test2) r2 = _run_cm_cli("install", "--uv-compile", REPO_TEST2) combined = r2.stdout + r2.stderr assert _pack_exists(PACK_TEST2), f"test2 not cloned (rc={r2.returncode})" assert r2.returncode != 0, f"Expected non-zero exit (conflict). rc={r2.returncode}" assert "Resolution failed" in combined, ( f"Missing 'Resolution failed'. stdout={r2.stdout[:500]!r}" ) assert "Conflicting packages (by node pack):" in combined class TestReinstall: """cm-cli reinstall --uv-compile""" def test_reinstall_with_uv_compile(self): """Reinstall an existing pack with --uv-compile.""" # Install first _run_cm_cli("install", REPO_TEST1) assert _pack_exists(PACK_TEST1) # Reinstall with --uv-compile r = _run_cm_cli("reinstall", "--uv-compile", REPO_TEST1) combined = r.stdout + r.stderr # Reinstall should re-resolve or report the pack exists # Note: Manager's reinstall may fail to remove the existing directory # before re-cloning (known issue — purge_node_state bug) assert _pack_exists(PACK_TEST1) assert "Resolving dependencies" in combined or "Already exists" in combined class TestUpdate: """cm-cli update --uv-compile""" def test_update_single_with_uv_compile(self): """Update an installed pack with --uv-compile.""" _run_cm_cli("install", REPO_TEST1) assert _pack_exists(PACK_TEST1) r = _run_cm_cli("update", "--uv-compile", REPO_TEST1) combined = r.stdout + r.stderr assert "Resolving dependencies" in combined def test_update_all_with_uv_compile(self): """update all --uv-compile runs uv-compile after updating.""" _run_cm_cli("install", REPO_TEST1) assert _pack_exists(PACK_TEST1) r = _run_cm_cli("update", "--uv-compile", "all") combined = r.stdout + r.stderr assert "Resolving dependencies" in combined class TestFix: """cm-cli fix --uv-compile""" def test_fix_single_with_uv_compile(self): """Fix an installed pack with --uv-compile.""" _run_cm_cli("install", REPO_TEST1) assert _pack_exists(PACK_TEST1) r = _run_cm_cli("fix", "--uv-compile", REPO_TEST1) combined = r.stdout + r.stderr assert "Resolving dependencies" in combined def test_fix_all_with_uv_compile(self): """fix all --uv-compile runs uv-compile after fixing.""" _run_cm_cli("install", REPO_TEST1) assert _pack_exists(PACK_TEST1) r = _run_cm_cli("fix", "--uv-compile", "all") combined = r.stdout + r.stderr assert "Resolving dependencies" in combined class TestUvCompileStandalone: """cm-cli uv-sync (standalone command, formerly uv-compile)""" def test_uv_compile_no_packs(self): """uv-compile with no node packs → 'No custom node packs found'.""" r = _run_cm_cli("uv-sync") combined = r.stdout + r.stderr # Only ComfyUI-Manager exists (no requirements.txt in it normally) # so either "No custom node packs found" or resolves 0 assert r.returncode == 0 or "No custom node packs" in combined def test_uv_compile_with_packs(self): """uv-compile after installing test pack → resolves.""" _run_cm_cli("install", REPO_TEST1) assert _pack_exists(PACK_TEST1) r = _run_cm_cli("uv-sync") combined = r.stdout + r.stderr assert "Resolving dependencies" in combined assert "Resolved" in combined def test_uv_compile_conflict_attribution(self): """uv-compile with conflicting packs → shows attribution.""" _run_cm_cli("install", REPO_TEST1) _run_cm_cli("install", REPO_TEST2) r = _run_cm_cli("uv-sync") combined = r.stdout + r.stderr assert r.returncode != 0 assert "Conflicting packages (by node pack):" in combined assert PACK_TEST1 in combined assert PACK_TEST2 in combined class TestRestoreDependencies: """cm-cli restore-dependencies --uv-compile""" def test_restore_dependencies_with_uv_compile(self): """restore-dependencies --uv-compile runs resolver after restore.""" _run_cm_cli("install", REPO_TEST1) assert _pack_exists(PACK_TEST1) r = _run_cm_cli("restore-dependencies", "--uv-compile") combined = r.stdout + r.stderr assert "Resolving dependencies" in combined class TestConflictAttributionDetail: """Verify conflict attribution output details.""" def test_both_packs_and_specs_shown(self): """Conflict output shows pack names AND version specs.""" _run_cm_cli("install", REPO_TEST1) _run_cm_cli("install", REPO_TEST2) r = _run_cm_cli("uv-sync") combined = r.stdout + r.stderr # Processed attribution must show exact version specs (not raw uv error) assert r.returncode != 0 assert "Conflicting packages (by node pack):" in combined assert "python-slugify==8.0.4" in combined assert "text-unidecode==1.2" in combined # Both pack names present in attribution block assert PACK_TEST1 in combined assert PACK_TEST2 in combined