Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
affc3e7d2e | ||
|
|
099aed1ad4 | ||
|
|
e129697151 | ||
|
|
cd60f33f6d | ||
|
|
ddccefbc70 | ||
|
|
3bc2e18f88 | ||
|
|
d7a2277017 | ||
|
|
f042d73b72 |
@@ -0,0 +1,74 @@
|
||||
name: "E2E Tests on Multiple Platforms"
|
||||
on:
|
||||
push:
|
||||
branches: [main, feat/*, fix/*]
|
||||
paths:
|
||||
- "comfyui_manager/**"
|
||||
- "cm_cli/**"
|
||||
- "tests/e2e/**"
|
||||
- ".github/workflows/e2e.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "comfyui_manager/**"
|
||||
- "cm_cli/**"
|
||||
- "tests/e2e/**"
|
||||
- ".github/workflows/e2e.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
name: "E2E (${{ matrix.os }}, py${{ matrix.python-version }})"
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
PYTHONIOENCODING: "utf8"
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
python-version: ["3.10"]
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
|
||||
- name: Set E2E_ROOT
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "$RUNNER_OS" == "Windows" ]]; then
|
||||
echo "E2E_ROOT=$RUNNER_TEMP\\e2e_env" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "E2E_ROOT=$RUNNER_TEMP/e2e_env" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Setup E2E environment
|
||||
shell: bash
|
||||
env:
|
||||
MANAGER_ROOT: ${{ github.workspace }}
|
||||
run: |
|
||||
python tests/e2e/scripts/setup_e2e_env.py
|
||||
|
||||
- name: Run E2E tests
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "$RUNNER_OS" == "Windows" ]]; then
|
||||
VENV_PY="$E2E_ROOT/venv/Scripts/python.exe"
|
||||
else
|
||||
VENV_PY="$E2E_ROOT/venv/bin/python"
|
||||
fi
|
||||
uv pip install --python "$VENV_PY" pytest pytest-timeout
|
||||
|
||||
"$VENV_PY" -m pytest tests/e2e/test_e2e_uv_compile.py -v -s --timeout=300
|
||||
+248
-42
@@ -183,18 +183,22 @@ class Ctx:
|
||||
cmd_ctx = Ctx()
|
||||
|
||||
|
||||
class NodeInstallError(Exception):
|
||||
"""Raised when a node installation fails and the caller requested failure propagation."""
|
||||
pass
|
||||
|
||||
|
||||
def install_node(node_spec_str, is_all=False, cnt_msg='', **kwargs):
|
||||
exit_on_fail = kwargs.get('exit_on_fail', False)
|
||||
print(f"install_node exit on fail:{exit_on_fail}...")
|
||||
|
||||
raise_on_fail = kwargs.get('raise_on_fail', False)
|
||||
|
||||
if core.is_valid_url(node_spec_str):
|
||||
# install via urls
|
||||
res = asyncio.run(core.gitclone_install(node_spec_str, no_deps=cmd_ctx.no_deps))
|
||||
if not res.result:
|
||||
print(res.msg)
|
||||
print(f"[bold red]ERROR: An error occurred while installing '{node_spec_str}'.[/bold red]")
|
||||
if exit_on_fail:
|
||||
sys.exit(1)
|
||||
if raise_on_fail:
|
||||
raise NodeInstallError(node_spec_str)
|
||||
else:
|
||||
print(f"{cnt_msg} [INSTALLED] {node_spec_str:50}")
|
||||
else:
|
||||
@@ -229,17 +233,34 @@ def install_node(node_spec_str, is_all=False, cnt_msg='', **kwargs):
|
||||
print("")
|
||||
else:
|
||||
print(f"[bold red]ERROR: An error occurred while installing '{node_name}'.\n{res.msg}[/bold red]")
|
||||
if exit_on_fail:
|
||||
sys.exit(1)
|
||||
if raise_on_fail:
|
||||
raise NodeInstallError(node_name)
|
||||
|
||||
|
||||
def reinstall_node(node_spec_str, is_all=False, cnt_msg=''):
|
||||
node_spec = unified_manager.resolve_node_spec(node_spec_str)
|
||||
if core.is_valid_url(node_spec_str):
|
||||
# URL-based: resolve_node_spec returns the full URL as node_name,
|
||||
# but internal dicts are keyed by repo basename or cnr_id.
|
||||
url = node_spec_str.rstrip('/')
|
||||
cnr = unified_manager.get_cnr_by_repo(url)
|
||||
if cnr:
|
||||
node_id = cnr['id']
|
||||
unified_manager.unified_uninstall(node_id, False)
|
||||
unified_manager.purge_node_state(node_id)
|
||||
else:
|
||||
repo_name = os.path.splitext(os.path.basename(url))[0]
|
||||
unified_manager.unified_uninstall(repo_name, True)
|
||||
unified_manager.purge_node_state(repo_name)
|
||||
|
||||
node_name, version_spec, _ = node_spec
|
||||
install_node(node_spec_str, is_all=is_all, cnt_msg=cnt_msg, raise_on_fail=True)
|
||||
else:
|
||||
node_spec = unified_manager.resolve_node_spec(node_spec_str)
|
||||
node_name, version_spec, _ = node_spec
|
||||
|
||||
unified_manager.unified_uninstall(node_name, version_spec == 'unknown')
|
||||
install_node(node_name, is_all=is_all, cnt_msg=cnt_msg)
|
||||
unified_manager.unified_uninstall(node_name, version_spec == 'unknown')
|
||||
unified_manager.purge_node_state(node_name)
|
||||
|
||||
install_node(node_name, is_all=is_all, cnt_msg=cnt_msg, raise_on_fail=True)
|
||||
|
||||
|
||||
def fix_node(node_spec_str, is_all=False, cnt_msg=''):
|
||||
@@ -613,14 +634,20 @@ def for_each_nodes(nodes, act, allow_all=True, **kwargs):
|
||||
nodes = [x for x in nodes if x.lower() not in ['comfy', 'comfyui', 'all']]
|
||||
|
||||
total = len(nodes)
|
||||
i = 1
|
||||
for x in nodes:
|
||||
failed = []
|
||||
for i, x in enumerate(nodes, 1):
|
||||
try:
|
||||
act(x, is_all=is_all, cnt_msg=f'{i}/{total}', **kwargs)
|
||||
except NodeInstallError:
|
||||
failed.append(x)
|
||||
except Exception as e:
|
||||
print(f"ERROR: {e}")
|
||||
traceback.print_exc()
|
||||
i += 1
|
||||
failed.append(x)
|
||||
|
||||
if failed:
|
||||
print(f"\n[bold red]Failed nodes ({len(failed)}/{total}): {', '.join(str(x) for x in failed)}[/bold red]")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
app = typer.Typer()
|
||||
@@ -688,21 +715,7 @@ def install(
|
||||
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, context.manager_files_path)
|
||||
for_each_nodes(nodes, act=install_node, exit_on_fail=exit_on_fail)
|
||||
|
||||
if uv_compile:
|
||||
try:
|
||||
_run_unified_resolve()
|
||||
except ImportError as e:
|
||||
print(f"[bold red]Failed to import unified_dep_resolver: {e}[/bold red]")
|
||||
raise typer.Exit(1)
|
||||
except typer.Exit:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"[bold red]Batch resolution failed: {e}[/bold red]")
|
||||
raise typer.Exit(1)
|
||||
finally:
|
||||
pip_fixer.fix_broken()
|
||||
else:
|
||||
pip_fixer.fix_broken()
|
||||
_finalize_resolve(pip_fixer, uv_compile)
|
||||
|
||||
|
||||
@app.command(help="Reinstall custom nodes")
|
||||
@@ -729,6 +742,14 @@ def reinstall(
|
||||
help="Skip installing any Python dependencies",
|
||||
),
|
||||
] = False,
|
||||
uv_compile: Annotated[
|
||||
Optional[bool],
|
||||
typer.Option(
|
||||
"--uv-compile",
|
||||
show_default=False,
|
||||
help="After reinstalling, batch-resolve all dependencies via uv pip compile",
|
||||
),
|
||||
] = False,
|
||||
user_directory: str = typer.Option(
|
||||
None,
|
||||
help="user directory"
|
||||
@@ -736,11 +757,20 @@ def reinstall(
|
||||
):
|
||||
cmd_ctx.set_user_directory(user_directory)
|
||||
cmd_ctx.set_channel_mode(channel, mode)
|
||||
cmd_ctx.set_no_deps(no_deps)
|
||||
|
||||
if uv_compile and no_deps:
|
||||
print("[bold red]--uv-compile and --no-deps are mutually exclusive.[/bold red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if uv_compile:
|
||||
cmd_ctx.set_no_deps(True)
|
||||
else:
|
||||
cmd_ctx.set_no_deps(no_deps)
|
||||
|
||||
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, context.manager_files_path)
|
||||
for_each_nodes(nodes, act=reinstall_node)
|
||||
pip_fixer.fix_broken()
|
||||
|
||||
_finalize_resolve(pip_fixer, uv_compile)
|
||||
|
||||
|
||||
@app.command(help="Uninstall custom nodes")
|
||||
@@ -785,10 +815,21 @@ def update(
|
||||
None,
|
||||
help="user directory"
|
||||
),
|
||||
uv_compile: Annotated[
|
||||
Optional[bool],
|
||||
typer.Option(
|
||||
"--uv-compile",
|
||||
show_default=False,
|
||||
help="After updating, batch-resolve all dependencies via uv pip compile",
|
||||
),
|
||||
] = False,
|
||||
):
|
||||
cmd_ctx.set_user_directory(user_directory)
|
||||
cmd_ctx.set_channel_mode(channel, mode)
|
||||
|
||||
if uv_compile:
|
||||
cmd_ctx.set_no_deps(True)
|
||||
|
||||
if 'all' in nodes:
|
||||
asyncio.run(auto_save_snapshot())
|
||||
|
||||
@@ -800,7 +841,8 @@ def update(
|
||||
break
|
||||
|
||||
update_parallel(nodes)
|
||||
pip_fixer.fix_broken()
|
||||
|
||||
_finalize_resolve(pip_fixer, uv_compile)
|
||||
|
||||
|
||||
@app.command(help="Disable custom nodes")
|
||||
@@ -886,16 +928,28 @@ def fix(
|
||||
None,
|
||||
help="user directory"
|
||||
),
|
||||
uv_compile: Annotated[
|
||||
Optional[bool],
|
||||
typer.Option(
|
||||
"--uv-compile",
|
||||
show_default=False,
|
||||
help="After fixing, batch-resolve all dependencies via uv pip compile",
|
||||
),
|
||||
] = False,
|
||||
):
|
||||
cmd_ctx.set_user_directory(user_directory)
|
||||
cmd_ctx.set_channel_mode(channel, mode)
|
||||
|
||||
if uv_compile:
|
||||
cmd_ctx.set_no_deps(True)
|
||||
|
||||
if 'all' in nodes:
|
||||
asyncio.run(auto_save_snapshot())
|
||||
|
||||
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, context.manager_files_path)
|
||||
for_each_nodes(nodes, fix_node, allow_all=True)
|
||||
pip_fixer.fix_broken()
|
||||
|
||||
_finalize_resolve(pip_fixer, uv_compile)
|
||||
|
||||
|
||||
@app.command("show-versions", help="Show all available versions of the node")
|
||||
@@ -1092,7 +1146,7 @@ def save_snapshot(
|
||||
|
||||
@app.command("restore-snapshot", help="Restore snapshot from snapshot file")
|
||||
def restore_snapshot(
|
||||
snapshot_name: str,
|
||||
snapshot_name: str,
|
||||
pip_non_url: Optional[bool] = typer.Option(
|
||||
default=None,
|
||||
show_default=False,
|
||||
@@ -1118,13 +1172,24 @@ def restore_snapshot(
|
||||
restore_to: Optional[str] = typer.Option(
|
||||
None,
|
||||
help="Manually specify the installation path for the custom node. Ignore user directory."
|
||||
)
|
||||
),
|
||||
uv_compile: Annotated[
|
||||
Optional[bool],
|
||||
typer.Option(
|
||||
"--uv-compile",
|
||||
show_default=False,
|
||||
help="After restoring, batch-resolve all dependencies via uv pip compile",
|
||||
),
|
||||
] = False,
|
||||
):
|
||||
cmd_ctx.set_user_directory(user_directory)
|
||||
|
||||
if restore_to:
|
||||
cmd_ctx.update_custom_nodes_dir(restore_to)
|
||||
|
||||
if uv_compile:
|
||||
cmd_ctx.set_no_deps(True)
|
||||
|
||||
extras = []
|
||||
if pip_non_url:
|
||||
extras.append('--pip-non-url')
|
||||
@@ -1151,8 +1216,11 @@ def restore_snapshot(
|
||||
except Exception:
|
||||
print("[bold red]ERROR: Failed to restore snapshot.[/bold red]")
|
||||
traceback.print_exc()
|
||||
if uv_compile:
|
||||
pip_fixer.fix_broken()
|
||||
raise typer.Exit(code=1)
|
||||
pip_fixer.fix_broken()
|
||||
|
||||
_finalize_resolve(pip_fixer, uv_compile)
|
||||
|
||||
|
||||
@app.command(
|
||||
@@ -1162,10 +1230,21 @@ def restore_dependencies(
|
||||
user_directory: str = typer.Option(
|
||||
None,
|
||||
help="user directory"
|
||||
)
|
||||
),
|
||||
uv_compile: Annotated[
|
||||
Optional[bool],
|
||||
typer.Option(
|
||||
"--uv-compile",
|
||||
show_default=False,
|
||||
help="After restoring, batch-resolve all dependencies via uv pip compile",
|
||||
),
|
||||
] = False,
|
||||
):
|
||||
cmd_ctx.set_user_directory(user_directory)
|
||||
|
||||
if uv_compile:
|
||||
cmd_ctx.set_no_deps(True)
|
||||
|
||||
node_paths = []
|
||||
|
||||
for base_path in cmd_ctx.get_custom_nodes_paths():
|
||||
@@ -1181,9 +1260,10 @@ def restore_dependencies(
|
||||
for x in node_paths:
|
||||
print("----------------------------------------------------------------------------------------------------")
|
||||
print(f"Restoring [{i}/{total}]: {x}")
|
||||
unified_manager.execute_install_script('', x, instant_execution=True)
|
||||
unified_manager.execute_install_script('', x, instant_execution=True, no_deps=bool(uv_compile))
|
||||
i += 1
|
||||
pip_fixer.fix_broken()
|
||||
|
||||
_finalize_resolve(pip_fixer, uv_compile)
|
||||
|
||||
|
||||
@app.command(
|
||||
@@ -1224,9 +1304,21 @@ def install_deps(
|
||||
None,
|
||||
help="user directory"
|
||||
),
|
||||
uv_compile: Annotated[
|
||||
Optional[bool],
|
||||
typer.Option(
|
||||
"--uv-compile",
|
||||
show_default=False,
|
||||
help="After installing, batch-resolve all dependencies via uv pip compile",
|
||||
),
|
||||
] = False,
|
||||
):
|
||||
cmd_ctx.set_user_directory(user_directory)
|
||||
cmd_ctx.set_channel_mode(channel, mode)
|
||||
|
||||
if uv_compile:
|
||||
cmd_ctx.set_no_deps(True)
|
||||
|
||||
asyncio.run(auto_save_snapshot())
|
||||
|
||||
if not os.path.exists(deps):
|
||||
@@ -1246,19 +1338,40 @@ def install_deps(
|
||||
if state == 'installed':
|
||||
continue
|
||||
elif state == 'not-installed':
|
||||
asyncio.run(core.gitclone_install(k, instant_execution=True))
|
||||
asyncio.run(core.gitclone_install(k, instant_execution=True, no_deps=bool(uv_compile)))
|
||||
else: # disabled
|
||||
core.gitclone_set_active([k], False)
|
||||
pip_fixer.fix_broken()
|
||||
|
||||
_finalize_resolve(pip_fixer, uv_compile)
|
||||
|
||||
print("Dependency installation and activation complete.")
|
||||
|
||||
|
||||
def _finalize_resolve(pip_fixer, uv_compile) -> None:
|
||||
"""Run batch resolution if --uv-compile is set, then fix broken packages."""
|
||||
if uv_compile:
|
||||
try:
|
||||
_run_unified_resolve()
|
||||
except ImportError as e:
|
||||
print(f"[bold red]Failed to import unified_dep_resolver: {e}[/bold red]")
|
||||
raise typer.Exit(1)
|
||||
except typer.Exit:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"[bold red]Batch resolution failed: {e}[/bold red]")
|
||||
raise typer.Exit(1)
|
||||
finally:
|
||||
pip_fixer.fix_broken()
|
||||
else:
|
||||
pip_fixer.fix_broken()
|
||||
|
||||
|
||||
def _run_unified_resolve():
|
||||
"""Shared logic for unified batch dependency resolution."""
|
||||
from comfyui_manager.common.unified_dep_resolver import (
|
||||
UnifiedDepResolver,
|
||||
UvNotAvailableError,
|
||||
attribute_conflicts,
|
||||
collect_base_requirements,
|
||||
collect_node_pack_paths,
|
||||
)
|
||||
@@ -1295,11 +1408,19 @@ def _run_unified_resolve():
|
||||
print("[bold green]Resolution complete (no deps needed).[/bold green]")
|
||||
else:
|
||||
print(f"[bold red]Resolution failed: {result.error}[/bold red]")
|
||||
if result.lockfile and result.lockfile.conflicts and result.collected:
|
||||
attributed = attribute_conflicts(result.collected.sources, result.lockfile.conflicts)
|
||||
if attributed:
|
||||
print("[bold yellow]Conflicting packages (by node pack):[/bold yellow]")
|
||||
for pkg_name, requesters in sorted(attributed.items()):
|
||||
print(f" [yellow]{pkg_name}[/yellow]:")
|
||||
for pack_path, pkg_spec in requesters:
|
||||
print(f" {os.path.basename(pack_path)} → {pkg_spec}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command(
|
||||
"uv-compile",
|
||||
"uv-sync",
|
||||
help="Batch-resolve and install all custom node dependencies via uv pip compile.",
|
||||
)
|
||||
def unified_uv_compile(
|
||||
@@ -1368,6 +1489,91 @@ def export_custom_node_ids(
|
||||
print(f"{x['id']}@unknown", file=output_file)
|
||||
|
||||
|
||||
@app.command("update-cache", help="Force-fetch all remote data and populate local cache (blocking)")
|
||||
def update_cache(
|
||||
channel: Annotated[
|
||||
str,
|
||||
typer.Option(
|
||||
show_default=False,
|
||||
help="Specify the channel"
|
||||
),
|
||||
] = None,
|
||||
user_directory: str = typer.Option(
|
||||
None,
|
||||
help="user directory"
|
||||
),
|
||||
):
|
||||
cmd_ctx.set_user_directory(user_directory)
|
||||
if channel is not None:
|
||||
cmd_ctx.channel = channel
|
||||
|
||||
asyncio.run(_force_update_cache(cmd_ctx.channel))
|
||||
|
||||
|
||||
async def _force_update_cache(channel):
|
||||
"""Fetch all remote data and save to cache, bypassing pip/offline guards."""
|
||||
core.refresh_channel_dict()
|
||||
config = core.get_config()
|
||||
channel_url = config['channel_url']
|
||||
|
||||
os.makedirs(manager_util.cache_dir, exist_ok=True)
|
||||
|
||||
failed = []
|
||||
|
||||
# Step 1: Fetch channel JSON files directly (bypasses get_data_by_mode pip guard)
|
||||
filenames = [
|
||||
"custom-node-list.json",
|
||||
"extension-node-map.json",
|
||||
"model-list.json",
|
||||
"alter-list.json",
|
||||
"github-stats.json",
|
||||
]
|
||||
|
||||
async def fetch_and_cache(filename):
|
||||
try:
|
||||
if config.get('default_cache_as_channel_url'):
|
||||
uri = f"{channel_url}/{filename}"
|
||||
else:
|
||||
uri = f"{core.DEFAULT_CHANNEL}/{filename}"
|
||||
|
||||
cache_uri = str(manager_util.simple_hash(uri)) + '_' + filename
|
||||
cache_uri = os.path.join(manager_util.cache_dir, cache_uri)
|
||||
|
||||
json_obj = await manager_util.get_data(uri, silent=True)
|
||||
|
||||
with manager_util.cache_lock:
|
||||
with open(cache_uri, "w", encoding='utf-8') as file:
|
||||
json.dump(json_obj, file, indent=4, sort_keys=True)
|
||||
|
||||
print(f" [CACHED] {filename}")
|
||||
except Exception as e:
|
||||
print(f" [bold red][FAILED] {filename}: {e}[/bold red]")
|
||||
failed.append(filename)
|
||||
|
||||
print("Fetching channel data...")
|
||||
await asyncio.gather(*[fetch_and_cache(f) for f in filenames])
|
||||
|
||||
# Step 2: Reload unified_manager with remote mode
|
||||
# cache_mode='remote' makes cache_mode==False in get_cnr_data,
|
||||
# which bypasses the dont_wait block and triggers blocking fetch_all()
|
||||
print("Fetching CNR registry data...")
|
||||
try:
|
||||
await unified_manager.reload('remote', dont_wait=False)
|
||||
except Exception as e:
|
||||
print(f" [bold red][FAILED] CNR registry: {e}[/bold red]")
|
||||
failed.append("CNR registry")
|
||||
|
||||
# Step 3: Load nightly data (cache files now exist from Step 1)
|
||||
print("Loading nightly data...")
|
||||
await unified_manager.load_nightly(channel or 'default', 'cache')
|
||||
|
||||
if failed:
|
||||
print(f"\n[bold red]Cache update incomplete. Failed: {', '.join(failed)}[/bold red]")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("[bold green]Cache update complete.[/bold green]")
|
||||
|
||||
|
||||
def main():
|
||||
app()
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import yaml
|
||||
import requests
|
||||
from tqdm.auto import tqdm
|
||||
from git.remote import RemoteProgress
|
||||
from comfyui_manager.common.timestamp_utils import get_backup_branch_name
|
||||
|
||||
|
||||
comfy_path = os.environ.get('COMFYUI_PATH')
|
||||
@@ -51,9 +50,6 @@ working_directory = os.getcwd()
|
||||
|
||||
if os.path.basename(working_directory) != 'custom_nodes':
|
||||
print("WARN: This script should be executed in custom_nodes dir")
|
||||
print(f"DBG: INFO {working_directory}")
|
||||
print(f"DBG: INFO {sys.argv}")
|
||||
# exit(-1)
|
||||
|
||||
|
||||
class GitProgress(RemoteProgress):
|
||||
@@ -68,14 +64,60 @@ class GitProgress(RemoteProgress):
|
||||
self.pbar.refresh()
|
||||
|
||||
|
||||
def get_backup_branch_name(repo=None):
|
||||
"""Get backup branch name with current timestamp.
|
||||
|
||||
Inlined from timestamp_utils to keep git_helper.py standalone — this script
|
||||
runs as a subprocess on Windows and must not import from comfyui_manager.
|
||||
"""
|
||||
import time as _time
|
||||
import uuid as _uuid
|
||||
|
||||
base_name = f'backup_{_time.strftime("%Y%m%d_%H%M%S")}'
|
||||
|
||||
if repo is None:
|
||||
return base_name
|
||||
|
||||
try:
|
||||
existing_branches = {b.name for b in repo.heads}
|
||||
except Exception:
|
||||
return base_name
|
||||
|
||||
if base_name not in existing_branches:
|
||||
return base_name
|
||||
|
||||
for i in range(1, 100):
|
||||
new_name = f'{base_name}_{i}'
|
||||
if new_name not in existing_branches:
|
||||
return new_name
|
||||
|
||||
return f'{base_name}_{_uuid.uuid4().hex[:6]}'
|
||||
|
||||
|
||||
def gitclone(custom_nodes_path, url, target_hash=None, repo_path=None):
|
||||
repo_name = os.path.splitext(os.path.basename(url))[0]
|
||||
|
||||
if repo_path is None:
|
||||
repo_path = os.path.join(custom_nodes_path, repo_name)
|
||||
|
||||
# Clone the repository from the remote URL
|
||||
repo = git.Repo.clone_from(url, repo_path, recursive=True, progress=GitProgress())
|
||||
# On Windows, previous failed clones may leave directories with locked
|
||||
# .git/objects/pack/* files (GitPython memory-mapped handle leak).
|
||||
# Rename stale directory out of the way so clone can proceed.
|
||||
if os.path.exists(repo_path):
|
||||
import shutil
|
||||
import uuid as _uuid
|
||||
trash_dir = os.path.join(custom_nodes_path, '.disabled', '.trash')
|
||||
os.makedirs(trash_dir, exist_ok=True)
|
||||
trash = os.path.join(trash_dir, repo_name + f'_{_uuid.uuid4().hex[:8]}')
|
||||
try:
|
||||
os.rename(repo_path, trash)
|
||||
shutil.rmtree(trash, ignore_errors=True)
|
||||
except OSError:
|
||||
shutil.rmtree(repo_path, ignore_errors=True)
|
||||
|
||||
# Disable tqdm progress when stderr is piped to avoid deadlock on Windows.
|
||||
progress = GitProgress() if sys.stderr.isatty() else None
|
||||
repo = git.Repo.clone_from(url, repo_path, recursive=True, progress=progress)
|
||||
|
||||
if target_hash is not None:
|
||||
print(f"CHECKOUT: {repo_name} [{target_hash}]")
|
||||
@@ -528,7 +570,9 @@ try:
|
||||
restore_pip_snapshot(pips, options)
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
sys.exit(-1)
|
||||
print(e, file=sys.stderr)
|
||||
if hasattr(e, 'stderr') and e.stderr:
|
||||
print(e.stderr, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
|
||||
@@ -56,7 +56,8 @@ class CollectedDeps:
|
||||
"""Aggregated dependency collection result."""
|
||||
requirements: list[PackageRequirement] = field(default_factory=list)
|
||||
skipped: list[tuple[str, str]] = field(default_factory=list)
|
||||
sources: dict[str, list[str]] = field(default_factory=dict)
|
||||
sources: dict[str, list[tuple[str, str]]] = field(default_factory=dict)
|
||||
"""pkg_name → [(pack_path, pkg_spec), ...] — tracks which node packs request each package."""
|
||||
extra_index_urls: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@@ -275,7 +276,7 @@ class UnifiedDepResolver:
|
||||
"""Collect dependencies from all node packs."""
|
||||
requirements: list[PackageRequirement] = []
|
||||
skipped: list[tuple[str, str]] = []
|
||||
sources: dict[str, list[str]] = defaultdict(list)
|
||||
sources: defaultdict[str, list[tuple[str, str]]] = defaultdict(list)
|
||||
extra_index_urls: list[str] = []
|
||||
|
||||
# Snapshot installed packages once to avoid repeated subprocess calls.
|
||||
@@ -362,7 +363,7 @@ class UnifiedDepResolver:
|
||||
requirements.append(
|
||||
PackageRequirement(name=pkg_name, spec=pkg_spec, source=pack_path)
|
||||
)
|
||||
sources[pkg_name].append(pack_path)
|
||||
sources[pkg_name].append((pack_path, pkg_spec))
|
||||
|
||||
# Commit staged index URLs only after all validation passed.
|
||||
if pending_urls:
|
||||
@@ -701,3 +702,23 @@ class UnifiedDepResolver:
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def attribute_conflicts(
|
||||
sources: dict[str, list[tuple[str, str]]],
|
||||
conflicts: list[str],
|
||||
) -> dict[str, list[tuple[str, str]]]:
|
||||
"""Cross-reference conflict packages with their requesting node packs.
|
||||
|
||||
Uses word-boundary regex to prevent false-positive prefix matches
|
||||
(e.g. ``torch`` does NOT match ``torchvision`` or ``torch_audio``).
|
||||
"""
|
||||
conflict_text = "\n".join(conflicts).lower().replace("-", "_")
|
||||
return {
|
||||
pkg: reqs
|
||||
for pkg, reqs in sources.items()
|
||||
if re.search(
|
||||
r'(?<![a-z0-9_])' + re.escape(pkg.lower().replace("-", "_")) + r'(?![a-z0-9_])',
|
||||
conflict_text,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1142,6 +1142,71 @@ class UnifiedManager:
|
||||
|
||||
return result
|
||||
|
||||
def purge_node_state(self, node_id: str):
|
||||
"""
|
||||
Remove a node's directory and clean ALL internal dictionaries regardless of categorization.
|
||||
Used by reinstall to guarantee clean state before re-installation.
|
||||
"""
|
||||
if 'comfyui-manager' in node_id.lower():
|
||||
return
|
||||
|
||||
custom_nodes_dir = os.path.normcase(os.path.realpath(get_default_custom_nodes_path()))
|
||||
paths_to_remove = set()
|
||||
|
||||
def _add_path(raw_path):
|
||||
"""Normalize and validate a path before adding to removal set."""
|
||||
if not raw_path:
|
||||
return
|
||||
resolved = os.path.normcase(os.path.realpath(raw_path))
|
||||
if resolved == custom_nodes_dir:
|
||||
logging.warning(f"[ComfyUI-Manager] purge_node_state: refusing to delete custom_nodes root: {raw_path}")
|
||||
return
|
||||
try:
|
||||
if os.path.commonpath([custom_nodes_dir, resolved]) != custom_nodes_dir:
|
||||
logging.warning(f"[ComfyUI-Manager] purge_node_state: path escapes custom_nodes scope, skipping: {raw_path}")
|
||||
return
|
||||
except ValueError:
|
||||
logging.warning(f"[ComfyUI-Manager] purge_node_state: cannot verify containment, skipping: {raw_path}")
|
||||
return
|
||||
paths_to_remove.add(resolved)
|
||||
|
||||
# Collect paths from all dictionaries
|
||||
entry = self.unknown_active_nodes.get(node_id)
|
||||
if entry is not None:
|
||||
_add_path(entry[1])
|
||||
|
||||
entry = self.active_nodes.get(node_id)
|
||||
if entry is not None:
|
||||
_add_path(entry[1])
|
||||
|
||||
entry = self.unknown_inactive_nodes.get(node_id)
|
||||
if entry is not None:
|
||||
_add_path(entry[1])
|
||||
|
||||
fullpath = self.nightly_inactive_nodes.get(node_id)
|
||||
if fullpath is not None:
|
||||
_add_path(fullpath)
|
||||
|
||||
ver_map = self.cnr_inactive_nodes.get(node_id)
|
||||
if ver_map is not None:
|
||||
for key, fp in ver_map.items():
|
||||
_add_path(fp)
|
||||
|
||||
# Convention-based fallback path
|
||||
_add_path(os.path.join(get_default_custom_nodes_path(), node_id))
|
||||
|
||||
# Remove all validated paths, then always clean dictionaries
|
||||
try:
|
||||
for path in paths_to_remove:
|
||||
if os.path.exists(path):
|
||||
try_rmtree(node_id, path)
|
||||
finally:
|
||||
self.unknown_active_nodes.pop(node_id, None)
|
||||
self.active_nodes.pop(node_id, None)
|
||||
self.unknown_inactive_nodes.pop(node_id, None)
|
||||
self.nightly_inactive_nodes.pop(node_id, None)
|
||||
self.cnr_inactive_nodes.pop(node_id, None)
|
||||
|
||||
def unified_uninstall(self, node_id: str, is_unknown: bool):
|
||||
"""
|
||||
Remove whole installed custom nodes including inactive nodes
|
||||
@@ -1417,8 +1482,18 @@ class UnifiedManager:
|
||||
else: # nightly
|
||||
repo_url = the_node['repository']
|
||||
else:
|
||||
result = ManagedResult('install')
|
||||
return result.fail(f"Node '{node_id}@{version_spec}' not found in [{channel}, {mode}]")
|
||||
# Fallback for nightly only: use repository URL from CNR map
|
||||
# when node is registered in CNR but absent from nightly manifest
|
||||
if version_spec == 'nightly':
|
||||
cnr_fallback = self.cnr_map.get(node_id)
|
||||
if cnr_fallback is not None and cnr_fallback.get('repository'):
|
||||
repo_url = cnr_fallback['repository']
|
||||
else:
|
||||
result = ManagedResult('install')
|
||||
return result.fail(f"Node '{node_id}@{version_spec}' not found in [{channel}, {mode}]")
|
||||
else:
|
||||
result = ManagedResult('install')
|
||||
return result.fail(f"Node '{node_id}@{version_spec}' not found in [{channel}, {mode}]")
|
||||
|
||||
if self.is_enabled(node_id, version_spec):
|
||||
return ManagedResult('skip').with_target(f"{node_id}@{version_spec}")
|
||||
@@ -1779,11 +1854,34 @@ def reserve_script(repo_path, install_cmds):
|
||||
|
||||
|
||||
def try_rmtree(title, fullpath):
|
||||
# Tier 1: retry with delay for transient Windows file locks
|
||||
for attempt in range(3):
|
||||
try:
|
||||
shutil.rmtree(fullpath)
|
||||
return
|
||||
except OSError:
|
||||
if attempt < 2:
|
||||
time.sleep(1)
|
||||
|
||||
# Tier 2: rename into .disabled/.trash/ so scanner ignores it
|
||||
trash_dir = os.path.join(os.path.dirname(fullpath), '.disabled', '.trash')
|
||||
os.makedirs(trash_dir, exist_ok=True)
|
||||
trash = os.path.join(trash_dir, os.path.basename(fullpath) + f'_{uuid.uuid4().hex[:8]}')
|
||||
try:
|
||||
shutil.rmtree(fullpath)
|
||||
except Exception as e:
|
||||
logging.warning(f"[ComfyUI-Manager] An error occurred while deleting '{fullpath}', so it has been scheduled for deletion upon restart.\nEXCEPTION: {e}")
|
||||
reserve_script(title, ["#LAZY-DELETE-NODEPACK", fullpath])
|
||||
os.rename(fullpath, trash)
|
||||
shutil.rmtree(trash, ignore_errors=True)
|
||||
if not os.path.exists(trash):
|
||||
return
|
||||
# Rename succeeded but delete failed — schedule trash path for lazy delete
|
||||
logging.warning(f"[ComfyUI-Manager] Renamed '{fullpath}' to '{trash}' but could not delete; scheduled for restart.")
|
||||
reserve_script(title, ["#LAZY-DELETE-NODEPACK", trash])
|
||||
return
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Tier 3: lazy delete on restart (ComfyUI GUI fallback)
|
||||
logging.warning(f"[ComfyUI-Manager] An error occurred while deleting '{fullpath}', so it has been scheduled for deletion upon restart.")
|
||||
reserve_script(title, ["#LAZY-DELETE-NODEPACK", fullpath])
|
||||
|
||||
|
||||
def try_install_script(url, repo_path, install_cmd, instant_execution=False):
|
||||
|
||||
@@ -995,7 +995,7 @@ async def task_worker():
|
||||
return OperationResult.failed.value
|
||||
|
||||
node_name = params.node_name
|
||||
is_unknown = params.is_unknown
|
||||
is_unknown = getattr(params, 'is_unknown', False) # guard: pydantic Union may match UpdatePackParams
|
||||
|
||||
logging.debug(
|
||||
"[ComfyUI-Manager] Uninstalling node: name=%s, is_unknown=%s",
|
||||
@@ -1019,15 +1019,16 @@ async def task_worker():
|
||||
|
||||
async def do_disable(params: DisablePackParams) -> str:
|
||||
node_name = params.node_name
|
||||
is_unknown = getattr(params, 'is_unknown', False) # guard: pydantic Union may match UpdatePackParams
|
||||
|
||||
logging.debug(
|
||||
"[ComfyUI-Manager] Disabling node: name=%s, is_unknown=%s",
|
||||
node_name,
|
||||
params.is_unknown,
|
||||
is_unknown,
|
||||
)
|
||||
|
||||
try:
|
||||
res = core.unified_manager.unified_disable(node_name, params.is_unknown)
|
||||
res = core.unified_manager.unified_disable(node_name, is_unknown)
|
||||
|
||||
if res:
|
||||
return OperationResult.success.value
|
||||
|
||||
@@ -1135,6 +1135,71 @@ class UnifiedManager:
|
||||
|
||||
return result
|
||||
|
||||
def purge_node_state(self, node_id: str):
|
||||
"""
|
||||
Remove a node's directory and clean ALL internal dictionaries regardless of categorization.
|
||||
Used by reinstall to guarantee clean state before re-installation.
|
||||
"""
|
||||
if 'comfyui-manager' in node_id.lower():
|
||||
return
|
||||
|
||||
custom_nodes_dir = os.path.normcase(os.path.realpath(get_default_custom_nodes_path()))
|
||||
paths_to_remove = set()
|
||||
|
||||
def _add_path(raw_path):
|
||||
"""Normalize and validate a path before adding to removal set."""
|
||||
if not raw_path:
|
||||
return
|
||||
resolved = os.path.normcase(os.path.realpath(raw_path))
|
||||
if resolved == custom_nodes_dir:
|
||||
logging.warning(f"[ComfyUI-Manager] purge_node_state: refusing to delete custom_nodes root: {raw_path}")
|
||||
return
|
||||
try:
|
||||
if os.path.commonpath([custom_nodes_dir, resolved]) != custom_nodes_dir:
|
||||
logging.warning(f"[ComfyUI-Manager] purge_node_state: path escapes custom_nodes scope, skipping: {raw_path}")
|
||||
return
|
||||
except ValueError:
|
||||
logging.warning(f"[ComfyUI-Manager] purge_node_state: cannot verify containment, skipping: {raw_path}")
|
||||
return
|
||||
paths_to_remove.add(resolved)
|
||||
|
||||
# Collect paths from all dictionaries
|
||||
entry = self.unknown_active_nodes.get(node_id)
|
||||
if entry is not None:
|
||||
_add_path(entry[1])
|
||||
|
||||
entry = self.active_nodes.get(node_id)
|
||||
if entry is not None:
|
||||
_add_path(entry[1])
|
||||
|
||||
entry = self.unknown_inactive_nodes.get(node_id)
|
||||
if entry is not None:
|
||||
_add_path(entry[1])
|
||||
|
||||
fullpath = self.nightly_inactive_nodes.get(node_id)
|
||||
if fullpath is not None:
|
||||
_add_path(fullpath)
|
||||
|
||||
ver_map = self.cnr_inactive_nodes.get(node_id)
|
||||
if ver_map is not None:
|
||||
for key, fp in ver_map.items():
|
||||
_add_path(fp)
|
||||
|
||||
# Convention-based fallback path
|
||||
_add_path(os.path.join(get_default_custom_nodes_path(), node_id))
|
||||
|
||||
# Remove all validated paths, then always clean dictionaries
|
||||
try:
|
||||
for path in paths_to_remove:
|
||||
if os.path.exists(path):
|
||||
try_rmtree(node_id, path)
|
||||
finally:
|
||||
self.unknown_active_nodes.pop(node_id, None)
|
||||
self.active_nodes.pop(node_id, None)
|
||||
self.unknown_inactive_nodes.pop(node_id, None)
|
||||
self.nightly_inactive_nodes.pop(node_id, None)
|
||||
self.cnr_inactive_nodes.pop(node_id, None)
|
||||
|
||||
def unified_uninstall(self, node_id: str, is_unknown: bool):
|
||||
"""
|
||||
Remove whole installed custom nodes including inactive nodes
|
||||
@@ -1411,8 +1476,18 @@ class UnifiedManager:
|
||||
else: # nightly
|
||||
repo_url = the_node['repository']
|
||||
else:
|
||||
result = ManagedResult('install')
|
||||
return result.fail(f"Node '{node_id}@{version_spec}' not found in [{channel}, {mode}]")
|
||||
# Fallback for nightly only: use repository URL from CNR map
|
||||
# when node is registered in CNR but absent from nightly manifest
|
||||
if version_spec == 'nightly':
|
||||
cnr_fallback = self.cnr_map.get(node_id)
|
||||
if cnr_fallback is not None and cnr_fallback.get('repository'):
|
||||
repo_url = cnr_fallback['repository']
|
||||
else:
|
||||
result = ManagedResult('install')
|
||||
return result.fail(f"Node '{node_id}@{version_spec}' not found in [{channel}, {mode}]")
|
||||
else:
|
||||
result = ManagedResult('install')
|
||||
return result.fail(f"Node '{node_id}@{version_spec}' not found in [{channel}, {mode}]")
|
||||
|
||||
if self.is_enabled(node_id, version_spec):
|
||||
return ManagedResult('skip').with_target(f"{node_id}@{version_spec}")
|
||||
@@ -1758,11 +1833,34 @@ def reserve_script(repo_path, install_cmds):
|
||||
|
||||
|
||||
def try_rmtree(title, fullpath):
|
||||
# Tier 1: retry with delay for transient Windows file locks
|
||||
for attempt in range(3):
|
||||
try:
|
||||
shutil.rmtree(fullpath)
|
||||
return
|
||||
except OSError:
|
||||
if attempt < 2:
|
||||
time.sleep(1)
|
||||
|
||||
# Tier 2: rename into .disabled/.trash/ so scanner ignores it
|
||||
trash_dir = os.path.join(os.path.dirname(fullpath), '.disabled', '.trash')
|
||||
os.makedirs(trash_dir, exist_ok=True)
|
||||
trash = os.path.join(trash_dir, os.path.basename(fullpath) + f'_{uuid.uuid4().hex[:8]}')
|
||||
try:
|
||||
shutil.rmtree(fullpath)
|
||||
except Exception as e:
|
||||
logging.warning(f"[ComfyUI-Manager] An error occurred while deleting '{fullpath}', so it has been scheduled for deletion upon restart.\nEXCEPTION: {e}")
|
||||
reserve_script(title, ["#LAZY-DELETE-NODEPACK", fullpath])
|
||||
os.rename(fullpath, trash)
|
||||
shutil.rmtree(trash, ignore_errors=True)
|
||||
if not os.path.exists(trash):
|
||||
return
|
||||
# Rename succeeded but delete failed — schedule trash path for lazy delete
|
||||
logging.warning(f"[ComfyUI-Manager] Renamed '{fullpath}' to '{trash}' but could not delete; scheduled for restart.")
|
||||
reserve_script(title, ["#LAZY-DELETE-NODEPACK", trash])
|
||||
return
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Tier 3: lazy delete on restart (ComfyUI GUI fallback)
|
||||
logging.warning(f"[ComfyUI-Manager] An error occurred while deleting '{fullpath}', so it has been scheduled for deletion upon restart.")
|
||||
reserve_script(title, ["#LAZY-DELETE-NODEPACK", fullpath])
|
||||
|
||||
|
||||
def try_install_script(url, repo_path, install_cmd, instant_execution=False):
|
||||
|
||||
@@ -149,7 +149,8 @@ class CollectedDeps:
|
||||
"""All collected dependencies"""
|
||||
requirements: list[PackageRequirement] # Collected deps (duplicates allowed, uv resolves)
|
||||
skipped: list[tuple[str, str]] # (package_name, skip_reason)
|
||||
sources: dict[str, list[str]] # {package_name: [source_node_packs]}
|
||||
sources: dict[str, list[tuple[str, str]]] # {package_name: [(pack_path, pkg_spec), ...]}
|
||||
"""pkg_name → [(pack_path, pkg_spec), ...] — tracks which node packs request each package."""
|
||||
extra_index_urls: list[str] # Additional index URLs separated from --index-url entries
|
||||
|
||||
@dataclass
|
||||
@@ -262,7 +263,7 @@ def collect_requirements(self) -> CollectedDeps:
|
||||
source=path,
|
||||
)
|
||||
requirements.append(req)
|
||||
sources[pkg_name].append(path)
|
||||
sources[pkg_name].append((path, pkg_spec))
|
||||
|
||||
return CollectedDeps(
|
||||
requirements=requirements,
|
||||
@@ -449,7 +450,7 @@ if os.path.exists(requirements_path) and not _unified_resolver_succeeded:
|
||||
|
||||
### 4.1.6 CLI Integration
|
||||
|
||||
Two entry points expose the unified resolver in `cm_cli`:
|
||||
Multiple entry points expose the unified resolver in `cm_cli`:
|
||||
|
||||
#### 4.1.6.1 Standalone Command: `cm_cli uv-compile`
|
||||
|
||||
@@ -478,19 +479,53 @@ When `--uv-compile` is set:
|
||||
This differs from per-node pip install: instead of resolving each node pack's
|
||||
`requirements.txt` independently, all deps are compiled together to avoid conflicts.
|
||||
|
||||
#### 4.1.6.3 Additional `--uv-compile` Commands
|
||||
|
||||
The following commands follow the same `no_deps` + batch-resolve pattern as `install --uv-compile`:
|
||||
`cmd_ctx.set_no_deps(True)` is set before node operations, then `_run_unified_resolve()`
|
||||
runs at the end via `try/finally` with `PIPFixer.fix_broken()`.
|
||||
|
||||
| Command | Operation |
|
||||
|---------|-----------|
|
||||
| `cm_cli reinstall --uv-compile` | Reinstall nodes then batch-resolve |
|
||||
| `cm_cli update --uv-compile` | Update nodes then batch-resolve |
|
||||
| `cm_cli fix --uv-compile` | Fix node dependencies then batch-resolve |
|
||||
| `cm_cli restore-snapshot --uv-compile` | Restore snapshot then batch-resolve |
|
||||
| `cm_cli restore-dependencies --uv-compile` | Restore all node deps then batch-resolve |
|
||||
| `cm_cli install-deps <deps.json> --uv-compile` | Install from deps spec file then batch-resolve |
|
||||
|
||||
> **`reinstall` only**: Has `--uv-compile` / `--no-deps` mutual exclusion check.
|
||||
> Both skip per-node pip, but `--no-deps` skips permanently while `--uv-compile` also
|
||||
> triggers batch resolution after all nodes are processed.
|
||||
>
|
||||
> **`restore-snapshot` only**: Has an additional pre-resolution exception guard — if the
|
||||
> snapshot restore itself fails (before `_run_unified_resolve()` is reached),
|
||||
> `PIPFixer.fix_broken()` runs in the exception handler before exit. The `try/finally`
|
||||
> applies to the `_run_unified_resolve()` call. See dec_7 for rationale.
|
||||
|
||||
#### Shared Design Decisions
|
||||
|
||||
- **Uses real `cm_global` values**: Unlike the startup path (4.1.3) which passes empty
|
||||
blacklist/overrides, CLI commands pass `cm_global.pip_blacklist`,
|
||||
`cm_global.pip_overrides`, and `cm_global.pip_downgrade_blacklist` — already
|
||||
initialized at `cm_cli/__main__.py` module scope (lines 45-60).
|
||||
initialized at `cm_cli/__main__.py` module scope.
|
||||
- **No `_unified_resolver_succeeded` flag**: Not needed — these are one-shot commands,
|
||||
not startup gates.
|
||||
- **Shared helper**: Both entry points delegate to `_run_unified_resolve()` which
|
||||
- **Shared helper**: All entry points delegate to `_run_unified_resolve()` which
|
||||
handles resolver instantiation, execution, and result reporting.
|
||||
- **Error handling**: `UvNotAvailableError` / `ImportError` → exit 1 with message.
|
||||
Both entry points use `try/finally` to guarantee `PIPFixer.fix_broken()` runs
|
||||
regardless of resolution outcome.
|
||||
All entry points guarantee `PIPFixer.fix_broken()` runs regardless of outcome —
|
||||
via `try/finally` around `_run_unified_resolve()`. `restore-snapshot` additionally
|
||||
calls `fix_broken()` in the snapshot restore exception handler (before
|
||||
`_run_unified_resolve()` is reached), per dec_7.
|
||||
- **Conflict attribution output**: When resolution fails and `result.lockfile.conflicts`
|
||||
is non-empty, `_run_unified_resolve()` cross-references conflict package names with
|
||||
`CollectedDeps.sources` to identify which node packs requested each conflicting package:
|
||||
- Normalization: both sources keys and conflict text apply `.lower().replace("-", "_")`
|
||||
- Word-boundary regex `(?<![a-z0-9_])pkg(?![a-z0-9_])` prevents false-positive prefix
|
||||
matches (e.g., `torch` does NOT match `torch_audio` or `torchvision`)
|
||||
- Output format: sorted by package name, each entry lists `pack_basename → pkg_spec`
|
||||
per requester (using `CollectedDeps.sources` tuple values `(pack_path, pkg_spec)`)
|
||||
|
||||
**Node pack discovery**: Uses `cmd_ctx.get_custom_nodes_paths()` → `collect_node_pack_paths()`,
|
||||
which is the CLI-native path resolution (respects `--user-directory` and `folder_paths`).
|
||||
|
||||
@@ -329,7 +329,7 @@ User requests installation of node packs A and B nearly simultaneously from UI
|
||||
|
||||
## 7. Future Extensions
|
||||
|
||||
- ~~**`cm_global` integration** [DONE]: `cm_cli uv-compile` and `cm_cli install --uv-compile` pass real `cm_global` values. Startup path (`prestartup_script.py`) still passes empty by design~~
|
||||
- ~~**`cm_global` integration** [DONE]: All `--uv-compile` CLI commands (`uv-compile`, `install`, `reinstall`, `update`, `fix`, `restore-snapshot`, `restore-dependencies`, `install-deps`) pass real `cm_global` values. Startup path (`prestartup_script.py`) still passes empty by design~~
|
||||
- Lockfile caching: Reuse for identical node pack configurations
|
||||
- Pre-install dependency conflict validation API: Check compatibility before installation
|
||||
- Dependency tree visualization: Display dependency relationships to users
|
||||
@@ -355,3 +355,9 @@ User requests installation of node packs A and B nearly simultaneously from UI
|
||||
| Legacy `execute_install_script()` (2 locations) | `legacy/manager_core.py` | ❌ No | Legacy paths |
|
||||
| `cm_cli uv-compile` | `cm_cli/__main__.py` | ✅ Yes | Standalone CLI batch resolution (with `cm_global` values) |
|
||||
| `cm_cli install --uv-compile` | `cm_cli/__main__.py` | ✅ Yes | Per-node pip skipped, batch resolution after all installs |
|
||||
| `cm_cli reinstall --uv-compile` | `cm_cli/__main__.py` | ✅ Yes | Per-node pip skipped, batch resolution after all reinstalls; mutually exclusive with `--no-deps` |
|
||||
| `cm_cli update --uv-compile` | `cm_cli/__main__.py` | ✅ Yes | Per-node pip skipped during updates, batch resolution after |
|
||||
| `cm_cli fix --uv-compile` | `cm_cli/__main__.py` | ✅ Yes | Per-node pip skipped during dep fix, batch resolution after |
|
||||
| `cm_cli restore-snapshot --uv-compile` | `cm_cli/__main__.py` | ✅ Yes | Per-node pip skipped during restore, batch resolution after |
|
||||
| `cm_cli restore-dependencies --uv-compile` | `cm_cli/__main__.py` | ✅ Yes | Per-node pip skipped, batch resolution after all node deps restored |
|
||||
| `cm_cli install-deps --uv-compile` | `cm_cli/__main__.py` | ✅ Yes | Per-node pip skipped, batch resolution after deps-spec install |
|
||||
|
||||
+45
-4
@@ -11,11 +11,15 @@ cm-cli [OPTIONS]
|
||||
|
||||
OPTIONS:
|
||||
[install|reinstall|uninstall|update|disable|enable|fix] node_name ... ?[--channel <channel name>] ?[--mode [remote|local|cache]]
|
||||
[install|reinstall|update|fix] node_name ... ?[--uv-compile]
|
||||
[update|disable|enable|fix] all ?[--channel <channel name>] ?[--mode [remote|local|cache]]
|
||||
[update|fix] all ?[--uv-compile]
|
||||
[simple-show|show] [installed|enabled|not-installed|disabled|all|snapshot|snapshot-list] ?[--channel <channel name>] ?[--mode [remote|local|cache]]
|
||||
save-snapshot ?[--output <snapshot .json/.yaml>]
|
||||
restore-snapshot <snapshot .json/.yaml> ?[--pip-non-url] ?[--pip-non-local-url] ?[--pip-local-url]
|
||||
restore-dependencies
|
||||
restore-snapshot <snapshot .json/.yaml> ?[--pip-non-url] ?[--pip-non-local-url] ?[--pip-local-url] ?[--uv-compile]
|
||||
restore-dependencies ?[--uv-compile]
|
||||
install-deps <deps.json> ?[--channel <channel name>] ?[--mode [remote|local|cache]] ?[--uv-compile]
|
||||
uv-compile
|
||||
clear
|
||||
```
|
||||
|
||||
@@ -107,6 +111,22 @@ ComfyUI-Loopchain
|
||||
* `enable`: Enables the specified custom nodes.
|
||||
* `fix`: Attempts to fix dependencies for the specified custom nodes.
|
||||
|
||||
#### `--uv-compile` flag (`install`, `reinstall`, `update`, `fix`)
|
||||
|
||||
When `--uv-compile` is specified, per-node pip installs are skipped during node operations.
|
||||
After all operations complete, `uv pip compile` resolves the full dependency graph in one batch.
|
||||
|
||||
* Requires `uv` to be installed.
|
||||
* Prevents dependency conflicts between multiple node packs.
|
||||
* On resolution failure, displays conflicting packages and which node packs requested them.
|
||||
* `reinstall --uv-compile` is mutually exclusive with `--no-deps`.
|
||||
|
||||
```bash
|
||||
cm-cli install ComfyUI-Impact-Pack ComfyUI-Inspire-Pack --uv-compile
|
||||
cm-cli update all --uv-compile
|
||||
cm-cli fix ComfyUI-Impact-Pack --uv-compile
|
||||
```
|
||||
|
||||
|
||||
### 4. Snapshot Management
|
||||
* `cm-cli save-snapshot [--output <snapshot .json/.yaml>]`: Saves the current snapshot.
|
||||
@@ -122,12 +142,33 @@ ComfyUI-Loopchain
|
||||
|
||||
### 5. Dependency Restoration
|
||||
|
||||
`restore-dependencies`
|
||||
`restore-dependencies ?[--uv-compile]`
|
||||
|
||||
* This command can be used if custom nodes are installed under the `ComfyUI/custom_nodes` path but their dependencies are not installed.
|
||||
* It is useful when starting a new cloud instance, like Colab, where dependencies need to be reinstalled and installation scripts re-executed.
|
||||
* It can also be utilized if ComfyUI is reinstalled and only the custom_nodes path has been backed up and restored.
|
||||
* Use `--uv-compile` to skip per-node pip installs and resolve all dependencies in one batch instead.
|
||||
|
||||
### 6. Clear
|
||||
### 6. Install from Dependency File
|
||||
|
||||
`install-deps <deps.json> ?[--channel <channel name>] ?[--mode [remote|local|cache]] ?[--uv-compile]`
|
||||
|
||||
* Installs custom nodes specified in a dependency spec file (`.json`) or workflow file (`.png`/`.json`).
|
||||
* Use `--uv-compile` to batch-resolve all dependencies after installation instead of per-node pip.
|
||||
|
||||
### 7. uv-compile
|
||||
|
||||
`uv-compile ?[--user-directory <path>]`
|
||||
|
||||
* Batch-resolves and installs all custom node pack dependencies using `uv pip compile`.
|
||||
* Useful for environment recovery or initial setup without starting ComfyUI.
|
||||
* Requires `uv` to be installed.
|
||||
|
||||
```bash
|
||||
cm-cli uv-compile
|
||||
cm-cli uv-compile --user-directory /path/to/comfyui
|
||||
```
|
||||
|
||||
### 8. Clear
|
||||
|
||||
In the GUI, installations, updates, or snapshot restorations are scheduled to execute the next time ComfyUI is launched. The `clear` command clears this scheduled state, ensuring no pre-execution actions are applied.
|
||||
|
||||
+43
-4
@@ -11,11 +11,15 @@ cm-cli [OPTIONS]
|
||||
|
||||
OPTIONS:
|
||||
[install|reinstall|uninstall|update|disable|enable|fix] node_name ... ?[--channel <channel name>] ?[--mode [remote|local|cache]]
|
||||
[install|reinstall|update|fix] node_name ... ?[--uv-compile]
|
||||
[update|disable|enable|fix] all ?[--channel <channel name>] ?[--mode [remote|local|cache]]
|
||||
[update|fix] all ?[--uv-compile]
|
||||
[simple-show|show] [installed|enabled|not-installed|disabled|all|snapshot|snapshot-list] ?[--channel <channel name>] ?[--mode [remote|local|cache]]
|
||||
save-snapshot ?[--output <snapshot .json/.yaml>]
|
||||
restore-snapshot <snapshot .json/.yaml> ?[--pip-non-url] ?[--pip-non-local-url] ?[--pip-local-url]
|
||||
restore-dependencies
|
||||
restore-snapshot <snapshot .json/.yaml> ?[--pip-non-url] ?[--pip-non-local-url] ?[--pip-local-url] ?[--uv-compile]
|
||||
restore-dependencies ?[--uv-compile]
|
||||
install-deps <deps.json> ?[--channel <channel name>] ?[--mode [remote|local|cache]] ?[--uv-compile]
|
||||
uv-compile
|
||||
clear
|
||||
```
|
||||
|
||||
@@ -108,6 +112,21 @@ ComfyUI-Loopchain
|
||||
* `enable`: 지정된 커스텀 노드들을 활성화합니다.
|
||||
* `fix`: 지정된 커스텀 노드의 의존성을 고치기 위한 시도를 합니다.
|
||||
|
||||
#### `--uv-compile` 플래그 (`install`, `reinstall`, `update`, `fix`)
|
||||
|
||||
`--uv-compile` 플래그를 사용하면 노드별 pip 설치를 건너뛰고, 모든 작업이 완료된 후 `uv pip compile`로 전체 의존성을 한 번에 일괄 해결합니다.
|
||||
|
||||
* `uv`가 설치된 환경에서만 동작합니다.
|
||||
* 여러 노드 팩 간의 의존성 충돌을 방지합니다.
|
||||
* 해결 실패 시 충돌 패키지와 해당 패키지를 요청한 노드 팩 목록을 표시합니다.
|
||||
* `reinstall --uv-compile`은 `--no-deps`와 동시에 사용할 수 없습니다.
|
||||
|
||||
```bash
|
||||
cm-cli install ComfyUI-Impact-Pack ComfyUI-Inspire-Pack --uv-compile
|
||||
cm-cli update all --uv-compile
|
||||
cm-cli fix ComfyUI-Impact-Pack --uv-compile
|
||||
```
|
||||
|
||||
|
||||
### 4. 스냅샷 관리 기능
|
||||
* `cm-cli save-snapshot ?[--output <snapshot .json/.yaml>]`: 현재의 snapshot을 저장합니다.
|
||||
@@ -123,13 +142,33 @@ ComfyUI-Loopchain
|
||||
|
||||
### 5. 의존성 설치
|
||||
|
||||
`restore-dependencies`
|
||||
`restore-dependencies ?[--uv-compile]`
|
||||
|
||||
* `ComfyUI/custom_nodes` 하위 경로에 커스텀 노드들이 설치되어 있긴 하지만, 의존성이 설치되지 않은 경우 사용할 수 있습니다.
|
||||
* Colab과 같이 cloud instance를 새로 시작하는 경우 의존성 재설치 및 설치 스크립트가 재실행되어야 하는 경우 사용합니다.
|
||||
* ComfyUI를 재설치할 경우, custom_nodes 경로만 백업했다가 재설치할 경우 활용 가능합니다.
|
||||
* `--uv-compile` 플래그를 사용하면 노드별 pip 설치를 건너뛰고 일괄 해결합니다.
|
||||
|
||||
### 6. 의존성 파일로 설치
|
||||
|
||||
### 6. clear
|
||||
`install-deps <deps.json> ?[--channel <channel name>] ?[--mode [remote|local|cache]] ?[--uv-compile]`
|
||||
|
||||
* 의존성 spec 파일(`.json`) 또는 워크플로우 파일(`.png`/`.json`)에 명시된 커스텀 노드를 설치합니다.
|
||||
* `--uv-compile` 플래그를 사용하면 모든 노드 설치 후 일괄 의존성 해결을 수행합니다.
|
||||
|
||||
### 7. uv-compile
|
||||
|
||||
`uv-compile ?[--user-directory <path>]`
|
||||
|
||||
* 설치된 모든 커스텀 노드 팩의 의존성을 `uv pip compile`로 일괄 해결하고 설치합니다.
|
||||
* ComfyUI를 재시작하지 않고 의존성 환경을 복구하거나 초기 설정 시 활용할 수 있습니다.
|
||||
* `uv`가 설치된 환경에서만 동작합니다.
|
||||
|
||||
```bash
|
||||
cm-cli uv-compile
|
||||
cm-cli uv-compile --user-directory /path/to/comfyui
|
||||
```
|
||||
|
||||
### 8. clear
|
||||
|
||||
GUI에서 install, update를 하거나 snapshot을 restore하는 경우 예약을 통해서 다음번 ComfyUI를 실행할 경우 실행되는 구조입니다. `clear` 는 이런 예약 상태를 clear해서, 아무런 사전 실행이 적용되지 않도록 합니다.
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
||||
[project]
|
||||
name = "comfyui-manager"
|
||||
license = { text = "GPL-3.0-only" }
|
||||
version = "4.1b2"
|
||||
version = "4.1b8"
|
||||
requires-python = ">= 3.9"
|
||||
description = "ComfyUI-Manager provides features to install and manage custom nodes for ComfyUI, as well as various functionalities to assist with ComfyUI."
|
||||
readme = "README.md"
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Minimal tests for git_helper standalone execution and get_backup_branch_name.
|
||||
|
||||
git_helper.py runs as a subprocess on Windows and must NOT import comfyui_manager.
|
||||
Tests validate: (1) no forbidden imports via AST, (2) subprocess-level standalone
|
||||
execution, (3) get_backup_branch_name behavioural correctness.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import pathlib
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
|
||||
import pytest
|
||||
|
||||
_GIT_HELPER_PATH = pathlib.Path(__file__).resolve().parents[2] / "comfyui_manager" / "common" / "git_helper.py"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. No comfyui_manager imports (AST check — no execution)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_no_comfyui_manager_import():
|
||||
"""git_helper.py must be free of comfyui_manager imports (Windows subprocess safety)."""
|
||||
tree = ast.parse(_GIT_HELPER_PATH.read_text(encoding="utf-8"))
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ImportFrom) and node.module and "comfyui_manager" in node.module:
|
||||
pytest.fail(f"Forbidden import at line {node.lineno}: from {node.module}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Standalone subprocess execution (mirrors the actual Windows bug scenario)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_standalone_subprocess_can_load(tmp_path):
|
||||
"""git_helper.py must load in a subprocess without comfyui_manager on sys.path."""
|
||||
(tmp_path / "folder_paths.py").touch()
|
||||
|
||||
# Run git_helper.py in a clean subprocess with stripped sys.path.
|
||||
# git_helper.py has a module-level sys.argv dispatcher that calls sys.exit,
|
||||
# so we set argv to --check with a dummy path to avoid IndexError, then
|
||||
# catch the SystemExit to verify the function loaded successfully.
|
||||
script = textwrap.dedent(f"""\
|
||||
import sys, os
|
||||
sys.path = [p for p in sys.path
|
||||
if "comfyui_manager" not in p and "comfyui-manager" not in p]
|
||||
os.environ["COMFYUI_PATH"] = {str(tmp_path)!r}
|
||||
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location("git_helper", {str(_GIT_HELPER_PATH)!r})
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
|
||||
# Intercept sys.exit from module-level argv dispatcher
|
||||
real_exit = sys.exit
|
||||
exit_code = [None]
|
||||
def fake_exit(code=0):
|
||||
exit_code[0] = code
|
||||
raise SystemExit(code)
|
||||
sys.exit = fake_exit
|
||||
|
||||
try:
|
||||
spec.loader.exec_module(mod)
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.exit = real_exit
|
||||
|
||||
# The function must be defined regardless of argv dispatcher outcome
|
||||
assert hasattr(mod, "get_backup_branch_name"), "Function not defined"
|
||||
name = mod.get_backup_branch_name()
|
||||
assert name.startswith("backup_"), f"Bad name: {{name}}"
|
||||
print(f"OK: {{name}}")
|
||||
""")
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", script],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
assert result.returncode == 0, f"stderr: {result.stderr}"
|
||||
assert "OK: backup_" in result.stdout
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. get_backup_branch_name behavioural tests (function extracted via AST)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def _get_backup_branch_name():
|
||||
"""Extract and compile get_backup_branch_name from git_helper.py source."""
|
||||
source = _GIT_HELPER_PATH.read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.FunctionDef) and node.name == "get_backup_branch_name":
|
||||
func_source = ast.get_source_segment(source, node)
|
||||
assert func_source is not None
|
||||
ns = {}
|
||||
exec(compile(ast.parse(func_source), "<test>", "exec"), ns) # noqa: S102
|
||||
return ns["get_backup_branch_name"]
|
||||
|
||||
pytest.fail("get_backup_branch_name not found in git_helper.py")
|
||||
|
||||
|
||||
class _FakeHead:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
|
||||
|
||||
class _FakeRepo:
|
||||
def __init__(self, branch_names):
|
||||
self.heads = [_FakeHead(n) for n in branch_names]
|
||||
|
||||
|
||||
def test_basic_name_format(_get_backup_branch_name):
|
||||
import time
|
||||
name = _get_backup_branch_name()
|
||||
assert name.startswith("backup_")
|
||||
expected = f"backup_{time.strftime('%Y%m%d_%H%M%S')}"
|
||||
assert name == expected
|
||||
|
||||
|
||||
def test_no_collision(_get_backup_branch_name):
|
||||
repo = _FakeRepo(["main", "dev"])
|
||||
name = _get_backup_branch_name(repo)
|
||||
assert name.startswith("backup_")
|
||||
|
||||
|
||||
def test_single_collision(_get_backup_branch_name):
|
||||
import time
|
||||
ts = time.strftime("%Y%m%d_%H%M%S")
|
||||
repo = _FakeRepo(["main", f"backup_{ts}"])
|
||||
name = _get_backup_branch_name(repo)
|
||||
assert name == f"backup_{ts}_1"
|
||||
|
||||
|
||||
def test_multi_collision(_get_backup_branch_name):
|
||||
import time
|
||||
ts = time.strftime("%Y%m%d_%H%M%S")
|
||||
repo = _FakeRepo([f"backup_{ts}", f"backup_{ts}_1", f"backup_{ts}_2"])
|
||||
name = _get_backup_branch_name(repo)
|
||||
assert name == f"backup_{ts}_3"
|
||||
|
||||
|
||||
def test_repo_none_returns_base(_get_backup_branch_name):
|
||||
name = _get_backup_branch_name(None)
|
||||
assert name.startswith("backup_")
|
||||
|
||||
|
||||
def test_repo_heads_exception_returns_base(_get_backup_branch_name):
|
||||
"""When repo.heads raises, fall back to base name without suffix."""
|
||||
|
||||
class _BrokenRepo:
|
||||
@property
|
||||
def heads(self):
|
||||
raise RuntimeError("simulated git error")
|
||||
|
||||
name = _get_backup_branch_name(_BrokenRepo())
|
||||
assert name.startswith("backup_")
|
||||
assert "_1" not in name # no suffix — exception path returns base
|
||||
|
||||
|
||||
def test_uuid_fallback_on_full_collision(_get_backup_branch_name):
|
||||
"""When all 99 suffixes are taken, fall back to UUID."""
|
||||
import time
|
||||
ts = time.strftime("%Y%m%d_%H%M%S")
|
||||
# All names from backup_TS through backup_TS_99 are taken
|
||||
taken = [f"backup_{ts}"] + [f"backup_{ts}_{i}" for i in range(1, 100)]
|
||||
repo = _FakeRepo(taken)
|
||||
name = _get_backup_branch_name(repo)
|
||||
assert name.startswith(f"backup_{ts}_")
|
||||
# UUID suffix is 6 hex chars
|
||||
suffix = name[len(f"backup_{ts}_"):]
|
||||
assert len(suffix) == 6, f"Expected 6-char UUID suffix, got: {suffix}"
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Cross-platform E2E environment setup for ComfyUI + Manager.
|
||||
|
||||
Creates an isolated ComfyUI installation with ComfyUI-Manager for E2E testing.
|
||||
Idempotent: skips setup if marker file and key artifacts already exist.
|
||||
|
||||
Input env vars:
|
||||
E2E_ROOT — target directory (required)
|
||||
MANAGER_ROOT — manager repo root (default: auto-detected)
|
||||
COMFYUI_BRANCH — ComfyUI branch to clone (default: master)
|
||||
|
||||
Output (last line of stdout):
|
||||
E2E_ROOT=/path/to/environment
|
||||
|
||||
Usage:
|
||||
python tests/e2e/scripts/setup_e2e_env.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
COMFYUI_REPO = "https://github.com/comfyanonymous/ComfyUI.git"
|
||||
PYTORCH_CPU_INDEX = "https://download.pytorch.org/whl/cpu"
|
||||
CONFIG_INI_CONTENT = """\
|
||||
[default]
|
||||
use_uv = true
|
||||
use_unified_resolver = true
|
||||
file_logging = false
|
||||
"""
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
print(f"[setup_e2e] {msg}", flush=True)
|
||||
|
||||
|
||||
def die(msg: str) -> None:
|
||||
print(f"[setup_e2e] ERROR: {msg}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def run(cmd: list[str], **kwargs) -> subprocess.CompletedProcess:
|
||||
log(f" $ {' '.join(cmd)}")
|
||||
return subprocess.run(cmd, check=True, **kwargs)
|
||||
|
||||
|
||||
def detect_manager_root() -> Path:
|
||||
"""Walk up from this script to find pyproject.toml."""
|
||||
d = Path(__file__).resolve().parent
|
||||
while d != d.parent:
|
||||
if (d / "pyproject.toml").exists():
|
||||
return d
|
||||
d = d.parent
|
||||
die("Cannot detect MANAGER_ROOT (no pyproject.toml found)")
|
||||
raise SystemExit(1) # unreachable, for type checker
|
||||
|
||||
|
||||
def venv_python(root: Path) -> str:
|
||||
if sys.platform == "win32":
|
||||
return str(root / "venv" / "Scripts" / "python.exe")
|
||||
return str(root / "venv" / "bin" / "python")
|
||||
|
||||
|
||||
def venv_bin(root: Path, name: str) -> str:
|
||||
if sys.platform == "win32":
|
||||
return str(root / "venv" / "Scripts" / f"{name}.exe")
|
||||
return str(root / "venv" / "bin" / name)
|
||||
|
||||
|
||||
def is_already_setup(root: Path, manager_root: Path) -> bool:
|
||||
marker = root / ".e2e_setup_complete"
|
||||
comfyui = root / "comfyui"
|
||||
venv = root / "venv"
|
||||
config = root / "comfyui" / "user" / "__manager" / "config.ini"
|
||||
manager_link = root / "comfyui" / "custom_nodes" / "ComfyUI-Manager"
|
||||
return (
|
||||
marker.exists()
|
||||
and comfyui.is_dir()
|
||||
and venv.is_dir()
|
||||
and config.exists()
|
||||
and (manager_link.exists() or manager_link.is_symlink())
|
||||
)
|
||||
|
||||
|
||||
def link_manager(custom_nodes: Path, manager_root: Path) -> None:
|
||||
"""Create symlink or junction to manager source."""
|
||||
link = custom_nodes / "ComfyUI-Manager"
|
||||
if link.exists() or link.is_symlink():
|
||||
if link.is_symlink():
|
||||
link.unlink()
|
||||
elif link.is_dir():
|
||||
import shutil
|
||||
shutil.rmtree(link)
|
||||
|
||||
if sys.platform == "win32":
|
||||
# Windows: use directory junction (no admin privileges needed)
|
||||
subprocess.run(
|
||||
["cmd", "/c", "mklink", "/J", str(link), str(manager_root)],
|
||||
check=True,
|
||||
)
|
||||
else:
|
||||
link.symlink_to(manager_root)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
manager_root = Path(os.environ.get("MANAGER_ROOT", "")) or detect_manager_root()
|
||||
manager_root = manager_root.resolve()
|
||||
log(f"MANAGER_ROOT={manager_root}")
|
||||
|
||||
e2e_root_str = os.environ.get("E2E_ROOT", "")
|
||||
if not e2e_root_str:
|
||||
die("E2E_ROOT environment variable is required")
|
||||
root = Path(e2e_root_str).resolve()
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
log(f"E2E_ROOT={root}")
|
||||
|
||||
branch = os.environ.get("COMFYUI_BRANCH", "master")
|
||||
|
||||
# Idempotency
|
||||
if is_already_setup(root, manager_root):
|
||||
log("Environment already set up (marker file exists). Skipping.")
|
||||
print(f"E2E_ROOT={root}")
|
||||
return
|
||||
|
||||
# Step 1: Clone ComfyUI
|
||||
comfyui_dir = root / "comfyui"
|
||||
if (comfyui_dir / ".git").is_dir():
|
||||
log("Step 1/7: ComfyUI already cloned, skipping")
|
||||
else:
|
||||
log(f"Step 1/7: Cloning ComfyUI (branch={branch})...")
|
||||
run(["git", "clone", "--depth=1", "--branch", branch, COMFYUI_REPO, str(comfyui_dir)])
|
||||
|
||||
# Step 2: Create venv
|
||||
venv_dir = root / "venv"
|
||||
if venv_dir.is_dir():
|
||||
log("Step 2/7: venv already exists, skipping")
|
||||
else:
|
||||
log("Step 2/7: Creating virtual environment...")
|
||||
run(["uv", "venv", str(venv_dir)])
|
||||
|
||||
py = venv_python(root)
|
||||
|
||||
# Step 3: Install ComfyUI dependencies (CPU-only)
|
||||
log("Step 3/7: Installing ComfyUI dependencies (CPU-only)...")
|
||||
run([
|
||||
"uv", "pip", "install",
|
||||
"--python", py,
|
||||
"-r", str(comfyui_dir / "requirements.txt"),
|
||||
"--extra-index-url", PYTORCH_CPU_INDEX,
|
||||
])
|
||||
|
||||
# Step 3.5: Ensure pip is available in the venv (Manager needs it for per-pack installs)
|
||||
log("Step 3.5: Ensuring pip is available...")
|
||||
run(["uv", "pip", "install", "--python", py, "pip"])
|
||||
|
||||
# Step 4: Install Manager
|
||||
log("Step 4/7: Installing ComfyUI-Manager...")
|
||||
run(["uv", "pip", "install", "--python", py, str(manager_root)])
|
||||
|
||||
# Step 5: Link manager into custom_nodes
|
||||
log("Step 5/7: Linking Manager into custom_nodes...")
|
||||
custom_nodes = comfyui_dir / "custom_nodes"
|
||||
custom_nodes.mkdir(parents=True, exist_ok=True)
|
||||
link_manager(custom_nodes, manager_root)
|
||||
|
||||
# Step 6: Write config.ini
|
||||
log("Step 6/7: Writing config.ini...")
|
||||
config_dir = comfyui_dir / "user" / "__manager"
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
(config_dir / "config.ini").write_text(CONFIG_INI_CONTENT)
|
||||
|
||||
# Step 7: Verify
|
||||
log("Step 7/7: Verifying setup...")
|
||||
errors = 0
|
||||
|
||||
if not (comfyui_dir / "main.py").exists():
|
||||
log(" FAIL: comfyui/main.py not found")
|
||||
errors += 1
|
||||
|
||||
if not os.path.isfile(py):
|
||||
log(f" FAIL: venv python not found at {py}")
|
||||
errors += 1
|
||||
|
||||
link = custom_nodes / "ComfyUI-Manager"
|
||||
if not link.exists():
|
||||
log(f" FAIL: Manager link not found at {link}")
|
||||
errors += 1
|
||||
|
||||
# Check cm-cli is installed
|
||||
cm_cli = venv_bin(root, "cm-cli")
|
||||
if not os.path.isfile(cm_cli):
|
||||
log(f" FAIL: cm-cli not found at {cm_cli}")
|
||||
errors += 1
|
||||
|
||||
if errors:
|
||||
die(f"Verification failed with {errors} error(s)")
|
||||
|
||||
log("Verification OK")
|
||||
|
||||
# Write marker
|
||||
from datetime import datetime
|
||||
(root / ".e2e_setup_complete").write_text(datetime.now().isoformat())
|
||||
|
||||
log("Setup complete.")
|
||||
print(f"E2E_ROOT={root}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,306 @@
|
||||
"""E2E tests for ComfyUI Manager HTTP API endpoints (install/uninstall).
|
||||
|
||||
Starts a real ComfyUI instance, exercises the task-queue-based install
|
||||
and uninstall endpoints, then verifies the results via the installed-list
|
||||
endpoint and filesystem checks.
|
||||
|
||||
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.
|
||||
|
||||
Install test methodology follows the main comfyui-manager test suite
|
||||
(tests/glob/test_queue_task_api.py):
|
||||
- Uses a CNR-registered package with proper version-based install
|
||||
- Verifies .tracking file for CNR installs
|
||||
- Checks installed-list API with cnr_id matching
|
||||
- Cleans up .disabled/ directory entries
|
||||
|
||||
Usage:
|
||||
E2E_ROOT=/tmp/e2e_full_test pytest tests/e2e/test_e2e_endpoint.py -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
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 ""
|
||||
SCRIPTS_DIR = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), "scripts"
|
||||
)
|
||||
|
||||
PORT = 8199
|
||||
BASE_URL = f"http://127.0.0.1:{PORT}"
|
||||
|
||||
# CNR-registered package with multiple versions, no heavy dependencies.
|
||||
# Same test package used by the main comfyui-manager test suite.
|
||||
PACK_ID = "ComfyUI_SigmoidOffsetScheduler"
|
||||
PACK_DIR_NAME = "ComfyUI_SigmoidOffsetScheduler"
|
||||
PACK_CNR_ID = "comfyui_sigmoidoffsetscheduler"
|
||||
PACK_VERSION = "1.0.1"
|
||||
|
||||
# Polling configuration for async task completion
|
||||
POLL_TIMEOUT = 30 # max seconds to wait for an operation
|
||||
POLL_INTERVAL = 0.5 # seconds between polls
|
||||
|
||||
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 _start_comfyui() -> int:
|
||||
"""Start ComfyUI and return its PID."""
|
||||
env = {**os.environ, "E2E_ROOT": E2E_ROOT, "PORT": str(PORT)}
|
||||
r = subprocess.run(
|
||||
["bash", os.path.join(SCRIPTS_DIR, "start_comfyui.sh")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=180,
|
||||
env=env,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
raise RuntimeError(f"Failed to start ComfyUI:\n{r.stderr}")
|
||||
for part in r.stdout.strip().split():
|
||||
if part.startswith("COMFYUI_PID="):
|
||||
return int(part.split("=")[1])
|
||||
raise RuntimeError(f"Could not parse PID from start_comfyui output:\n{r.stdout}")
|
||||
|
||||
|
||||
def _stop_comfyui():
|
||||
"""Stop ComfyUI."""
|
||||
env = {**os.environ, "E2E_ROOT": E2E_ROOT, "PORT": str(PORT)}
|
||||
subprocess.run(
|
||||
["bash", os.path.join(SCRIPTS_DIR, "stop_comfyui.sh")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
env=env,
|
||||
)
|
||||
|
||||
|
||||
def _queue_task(task: dict) -> None:
|
||||
"""Queue a task and start the worker."""
|
||||
resp = requests.post(
|
||||
f"{BASE_URL}/v2/manager/queue/task",
|
||||
json=task,
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
requests.get(f"{BASE_URL}/v2/manager/queue/start", timeout=10)
|
||||
|
||||
|
||||
def _remove_pack(name: str) -> None:
|
||||
"""Remove a node pack directory and any .disabled/ entries."""
|
||||
# Active directory
|
||||
path = os.path.join(CUSTOM_NODES, name)
|
||||
if os.path.islink(path):
|
||||
os.unlink(path)
|
||||
elif os.path.isdir(path):
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
# .disabled/ entries (CNR versioned + nightly)
|
||||
disabled_dir = os.path.join(CUSTOM_NODES, ".disabled")
|
||||
if os.path.isdir(disabled_dir):
|
||||
cnr_lower = name.lower().replace("_", "").replace("-", "")
|
||||
for entry in os.listdir(disabled_dir):
|
||||
entry_lower = entry.lower().replace("_", "").replace("-", "")
|
||||
if entry_lower.startswith(cnr_lower):
|
||||
entry_path = os.path.join(disabled_dir, entry)
|
||||
if os.path.isdir(entry_path):
|
||||
shutil.rmtree(entry_path, ignore_errors=True)
|
||||
|
||||
|
||||
def _pack_exists(name: str) -> bool:
|
||||
return os.path.isdir(os.path.join(CUSTOM_NODES, name))
|
||||
|
||||
|
||||
def _has_tracking(name: str) -> bool:
|
||||
"""Check if the pack has a .tracking file (CNR install marker)."""
|
||||
return os.path.isfile(os.path.join(CUSTOM_NODES, name, ".tracking"))
|
||||
|
||||
|
||||
def _wait_for(predicate, timeout=POLL_TIMEOUT, interval=POLL_INTERVAL):
|
||||
"""Poll *predicate* until it returns True or *timeout* seconds elapse.
|
||||
|
||||
Returns True if predicate was satisfied, False on timeout.
|
||||
"""
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(interval)
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def comfyui():
|
||||
"""Start ComfyUI once for the module, stop after all tests."""
|
||||
_remove_pack(PACK_DIR_NAME)
|
||||
pid = _start_comfyui()
|
||||
yield pid
|
||||
_stop_comfyui()
|
||||
_remove_pack(PACK_DIR_NAME)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEndpointInstallUninstall:
|
||||
"""Install and uninstall via HTTP endpoints on a running ComfyUI.
|
||||
|
||||
Follows the same methodology as tests/glob/test_queue_task_api.py in
|
||||
the main comfyui-manager repo: CNR version-based install, .tracking
|
||||
verification, installed-list API check.
|
||||
"""
|
||||
|
||||
def test_install_via_endpoint(self, comfyui):
|
||||
"""POST /v2/manager/queue/task (install) -> pack appears on disk with .tracking."""
|
||||
_remove_pack(PACK_DIR_NAME)
|
||||
|
||||
_queue_task({
|
||||
"ui_id": "e2e-install",
|
||||
"client_id": "e2e-install",
|
||||
"kind": "install",
|
||||
"params": {
|
||||
"id": PACK_ID,
|
||||
"version": PACK_VERSION,
|
||||
"selected_version": "latest",
|
||||
"mode": "remote",
|
||||
"channel": "default",
|
||||
},
|
||||
})
|
||||
assert _wait_for(
|
||||
lambda: _pack_exists(PACK_DIR_NAME),
|
||||
), f"{PACK_DIR_NAME} not found in custom_nodes within {POLL_TIMEOUT}s"
|
||||
assert _has_tracking(PACK_DIR_NAME), f"{PACK_DIR_NAME} missing .tracking (not a CNR install?)"
|
||||
|
||||
def test_installed_list_shows_pack(self, comfyui):
|
||||
"""GET /v2/customnode/installed includes the installed pack."""
|
||||
if not _pack_exists(PACK_DIR_NAME):
|
||||
pytest.skip("Pack not installed (previous test may have failed)")
|
||||
|
||||
resp = requests.get(f"{BASE_URL}/v2/customnode/installed", timeout=10)
|
||||
resp.raise_for_status()
|
||||
installed = resp.json()
|
||||
|
||||
# Match by cnr_id (case-insensitive) following main repo pattern
|
||||
package_found = any(
|
||||
pkg.get("cnr_id", "").lower() == PACK_CNR_ID.lower()
|
||||
for pkg in installed.values()
|
||||
if isinstance(pkg, dict) and pkg.get("cnr_id")
|
||||
)
|
||||
assert package_found, (
|
||||
f"{PACK_CNR_ID} not found in installed list: {list(installed.keys())}"
|
||||
)
|
||||
|
||||
def test_uninstall_via_endpoint(self, comfyui):
|
||||
"""POST /v2/manager/queue/task (uninstall) -> pack removed from disk."""
|
||||
if not _pack_exists(PACK_DIR_NAME):
|
||||
pytest.skip("Pack not installed (previous test may have failed)")
|
||||
|
||||
_queue_task({
|
||||
"ui_id": "e2e-uninstall",
|
||||
"client_id": "e2e-uninstall",
|
||||
"kind": "uninstall",
|
||||
"params": {
|
||||
"node_name": PACK_CNR_ID,
|
||||
},
|
||||
})
|
||||
assert _wait_for(
|
||||
lambda: not _pack_exists(PACK_DIR_NAME),
|
||||
), f"{PACK_DIR_NAME} still exists after uninstall ({POLL_TIMEOUT}s timeout)"
|
||||
|
||||
def test_installed_list_after_uninstall(self, comfyui):
|
||||
"""After uninstall, pack no longer appears in installed list."""
|
||||
if _pack_exists(PACK_DIR_NAME):
|
||||
pytest.skip("Pack still exists (previous test may have failed)")
|
||||
|
||||
resp = requests.get(f"{BASE_URL}/v2/customnode/installed", timeout=10)
|
||||
resp.raise_for_status()
|
||||
installed = resp.json()
|
||||
|
||||
package_found = any(
|
||||
pkg.get("cnr_id", "").lower() == PACK_CNR_ID.lower()
|
||||
for pkg in installed.values()
|
||||
if isinstance(pkg, dict) and pkg.get("cnr_id")
|
||||
)
|
||||
assert not package_found, f"{PACK_CNR_ID} still in installed list after uninstall"
|
||||
|
||||
def test_install_uninstall_cycle(self, comfyui):
|
||||
"""Complete install/uninstall cycle in a single test."""
|
||||
_remove_pack(PACK_DIR_NAME)
|
||||
|
||||
# Install
|
||||
_queue_task({
|
||||
"ui_id": "e2e-cycle-install",
|
||||
"client_id": "e2e-cycle",
|
||||
"kind": "install",
|
||||
"params": {
|
||||
"id": PACK_ID,
|
||||
"version": PACK_VERSION,
|
||||
"selected_version": "latest",
|
||||
"mode": "remote",
|
||||
"channel": "default",
|
||||
},
|
||||
})
|
||||
assert _wait_for(
|
||||
lambda: _pack_exists(PACK_DIR_NAME),
|
||||
), f"Pack not installed within {POLL_TIMEOUT}s"
|
||||
assert _has_tracking(PACK_DIR_NAME), "Pack missing .tracking"
|
||||
|
||||
# Verify in installed list
|
||||
resp = requests.get(f"{BASE_URL}/v2/customnode/installed", timeout=10)
|
||||
resp.raise_for_status()
|
||||
installed = resp.json()
|
||||
package_found = any(
|
||||
pkg.get("cnr_id", "").lower() == PACK_CNR_ID.lower()
|
||||
for pkg in installed.values()
|
||||
if isinstance(pkg, dict) and pkg.get("cnr_id")
|
||||
)
|
||||
assert package_found, f"{PACK_CNR_ID} not in installed list"
|
||||
|
||||
# Uninstall
|
||||
_queue_task({
|
||||
"ui_id": "e2e-cycle-uninstall",
|
||||
"client_id": "e2e-cycle",
|
||||
"kind": "uninstall",
|
||||
"params": {
|
||||
"node_name": PACK_CNR_ID,
|
||||
},
|
||||
})
|
||||
assert _wait_for(
|
||||
lambda: not _pack_exists(PACK_DIR_NAME),
|
||||
), f"Pack not uninstalled within {POLL_TIMEOUT}s"
|
||||
|
||||
|
||||
class TestEndpointStartup:
|
||||
"""Verify ComfyUI startup with unified resolver."""
|
||||
|
||||
def test_comfyui_started(self, comfyui):
|
||||
"""ComfyUI is running and responds to health check."""
|
||||
resp = requests.get(f"{BASE_URL}/system_stats", timeout=10)
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_startup_resolver_ran(self, comfyui):
|
||||
"""Startup log contains unified resolver output."""
|
||||
log_path = os.path.join(E2E_ROOT, "logs", "comfyui.log")
|
||||
with open(log_path) as f:
|
||||
log = f.read()
|
||||
assert "[UnifiedDepResolver]" in log
|
||||
assert "startup batch resolution succeeded" in log
|
||||
@@ -0,0 +1,273 @@
|
||||
"""E2E tests for git-clone-based node installation via ComfyUI Manager API.
|
||||
|
||||
Starts a real ComfyUI instance and installs custom nodes by URL (nightly mode),
|
||||
which triggers git_helper.py as a subprocess. This is the code path that crashed
|
||||
on Windows with ModuleNotFoundError (Phase 1) and exit 128 (Phase 2).
|
||||
|
||||
Requires a pre-built E2E environment (from setup_e2e_env.py).
|
||||
Set E2E_ROOT env var to point at it, or the tests will be skipped.
|
||||
|
||||
Supply-chain safety policy:
|
||||
Only install from verified, controllable authors (ltdrdata).
|
||||
|
||||
Usage:
|
||||
E2E_ROOT=/tmp/e2e_full_test pytest tests/e2e/test_e2e_git_clone.py -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
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 ""
|
||||
|
||||
PORT = 8198 # Different port from endpoint tests to avoid conflicts
|
||||
BASE_URL = f"http://127.0.0.1:{PORT}"
|
||||
|
||||
REPO_TEST1 = "https://github.com/ltdrdata/nodepack-test1-do-not-install"
|
||||
PACK_TEST1 = "nodepack-test1-do-not-install"
|
||||
|
||||
POLL_TIMEOUT = 60
|
||||
POLL_INTERVAL = 1.0
|
||||
|
||||
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",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_comfyui_proc: subprocess.Popen | None = None
|
||||
|
||||
|
||||
def _venv_python() -> str:
|
||||
if sys.platform == "win32":
|
||||
return os.path.join(E2E_ROOT, "venv", "Scripts", "python.exe")
|
||||
return os.path.join(E2E_ROOT, "venv", "bin", "python")
|
||||
|
||||
|
||||
def _cm_cli_path() -> str:
|
||||
if sys.platform == "win32":
|
||||
return os.path.join(E2E_ROOT, "venv", "Scripts", "cm-cli.exe")
|
||||
return os.path.join(E2E_ROOT, "venv", "bin", "cm-cli")
|
||||
|
||||
|
||||
def _ensure_cache():
|
||||
"""Run cm-cli update-cache (blocking) to populate Manager cache before tests."""
|
||||
env = {**os.environ, "COMFYUI_PATH": COMFYUI_PATH}
|
||||
r = subprocess.run(
|
||||
[_cm_cli_path(), "update-cache"],
|
||||
capture_output=True, text=True, timeout=120, env=env,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
raise RuntimeError(f"update-cache failed:\n{r.stderr}")
|
||||
|
||||
|
||||
def _start_comfyui() -> int:
|
||||
"""Start ComfyUI via Popen (cross-platform, no bash dependency)."""
|
||||
global _comfyui_proc # noqa: PLW0603
|
||||
log_dir = os.path.join(E2E_ROOT, "logs")
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
log_file = open(os.path.join(log_dir, "comfyui.log"), "w") # noqa: SIM115
|
||||
|
||||
env = {
|
||||
**os.environ,
|
||||
"COMFYUI_PATH": COMFYUI_PATH,
|
||||
"PYTHONUNBUFFERED": "1",
|
||||
}
|
||||
_comfyui_proc = subprocess.Popen(
|
||||
[_venv_python(), "-u", os.path.join(COMFYUI_PATH, "main.py"),
|
||||
"--listen", "127.0.0.1", "--port", str(PORT),
|
||||
"--cpu", "--enable-manager"],
|
||||
stdout=log_file, stderr=subprocess.STDOUT,
|
||||
env=env,
|
||||
)
|
||||
# Wait for server to be ready.
|
||||
# Manager may restart ComfyUI after startup dependency install (exit 0 → re-launch).
|
||||
# If the process exits with code 0, keep polling — the restarted process will bind the port.
|
||||
deadline = time.monotonic() + 120
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
r = requests.get(f"{BASE_URL}/system_stats", timeout=2)
|
||||
if r.status_code == 200:
|
||||
return _comfyui_proc.pid
|
||||
except requests.ConnectionError:
|
||||
pass
|
||||
if _comfyui_proc.poll() is not None:
|
||||
if _comfyui_proc.returncode != 0:
|
||||
log_file.close()
|
||||
log_content = open(os.path.join(log_dir, "comfyui.log")).read()[-2000:] # noqa: SIM115
|
||||
raise RuntimeError(
|
||||
f"ComfyUI exited with code {_comfyui_proc.returncode}:\n{log_content}"
|
||||
)
|
||||
# exit 0 = Manager restart. Keep polling for the restarted process.
|
||||
time.sleep(1)
|
||||
log_file.close()
|
||||
log_content = open(os.path.join(log_dir, "comfyui.log")).read()[-2000:] # noqa: SIM115
|
||||
raise RuntimeError(f"ComfyUI did not start within 120s. Log:\n{log_content}")
|
||||
|
||||
|
||||
def _stop_comfyui():
|
||||
"""Stop ComfyUI process."""
|
||||
global _comfyui_proc # noqa: PLW0603
|
||||
if _comfyui_proc is None:
|
||||
return
|
||||
_comfyui_proc.terminate()
|
||||
try:
|
||||
_comfyui_proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
_comfyui_proc.kill()
|
||||
_comfyui_proc = None
|
||||
|
||||
|
||||
def _queue_task(task: dict) -> None:
|
||||
"""Queue a Manager task and start the worker."""
|
||||
resp = requests.post(f"{BASE_URL}/v2/manager/queue/task", json=task, timeout=10)
|
||||
resp.raise_for_status()
|
||||
requests.get(f"{BASE_URL}/v2/manager/queue/start", timeout=10)
|
||||
|
||||
|
||||
def _remove_pack(name: str) -> None:
|
||||
"""Remove a node pack from custom_nodes.
|
||||
|
||||
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
|
||||
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))
|
||||
|
||||
|
||||
def _wait_for(predicate, timeout=POLL_TIMEOUT, interval=POLL_INTERVAL):
|
||||
"""Poll predicate until True or timeout."""
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(interval)
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def comfyui():
|
||||
"""Populate cache, start ComfyUI, stop after all tests."""
|
||||
_remove_pack(PACK_TEST1)
|
||||
_ensure_cache()
|
||||
pid = _start_comfyui()
|
||||
yield pid
|
||||
_stop_comfyui()
|
||||
_remove_pack(PACK_TEST1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: nightly (URL) install via Manager API → git_helper.py subprocess
|
||||
#
|
||||
# Single sequential test to avoid autouse cleanup races. The task queue
|
||||
# is async so we poll for completion between steps.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_INSTALL_PARAMS = {
|
||||
"id": PACK_TEST1,
|
||||
"selected_version": "nightly",
|
||||
"mode": "remote",
|
||||
"channel": "default",
|
||||
"repository": REPO_TEST1,
|
||||
"version": "1.0.0",
|
||||
}
|
||||
|
||||
|
||||
class TestNightlyInstallCycle:
|
||||
"""Full nightly install/verify/uninstall cycle in one test class.
|
||||
|
||||
Tests MUST run in order (install → verify → uninstall). pytest preserves
|
||||
method definition order within a class.
|
||||
"""
|
||||
|
||||
def test_01_nightly_install(self, comfyui):
|
||||
"""Nightly install should git-clone the repo into custom_nodes."""
|
||||
_remove_pack(PACK_TEST1)
|
||||
assert not _pack_exists(PACK_TEST1), (
|
||||
f"Failed to clean {PACK_TEST1} — file locks may be holding the directory"
|
||||
)
|
||||
|
||||
_queue_task({
|
||||
"ui_id": "e2e-nightly-install",
|
||||
"client_id": "e2e-nightly",
|
||||
"kind": "install",
|
||||
"params": _INSTALL_PARAMS,
|
||||
})
|
||||
|
||||
assert _wait_for(lambda: _pack_exists(PACK_TEST1)), (
|
||||
f"{PACK_TEST1} not cloned within {POLL_TIMEOUT}s"
|
||||
)
|
||||
|
||||
# Verify .git directory exists (git clone, not zip download)
|
||||
git_dir = os.path.join(CUSTOM_NODES, PACK_TEST1, ".git")
|
||||
assert os.path.isdir(git_dir), "No .git directory — not a git clone"
|
||||
|
||||
def test_02_no_module_error(self, comfyui):
|
||||
"""Server log must not contain ModuleNotFoundError (Phase 1 regression)."""
|
||||
log_path = os.path.join(E2E_ROOT, "logs", "comfyui.log")
|
||||
if not os.path.isfile(log_path):
|
||||
pytest.skip("Log file not found (server may use different log path)")
|
||||
|
||||
with open(log_path) as f:
|
||||
log = f.read()
|
||||
assert "ModuleNotFoundError" not in log, (
|
||||
"ModuleNotFoundError in server log — git_helper.py import isolation broken"
|
||||
)
|
||||
|
||||
def test_03_nightly_uninstall(self, comfyui):
|
||||
"""Uninstall the nightly-installed pack."""
|
||||
if not _pack_exists(PACK_TEST1):
|
||||
pytest.skip("Pack not installed (previous test may have failed)")
|
||||
|
||||
_queue_task({
|
||||
"ui_id": "e2e-nightly-uninst",
|
||||
"client_id": "e2e-nightly",
|
||||
"kind": "uninstall",
|
||||
"params": {
|
||||
"node_name": PACK_TEST1,
|
||||
},
|
||||
})
|
||||
assert _wait_for(lambda: not _pack_exists(PACK_TEST1)), (
|
||||
f"{PACK_TEST1} still exists after uninstall ({POLL_TIMEOUT}s timeout)"
|
||||
)
|
||||
@@ -0,0 +1,325 @@
|
||||
"""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
|
||||
@@ -0,0 +1,325 @@
|
||||
"""Unit tests for CNR fallback in install_by_id nightly path and getattr guard.
|
||||
|
||||
Tests two targeted bug fixes:
|
||||
1. install_by_id nightly: falls back to cnr_map when custom_nodes lookup fails
|
||||
2. do_uninstall/do_disable: getattr guard prevents AttributeError on Union mismatch
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import types
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Minimal stubs — avoid importing the full ComfyUI runtime
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _ManagedResult:
|
||||
"""Minimal ManagedResult stub matching glob/manager_core.py."""
|
||||
|
||||
def __init__(self, action):
|
||||
self.action = action
|
||||
self.result = True
|
||||
self.msg = None
|
||||
self.target = None
|
||||
|
||||
def fail(self, msg):
|
||||
self.result = False
|
||||
self.msg = msg
|
||||
return self
|
||||
|
||||
def with_target(self, target):
|
||||
self.target = target
|
||||
return self
|
||||
|
||||
|
||||
class _NormalizedKeyDict:
|
||||
"""Minimal NormalizedKeyDict stub matching glob/manager_core.py."""
|
||||
|
||||
def __init__(self):
|
||||
self._store = {}
|
||||
self._key_map = {}
|
||||
|
||||
def _normalize_key(self, key):
|
||||
return key.strip().lower() if isinstance(key, str) else key
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
norm = self._normalize_key(key)
|
||||
self._key_map[norm] = key
|
||||
self._store[key] = value
|
||||
|
||||
def __getitem__(self, key):
|
||||
norm = self._normalize_key(key)
|
||||
return self._store[self._key_map[norm]]
|
||||
|
||||
def __contains__(self, key):
|
||||
return self._normalize_key(key) in self._key_map
|
||||
|
||||
def get(self, key, default=None):
|
||||
return self[key] if key in self else default
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Test 1: CNR fallback in install_by_id nightly path
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestNightlyCnrFallback:
|
||||
"""install_by_id with version_spec='nightly' should fall back to cnr_map
|
||||
when custom_nodes lookup returns None for the node_id."""
|
||||
|
||||
def _make_manager(self, cnr_map_entries=None, custom_nodes_entries=None):
|
||||
"""Create a minimal UnifiedManager-like object with the install_by_id
|
||||
nightly fallback logic extracted for unit testing."""
|
||||
mgr = types.SimpleNamespace()
|
||||
mgr.cnr_map = _NormalizedKeyDict()
|
||||
if cnr_map_entries:
|
||||
for k, v in cnr_map_entries.items():
|
||||
mgr.cnr_map[k] = v
|
||||
|
||||
# Mock get_custom_nodes to return a NormalizedKeyDict
|
||||
custom_nodes = _NormalizedKeyDict()
|
||||
if custom_nodes_entries:
|
||||
for k, v in custom_nodes_entries.items():
|
||||
custom_nodes[k] = v
|
||||
|
||||
async def get_custom_nodes(channel=None, mode=None):
|
||||
return custom_nodes
|
||||
|
||||
mgr.get_custom_nodes = get_custom_nodes
|
||||
|
||||
# Stubs for is_enabled/is_disabled that always return False (not installed)
|
||||
mgr.is_enabled = lambda *a, **kw: False
|
||||
mgr.is_disabled = lambda *a, **kw: False
|
||||
|
||||
return mgr
|
||||
|
||||
@staticmethod
|
||||
async def _run_nightly_lookup(mgr, node_id, channel='default', mode='remote'):
|
||||
"""Execute the nightly lookup logic from install_by_id.
|
||||
|
||||
Reproduces lines ~1407-1431 of glob/manager_core.py to test the
|
||||
CNR fallback path in isolation.
|
||||
"""
|
||||
version_spec = 'nightly'
|
||||
repo_url = None
|
||||
|
||||
custom_nodes = await mgr.get_custom_nodes(channel, mode)
|
||||
the_node = custom_nodes.get(node_id)
|
||||
|
||||
if the_node is not None:
|
||||
repo_url = the_node['repository']
|
||||
else:
|
||||
# Fallback for nightly only: use repository URL from CNR map
|
||||
if version_spec == 'nightly':
|
||||
cnr_fallback = mgr.cnr_map.get(node_id)
|
||||
if cnr_fallback is not None and cnr_fallback.get('repository'):
|
||||
repo_url = cnr_fallback['repository']
|
||||
else:
|
||||
result = _ManagedResult('install')
|
||||
return result.fail(
|
||||
f"Node '{node_id}@{version_spec}' not found in [{channel}, {mode}]"
|
||||
)
|
||||
|
||||
return repo_url
|
||||
|
||||
def test_fallback_to_cnr_map_when_custom_nodes_missing(self):
|
||||
"""Node absent from custom_nodes but present in cnr_map -> uses cnr_map repo URL."""
|
||||
mgr = self._make_manager(
|
||||
cnr_map_entries={
|
||||
'my-test-pack': {
|
||||
'id': 'my-test-pack',
|
||||
'repository': 'https://github.com/test/my-test-pack',
|
||||
'publisher': 'testuser',
|
||||
},
|
||||
},
|
||||
custom_nodes_entries={}, # empty — node not in nightly manifest
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
self._run_nightly_lookup(mgr, 'my-test-pack')
|
||||
)
|
||||
assert result == 'https://github.com/test/my-test-pack'
|
||||
|
||||
def test_fallback_fails_when_cnr_map_also_missing(self):
|
||||
"""Node absent from both custom_nodes and cnr_map -> ManagedResult.fail."""
|
||||
mgr = self._make_manager(
|
||||
cnr_map_entries={},
|
||||
custom_nodes_entries={},
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
self._run_nightly_lookup(mgr, 'nonexistent-pack')
|
||||
)
|
||||
assert isinstance(result, _ManagedResult)
|
||||
assert result.result is False
|
||||
assert 'nonexistent-pack@nightly' in result.msg
|
||||
|
||||
def test_fallback_fails_when_cnr_entry_has_no_repository(self):
|
||||
"""Node in cnr_map but repository is None/empty -> ManagedResult.fail."""
|
||||
mgr = self._make_manager(
|
||||
cnr_map_entries={
|
||||
'no-repo-pack': {
|
||||
'id': 'no-repo-pack',
|
||||
'repository': None,
|
||||
'publisher': 'testuser',
|
||||
},
|
||||
},
|
||||
custom_nodes_entries={},
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
self._run_nightly_lookup(mgr, 'no-repo-pack')
|
||||
)
|
||||
assert isinstance(result, _ManagedResult)
|
||||
assert result.result is False
|
||||
|
||||
def test_fallback_fails_when_cnr_entry_has_empty_repository(self):
|
||||
"""Node in cnr_map but repository is '' -> ManagedResult.fail (truthy check)."""
|
||||
mgr = self._make_manager(
|
||||
cnr_map_entries={
|
||||
'empty-repo-pack': {
|
||||
'id': 'empty-repo-pack',
|
||||
'repository': '',
|
||||
'publisher': 'testuser',
|
||||
},
|
||||
},
|
||||
custom_nodes_entries={},
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
self._run_nightly_lookup(mgr, 'empty-repo-pack')
|
||||
)
|
||||
assert isinstance(result, _ManagedResult)
|
||||
assert result.result is False
|
||||
|
||||
def test_direct_custom_nodes_hit_skips_cnr_fallback(self):
|
||||
"""Node present in custom_nodes -> uses custom_nodes directly, no fallback needed."""
|
||||
mgr = self._make_manager(
|
||||
cnr_map_entries={
|
||||
'found-pack': {
|
||||
'id': 'found-pack',
|
||||
'repository': 'https://github.com/test/found-cnr',
|
||||
},
|
||||
},
|
||||
custom_nodes_entries={
|
||||
'found-pack': {
|
||||
'repository': 'https://github.com/test/found-custom',
|
||||
'files': ['https://github.com/test/found-custom'],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
self._run_nightly_lookup(mgr, 'found-pack')
|
||||
)
|
||||
# Should use custom_nodes repo URL, NOT cnr_map
|
||||
assert result == 'https://github.com/test/found-custom'
|
||||
|
||||
def test_unknown_version_spec_does_not_use_cnr_fallback(self):
|
||||
"""version_spec='unknown' path should NOT use cnr_map fallback."""
|
||||
mgr = self._make_manager(
|
||||
cnr_map_entries={
|
||||
'unknown-pack': {
|
||||
'id': 'unknown-pack',
|
||||
'repository': 'https://github.com/test/unknown-pack',
|
||||
},
|
||||
},
|
||||
custom_nodes_entries={},
|
||||
)
|
||||
|
||||
async def _run_unknown_lookup():
|
||||
version_spec = 'unknown'
|
||||
custom_nodes = await mgr.get_custom_nodes()
|
||||
the_node = custom_nodes.get('unknown-pack')
|
||||
|
||||
if the_node is not None:
|
||||
return the_node['files'][0]
|
||||
else:
|
||||
if version_spec == 'nightly':
|
||||
# This branch should NOT be taken for 'unknown'
|
||||
cnr_fallback = mgr.cnr_map.get('unknown-pack')
|
||||
if cnr_fallback is not None and cnr_fallback.get('repository'):
|
||||
return cnr_fallback['repository']
|
||||
# Fall through to error for 'unknown'
|
||||
result = _ManagedResult('install')
|
||||
return result.fail(
|
||||
f"Node 'unknown-pack@{version_spec}' not found"
|
||||
)
|
||||
|
||||
result = asyncio.run(_run_unknown_lookup())
|
||||
assert isinstance(result, _ManagedResult)
|
||||
assert result.result is False
|
||||
assert 'unknown' in result.msg
|
||||
|
||||
def test_case_insensitive_cnr_map_lookup(self):
|
||||
"""CNR map uses NormalizedKeyDict — lookup should be case-insensitive."""
|
||||
mgr = self._make_manager(
|
||||
cnr_map_entries={
|
||||
'My-Test-Pack': {
|
||||
'id': 'my-test-pack',
|
||||
'repository': 'https://github.com/test/my-test-pack',
|
||||
},
|
||||
},
|
||||
custom_nodes_entries={},
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
self._run_nightly_lookup(mgr, 'my-test-pack')
|
||||
)
|
||||
assert result == 'https://github.com/test/my-test-pack'
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Test 2: getattr guard in do_uninstall / do_disable
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestGetAttrGuard:
|
||||
"""do_uninstall and do_disable use getattr(params, 'is_unknown', False)
|
||||
to guard against pydantic Union matching UpdatePackParams (which lacks
|
||||
is_unknown field) instead of UninstallPackParams/DisablePackParams."""
|
||||
|
||||
def test_getattr_on_object_with_is_unknown(self):
|
||||
"""Normal case: params has is_unknown -> returns its value."""
|
||||
params = types.SimpleNamespace(node_name='test-pack', is_unknown=True)
|
||||
assert getattr(params, 'is_unknown', False) is True
|
||||
|
||||
def test_getattr_on_object_without_is_unknown(self):
|
||||
"""Bug case: params is UpdatePackParams-like (no is_unknown) -> returns False."""
|
||||
params = types.SimpleNamespace(node_name='test-pack', node_ver='1.0.0')
|
||||
# Without getattr guard, this would be: params.is_unknown -> AttributeError
|
||||
assert getattr(params, 'is_unknown', False) is False
|
||||
|
||||
def test_getattr_default_false_on_missing_attribute(self):
|
||||
"""Minimal case: bare object with only node_name."""
|
||||
params = types.SimpleNamespace(node_name='test-pack')
|
||||
assert getattr(params, 'is_unknown', False) is False
|
||||
|
||||
def test_pydantic_union_matching_demonstrates_bug(self):
|
||||
"""Demonstrate why getattr is needed: pydantic Union without discriminator
|
||||
can match UpdatePackParams for uninstall/disable payloads."""
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, Union
|
||||
|
||||
class UpdateLike(BaseModel):
|
||||
node_name: str
|
||||
node_ver: Optional[str] = None
|
||||
|
||||
class UninstallLike(BaseModel):
|
||||
node_name: str
|
||||
is_unknown: Optional[bool] = Field(False)
|
||||
|
||||
# When Union tries to match {"node_name": "foo"}, UpdateLike matches first
|
||||
# because it has fewer required fields and node_name satisfies it
|
||||
class TaskItem(BaseModel):
|
||||
params: Union[UpdateLike, UninstallLike]
|
||||
|
||||
item = TaskItem(params={"node_name": "foo"})
|
||||
|
||||
# The matched type may be UpdateLike (no is_unknown attribute)
|
||||
# This is the exact scenario the getattr guard protects against
|
||||
is_unknown = getattr(item.params, 'is_unknown', False)
|
||||
# Regardless of which Union member matched, getattr safely returns a value
|
||||
assert isinstance(is_unknown, bool)
|
||||
@@ -244,6 +244,26 @@ class TestCollectRequirements:
|
||||
deps = r.collect_requirements()
|
||||
assert len(deps.sources["numpy"]) == 2
|
||||
|
||||
def test_sources_stores_pack_path_and_spec_tuple(self, tmp_path):
|
||||
"""sources entries must be (pack_path, pkg_spec) tuples."""
|
||||
p = _make_node_pack(str(tmp_path), "pack_a", "numpy>=1.20\n")
|
||||
r = _resolver([p])
|
||||
deps = r.collect_requirements()
|
||||
entries = deps.sources["numpy"]
|
||||
assert len(entries) == 1
|
||||
pack_path, pkg_spec = entries[0]
|
||||
assert pack_path == p
|
||||
assert pkg_spec == "numpy>=1.20"
|
||||
|
||||
def test_sources_captures_spec_per_requester(self, tmp_path):
|
||||
"""Each requester's exact spec is preserved independently."""
|
||||
p1 = _make_node_pack(str(tmp_path), "pack_a", "torch>=2.1\n")
|
||||
p2 = _make_node_pack(str(tmp_path), "pack_b", "torch<2.0\n")
|
||||
r = _resolver([p1, p2])
|
||||
deps = r.collect_requirements()
|
||||
specs = {pkg_spec for _, pkg_spec in deps.sources["torch"]}
|
||||
assert specs == {"torch>=2.1", "torch<2.0"}
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Input sanitization
|
||||
@@ -883,6 +903,106 @@ class TestResolveAndInstall:
|
||||
assert not result.success
|
||||
assert "compile failed" in result.error
|
||||
|
||||
def test_compile_failure_result_includes_collected(self, tmp_path):
|
||||
"""result.collected must be populated on compile failure for conflict attribution."""
|
||||
p = _make_node_pack(str(tmp_path), "pack_a", "torch>=2.1\n")
|
||||
r = _resolver([p])
|
||||
|
||||
with mock.patch.object(r, "_get_uv_cmd", return_value=["uv"]):
|
||||
with mock.patch("subprocess.run", return_value=subprocess.CompletedProcess(
|
||||
[], 1, stdout="",
|
||||
stderr="error: Because torch>=2.1 conflicts with torch<2.0, no solution found.",
|
||||
)):
|
||||
result = r.resolve_and_install()
|
||||
|
||||
assert not result.success
|
||||
assert result.collected is not None
|
||||
assert result.lockfile is not None
|
||||
assert result.lockfile.conflicts # conflict lines present for attribution
|
||||
|
||||
def test_conflict_attribution_sources_filter(self, tmp_path):
|
||||
"""Packages named in conflict lines can be looked up from sources."""
|
||||
from comfyui_manager.common.unified_dep_resolver import attribute_conflicts
|
||||
p1 = _make_node_pack(str(tmp_path), "pack_a", "torch>=2.1\n")
|
||||
p2 = _make_node_pack(str(tmp_path), "pack_b", "torch<2.0\n")
|
||||
r = _resolver([p1, p2])
|
||||
|
||||
conflict_text = "error: torch>=2.1 conflicts with torch<2.0"
|
||||
|
||||
with mock.patch.object(r, "_get_uv_cmd", return_value=["uv"]):
|
||||
with mock.patch("subprocess.run", return_value=subprocess.CompletedProcess(
|
||||
[], 1, stdout="", stderr=conflict_text,
|
||||
)):
|
||||
result = r.resolve_and_install()
|
||||
|
||||
assert not result.success
|
||||
assert result.collected is not None
|
||||
attributed = attribute_conflicts(result.collected.sources, result.lockfile.conflicts)
|
||||
assert "torch" in attributed
|
||||
specs = {spec for _, spec in attributed["torch"]}
|
||||
assert specs == {"torch>=2.1", "torch<2.0"}
|
||||
|
||||
def test_conflict_attribution_no_false_positive_on_underscore_prefix(self, tmp_path):
|
||||
"""'torch' must NOT match 'torch_audio' in conflict text (underscore boundary)."""
|
||||
from comfyui_manager.common.unified_dep_resolver import attribute_conflicts
|
||||
p = _make_node_pack(str(tmp_path), "pack_a", "torch>=2.1\n")
|
||||
r = _resolver([p])
|
||||
|
||||
conflict_text = "error: torch_audio>=2.1 conflicts with torch_audio<2.0"
|
||||
|
||||
with mock.patch.object(r, "_get_uv_cmd", return_value=["uv"]):
|
||||
with mock.patch("subprocess.run", return_value=subprocess.CompletedProcess(
|
||||
[], 1, stdout="", stderr=conflict_text,
|
||||
)):
|
||||
result = r.resolve_and_install()
|
||||
|
||||
assert not result.success
|
||||
assert result.collected is not None
|
||||
attributed = attribute_conflicts(result.collected.sources, result.lockfile.conflicts)
|
||||
# 'torch' should NOT match: conflict only mentions 'torch_audio'
|
||||
assert "torch" not in attributed
|
||||
|
||||
def test_conflict_attribution_no_false_positive_on_prefix_match(self, tmp_path):
|
||||
"""'torch' must NOT match 'torchvision' in conflict text (word boundary)."""
|
||||
from comfyui_manager.common.unified_dep_resolver import attribute_conflicts
|
||||
p = _make_node_pack(str(tmp_path), "pack_a", "torch>=2.1\n")
|
||||
r = _resolver([p])
|
||||
|
||||
conflict_text = "error: torchvision>=0.16 conflicts with torchvision<0.15"
|
||||
|
||||
with mock.patch.object(r, "_get_uv_cmd", return_value=["uv"]):
|
||||
with mock.patch("subprocess.run", return_value=subprocess.CompletedProcess(
|
||||
[], 1, stdout="", stderr=conflict_text,
|
||||
)):
|
||||
result = r.resolve_and_install()
|
||||
|
||||
assert not result.success
|
||||
assert result.collected is not None
|
||||
attributed = attribute_conflicts(result.collected.sources, result.lockfile.conflicts)
|
||||
# 'torch' should NOT appear: conflict only mentions 'torchvision'
|
||||
assert "torch" not in attributed
|
||||
|
||||
def test_conflict_attribution_hyphen_underscore_normalization(self, tmp_path):
|
||||
"""Packages stored with hyphens match conflict text using underscores."""
|
||||
from comfyui_manager.common.unified_dep_resolver import attribute_conflicts
|
||||
p = _make_node_pack(str(tmp_path), "pack_a", "torch-audio>=2.1\n")
|
||||
r = _resolver([p])
|
||||
|
||||
# uv may print 'torch_audio' (underscore) in conflict output
|
||||
conflict_text = "error: torch_audio>=2.1 conflicts with torch_audio<2.0"
|
||||
|
||||
with mock.patch.object(r, "_get_uv_cmd", return_value=["uv"]):
|
||||
with mock.patch("subprocess.run", return_value=subprocess.CompletedProcess(
|
||||
[], 1, stdout="", stderr=conflict_text,
|
||||
)):
|
||||
result = r.resolve_and_install()
|
||||
|
||||
assert not result.success
|
||||
assert result.collected is not None
|
||||
attributed = attribute_conflicts(result.collected.sources, result.lockfile.conflicts)
|
||||
# _extract_package_name normalizes 'torch-audio' → 'torch_audio'; uv uses underscores too
|
||||
assert "torch_audio" in attributed
|
||||
|
||||
def test_full_success_pipeline(self, tmp_path):
|
||||
p = _make_node_pack(str(tmp_path), "pack_a", "numpy>=1.20\n")
|
||||
r = _resolver([p])
|
||||
|
||||
Reference in New Issue
Block a user