From 82e6744f8222a635b9713a283ee17391b9a14f23 Mon Sep 17 00:00:00 2001 From: "Dr.Lt.Data" Date: Tue, 24 Mar 2026 20:19:03 +0900 Subject: [PATCH] feat: add ComfyUI-Manager v4 support, uv-sync, and unified dependency resolution Manager v4 is now installed as a pip package (via manager_requirements.txt) rather than being git-cloned into custom_nodes/. Breaking changes: - Remove --manager-url and --manager-commit options from comfy install. Users who need a custom Manager should use --skip-manager and install their own wheel into the workspace venv. New features: - Manager GUI mode system (disable, enable-gui, disable-gui, enable-legacy-gui) - --uv-compile/--no-uv-compile flag on 7 node commands for unified dep resolution - comfy node uv-sync standalone command - comfy manager uv-compile-default config command - comfy manager migrate-legacy for git-to-pip migration - comfy node update-cache command - Windows pipe deadlock fix (stderr background drain thread) Tests: - Comprehensive unit tests for cm_cli_util, Manager GUI, node commands - E2E tests for uv-compile with conflict detection packs --- DEV_README.md | 45 +- README.md | 71 +- comfy_cli/cmdline.py | 15 - comfy_cli/command/custom_nodes/cm_cli_util.py | 114 +- comfy_cli/command/custom_nodes/command.py | 343 ++++- comfy_cli/command/install.py | 98 +- comfy_cli/command/launch.py | 33 + comfy_cli/constants.py | 9 +- comfy_cli/workspace_manager.py | 56 +- docs/DESIGN-uv-compile.md | 282 ++++ docs/PRD-uv-compile.md | 102 ++ docs/TESTING-e2e.md | 145 +++ .../command/nodes/test_node_install.py | 326 ++++- tests/comfy_cli/command/test_cm_cli_util.py | 378 ++++++ tests/comfy_cli/command/test_command.py | 3 +- tests/comfy_cli/command/test_manager_gui.py | 1157 +++++++++++++++++ .../test_cm_cli_python_resolution.py | 32 +- tests/comfy_cli/test_install.py | 34 +- .../test_install_python_resolution.py | 16 +- tests/e2e/test_e2e.py | 105 +- tests/e2e/test_e2e_uv_compile.py | 382 ++++++ 21 files changed, 3528 insertions(+), 218 deletions(-) create mode 100644 docs/DESIGN-uv-compile.md create mode 100644 docs/PRD-uv-compile.md create mode 100644 docs/TESTING-e2e.md create mode 100644 tests/comfy_cli/command/test_cm_cli_util.py create mode 100644 tests/comfy_cli/command/test_manager_gui.py create mode 100644 tests/e2e/test_e2e_uv_compile.py diff --git a/DEV_README.md b/DEV_README.md index ccc82867..f7744e57 100644 --- a/DEV_README.md +++ b/DEV_README.md @@ -124,24 +124,49 @@ def remove(name: str): - For progress reporting, use either [`rich.progress`](https://rich.readthedocs.io/en/stable/progress.html) ## Develop comfy-cli and ComfyUI-Manager (cm-cli) together + +ComfyUI-Manager is now installed as a pip package (via `manager_requirements.txt` +in the ComfyUI root) rather than being git-cloned into `custom_nodes/`. + ### Making changes to both -1. Fork your own branches of `comfy-cli` and `ComfyUI-Manager`, make changes -2. Be sure to commit any changes to `ComfyUI-Manager` to a new branch, and push to remote +1. Fork your own branches of `comfy-cli` and `ComfyUI-Manager`, make changes. +2. Live-install `comfy-cli`: + - `pip install -e /path/to/comfy-cli` +3. Live-install your fork of `ComfyUI-Manager` in editable mode: + - `pip install -e /path/to/ComfyUI-Manager` +4. This makes the `cm-cli` entry point available and points it at your local source. ### Trying changes to both -1. clone the changed branch of `comfy-cli`, then live install `comfy-cli`: - - `pip install -e comfy-cli` +1. Install both packages in editable mode as described above. 2. Go to a test dir and run: - - `comfy --here install --manager-url=` -3. Run: - - `cd ComfyUI/custom_nodes/ComfyUI-Manager/ && git checkout && cd -` -4. Further changes can be pulled into these copies of the `comfy-cli` and `ComfyUI-Manager` repos + - `comfy --here install` +3. The `cm-cli` command will resolve to your locally installed editable package. ### Debugging both simultaneously -1. Follow instructions above to get working install with changes +1. Follow instructions above to get working install with changes. 2. Add breakpoints directly to code: `import ipdb; ipdb.set_trace()` -3. Execute relevant `comfy-cli` command +3. Execute relevant `comfy-cli` command. + + +## Running E2E tests + +E2E tests perform real `comfy install`, `comfy launch`, and `comfy node` operations. +They are **disabled by default** and must be explicitly enabled. + +```bash +TEST_E2E=true pytest tests/e2e/ +``` + +For pre-release testing against alternate ComfyUI repositories (e.g. Manager v4): + +```bash +TEST_E2E=true \ +TEST_E2E_COMFY_URL="https://github.com/ltdrdata/ComfyUI.git@dr-bump-manager" \ +pytest tests/e2e/ -v +``` +See [docs/TESTING-e2e.md](docs/TESTING-e2e.md) for the full guide including +environment variables, test suite details, and scenario descriptions. ## Contact diff --git a/README.md b/README.md index f9ca7b43..6824f3cc 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,15 @@ system. If you run in a ComfyUI repo that has already been setup. The command will simply update the comfy.yaml file to reflect the local setup - `comfy install --skip-manager`: Install ComfyUI without ComfyUI-Manager. + To use a custom Manager fork or specific version, skip the default installation + and install your own into the workspace venv: + ```bash + comfy install --skip-manager + # Then install your custom Manager: + pip install -e /path/to/your-manager-fork # editable install + # or + pip install comfyui-manager==4.1b8 # specific version + ``` - `comfy --workspace= install`: Install ComfyUI into `/ComfyUI`. - For `comfy install`, if no path specification like `--workspace, --recent, or --here` is provided, it will be implicitly installed in `/comfy`. @@ -75,7 +84,7 @@ will simply update the comfy.yaml file to reflect the local setup - Example 1: To run the recently executed ComfyUI: - `comfy --recent launch` - Example 2: To install a package on the ComfyUI in the current directory: - - `comfy --here node install ComfyUI-Impact-Pack` + - `comfy --here node install comfyui-impact-pack` - Example 3: To update the automatically selected path of ComfyUI and custom nodes based on priority: - `comfy node update all` @@ -168,7 +177,7 @@ comfy node [show|simple-show] [installed|enabled|not-installed|disabled|all|snap `comfy node update all` - `comfy node install ComfyUI-Impact-Pack` + `comfy node install comfyui-impact-pack` - Managing snapshot: @@ -186,6 +195,32 @@ comfy node [show|simple-show] [installed|enabled|not-installed|disabled|all|snap `comfy node deps-in-workflow --workflow= --output=` +#### Unified Dependency Resolution (--uv-compile) + +Requires ComfyUI-Manager v4.1+. Instead of installing dependencies per-node with +`pip install`, `--uv-compile` batch-resolves all custom node dependencies via +`uv pip compile` to avoid version conflicts. + +- Install with unified resolution: + + `comfy node install comfyui-impact-pack --uv-compile` + +- Available on: `install`, `reinstall`, `update`, `fix`, `restore-snapshot`, + `restore-dependencies`, `install-deps` + +- Run standalone (resolve all existing custom node dependencies): + + `comfy node uv-sync` + +- `--uv-compile` is mutually exclusive with `--fast-deps` and `--no-deps`. + +- To make `--uv-compile` the default for all commands, see + [uv-compile default](#uv-compile-default) below. + +- Use `--no-uv-compile` to override the default for a single command: + + `comfy node install comfyui-impact-pack --no-uv-compile` + #### Bisect custom nodes If you encounter bugs only with custom nodes enabled, and want to find out which custom node(s) causes the bug, @@ -216,18 +251,44 @@ the bisect tool can help you pinpoint the custom node that causes the issue. ### Managing ComfyUI-Manager -- disable GUI of ComfyUI-Manager (disable Manager menu and Server) +- Disable ComfyUI-Manager completely (no manager flags passed to ComfyUI): - `comfy manager disable-gui` + `comfy manager disable` -- enable GUI of ComfyUI-Manager +- Enable ComfyUI-Manager with new GUI: `comfy manager enable-gui` +- Enable ComfyUI-Manager without GUI (manager runs but UI is hidden): + + `comfy manager disable-gui` + +- Enable ComfyUI-Manager with legacy GUI: + + `comfy manager enable-legacy-gui` + - Clear reserved startup action: `comfy manager clear` +- Migrate legacy git-cloned ComfyUI-Manager to pip package: + + `comfy manager migrate-legacy` + +#### uv-compile default + +Set `--uv-compile` as the default behavior for all custom node operations: + + `comfy manager uv-compile-default true` + +When enabled, all node commands (`install`, `reinstall`, `update`, `fix`, +`restore-snapshot`, `restore-dependencies`, `install-deps`) will automatically +use `--uv-compile`. Use `--no-uv-compile` on any individual command to override. + +To disable: + + `comfy manager uv-compile-default false` + ## Beta Feature: format of comfy-lock.yaml (WIP) ``` diff --git a/comfy_cli/cmdline.py b/comfy_cli/cmdline.py index b8d5cc47..31680c3f 100644 --- a/comfy_cli/cmdline.py +++ b/comfy_cli/cmdline.py @@ -160,13 +160,6 @@ def install( callback=validate_version, ), ] = "nightly", - manager_url: Annotated[ - str, - typer.Option( - show_default=False, - help="url or local path pointing to the ComfyUI-Manager git repo to be installed. A specific branch can optionally be specified using a setuptools-like syntax, eg https://foo.git@bar", - ), - ] = constants.COMFY_MANAGER_GITHUB_URL, restore: Annotated[ bool, typer.Option( @@ -239,10 +232,6 @@ def install( help="Use new fast dependency installer", ), ] = False, - manager_commit: Annotated[ - str | None, - typer.Option(help="Specify commit hash for ComfyUI-Manager"), - ] = None, pr: Annotated[ str | None, typer.Option( @@ -280,7 +269,6 @@ def install( rprint("[bold yellow]Installing for CPU[/bold yellow]") install_inner.execute( url, - manager_url, comfy_path, restore, skip_manager, @@ -293,7 +281,6 @@ def install( skip_torch_or_directml=skip_torch_or_directml, skip_requirement=skip_requirement, fast_deps=fast_deps, - manager_commit=manager_commit, pr=pr, ) rprint(f"ComfyUI is installed at: {comfy_path}") @@ -336,7 +323,6 @@ def install( install_inner.execute( url, - manager_url, comfy_path, restore, skip_manager, @@ -349,7 +335,6 @@ def install( skip_torch_or_directml=skip_torch_or_directml, skip_requirement=skip_requirement, fast_deps=fast_deps, - manager_commit=manager_commit, pr=pr, ) diff --git a/comfy_cli/command/custom_nodes/cm_cli_util.py b/comfy_cli/command/custom_nodes/cm_cli_util.py index 2a5c1f8b..5853bf2a 100644 --- a/comfy_cli/command/custom_nodes/cm_cli_util.py +++ b/comfy_cli/command/custom_nodes/cm_cli_util.py @@ -1,9 +1,12 @@ from __future__ import annotations +import importlib.util import os import subprocess import sys +import threading import uuid +from functools import lru_cache import typer from rich import print @@ -11,7 +14,7 @@ from comfy_cli.config_manager import ConfigManager from comfy_cli.resolve_python import resolve_workspace_python from comfy_cli.uv import DependencyCompiler -from comfy_cli.workspace_manager import WorkspaceManager +from comfy_cli.workspace_manager import WorkspaceManager, check_comfy_repo workspace_manager = WorkspaceManager() @@ -22,7 +25,70 @@ } -def execute_cm_cli(args, channel=None, fast_deps=False, no_deps=False, mode=None, raise_on_error=False) -> str | None: +@lru_cache(maxsize=1) +def find_cm_cli() -> bool: + """Check if cm_cli module is available in the workspace Python. + + First checks the workspace venv Python (primary path — matches the Python + used by execute_cm_cli). Falls back to the current Python environment only + when the workspace Python is the same as sys.executable. + + Results are cached for the session lifetime. + """ + ws = workspace_manager.workspace_path + if ws: + python = resolve_workspace_python(ws) + if python != sys.executable: + # Workspace uses a different Python — check that one + try: + result = subprocess.run( + [python, "-c", "import cm_cli"], + capture_output=True, + timeout=10, + ) + return result.returncode == 0 + except (subprocess.TimeoutExpired, OSError): + return False + + # Same Python or no workspace — check current environment + return importlib.util.find_spec("cm_cli") is not None + + +def resolve_manager_gui_mode(not_installed_value: str | None = None) -> str | None: + """Resolve manager GUI mode from config, with legacy migration. + + Priority: CONFIG_KEY_MANAGER_GUI_MODE > CONFIG_KEY_MANAGER_GUI_ENABLED > auto-detect. + + Args: + not_installed_value: Value to return when manager is not installed and no config exists. + Callers use None (launch — means "no flags") or "not-installed" (display). + """ + from comfy_cli import constants + + config_manager = ConfigManager() + mode = config_manager.get(constants.CONFIG_KEY_MANAGER_GUI_MODE) + + if mode is not None: + return mode + + # Legacy migration + old_value = config_manager.get(constants.CONFIG_KEY_MANAGER_GUI_ENABLED) + if old_value is not None: + old_str = str(old_value).lower() + if old_str in ("false", "0", "off"): + return "disable" + if old_str in ("true", "1", "on"): + return "enable-gui" + + # No config at all — check manager availability + if not find_cm_cli(): + return not_installed_value + return "enable-gui" + + +def execute_cm_cli( + args, channel=None, fast_deps=False, no_deps=False, uv_compile=False, mode=None, raise_on_error=False +) -> str | None: _config_manager = ConfigManager() workspace_path = workspace_manager.workspace_path @@ -31,21 +97,30 @@ def execute_cm_cli(args, channel=None, fast_deps=False, no_deps=False, mode=None print("\n[bold red]ComfyUI path is not resolved.[/bold red]\n", file=sys.stderr) raise typer.Exit(code=1) - cm_cli_path = os.path.join(workspace_path, "custom_nodes", "ComfyUI-Manager", "cm-cli.py") - if not os.path.exists(cm_cli_path): + if not check_comfy_repo(workspace_path)[0]: + print( + f"\n[bold red]'{workspace_path}' is not a valid ComfyUI workspace.[/bold red]\n" + "Run [bold]comfy install[/bold] to set up ComfyUI, or use [bold]--workspace [/bold] to specify a valid path.\n", + file=sys.stderr, + ) + raise typer.Exit(code=1) + + if not find_cm_cli(): print( - f"\n[bold red]ComfyUI-Manager not found: {cm_cli_path}[/bold red]\n", + "\n[bold red]ComfyUI-Manager not found. 'cm-cli' command is not available.[/bold red]\n", file=sys.stderr, ) raise typer.Exit(code=1) python = resolve_workspace_python(workspace_path) - cmd = [python, cm_cli_path] + args + cmd = [python, "-m", "cm_cli"] + args if channel is not None: cmd += ["--channel", channel] - if fast_deps or no_deps: + if uv_compile: + cmd += ["--uv-compile"] + elif fast_deps or no_deps: cmd += ["--no-deps"] if mode is not None: @@ -64,19 +139,39 @@ def execute_cm_cli(args, channel=None, fast_deps=False, no_deps=False, mode=None cmd, env=new_env, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", ) + + # Read stderr in a background thread to avoid pipe deadlock on Windows. + # Windows pipe buffers are small (4 KB); if stderr fills up while the main + # thread is blocked reading stdout line-by-line, the child process blocks + # on stderr writes and never closes stdout — classic deadlock. + stderr_lines: list[str] = [] + + def _drain_stderr(): + for line in process.stderr: + sys.stderr.write(line) + sys.stderr.flush() + stderr_lines.append(line) + + stderr_thread = threading.Thread(target=_drain_stderr, daemon=True) + stderr_thread.start() + stdout_lines = [] for line in process.stdout: sys.stdout.write(line) sys.stdout.flush() stdout_lines.append(line) + + stderr_thread.join(timeout=10) return_code = process.wait() stdout_output = "".join(stdout_lines) + stderr_output = "".join(stderr_lines) if return_code != 0: - raise subprocess.CalledProcessError(return_code, cmd, output=stdout_output) + raise subprocess.CalledProcessError(return_code, cmd, output=stdout_output, stderr=stderr_output) if fast_deps and args[0] in _dependency_cmds: # we're using the fast_deps behavior and just ran a command that invalidated the dependencies @@ -84,6 +179,7 @@ def execute_cm_cli(args, channel=None, fast_deps=False, no_deps=False, mode=None depComp.compile_deps() depComp.install_deps() + workspace_manager.set_recent_workspace(workspace_path) return stdout_output except subprocess.CalledProcessError as e: if raise_on_error: @@ -97,5 +193,3 @@ def execute_cm_cli(args, channel=None, fast_deps=False, no_deps=False, mode=None return None raise e - finally: - workspace_manager.set_recent_workspace(workspace_path) diff --git a/comfy_cli/command/custom_nodes/command.py b/comfy_cli/command/custom_nodes/command.py index 7e42862b..b995ad1e 100644 --- a/comfy_cli/command/custom_nodes/command.py +++ b/comfy_cli/command/custom_nodes/command.py @@ -1,6 +1,7 @@ import os import pathlib import platform +import shutil import subprocess import sys import uuid @@ -11,9 +12,9 @@ from rich import print from rich.console import Console -from comfy_cli import logging, tracking, ui, utils +from comfy_cli import constants, logging, tracking, ui, utils from comfy_cli.command.custom_nodes.bisect_custom_nodes import bisect_app -from comfy_cli.command.custom_nodes.cm_cli_util import execute_cm_cli +from comfy_cli.command.custom_nodes.cm_cli_util import execute_cm_cli, find_cm_cli from comfy_cli.config_manager import ConfigManager from comfy_cli.constants import NODE_ZIP_FILENAME from comfy_cli.file_utils import ( @@ -49,21 +50,27 @@ class ShowTarget(str, Enum): SNAPSHOT_LIST = "snapshot-list" -def validate_comfyui_manager(_env_checker): - manager_path = _env_checker.get_comfyui_manager_path() +def _resolve_uv_compile(uv_compile: bool | None, fast_deps: bool = False, no_deps: bool = False) -> bool: + """Resolve effective uv_compile value from explicit flag, config default, and conflicting flags. - if manager_path is None: - print("[bold red]If ComfyUI is not installed, this feature cannot be used.[/bold red]") - raise typer.Exit(code=1) - elif not os.path.exists(manager_path): - print( - f"[bold red]If ComfyUI-Manager is not installed, this feature cannot be used.[/bold red] \\[{manager_path}]" - ) - raise typer.Exit(code=1) - elif not os.path.exists(os.path.join(manager_path, ".git")): - print( - f"[bold red]The ComfyUI-Manager installation is invalid. This feature cannot be used.[/bold red] \\[{manager_path}]" - ) + Priority: explicit --uv-compile/--no-uv-compile > config default > False. + When config default is True, explicit --fast-deps or --no-deps silently override it. + """ + if uv_compile is not None: + return uv_compile + + config_manager = ConfigManager() + config_value = config_manager.get(constants.CONFIG_KEY_UV_COMPILE_DEFAULT) + if config_value is not None and config_value.lower() == "true": + if fast_deps or no_deps: + return False + return True + return False + + +def validate_comfyui_manager(): + if not find_cm_cli(): + print("[bold red]ComfyUI-Manager is not installed. 'cm-cli' command is not available.[/bold red]") raise typer.Exit(code=1) @@ -212,6 +219,14 @@ def restore_snapshot( show_default=False, help="Restore for pip packages specified by local paths.", ), + uv_compile: Annotated[ + bool | None, + typer.Option( + "--uv-compile/--no-uv-compile", + show_default=False, + help="After restoring, batch-resolve all dependencies via uv pip compile (requires ComfyUI-Manager v4.1+)", + ), + ] = None, ): extras = [] @@ -225,25 +240,207 @@ def restore_snapshot( extras += ["--pip-local-url"] path = os.path.abspath(path) - execute_cm_cli(["restore-snapshot", path] + extras) + execute_cm_cli(["restore-snapshot", path] + extras, uv_compile=_resolve_uv_compile(uv_compile)) @app.command("restore-dependencies", help="Restore dependencies from installed custom nodes") @tracking.track_command("node") -def restore_dependencies(): - execute_cm_cli(["restore-dependencies"]) +def restore_dependencies( + uv_compile: Annotated[ + bool | None, + typer.Option( + "--uv-compile/--no-uv-compile", + show_default=False, + help="After restoring, batch-resolve all dependencies via uv pip compile (requires ComfyUI-Manager v4.1+)", + ), + ] = None, +): + execute_cm_cli(["restore-dependencies"], uv_compile=_resolve_uv_compile(uv_compile)) -@manager_app.command("disable-gui", help="Disable GUI mode of ComfyUI-Manager") +@manager_app.command("disable", help="Disable ComfyUI-Manager completely") @tracking.track_command("node") -def disable_gui(): - execute_cm_cli(["cli-only-mode", "enable"]) +def disable_manager(): + """Disable ComfyUI-Manager. No manager flags will be passed to ComfyUI.""" + config_manager = ConfigManager() + config_manager.set(constants.CONFIG_KEY_MANAGER_GUI_MODE, "disable") + print("[bold yellow]ComfyUI-Manager has been disabled.[/bold yellow]") + print("No manager flags will be passed to ComfyUI on next launch.") -@manager_app.command("enable-gui", help="Enable GUI mode of ComfyUI-Manager") +@manager_app.command("enable-gui", help="Enable ComfyUI-Manager with new GUI") @tracking.track_command("node") def enable_gui(): - execute_cm_cli(["cli-only-mode", "disable"]) + """Enable ComfyUI-Manager with new GUI.""" + config_manager = ConfigManager() + config_manager.set(constants.CONFIG_KEY_MANAGER_GUI_MODE, "enable-gui") + print("[bold green]ComfyUI-Manager GUI has been enabled.[/bold green]") + print("[dim]ComfyUI will launch with: --enable-manager[/dim]") + + +@manager_app.command("disable-gui", help="Enable ComfyUI-Manager without GUI") +@tracking.track_command("node") +def disable_gui(): + """Enable ComfyUI-Manager but disable its GUI.""" + config_manager = ConfigManager() + config_manager.set(constants.CONFIG_KEY_MANAGER_GUI_MODE, "disable-gui") + print("[bold green]ComfyUI-Manager enabled with GUI disabled.[/bold green]") + print("[dim]ComfyUI will launch with: --enable-manager --disable-manager-ui[/dim]") + + +@manager_app.command("enable-legacy-gui", help="Enable ComfyUI-Manager with legacy GUI") +@tracking.track_command("node") +def enable_legacy_gui(): + """Enable ComfyUI-Manager with legacy GUI.""" + config_manager = ConfigManager() + config_manager.set(constants.CONFIG_KEY_MANAGER_GUI_MODE, "enable-legacy-gui") + print("[bold green]ComfyUI-Manager legacy GUI has been enabled.[/bold green]") + print("[dim]ComfyUI will launch with: --enable-manager --enable-manager-legacy-ui[/dim]") + + +@manager_app.command("migrate-legacy", help="Migrate legacy git-cloned ComfyUI-Manager to .disabled") +@tracking.track_command("node") +def migrate_legacy( + yes: Annotated[ + bool, + typer.Option("--yes", "-y", help="Skip confirmation prompt"), + ] = False, +): + """ + Migrate legacy ComfyUI-Manager from custom_nodes/ to custom_nodes/.disabled/ + + Detects .enable-cli-only-mode file to set appropriate mode: + - If .enable-cli-only-mode exists → mode = disable + - Otherwise → mode = enable-gui + """ + if not workspace_manager.workspace_path: + print("[bold red]ComfyUI workspace is not set.[/bold red]") + print("[dim]Use --workspace or run from a ComfyUI directory.[/dim]") + raise typer.Exit(code=1) + + custom_nodes_path = pathlib.Path(workspace_manager.workspace_path) / "custom_nodes" + + # Find legacy manager with case-insensitive matching (must be a real directory, not symlink) + legacy_manager_path = None + if custom_nodes_path.exists(): + for item in custom_nodes_path.iterdir(): + if item.is_dir() and not item.is_symlink() and item.name.lower() == "comfyui-manager": + legacy_manager_path = item + break + + # Check if legacy manager exists + if legacy_manager_path is None: + print("[bold yellow]No legacy ComfyUI-Manager found in custom_nodes/[/bold yellow]") + print("Nothing to migrate.") + return + + # Verify it's a git-cloned repository + git_dir = legacy_manager_path / ".git" + if not git_dir.exists(): + print(f"[bold yellow]Warning: {legacy_manager_path.name} does not appear to be a git repository.[/bold yellow]") + print("[dim]Expected a git-cloned ComfyUI-Manager. Skipping migration.[/dim]") + return + + # Detect CLI-only mode before any changes + cli_only_mode_file = legacy_manager_path / ".enable-cli-only-mode" + cli_only_mode = cli_only_mode_file.exists() + + # Show what will happen and ask for confirmation + print(f"[bold]Found legacy ComfyUI-Manager:[/bold] {legacy_manager_path}") + print(f"[dim]CLI-only mode: {cli_only_mode}[/dim]") + print() + print("[bold]This will:[/bold]") + print(f" 1. Move {legacy_manager_path.name} to custom_nodes/.disabled/") + print(f" 2. Set manager mode to: {'disable' if cli_only_mode else 'enable-gui'}") + print(" 3. Install manager_requirements.txt (if present)") + print() + + if not yes: + confirm = ui.prompt_confirm_action("Proceed with migration?", False) + if not confirm: + print("[dim]Migration cancelled.[/dim]") + return + + # Create .disabled directory + disabled_path = custom_nodes_path / ".disabled" + disabled_path.mkdir(exist_ok=True) + + # Check if target already exists (case-insensitive) + existing_target = None + for item in disabled_path.iterdir(): + if item.is_dir() and item.name.lower() == "comfyui-manager": + existing_target = item + break + + if existing_target is not None: + print(f"[bold red]Target path already exists: {existing_target}[/bold red]") + print("Please remove it manually and try again.") + raise typer.Exit(code=1) + + # Move legacy manager (preserve original directory name) + target_path = disabled_path / legacy_manager_path.name + try: + shutil.move(str(legacy_manager_path), str(target_path)) + except OSError as e: + print(f"[bold red]Failed to move legacy manager: {e}[/bold red]") + raise typer.Exit(code=1) + + # Install manager_requirements.txt if present + workspace_path = pathlib.Path(workspace_manager.workspace_path) + manager_req_path = workspace_path / constants.MANAGER_REQUIREMENTS_FILE + python = resolve_workspace_python(str(workspace_path)) + install_success = False # Default to failure, set True only on success + if manager_req_path.exists(): + print("[dim]Installing ComfyUI-Manager dependencies...[/dim]") + result = subprocess.run( + [python, "-m", "pip", "install", "-r", str(manager_req_path)], + check=False, + ) + if result.returncode != 0: + print("[bold yellow]Warning: Failed to install ComfyUI-Manager dependencies.[/bold yellow]") + print("[dim]You may need to run: pip install -r manager_requirements.txt[/dim]") + else: + install_success = True + else: + print("[bold yellow]Warning: manager_requirements.txt not found (older ComfyUI version?).[/bold yellow]") + print("[dim]ComfyUI-Manager pip package not installed.[/dim]") + + # Set config mode + config_manager = ConfigManager() + if cli_only_mode or not install_success: + config_manager.set(constants.CONFIG_KEY_MANAGER_GUI_MODE, "disable") + print("[bold green]Legacy ComfyUI-Manager migrated to .disabled/[/bold green]") + if cli_only_mode: + print("[dim]Detected .enable-cli-only-mode → Manager set to: disable[/dim]") + else: + print("[dim]Manager installation failed → Manager set to: disable[/dim]") + print("[dim]After fixing installation, run: comfy manager enable-gui[/dim]") + else: + config_manager.set(constants.CONFIG_KEY_MANAGER_GUI_MODE, "enable-gui") + print("[bold green]Legacy ComfyUI-Manager migrated to .disabled/[/bold green]") + print("[dim]Manager set to: enable-gui (new GUI)[/dim]") + + print("\n[bold]The new pip-installed ComfyUI-Manager will be used on next launch.[/bold]") + + +@manager_app.command( + "uv-compile-default", help="Set whether --uv-compile is used by default for custom node operations" +) +@tracking.track_command("node") +def uv_compile_default( + enabled: Annotated[ + bool, + typer.Argument(help="true to enable, false to disable"), + ], +): + config_manager = ConfigManager() + config_manager.set(constants.CONFIG_KEY_UV_COMPILE_DEFAULT, str(enabled)) + if enabled: + print("[bold green]uv-compile is now enabled by default.[/bold green]") + print("[dim]Use --no-uv-compile to override for individual commands.[/dim]") + else: + print("[bold yellow]uv-compile default has been disabled.[/bold yellow]") + print("[dim]Use --uv-compile to enable for individual commands.[/dim]") @manager_app.command(help="Clear reserved startup action in ComfyUI-Manager") @@ -252,6 +449,12 @@ def clear(): execute_cm_cli(["clear"]) +@app.command("update-cache", help="Force-fetch remote data and populate local Manager cache (blocking)") +@tracking.track_command("node") +def update_cache(): + execute_cm_cli(["update-cache"]) + + # completers mode_completer = utils.create_choice_completer(["remote", "local", "cache"]) @@ -376,6 +579,14 @@ def install( help="Skip dependency installation", ), ] = False, + uv_compile: Annotated[ + bool | None, + typer.Option( + "--uv-compile/--no-uv-compile", + show_default=False, + help="After installing, batch-resolve all dependencies via uv pip compile (requires ComfyUI-Manager v4.1+)", + ), + ] = None, exit_on_fail: Annotated[ bool, typer.Option( @@ -393,10 +604,15 @@ def install( typer.echo(f"Invalid command: {mode}. `install all` is not allowed", err=True) raise typer.Exit(code=1) - if fast_deps and no_deps: - typer.echo("Cannot use --fast-deps and --no-deps together", err=True) + exclusive_flags = [ + name for name, val in [("--fast-deps", fast_deps), ("--no-deps", no_deps), ("--uv-compile", uv_compile)] if val + ] + if len(exclusive_flags) > 1: + typer.echo(f"Cannot use {' and '.join(exclusive_flags)} together", err=True) raise typer.Exit(code=1) + effective_uv_compile = _resolve_uv_compile(uv_compile, fast_deps=fast_deps, no_deps=no_deps) + validate_mode(mode) if exit_on_fail: @@ -406,7 +622,13 @@ def install( try: execute_cm_cli( - cmd, channel=channel, fast_deps=fast_deps, no_deps=no_deps, mode=mode, raise_on_error=exit_on_fail + cmd, + channel=channel, + fast_deps=fast_deps, + no_deps=no_deps, + uv_compile=effective_uv_compile, + mode=mode, + raise_on_error=exit_on_fail, ) except subprocess.CalledProcessError as e: if exit_on_fail: @@ -426,13 +648,21 @@ def reinstall( ), ] = None, fast_deps: Annotated[ - bool | None, + bool, typer.Option( "--fast-deps", show_default=False, help="Use new fast dependency installer", ), ] = False, + uv_compile: Annotated[ + bool | None, + typer.Option( + "--uv-compile/--no-uv-compile", + show_default=False, + help="After reinstalling, batch-resolve all dependencies via uv pip compile (requires ComfyUI-Manager v4.1+)", + ), + ] = None, mode: str = typer.Option( None, help="[remote|local|cache]", @@ -443,9 +673,27 @@ def reinstall( typer.echo(f"Invalid command: {mode}. `reinstall all` is not allowed", err=True) raise typer.Exit(code=1) + exclusive_flags = [name for name, val in [("--fast-deps", fast_deps), ("--uv-compile", uv_compile)] if val] + if len(exclusive_flags) > 1: + typer.echo(f"Cannot use {' and '.join(exclusive_flags)} together", err=True) + raise typer.Exit(code=1) + + effective_uv_compile = _resolve_uv_compile(uv_compile, fast_deps=fast_deps) + validate_mode(mode) - execute_cm_cli(["reinstall"] + nodes, channel=channel, fast_deps=fast_deps, mode=mode) + execute_cm_cli( + ["reinstall"] + nodes, channel=channel, fast_deps=fast_deps, uv_compile=effective_uv_compile, mode=mode + ) + + +@app.command( + "uv-sync", + help="Batch-resolve and install all custom node dependencies via uv (requires ComfyUI-Manager v4.1+)", +) +@tracking.track_command("node") +def uv_sync(): + execute_cm_cli(["uv-sync"]) @app.command(help="Uninstall custom nodes") @@ -479,7 +727,8 @@ def update_node_id_cache(): config_manager = ConfigManager() workspace_path = workspace_manager.workspace_path - cm_cli_path = os.path.join(workspace_path, "custom_nodes", "ComfyUI-Manager", "cm-cli.py") + if not find_cm_cli(): + raise FileNotFoundError("cm-cli not found") tmp_path = os.path.join(config_manager.get_config_path(), "tmp") if not os.path.exists(tmp_path): @@ -487,7 +736,7 @@ def update_node_id_cache(): cache_path = os.path.join(tmp_path, "node-cache.list") python = resolve_workspace_python(workspace_path) - cmd = [python, cm_cli_path, "export-custom-node-ids", cache_path] + cmd = [python, "-m", "cm_cli", "export-custom-node-ids", cache_path] new_env = os.environ.copy() new_env["COMFYUI_PATH"] = workspace_path @@ -511,6 +760,14 @@ def update( autocompletion=channel_completer, ), ] = None, + uv_compile: Annotated[ + bool | None, + typer.Option( + "--uv-compile/--no-uv-compile", + show_default=False, + help="After updating, batch-resolve all dependencies via uv pip compile (requires ComfyUI-Manager v4.1+)", + ), + ] = None, mode: str = typer.Option( None, help="[remote|local|cache]", @@ -519,7 +776,7 @@ def update( ): validate_mode(mode) - execute_cm_cli(["update"] + nodes, channel=channel, mode=mode) + execute_cm_cli(["update"] + nodes, channel=channel, uv_compile=_resolve_uv_compile(uv_compile), mode=mode) update_node_id_cache() @@ -594,6 +851,14 @@ def fix( autocompletion=channel_completer, ), ] = None, + uv_compile: Annotated[ + bool | None, + typer.Option( + "--uv-compile/--no-uv-compile", + show_default=False, + help="After fixing, batch-resolve all dependencies via uv pip compile (requires ComfyUI-Manager v4.1+)", + ), + ] = None, mode: str = typer.Option( None, help="[remote|local|cache]", @@ -602,7 +867,7 @@ def fix( ): validate_mode(mode) - execute_cm_cli(["fix"] + nodes, channel=channel, mode=mode) + execute_cm_cli(["fix"] + nodes, channel=channel, uv_compile=_resolve_uv_compile(uv_compile), mode=mode) @app.command( @@ -627,6 +892,14 @@ def install_deps( autocompletion=channel_completer, ), ] = None, + uv_compile: Annotated[ + bool | None, + typer.Option( + "--uv-compile/--no-uv-compile", + show_default=False, + help="After installing, batch-resolve all dependencies via uv pip compile (requires ComfyUI-Manager v4.1+)", + ), + ] = None, mode: str = typer.Option( None, help="[remote|local|cache]", @@ -638,6 +911,8 @@ def install_deps( if deps is None and workflow is None: print("[bold red]One of --deps or --workflow must be provided as an argument.[/bold red]\n") + effective_uv_compile = _resolve_uv_compile(uv_compile) + tmp_path = None if workflow is not None: workflow = os.path.abspath(os.path.expanduser(workflow)) @@ -656,7 +931,7 @@ def install_deps( else: deps_file = os.path.abspath(os.path.expanduser(deps)) - execute_cm_cli(["install-deps", deps_file], channel=channel, mode=mode) + execute_cm_cli(["install-deps", deps_file], channel=channel, uv_compile=effective_uv_compile, mode=mode) if tmp_path is not None and os.path.exists(tmp_path): os.remove(tmp_path) diff --git a/comfy_cli/command/install.py b/comfy_cli/command/install.py index 8feab0fb..24ce1a59 100755 --- a/comfy_cli/command/install.py +++ b/comfy_cli/command/install.py @@ -5,6 +5,7 @@ from typing import TypedDict from urllib.parse import urlparse +import git import requests import semver import typer @@ -158,21 +159,42 @@ def pip_install_comfyui_dependencies( sys.exit(1) -# install requirements for manager -def pip_install_manager_dependencies(repo_dir, python=sys.executable): - os.chdir(os.path.join(repo_dir, "custom_nodes", "ComfyUI-Manager")) - subprocess.run([python, "-m", "pip", "install", "-r", "requirements.txt"], check=True) +def pip_install_manager(repo_dir, python=sys.executable): + """Install ComfyUI-Manager via manager_requirements.txt.""" + from comfy_cli.command.custom_nodes.cm_cli_util import find_cm_cli + + manager_req_path = os.path.join(repo_dir, constants.MANAGER_REQUIREMENTS_FILE) + if not os.path.exists(manager_req_path): + rprint( + f"[bold yellow]Warning: {constants.MANAGER_REQUIREMENTS_FILE} not found. " + "Skipping manager installation (older ComfyUI version?).[/bold yellow]" + ) + return False + result = subprocess.run( + [python, "-m", "pip", "install", "-r", constants.MANAGER_REQUIREMENTS_FILE], + cwd=repo_dir, + check=False, + capture_output=True, + text=True, + ) + if result.returncode != 0: + rprint("[bold red]Failed to install ComfyUI-Manager.[/bold red]") + if result.stderr: + rprint(f"[dim]{result.stderr.strip()}[/dim]") + return False + + # Clear cache so find_cm_cli() picks up the newly installed module + find_cm_cli.cache_clear() + return True def execute( url: str, - manager_url: str, comfy_path: str, restore: bool, skip_manager: bool, version: str, commit: str | None = None, - manager_commit: str | None = None, gpu: constants.GPU_OPTION = None, cuda_version: constants.CUDAVersion = constants.CUDAVersion.v12_6, rocm_version: constants.ROCmVersion = constants.ROCmVersion.v6_3, @@ -218,9 +240,22 @@ def execute( sys.exit(1) elif not check_comfy_repo(repo_dir)[0]: - rprint( - f"[bold red]'{repo_dir}' already exists. But it is an invalid ComfyUI repository. Remove it and retry.[/bold red]" - ) + # Get actual remote URL for better error message + try: + repo = git.Repo(repo_dir) + remote_urls = [r.url for r in repo.remotes] + rprint( + f"[bold red]'{repo_dir}' exists but its remote URL is not a recognized ComfyUI repository.[/bold red]" + ) + if remote_urls: + rprint(f"[yellow]Found remotes: {', '.join(remote_urls)}[/yellow]") + rprint("[yellow]Recognized sources: Comfy-Org, comfyanonymous, drip-art, ltdrdata[/yellow]") + except git.InvalidGitRepositoryError: + rprint(f"[bold red]'{repo_dir}' exists but is not a valid git repository.[/bold red]") + except Exception: + rprint( + f"[bold red]'{repo_dir}' already exists. But it is an invalid ComfyUI repository. Remove it and retry.[/bold red]" + ) sys.exit(-1) # checkout specified commit @@ -251,33 +286,19 @@ def execute( # install ComfyUI-Manager if skip_manager: rprint("Skipping installation of ComfyUI-Manager. (by --skip-manager)") - else: - manager_repo_dir = os.path.join(repo_dir, "custom_nodes", "ComfyUI-Manager") + # Save to config so launch doesn't inject --enable-manager + from comfy_cli.config_manager import ConfigManager - if os.path.exists(manager_repo_dir): - if restore and not fast_deps: - pip_install_manager_dependencies(repo_dir, python=python) - else: - rprint( - f"Directory {manager_repo_dir} already exists. Skipping installation of ComfyUI-Manager.\nIf you want to restore dependencies, add the '--restore' option." - ) - else: - rprint("\nInstalling ComfyUI-Manager..") - - if "@" in manager_url: - # clone specific branch - manager_url, manager_branch = manager_url.rsplit("@", 1) - subprocess.run( - ["git", "clone", "-b", manager_branch, manager_url, manager_repo_dir], - check=True, - ) - else: - subprocess.run(["git", "clone", manager_url, manager_repo_dir], check=True) - if manager_commit is not None: - subprocess.run(["git", "checkout", manager_commit], check=True, cwd=manager_repo_dir) + ConfigManager().set(constants.CONFIG_KEY_MANAGER_GUI_MODE, "disable") + else: + rprint("\nInstalling ComfyUI-Manager..") + if not fast_deps: + if not pip_install_manager(repo_dir, python=python): + # Manager installation failed - disable to prevent launch issues + from comfy_cli.config_manager import ConfigManager - if not fast_deps: - pip_install_manager_dependencies(repo_dir, python=python) + ConfigManager().set(constants.CONFIG_KEY_MANAGER_GUI_MODE, "disable") + rprint("[yellow]Manager not installed. Launch will run without manager flags.[/yellow]") if fast_deps: DependencyCompiler.Install_Build_Deps(executable=python) @@ -291,11 +312,18 @@ def execute( ) depComp.compile_deps() depComp.install_deps() + # Install manager separately (not included in DependencyCompiler) + if not skip_manager: + if not pip_install_manager(repo_dir, python=python): + from comfy_cli.config_manager import ConfigManager + + ConfigManager().set(constants.CONFIG_KEY_MANAGER_GUI_MODE, "disable") + rprint("[yellow]Manager not installed. Launch will run without manager flags.[/yellow]") if not skip_manager: try: update_node_id_cache() - except subprocess.CalledProcessError as e: + except (FileNotFoundError, subprocess.CalledProcessError) as e: rprint(f"Failed to update node id cache: {e}") os.chdir(repo_dir) diff --git a/comfy_cli/command/launch.py b/comfy_cli/command/launch.py index 31505bc0..61d0e8c3 100644 --- a/comfy_cli/command/launch.py +++ b/comfy_cli/command/launch.py @@ -13,6 +13,7 @@ from rich.panel import Panel from comfy_cli import constants, utils +from comfy_cli.command.custom_nodes.cm_cli_util import find_cm_cli, resolve_manager_gui_mode from comfy_cli.config_manager import ConfigManager from comfy_cli.env_checker import check_comfy_server_running from comfy_cli.resolve_python import resolve_workspace_python @@ -23,6 +24,32 @@ console = Console() +def _get_manager_flags() -> list[str]: + """Get manager flags based on config mode.""" + mode = resolve_manager_gui_mode(not_installed_value=None) + + if mode is None or mode == "disable": + return [] + + # For enable-* modes, verify cm-cli is available + if not find_cm_cli(): + print( + "[bold yellow]Warning: ComfyUI-Manager (cm-cli) not found. " + "Manager flags will not be injected.[/bold yellow]" + ) + return [] + + if mode == "enable-gui": + return ["--enable-manager"] + elif mode == "disable-gui": + return ["--enable-manager", "--disable-manager-ui"] + elif mode == "enable-legacy-gui": + return ["--enable-manager", "--enable-manager-legacy-ui"] + else: + print(f"[bold yellow]Warning: Unknown manager mode '{mode}'. Falling back to --enable-manager.[/bold yellow]") + return ["--enable-manager"] # fallback to default + + def launch_comfyui(extra, frontend_pr=None, python=sys.executable): reboot_path = None @@ -149,6 +176,12 @@ def launch( os.chdir(resolved_workspace) python = resolve_workspace_python(resolved_workspace) + + # Inject manager flags based on config mode + manager_flags = _get_manager_flags() + if manager_flags: + extra = (extra or []) + manager_flags + if background: background_launch(extra, frontend_pr) else: diff --git a/comfy_cli/constants.py b/comfy_cli/constants.py index 9c8ef5cd..cd9ac637 100644 --- a/comfy_cli/constants.py +++ b/comfy_cli/constants.py @@ -14,7 +14,8 @@ class PROC(str, Enum): COMFY_GITHUB_URL = "https://github.com/comfyanonymous/ComfyUI" -COMFY_MANAGER_GITHUB_URL = "https://github.com/ltdrdata/ComfyUI-Manager" + +MANAGER_REQUIREMENTS_FILE = "manager_requirements.txt" DEFAULT_COMFY_MODEL_PATH = "models" DEFAULT_COMFY_WORKSPACE = { @@ -40,6 +41,9 @@ class PROC(str, Enum): CONFIG_KEY_USER_ID = "user_id" CONFIG_KEY_INSTALL_EVENT_TRIGGERED = "install_event_triggered" CONFIG_KEY_BACKGROUND = "background" +CONFIG_KEY_MANAGER_GUI_ENABLED = "manager_gui_enabled" # Legacy, kept for backward compatibility +CONFIG_KEY_MANAGER_GUI_MODE = "manager_gui_mode" # Valid: "disable", "enable-gui", "disable-gui", "enable-legacy-gui" +CONFIG_KEY_UV_COMPILE_DEFAULT = "uv_compile_default" CIVITAI_API_TOKEN_KEY = "civitai_api_token" CIVITAI_API_TOKEN_ENV_KEY = "CIVITAI_API_TOKEN" @@ -55,12 +59,15 @@ class PROC(str, Enum): "git@github.com:Comfy-Org/ComfyUI.git", "git@github.com:comfyanonymous/ComfyUI.git", "git@github.com:drip-art/comfy.git", + "git@github.com:ltdrdata/ComfyUI.git", "https://github.com/Comfy-Org/ComfyUI.git", "https://github.com/comfyanonymous/ComfyUI.git", "https://github.com/drip-art/ComfyUI.git", + "https://github.com/ltdrdata/ComfyUI.git", "https://github.com/Comfy-Org/ComfyUI", "https://github.com/comfyanonymous/ComfyUI", "https://github.com/drip-art/ComfyUI", + "https://github.com/ltdrdata/ComfyUI", } diff --git a/comfy_cli/workspace_manager.py b/comfy_cli/workspace_manager.py index 80012381..e16d647c 100644 --- a/comfy_cli/workspace_manager.py +++ b/comfy_cli/workspace_manager.py @@ -257,32 +257,19 @@ def get_workspace_path(self) -> tuple[str, WorkspaceType]: # Fallback to the most recent workspace if it exists if self.use_recent is None: recent_workspace = self.config_manager.get(constants.CONFIG_KEY_RECENT_WORKSPACE) - if recent_workspace and check_comfy_repo(recent_workspace)[0]: - return recent_workspace, WorkspaceType.RECENT - else: - print( - f"[bold red]warn: The recent workspace {recent_workspace} is not a valid ComfyUI path.[/bold red]" - ) + if recent_workspace: + if check_comfy_repo(recent_workspace)[0]: + return recent_workspace, WorkspaceType.RECENT + else: + self.config_manager.set(constants.CONFIG_KEY_RECENT_WORKSPACE, "") + print( + f"[bold red]warn: Recent workspace '{recent_workspace}' is not a valid ComfyUI path. Reset.[/bold red]" + ) # Check for comfy-cli default workspace default_workspace = utils.get_not_user_set_default_workspace() return default_workspace, WorkspaceType.DEFAULT - def get_comfyui_manager_path(self): - if self.workspace_path is None: - return None - - # To check more robustly, verify up to the `.git` path. - return os.path.join(self.workspace_path, "custom_nodes", "ComfyUI-Manager") - - def is_comfyui_manager_installed(self): - if self.workspace_path is None: - return False - - # To check more robustly, verify up to the `.git` path. - manager_git_path = os.path.join(self.workspace_path, "custom_nodes", "ComfyUI-Manager", ".git") - return os.path.exists(manager_git_path) - def scan_dir(self): if not self.workspace_path: return [] @@ -321,4 +308,29 @@ def save_metadata(self): save_yaml(file_path, self.metadata) def fill_print_table(self): - return [("Current selected workspace", f"[bold green]→ {self.workspace_path}[/bold green]")] + # Lazy import to avoid circular dependency + from comfy_cli.command.custom_nodes.cm_cli_util import resolve_manager_gui_mode + + config_manager = ConfigManager() + mode = resolve_manager_gui_mode(not_installed_value="not-installed") + + status_map = { + "disable": "[bold red]Disabled[/bold red]", + "enable-gui": "[bold green]GUI Enabled[/bold green]", + "disable-gui": "[bold yellow]GUI Disabled[/bold yellow]", + "enable-legacy-gui": "[bold cyan]Legacy GUI[/bold cyan]", + "not-installed": "[dim]Not Installed[/dim]", + } + manager_status = status_map.get(mode, "[bold green]GUI Enabled[/bold green]") + + uv_compile_value = config_manager.get(constants.CONFIG_KEY_UV_COMPILE_DEFAULT) + if uv_compile_value is not None and str(uv_compile_value).lower() == "true": + uv_compile_status = "[bold green]Enabled[/bold green]" + else: + uv_compile_status = "[dim]Disabled[/dim]" + + return [ + ("Current selected workspace", f"[bold green]→ {self.workspace_path}[/bold green]"), + ("Manager", manager_status), + ("UV Compile Default", uv_compile_status), + ] diff --git a/docs/DESIGN-uv-compile.md b/docs/DESIGN-uv-compile.md new file mode 100644 index 00000000..398d1586 --- /dev/null +++ b/docs/DESIGN-uv-compile.md @@ -0,0 +1,282 @@ +# DESIGN: Unified Dependency Resolution (--uv-compile) Implementation + +## Architecture Decision: Pass-Through + +cm_cli already fully implements `--uv-compile` and `UnifiedDepResolver`, so +comfy-cli adopts a **pass-through** approach. + +**Rationale:** +- Avoids duplicating logic already in cm_cli +- Maintains separation of concerns with comfy-cli's `DependencyCompiler` (`--fast-deps`) +- No comfy-cli changes needed when cm_cli updates its resolver + +**Alternative (rejected):** Import `UnifiedDepResolver` directly in comfy-cli +— increases coupling between cm_cli and comfy-cli, adds maintenance burden. + +## Component Diagram + +``` +User CLI + │ + ├─ comfy node install --uv-compile + │ │ + │ ▼ + │ command.py:install() + │ │ 1. mutual exclusivity check + │ │ 2. _resolve_uv_compile() → effective value + │ │ 3. execute_cm_cli(..., uv_compile=True) + │ │ + │ ▼ + │ cm_cli_util.py:execute_cm_cli() + │ │ → cmd += ["--uv-compile"] + │ │ + │ ▼ + │ subprocess: python -m cm_cli install --uv-compile + │ │ + │ ▼ + │ cm_cli → UnifiedDepResolver → uv pip compile → pip install + │ + ├─ comfy manager uv-compile-default true + │ │ + │ ▼ + │ command.py:uv_compile_default() + │ │ → ConfigManager.set("uv_compile_default", "True") + │ │ → config.ini [DEFAULT] section + │ + └─ comfy node uv-sync + │ + ▼ + execute_cm_cli(["uv-sync"]) + │ + ▼ + subprocess: python -m cm_cli uv-sync +``` + +## File Changes + +### 1. `comfy_cli/constants.py` + +```python +CONFIG_KEY_UV_COMPILE_DEFAULT = "uv_compile_default" +``` + +INI config key. Stored as `"True"` / `"False"` string in `[DEFAULT]` section. + +### 2. `comfy_cli/command/custom_nodes/cm_cli_util.py` + +Added `uv_compile=False` parameter to `execute_cm_cli()`: + +```python +def execute_cm_cli(args, channel=None, fast_deps=False, no_deps=False, + uv_compile=False, mode=None, raise_on_error=False): +``` + +Flag pass-through logic (added alongside existing `fast_deps`/`no_deps` branch): + +```python +if uv_compile: + cmd += ["--uv-compile"] +elif fast_deps or no_deps: + cmd += ["--no-deps"] +``` + +`uv_compile` takes priority over `fast_deps`/`no_deps`. By the time this +function is called, the value is already resolved to a plain `bool` — no +`None` handling needed here. + +### 3. `comfy_cli/command/custom_nodes/command.py` + +#### 3.1 Tri-state flag pattern + +All 7 commands changed `uv_compile` parameter to `bool | None`: + +```python +uv_compile: Annotated[ + bool | None, + typer.Option( + "--uv-compile/--no-uv-compile", + show_default=False, + help="After {verb}, batch-resolve all dependencies via uv pip compile ...", + ), +] = None, +``` + +typer's `--flag/--no-flag` pattern: +- `--uv-compile` → `True` +- `--no-uv-compile` → `False` +- not specified → `None` + +#### 3.2 Resolution helper + +```python +def _resolve_uv_compile( + uv_compile: bool | None, + fast_deps: bool = False, + no_deps: bool = False, +) -> bool: +``` + +**Resolution priority:** + +``` +uv_compile is True → return True (explicit --uv-compile) +uv_compile is False → return False (explicit --no-uv-compile) +uv_compile is None → check config: + config == "True" AND (fast_deps or no_deps) → return False (conflict: explicit flag wins) + config == "True" → return True (config default) + otherwise → return False (no config) +``` + +Each command passes the appropriate conflicting flags: + +| Command | Call | +|---------|------| +| `install` | `_resolve_uv_compile(uv_compile, fast_deps, no_deps)` | +| `reinstall` | `_resolve_uv_compile(uv_compile, fast_deps=fast_deps)` | +| Other 5 | `_resolve_uv_compile(uv_compile)` | + +#### 3.3 Mutual exclusivity validation + +**install** (3-way): + +```python +exclusive_flags = [ + name for name, val in + [("--fast-deps", fast_deps), ("--no-deps", no_deps), ("--uv-compile", uv_compile)] + if val +] +if len(exclusive_flags) > 1: + typer.echo(f"Cannot use {' and '.join(exclusive_flags)} together", err=True) + raise typer.Exit(code=1) +``` + +`uv_compile=None` is falsy, so it is not included in the list. Config-resolved +values are not checked here — only the raw flag value — so config defaults +never trigger mutual exclusivity errors. + +**reinstall** (2-way): + +```python +if fast_deps and uv_compile is True: + typer.echo("Cannot use --fast-deps and --uv-compile together", err=True) + raise typer.Exit(code=1) +``` + +`is True` identity check explicitly excludes `None`. + +#### 3.4 Manager config command + +```python +@manager_app.command("uv-compile-default") +def uv_compile_default( + enabled: Annotated[bool, typer.Argument(help="true to enable, false to disable")], +): + config_manager = ConfigManager() + config_manager.set(constants.CONFIG_KEY_UV_COMPILE_DEFAULT, str(enabled)) +``` + +typer automatically parses `true`/`false` strings to `bool`. +`ConfigManager.set()` writes to `config.ini` immediately. + +#### 3.5 Standalone command + +```python +@app.command("uv-sync") +def uv_sync(): + execute_cm_cli(["uv-sync"]) +``` + +Independent of config default. Always directly invokes cm_cli's `uv-sync` +subcommand. + +## Data Flow + +### Config storage + +```ini +# ~/.config/comfy-cli/config.ini +[DEFAULT] +uv_compile_default = True +``` + +`ConfigManager.get("uv_compile_default")` → `"True"` | `"False"` | `None` + +### Flag resolution flow + +``` +CLI input + │ + ├─ --uv-compile → uv_compile = True + ├─ --no-uv-compile → uv_compile = False + └─ (none) → uv_compile = None + │ + _resolve_uv_compile() + │ + ┌──────┴──────┐ + │ not None? │ + └──────┬──────┘ + Yes │ No + │ │ + return ▼ + as-is config.ini + │ + ┌──────┴──────┐ + │ == "True"? │ + └──────┬──────┘ + Yes │ No + │ │ + ▼ └→ return False + conflicting + flags? + │ + Yes │ No + │ │ + return return + False True +``` + +### Subprocess command construction + +``` +execute_cm_cli(["install", "node-a"], uv_compile=True) +→ [python, -m, cm_cli, install, node-a, --uv-compile] + +execute_cm_cli(["install", "node-a"], fast_deps=True) +→ [python, -m, cm_cli, install, node-a, --no-deps] + + DependencyCompiler.compile_deps() / install_deps() + +execute_cm_cli(["install", "node-a"], uv_compile=False) +→ [python, -m, cm_cli, install, node-a] + (no extra flags — default per-node pip install) +``` + +## Test Strategy + +### Existing tests (regression) + +All 207 existing tests pass. Changes to `--uv-compile` do not affect existing behavior. + +### Recommended new tests + +| Test | Verifies | +|------|----------| +| `test_resolve_uv_compile_explicit_true` | Explicit True → True | +| `test_resolve_uv_compile_explicit_false` | Explicit False → False | +| `test_resolve_uv_compile_config_true` | None + config True → True | +| `test_resolve_uv_compile_config_false` | None + config False → False | +| `test_resolve_uv_compile_config_none` | None + config None → False | +| `test_resolve_uv_compile_config_with_fast_deps` | None + config True + fast_deps → False | +| `test_resolve_uv_compile_config_with_no_deps` | None + config True + no_deps → False | +| `test_install_mutual_exclusivity` | --uv-compile + --fast-deps → exit 1 | +| `test_install_config_no_exclusivity` | config True + --fast-deps → no error | +| `test_manager_uv_compile_default_enable` | Config stores "True" | +| `test_manager_uv_compile_default_disable` | Config stores "False" | + +## Compatibility Matrix + +| comfy-cli | ComfyUI-Manager | Behavior | +|-----------|-----------------|----------| +| This change | v4.1+ | `--uv-compile` works correctly | +| This change | v4.0 or older | cm_cli returns unknown flag error | +| Previous version | v4.1+ | `--uv-compile` unavailable (no flag) | +| Previous version | v4.0 or older | Existing behavior unchanged | diff --git a/docs/PRD-uv-compile.md b/docs/PRD-uv-compile.md new file mode 100644 index 00000000..8b94e1bf --- /dev/null +++ b/docs/PRD-uv-compile.md @@ -0,0 +1,102 @@ +# PRD: Unified Dependency Resolution (--uv-compile) Support + +## Overview + +Add `--uv-compile` flag to comfy-cli to integrate with ComfyUI-Manager v4.1+'s +Unified Dependency Resolver. Users can batch-resolve all custom node dependencies +via `uv pip compile` after install/update operations. + +## Background + +### Problem + +Each ComfyUI custom node ships its own `requirements.txt`. The default approach +(`pip install` per node) frequently causes dependency conflicts between nodes. +ComfyUI-Manager v4.1+ introduced `UnifiedDepResolver` to solve this, but +comfy-cli had no way to invoke it. + +### Existing Approaches + +| Approach | Flag | Implementation | Behavior | +|----------|------|----------------|----------| +| Default | (none) | cm_cli | Per-node `pip install` | +| Fast deps | `--fast-deps` | comfy-cli `DependencyCompiler` | comfy-cli side `uv pip compile` | +| No deps | `--no-deps` | cm_cli | Skip dependency installation | +| **Unified** | **`--uv-compile`** | **cm_cli `UnifiedDepResolver`** | **cm_cli side batch resolution** | + +### Target Users + +- ComfyUI-Manager v4.1+ users +- Users managing many custom nodes +- Users experiencing dependency conflicts + +## Requirements + +### FR-1: Add --uv-compile flag to 7 commands + +**Target commands:** + +| # | Command | Existing dep flags | +|---|---------|-------------------| +| 1 | `comfy node install` | `--fast-deps`, `--no-deps` | +| 2 | `comfy node reinstall` | `--fast-deps` | +| 3 | `comfy node update` | (none) | +| 4 | `comfy node fix` | (none) | +| 5 | `comfy node restore-snapshot` | (none) | +| 6 | `comfy node restore-dependencies` | (none) | +| 7 | `comfy node install-deps` | (none) | + +**Behavior:** When the flag is passed, append `--uv-compile` to the cm_cli +subprocess command. + +### FR-2: Standalone uv-sync command + +``` +comfy node uv-sync +``` + +Directly invokes cm_cli's `uv-sync` subcommand. Batch-resolves all installed +custom node dependencies without requiring a prior install/update operation. + +### FR-3: --no-uv-compile flag + +Add `--no-uv-compile` to all 7 commands so users can explicitly disable the +config default on a per-command basis. + +### FR-4: Config default setting + +``` +comfy manager uv-compile-default true # Enable by default +comfy manager uv-compile-default false # Disable by default +``` + +Once enabled, `--uv-compile` is automatically applied to all custom node +operations. + +### FR-5: Mutual exclusivity + +`--uv-compile`, `--fast-deps`, and `--no-deps` are mutually exclusive. + +| Combination | Result | +|-------------|--------| +| `--uv-compile --fast-deps` | Error | +| `--uv-compile --no-deps` | Error | +| `--fast-deps --no-deps` | Error | +| config default + `--fast-deps` | `--fast-deps` wins (no error) | +| config default + `--no-uv-compile` | Disabled | + +### NFR-1: Backward compatibility + +- No impact on existing `--fast-deps` / `--no-deps` behavior +- Without flag and without config, behavior is identical to before (per-node pip install) + +### NFR-2: Minimum version + +Requires ComfyUI-Manager v4.1+. On older versions, cm_cli returns its own +error for the unknown flag. comfy-cli does not perform version checking. + +## Out of Scope + +- `comfy install` (core ComfyUI installation) — separate dependency system +- Modifications to cm_cli's internal UnifiedDepResolver logic +- Automatic ComfyUI-Manager version detection diff --git a/docs/TESTING-e2e.md b/docs/TESTING-e2e.md new file mode 100644 index 00000000..849c0230 --- /dev/null +++ b/docs/TESTING-e2e.md @@ -0,0 +1,145 @@ +# E2E Testing Guide + +E2E tests perform real `comfy install`, `comfy launch`, and `comfy node` operations. +They are **disabled by default** and must be explicitly enabled. + +## Environment variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `TEST_E2E` | `false` | Set to `true` to enable E2E tests | +| `TEST_E2E_COMFY_URL` | *(empty — uses default)* | Custom ComfyUI repo URL. Supports `@branch` syntax | +| `TEST_E2E_COMFY_INSTALL_FLAGS` | `--cpu` | Extra flags passed to `comfy install` | +| `TEST_E2E_COMFY_LAUNCH_FLAGS_EXTRA` | `--cpu` | Extra flags passed to `comfy launch` | + +## Basic usage + +```bash +TEST_E2E=true pytest tests/e2e/ +``` + +Installs ComfyUI from the default upstream (`Comfy-Org/ComfyUI`), launches it +in the background, runs the test suite, then stops the server. + +## Pre-release testing + +To test features that depend on unreleased ComfyUI changes (e.g. +`manager_requirements.txt` not yet merged upstream), point the E2E suite at a +fork/branch: + +```bash +TEST_E2E=true \ +TEST_E2E_COMFY_URL="https://github.com/ltdrdata/ComfyUI.git@dr-bump-manager" \ +pytest tests/e2e/ -v +``` + +This clones `ltdrdata/ComfyUI` at the `dr-bump-manager` branch, which contains +`manager_requirements.txt` for pip-based Manager v4 installation. + +### Full pre-release run (GPU) + +```bash +TEST_E2E=true \ +TEST_E2E_COMFY_URL="https://github.com/ltdrdata/ComfyUI.git@dr-bump-manager" \ +TEST_E2E_COMFY_INSTALL_FLAGS="" \ +TEST_E2E_COMFY_LAUNCH_FLAGS_EXTRA="" \ +pytest tests/e2e/ -v +``` + +## Test suites + +### `test_e2e.py` — General functionality + +Covers model download, custom node lifecycle, workflow execution, and basic +Manager v4 smoke tests. + +| Test | Description | +|------|-------------| +| `test_model` | Download, list, and remove a model | +| `test_node` | Install, reinstall, show, update, disable, enable, publish a custom node | +| `test_manager_installed` | Verifies `cm_cli` is importable after install | +| `test_node_uv_compile` | Installs a node with `--uv-compile` and runs `comfy node uv-sync` | +| `test_uv_compile_default_config` | Sets `uv-compile-default`, verifies `comfy env` display | +| `test_run` | Downloads a checkpoint and executes a workflow end-to-end | + +### `test_e2e_uv_compile.py` — Unified dependency resolution + +Comprehensive `--uv-compile` E2E suite. **Requires Manager v4.1+** — automatically +skipped when `cm_cli` is not importable. + +#### Test packs + +Two categories of node packs are used: + +- **Real packs** (`comfyui-impact-pack`, `comfyui-inspire-pack`) — production + node packs for verifying normal installation succeeds without conflicts. +- **Conflict fixture packs** (`nodepack-test1-do-not-install`, + `nodepack-test2-do-not-install`) — ltdrdata's dedicated test packs that + intentionally conflict on ansible versions (`ansible==9.13.0` vs + `ansible-core==2.14.0`). Contain no executable code. + +Supply-chain safety: only node packs from verified authors (ltdrdata, +comfyanonymous, Comfy-Org) are used. + +#### Test scenarios + +**Normal installation (real packs)** + +| Test | Scenario | Packs | +|------|----------|-------| +| `test_real_packs_sequential_no_conflict` | Install two real packs one-by-one with `--uv-compile` — each resolves successfully, no conflicts | impact, inspire | +| `test_real_packs_simultaneous_no_conflict` | Install two real packs in a single command with `--uv-compile` — resolves successfully, no conflicts | impact, inspire | + +**Progressive conflict** + +| Test | Scenario | Packs | +|------|----------|-------| +| `test_progressive_conflict` | Install real packs (OK) → add conflict-pack-1 (still OK) → add conflict-pack-2 (conflict detected with attribution) | impact, inspire, test1, test2 | + +**Command coverage (--uv-compile flag on each command)** + +| Test | Scenario | Packs | +|------|----------|-------| +| `test_node_reinstall_uv_compile` | Reinstall an installed pack with `--uv-compile` — resolution runs | test1 | +| `test_node_update_uv_compile` | Update an installed pack with `--uv-compile` — resolution runs | test1 | +| `test_node_fix_uv_compile` | Fix an installed pack with `--uv-compile` — resolution runs | test1 | +| `test_node_restore_deps_uv_compile` | `restore-dependencies --uv-compile` — resolution runs | test1 | + +**Standalone uv-sync** + +| Test | Scenario | Packs | +|------|----------|-------| +| `test_node_uv_sync_standalone` | `comfy node uv-sync` resolves installed pack dependencies | test1 | +| `test_node_uv_sync_standalone_conflict` | `comfy node uv-sync` with conflicting packs — shows conflict attribution | test1, test2 | + +**Config default and overrides** + +| Test | Scenario | Packs | +|------|----------|-------| +| `test_uv_compile_config_default` | `uv-compile-default true` → install without flag triggers resolution | test1 | +| `test_no_uv_compile_overrides_config` | Config default enabled, `--no-uv-compile` overrides — resolution does not run | test1 | + +**Mutual exclusivity** + +| Test | Scenario | Packs | +|------|----------|-------| +| `test_uv_compile_mutual_exclusivity` | `--uv-compile` with `--fast-deps` or `--no-deps` — rejected with error | test1 | + +#### Fixtures and isolation + +- `workspace` (module-scoped): installs ComfyUI, launches server in background, + yields workspace path, stops server on teardown. +- `comfy_cli`: returns `comfy --workspace {ws}` command prefix. +- `_clean_test_packs` (autouse, function-scoped): removes conflict fixture packs + before and after each test. Real packs are **not** removed between tests + (they persist in the workspace). +- Config default tests use `try/finally` to restore the setting after each test. + +## Notes + +- E2E tests create a temporary workspace directory (`comfy-`) in the + current working directory. It is **not** automatically cleaned up. +- Each test file has its own `workspace` fixture (`module`-scoped) — all tests + within a file share a single ComfyUI installation. +- Tests that require Manager v4 are automatically skipped when `cm_cli` is not + importable. diff --git a/tests/comfy_cli/command/nodes/test_node_install.py b/tests/comfy_cli/command/nodes/test_node_install.py index 4e61c61c..5c9fc7d0 100644 --- a/tests/comfy_cli/command/nodes/test_node_install.py +++ b/tests/comfy_cli/command/nodes/test_node_install.py @@ -10,13 +10,11 @@ def strip_ansi(text): - """Remove ANSI escape sequences from text.""" ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") return ansi_escape.sub("", text) def test_install_no_deps_option_exists(): - """Test that the --no-deps option appears in the help.""" result = runner.invoke(app, ["install", "--help"]) assert result.exit_code == 0 clean_output = strip_ansi(result.stdout) @@ -25,95 +23,309 @@ def test_install_no_deps_option_exists(): def test_install_fast_deps_and_no_deps_mutually_exclusive(): - """Test that --fast-deps and --no-deps cannot be used together.""" result = runner.invoke(app, ["install", "test-node", "--fast-deps", "--no-deps"]) assert result.exit_code != 0 - # Check output for the error message assert "Cannot use --fast-deps and --no-deps together" in result.output def test_install_no_deps_alone_works(): - """Test that --no-deps can be used by itself.""" with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute: result = runner.invoke(app, ["install", "test-node", "--no-deps"]) - - # Should not exit with error due to mutual exclusivity - if result.exit_code != 0: - # Only acceptable if it fails due to missing ComfyUI setup, not mutual exclusivity - assert "Cannot use --fast-deps and --no-deps together" not in result.stdout - - # Verify execute_cm_cli was called with no_deps=True - if mock_execute.called: - _, kwargs = mock_execute.call_args - assert kwargs.get("no_deps") is True - assert kwargs.get("fast_deps") is False + assert result.exit_code == 0 + mock_execute.assert_called_once() + _, kwargs = mock_execute.call_args + assert kwargs.get("no_deps") is True + assert kwargs.get("fast_deps") is False def test_install_fast_deps_alone_works(): - """Test that --fast-deps can be used by itself.""" with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute: result = runner.invoke(app, ["install", "test-node", "--fast-deps"]) - - # Should not exit with error due to mutual exclusivity - if result.exit_code != 0: - # Only acceptable if it fails due to missing ComfyUI setup, not mutual exclusivity - assert "Cannot use --fast-deps and --no-deps together" not in result.stdout - - # Verify execute_cm_cli was called with fast_deps=True - if mock_execute.called: - _, kwargs = mock_execute.call_args - assert kwargs.get("fast_deps") is True - assert kwargs.get("no_deps") is False + assert result.exit_code == 0 + mock_execute.assert_called_once() + _, kwargs = mock_execute.call_args + assert kwargs.get("fast_deps") is True + assert kwargs.get("no_deps") is False def test_install_neither_deps_option(): - """Test that install works without any deps options.""" with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute: result = runner.invoke(app, ["install", "test-node"]) - - # Should not exit with error due to mutual exclusivity - if result.exit_code != 0: - # Only acceptable if it fails due to missing ComfyUI setup, not mutual exclusivity - assert "Cannot use --fast-deps and --no-deps together" not in result.stdout - - # Verify execute_cm_cli was called with both flags False - if mock_execute.called: - _, kwargs = mock_execute.call_args - assert kwargs.get("fast_deps") is False - assert kwargs.get("no_deps") is False + assert result.exit_code == 0 + mock_execute.assert_called_once() + _, kwargs = mock_execute.call_args + assert kwargs.get("fast_deps") is False + assert kwargs.get("no_deps") is False def test_multiple_commands_work_independently(): - """Test that multiple commands work independently without state interference.""" - # First command with --no-deps should work with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli"): result1 = runner.invoke(app, ["install", "test-node", "--no-deps"]) - if result1.exit_code != 0: - assert "Cannot use --fast-deps and --no-deps together" not in result1.stdout + assert result1.exit_code == 0 - # Second command with --fast-deps should also work with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli"): result2 = runner.invoke(app, ["install", "test-node2", "--fast-deps"]) - if result2.exit_code != 0: - assert "Cannot use --fast-deps and --no-deps together" not in result2.stdout + assert result2.exit_code == 0 + + +def test_install_uv_compile_passes_to_execute(): + with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute: + result = runner.invoke(app, ["install", "test-node", "--uv-compile"]) + assert result.exit_code == 0 + mock_execute.assert_called_once() + _, kwargs = mock_execute.call_args + assert kwargs.get("uv_compile") is True + assert kwargs.get("fast_deps") is False + assert kwargs.get("no_deps") is False + + +def test_install_no_uv_compile_passes_false(): + with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute: + result = runner.invoke(app, ["install", "test-node", "--no-uv-compile"]) + assert result.exit_code == 0 + mock_execute.assert_called_once() + _, kwargs = mock_execute.call_args + assert kwargs.get("uv_compile") is False + + +def test_install_uv_compile_and_fast_deps_mutually_exclusive(): + result = runner.invoke(app, ["install", "test-node", "--uv-compile", "--fast-deps"]) + assert result.exit_code != 0 + assert "Cannot use" in result.output + + +def test_install_uv_compile_and_no_deps_mutually_exclusive(): + result = runner.invoke(app, ["install", "test-node", "--uv-compile", "--no-deps"]) + assert result.exit_code != 0 + assert "Cannot use" in result.output + + +def test_uv_sync_calls_execute_cm_cli(): + with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute: + result = runner.invoke(app, ["uv-sync"]) + assert result.exit_code == 0 + mock_execute.assert_called_once() + args, _ = mock_execute.call_args + assert args[0] == ["uv-sync"] + + +def test_reinstall_uv_compile_passes_to_execute(): + with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute: + result = runner.invoke(app, ["reinstall", "test-node", "--uv-compile"]) + assert result.exit_code == 0 + mock_execute.assert_called_once() + _, kwargs = mock_execute.call_args + assert kwargs.get("uv_compile") is True + + +def test_reinstall_uv_compile_and_fast_deps_mutually_exclusive(): + result = runner.invoke(app, ["reinstall", "test-node", "--uv-compile", "--fast-deps"]) + assert result.exit_code != 0 + assert "Cannot use" in result.output + + +def test_reinstall_no_uv_compile_passes_false(): + with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute: + result = runner.invoke(app, ["reinstall", "test-node", "--no-uv-compile"]) + assert result.exit_code == 0 + mock_execute.assert_called_once() + _, kwargs = mock_execute.call_args + assert kwargs.get("uv_compile") is False def test_install_exit_on_fail_reraises_and_propagates_code(): - """ - When --exit-on-fail is used and the underlying cm-cli install fails, - the CalledProcessError is re-raised and the Typer command exits with the same code. - """ with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute: - # Simulate ComfyUI-Manager's cm-cli failing with a nonzero return code mock_execute.side_effect = subprocess.CalledProcessError(7, "cm-cli") - result = runner.invoke(app, ["install", "bad-node", "--exit-on-fail"]) - - # Typer should exit with the original return code assert result.exit_code == 7 - - # Ensure we asked execute_cm_cli to raise on error and forwarded flags correctly assert mock_execute.called args, kwargs = mock_execute.call_args assert kwargs.get("raise_on_error") is True assert args[0][0] == "install" and "--exit-on-fail" in args[0] and "bad-node" in args[0] + + +def test_save_snapshot_no_output(): + with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute: + result = runner.invoke(app, ["save-snapshot"]) + assert result.exit_code == 0 + mock_execute.assert_called_once() + args, _ = mock_execute.call_args + assert args[0] == ["save-snapshot"] + + +def test_save_snapshot_with_output(): + with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute: + result = runner.invoke(app, ["save-snapshot", "--output", "/tmp/snap.json"]) + assert result.exit_code == 0 + mock_execute.assert_called_once() + args, _ = mock_execute.call_args + assert args[0][0] == "save-snapshot" + assert "--output" in args[0] + + +def test_restore_snapshot_with_uv_compile(): + with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute: + result = runner.invoke(app, ["restore-snapshot", "/tmp/snap.json", "--uv-compile"]) + assert result.exit_code == 0 + mock_execute.assert_called_once() + _, kwargs = mock_execute.call_args + assert kwargs.get("uv_compile") is True + + +def test_restore_snapshot_with_pip_flags(): + with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute: + result = runner.invoke(app, ["restore-snapshot", "/tmp/snap.json", "--pip-non-url", "--pip-local-url"]) + assert result.exit_code == 0 + mock_execute.assert_called_once() + args, _ = mock_execute.call_args + assert "--pip-non-url" in args[0] + assert "--pip-local-url" in args[0] + + +def test_restore_dependencies_with_uv_compile(): + with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute: + result = runner.invoke(app, ["restore-dependencies", "--uv-compile"]) + assert result.exit_code == 0 + mock_execute.assert_called_once() + _, kwargs = mock_execute.call_args + assert kwargs.get("uv_compile") is True + + +def test_update_with_uv_compile(): + with ( + patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute, + patch("comfy_cli.command.custom_nodes.command.update_node_id_cache"), + ): + result = runner.invoke(app, ["update", "test-node", "--uv-compile"]) + assert result.exit_code == 0 + mock_execute.assert_called_once() + _, kwargs = mock_execute.call_args + assert kwargs.get("uv_compile") is True + + +def test_fix_with_uv_compile(): + with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute: + result = runner.invoke(app, ["fix", "test-node", "--uv-compile"]) + assert result.exit_code == 0 + mock_execute.assert_called_once() + _, kwargs = mock_execute.call_args + assert kwargs.get("uv_compile") is True + + +def test_uninstall_rejects_all(): + result = runner.invoke(app, ["uninstall", "all"]) + assert result.exit_code != 0 + + +def test_reinstall_rejects_all(): + result = runner.invoke(app, ["reinstall", "all"]) + assert result.exit_code != 0 + + +def test_validate_mode_rejects_invalid(): + result = runner.invoke(app, ["install", "test-node", "--mode", "invalid-mode"]) + assert result.exit_code != 0 + assert "Invalid mode" in result.output + + +def test_install_deps_with_deps_file(): + with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute: + result = runner.invoke(app, ["install-deps", "--deps", "/tmp/deps.json"]) + assert result.exit_code == 0 + mock_execute.assert_called_once() + args, _ = mock_execute.call_args + assert "install-deps" in args[0] + + +def test_install_deps_with_uv_compile(): + with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute: + result = runner.invoke(app, ["install-deps", "--deps", "/tmp/deps.json", "--uv-compile"]) + assert result.exit_code == 0 + mock_execute.assert_called_once() + _, kwargs = mock_execute.call_args + assert kwargs.get("uv_compile") is True + + +def test_install_deps_no_args_shows_error(): + with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli"): + result = runner.invoke(app, ["install-deps"]) + assert "One of --deps or --workflow" in result.output + + +def test_restore_snapshot_with_pip_non_local_url(): + with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute: + result = runner.invoke(app, ["restore-snapshot", "/tmp/snap.json", "--pip-non-local-url"]) + assert result.exit_code == 0 + mock_execute.assert_called_once() + args, _ = mock_execute.call_args + assert "--pip-non-local-url" in args[0] + + +def test_update_calls_update_node_id_cache(): + with ( + patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute, + patch("comfy_cli.command.custom_nodes.command.update_node_id_cache") as mock_cache, + ): + result = runner.invoke(app, ["update", "test-node"]) + assert result.exit_code == 0 + mock_execute.assert_called_once() + mock_cache.assert_called_once() + + +def test_uninstall_calls_execute(): + with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute: + result = runner.invoke(app, ["uninstall", "test-node"]) + assert result.exit_code == 0 + mock_execute.assert_called_once() + args, _ = mock_execute.call_args + assert args[0] == ["uninstall", "test-node"] + + +def test_show_installed(): + with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute: + result = runner.invoke(app, ["show", "installed"]) + assert result.exit_code == 0 + mock_execute.assert_called_once() + args, _ = mock_execute.call_args + assert args[0] == ["show", "installed"] + + +def test_install_deps_with_workflow(tmp_path): + workflow_file = tmp_path / "workflow.json" + workflow_file.write_text("{}") + with ( + patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute, + patch("comfy_cli.command.custom_nodes.command.workspace_manager") as mock_ws, + ): + mock_ws.config_manager.get_config_path.return_value = str(tmp_path) + result = runner.invoke(app, ["install-deps", "--workflow", str(workflow_file)]) + assert result.exit_code == 0 + assert mock_execute.call_count == 2 + first_call_args = mock_execute.call_args_list[0][0][0] + second_call_args = mock_execute.call_args_list[1][0][0] + assert first_call_args[0] == "deps-in-workflow" + assert second_call_args[0] == "install-deps" + + +def test_install_rejects_all(): + result = runner.invoke(app, ["install", "all"]) + assert result.exit_code != 0 + assert "not allowed" in result.output + + +def test_simple_show_installed(): + with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute: + result = runner.invoke(app, ["simple-show", "installed"]) + assert result.exit_code == 0 + mock_execute.assert_called_once() + args, _ = mock_execute.call_args + assert args[0] == ["simple-show", "installed"] + + +def test_show_with_channel(): + with patch("comfy_cli.command.custom_nodes.command.execute_cm_cli") as mock_execute: + result = runner.invoke(app, ["show", "installed", "--channel", "dev"]) + assert result.exit_code == 0 + mock_execute.assert_called_once() + _, kwargs = mock_execute.call_args + assert kwargs.get("channel") == "dev" diff --git a/tests/comfy_cli/command/test_cm_cli_util.py b/tests/comfy_cli/command/test_cm_cli_util.py new file mode 100644 index 00000000..9f4a8ae2 --- /dev/null +++ b/tests/comfy_cli/command/test_cm_cli_util.py @@ -0,0 +1,378 @@ +import subprocess +import sys +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from comfy_cli.command.custom_nodes import cm_cli_util + + +def _make_mock_proc(returncode, stdout_lines=None, stderr_lines=None): + """Create a mock Popen process with given returncode and stdout/stderr lines.""" + mock_proc = MagicMock() + mock_proc.stdout = iter(stdout_lines or []) + mock_proc.stderr = iter(stderr_lines or []) + mock_proc.wait.return_value = returncode + return mock_proc + + +@pytest.fixture(autouse=True) +def _clear_find_cm_cli_cache(): + cm_cli_util.find_cm_cli.cache_clear() + yield + cm_cli_util.find_cm_cli.cache_clear() + + +@pytest.fixture() +def _cm_cli_env(tmp_path): + mock_proc = MagicMock() + mock_proc.stdout = iter(["ok\n"]) + mock_proc.stderr = iter([]) + mock_proc.wait.return_value = 0 + with ( + patch.object(cm_cli_util.workspace_manager, "workspace_path", str(tmp_path)), + patch.object(cm_cli_util.workspace_manager, "set_recent_workspace"), + patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") as mock_cfg, + patch("comfy_cli.command.custom_nodes.cm_cli_util.check_comfy_repo", return_value=(True, None)), + patch("comfy_cli.command.custom_nodes.cm_cli_util.resolve_workspace_python", return_value="/resolved/python"), + patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli", return_value=True), + patch("comfy_cli.command.custom_nodes.cm_cli_util.subprocess.Popen", return_value=mock_proc) as mock_popen, + patch("comfy_cli.command.custom_nodes.cm_cli_util.DependencyCompiler") as mock_compiler, + ): + mock_cfg.return_value.get_config_path.return_value = str(tmp_path / "config") + mock_compiler.return_value = MagicMock() + yield {"mock_popen": mock_popen, "mock_proc": mock_proc, "mock_compiler": mock_compiler} + + +class TestFindCmCli: + def test_returns_true_when_module_exists_same_python(self): + """When workspace Python == sys.executable, checks importlib.util.find_spec.""" + with ( + patch.object(cm_cli_util.workspace_manager, "workspace_path", "/fake/workspace"), + patch( + "comfy_cli.command.custom_nodes.cm_cli_util.resolve_workspace_python", + return_value=sys.executable, + ), + patch("comfy_cli.command.custom_nodes.cm_cli_util.importlib.util.find_spec", return_value=MagicMock()), + ): + assert cm_cli_util.find_cm_cli() is True + + def test_returns_true_no_workspace_module_exists(self): + """When no workspace, falls back to importlib.util.find_spec.""" + with ( + patch.object(cm_cli_util.workspace_manager, "workspace_path", None), + patch("comfy_cli.command.custom_nodes.cm_cli_util.importlib.util.find_spec", return_value=MagicMock()), + ): + assert cm_cli_util.find_cm_cli() is True + + def test_returns_false_when_module_missing(self): + with ( + patch("comfy_cli.command.custom_nodes.cm_cli_util.importlib.util.find_spec", return_value=None), + patch.object(cm_cli_util.workspace_manager, "workspace_path", None), + ): + assert cm_cli_util.find_cm_cli() is False + + def test_returns_true_when_found_in_workspace_venv(self): + mock_result = MagicMock(returncode=0) + with ( + patch.object(cm_cli_util.workspace_manager, "workspace_path", "/fake/workspace"), + patch( + "comfy_cli.command.custom_nodes.cm_cli_util.resolve_workspace_python", + return_value="/fake/venv/python", + ), + patch("comfy_cli.command.custom_nodes.cm_cli_util.subprocess.run", return_value=mock_result), + ): + assert cm_cli_util.find_cm_cli() is True + + def test_returns_false_when_missing_from_workspace_venv(self): + mock_result = MagicMock(returncode=1) + with ( + patch.object(cm_cli_util.workspace_manager, "workspace_path", "/fake/workspace"), + patch( + "comfy_cli.command.custom_nodes.cm_cli_util.resolve_workspace_python", + return_value="/fake/venv/python", + ), + patch("comfy_cli.command.custom_nodes.cm_cli_util.subprocess.run", return_value=mock_result), + ): + assert cm_cli_util.find_cm_cli() is False + + def test_workspace_python_checked_first_not_cli_interpreter(self): + """Core bug fix: when workspace Python differs, check workspace NOT cli env. + + Even if importlib.util.find_spec would return True in the CLI env, + the function must check the workspace Python since that's what + execute_cm_cli() actually uses. + """ + mock_result = MagicMock(returncode=1) # cm_cli NOT in workspace venv + with ( + patch.object(cm_cli_util.workspace_manager, "workspace_path", "/fake/workspace"), + patch( + "comfy_cli.command.custom_nodes.cm_cli_util.resolve_workspace_python", + return_value="/fake/venv/python", + ), + patch("comfy_cli.command.custom_nodes.cm_cli_util.subprocess.run", return_value=mock_result), + # find_spec would return True, but should NOT be called when workspace Python differs + patch( + "comfy_cli.command.custom_nodes.cm_cli_util.importlib.util.find_spec", + return_value=MagicMock(), + ) as mock_spec, + ): + assert cm_cli_util.find_cm_cli() is False + mock_spec.assert_not_called() + + def test_result_is_cached(self): + with ( + patch.object(cm_cli_util.workspace_manager, "workspace_path", None), + patch( + "comfy_cli.command.custom_nodes.cm_cli_util.importlib.util.find_spec", return_value=MagicMock() + ) as mock_spec, + ): + cm_cli_util.find_cm_cli() + cm_cli_util.find_cm_cli() + mock_spec.assert_called_once() + + def test_cache_clear_allows_recheck(self): + with ( + patch( + "comfy_cli.command.custom_nodes.cm_cli_util.importlib.util.find_spec", return_value=None + ) as mock_spec, + patch.object(cm_cli_util.workspace_manager, "workspace_path", None), + ): + assert cm_cli_util.find_cm_cli() is False + cm_cli_util.find_cm_cli.cache_clear() + mock_spec.return_value = MagicMock() + assert cm_cli_util.find_cm_cli() is True + + def test_returns_false_on_subprocess_timeout(self): + """When workspace Python check times out, returns False (not fallback to cli).""" + with ( + patch.object(cm_cli_util.workspace_manager, "workspace_path", "/fake/workspace"), + patch( + "comfy_cli.command.custom_nodes.cm_cli_util.resolve_workspace_python", + return_value="/fake/venv/python", + ), + patch( + "comfy_cli.command.custom_nodes.cm_cli_util.subprocess.run", + side_effect=subprocess.TimeoutExpired(cmd="test", timeout=10), + ), + ): + assert cm_cli_util.find_cm_cli() is False + + +class TestResolveManagerGuiMode: + def test_returns_config_mode_when_set(self): + with patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") as mock_cfg: + mock_cfg.return_value.get.side_effect = lambda k: "disable" if k == "manager_gui_mode" else None + assert cm_cli_util.resolve_manager_gui_mode() == "disable" + + def test_legacy_false_returns_disable(self): + with patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") as mock_cfg: + mock_cfg.return_value.get.side_effect = lambda k: "False" if k == "manager_gui_enabled" else None + assert cm_cli_util.resolve_manager_gui_mode() == "disable" + + def test_legacy_true_returns_enable_gui(self): + with patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") as mock_cfg: + mock_cfg.return_value.get.side_effect = lambda k: "True" if k == "manager_gui_enabled" else None + assert cm_cli_util.resolve_manager_gui_mode() == "enable-gui" + + def test_legacy_boolean_0_returns_disable(self): + with patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") as mock_cfg: + mock_cfg.return_value.get.side_effect = lambda k: "0" if k == "manager_gui_enabled" else None + assert cm_cli_util.resolve_manager_gui_mode() == "disable" + + def test_no_config_manager_available_returns_enable_gui(self): + with ( + patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") as mock_cfg, + patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli", return_value=True), + ): + mock_cfg.return_value.get.return_value = None + assert cm_cli_util.resolve_manager_gui_mode() == "enable-gui" + + def test_no_config_no_manager_returns_not_installed_value(self): + with ( + patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") as mock_cfg, + patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli", return_value=False), + ): + mock_cfg.return_value.get.return_value = None + assert cm_cli_util.resolve_manager_gui_mode("not-installed") == "not-installed" + + def test_no_config_no_manager_returns_none_by_default(self): + with ( + patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") as mock_cfg, + patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli", return_value=False), + ): + mock_cfg.return_value.get.return_value = None + assert cm_cli_util.resolve_manager_gui_mode() is None + + +class TestExecuteCmCli: + def test_no_workspace_raises_exit(self): + with ( + patch.object(cm_cli_util.workspace_manager, "workspace_path", None), + pytest.raises(typer.Exit), + ): + cm_cli_util.execute_cm_cli(["show"]) + + def test_no_cm_cli_raises_exit(self): + with ( + patch.object(cm_cli_util.workspace_manager, "workspace_path", "/workspace"), + patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli", return_value=False), + pytest.raises(typer.Exit), + ): + cm_cli_util.execute_cm_cli(["show"]) + + def test_happy_path_returns_stdout(self, _cm_cli_env): + result = cm_cli_util.execute_cm_cli(["show", "installed"]) + assert result == "ok\n" + + def test_cmd_uses_python_m_cm_cli(self, _cm_cli_env): + cm_cli_util.execute_cm_cli(["show"]) + cmd = _cm_cli_env["mock_popen"].call_args[0][0] + assert cmd[:3] == ["/resolved/python", "-m", "cm_cli"] + + def test_channel_appended(self, _cm_cli_env): + cm_cli_util.execute_cm_cli(["show"], channel="stable") + cmd = _cm_cli_env["mock_popen"].call_args[0][0] + assert "--channel" in cmd + assert cmd[cmd.index("--channel") + 1] == "stable" + + def test_uv_compile_flag(self, _cm_cli_env): + cm_cli_util.execute_cm_cli(["install", "node"], uv_compile=True) + cmd = _cm_cli_env["mock_popen"].call_args[0][0] + assert "--uv-compile" in cmd + + def test_fast_deps_adds_no_deps(self, _cm_cli_env): + cm_cli_util.execute_cm_cli(["install", "node"], fast_deps=True) + cmd = _cm_cli_env["mock_popen"].call_args[0][0] + assert "--no-deps" in cmd + + def test_no_deps_adds_no_deps(self, _cm_cli_env): + cm_cli_util.execute_cm_cli(["install", "node"], no_deps=True) + cmd = _cm_cli_env["mock_popen"].call_args[0][0] + assert "--no-deps" in cmd + + def test_uv_compile_takes_precedence_over_fast_deps(self, _cm_cli_env): + cm_cli_util.execute_cm_cli(["install", "node"], uv_compile=True, fast_deps=True) + cmd = _cm_cli_env["mock_popen"].call_args[0][0] + assert "--uv-compile" in cmd + assert "--no-deps" not in cmd + + def test_mode_appended(self, _cm_cli_env): + cm_cli_util.execute_cm_cli(["install", "node"], mode="remote") + cmd = _cm_cli_env["mock_popen"].call_args[0][0] + assert "--mode" in cmd + assert cmd[cmd.index("--mode") + 1] == "remote" + + def test_error_returncode_1_returns_none(self, tmp_path): + mock_proc = _make_mock_proc(1) + with ( + patch.object(cm_cli_util.workspace_manager, "workspace_path", str(tmp_path)), + patch.object(cm_cli_util.workspace_manager, "set_recent_workspace"), + patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") as mock_cfg, + patch("comfy_cli.command.custom_nodes.cm_cli_util.check_comfy_repo", return_value=(True, None)), + patch("comfy_cli.command.custom_nodes.cm_cli_util.resolve_workspace_python", return_value="/python"), + patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli", return_value=True), + patch("comfy_cli.command.custom_nodes.cm_cli_util.subprocess.Popen", return_value=mock_proc), + ): + mock_cfg.return_value.get_config_path.return_value = str(tmp_path) + result = cm_cli_util.execute_cm_cli(["install", "node"]) + assert result is None + + def test_error_returncode_2_returns_none(self, tmp_path): + mock_proc = _make_mock_proc(2) + with ( + patch.object(cm_cli_util.workspace_manager, "workspace_path", str(tmp_path)), + patch.object(cm_cli_util.workspace_manager, "set_recent_workspace"), + patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") as mock_cfg, + patch("comfy_cli.command.custom_nodes.cm_cli_util.check_comfy_repo", return_value=(True, None)), + patch("comfy_cli.command.custom_nodes.cm_cli_util.resolve_workspace_python", return_value="/python"), + patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli", return_value=True), + patch("comfy_cli.command.custom_nodes.cm_cli_util.subprocess.Popen", return_value=mock_proc), + ): + mock_cfg.return_value.get_config_path.return_value = str(tmp_path) + result = cm_cli_util.execute_cm_cli(["install", "node"]) + assert result is None + + def test_error_other_returncode_raises(self, tmp_path): + mock_proc = _make_mock_proc(42) + with ( + patch.object(cm_cli_util.workspace_manager, "workspace_path", str(tmp_path)), + patch.object(cm_cli_util.workspace_manager, "set_recent_workspace"), + patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") as mock_cfg, + patch("comfy_cli.command.custom_nodes.cm_cli_util.check_comfy_repo", return_value=(True, None)), + patch("comfy_cli.command.custom_nodes.cm_cli_util.resolve_workspace_python", return_value="/python"), + patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli", return_value=True), + patch("comfy_cli.command.custom_nodes.cm_cli_util.subprocess.Popen", return_value=mock_proc), + pytest.raises(subprocess.CalledProcessError, match="42"), + ): + mock_cfg.return_value.get_config_path.return_value = str(tmp_path) + cm_cli_util.execute_cm_cli(["install", "node"]) + + def test_raise_on_error_reraises(self, tmp_path): + mock_proc = _make_mock_proc(1) + with ( + patch.object(cm_cli_util.workspace_manager, "workspace_path", str(tmp_path)), + patch.object(cm_cli_util.workspace_manager, "set_recent_workspace"), + patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") as mock_cfg, + patch("comfy_cli.command.custom_nodes.cm_cli_util.check_comfy_repo", return_value=(True, None)), + patch("comfy_cli.command.custom_nodes.cm_cli_util.resolve_workspace_python", return_value="/python"), + patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli", return_value=True), + patch("comfy_cli.command.custom_nodes.cm_cli_util.subprocess.Popen", return_value=mock_proc), + pytest.raises(subprocess.CalledProcessError), + ): + mock_cfg.return_value.get_config_path.return_value = str(tmp_path) + cm_cli_util.execute_cm_cli(["install", "node"], raise_on_error=True) + + def test_fast_deps_triggers_dependency_compiler(self, tmp_path): + mock_proc = MagicMock() + mock_proc.stdout = iter(["ok\n"]) + mock_proc.stderr = iter([]) + mock_proc.wait.return_value = 0 + with ( + patch.object(cm_cli_util.workspace_manager, "workspace_path", str(tmp_path)), + patch.object(cm_cli_util.workspace_manager, "set_recent_workspace"), + patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") as mock_cfg, + patch("comfy_cli.command.custom_nodes.cm_cli_util.check_comfy_repo", return_value=(True, None)), + patch("comfy_cli.command.custom_nodes.cm_cli_util.resolve_workspace_python", return_value="/python"), + patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli", return_value=True), + patch("comfy_cli.command.custom_nodes.cm_cli_util.subprocess.Popen", return_value=mock_proc), + patch("comfy_cli.command.custom_nodes.cm_cli_util.DependencyCompiler") as mock_compiler, + ): + mock_cfg.return_value.get_config_path.return_value = str(tmp_path) + mock_instance = MagicMock() + mock_compiler.return_value = mock_instance + cm_cli_util.execute_cm_cli(["install", "node"], fast_deps=True) + mock_compiler.assert_called_once() + mock_instance.compile_deps.assert_called_once() + mock_instance.install_deps.assert_called_once() + + def test_fast_deps_non_dependency_cmd_skips_compiler(self, tmp_path): + mock_proc = MagicMock() + mock_proc.stdout = iter(["ok\n"]) + mock_proc.stderr = iter([]) + mock_proc.wait.return_value = 0 + with ( + patch.object(cm_cli_util.workspace_manager, "workspace_path", str(tmp_path)), + patch.object(cm_cli_util.workspace_manager, "set_recent_workspace"), + patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") as mock_cfg, + patch("comfy_cli.command.custom_nodes.cm_cli_util.check_comfy_repo", return_value=(True, None)), + patch("comfy_cli.command.custom_nodes.cm_cli_util.resolve_workspace_python", return_value="/python"), + patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli", return_value=True), + patch("comfy_cli.command.custom_nodes.cm_cli_util.subprocess.Popen", return_value=mock_proc), + patch("comfy_cli.command.custom_nodes.cm_cli_util.DependencyCompiler") as mock_compiler, + ): + mock_cfg.return_value.get_config_path.return_value = str(tmp_path) + cm_cli_util.execute_cm_cli(["show", "all"], fast_deps=True) + mock_compiler.assert_not_called() + + def test_sets_comfyui_path_env(self, _cm_cli_env): + cm_cli_util.execute_cm_cli(["show"]) + env = _cm_cli_env["mock_popen"].call_args[1]["env"] + assert "COMFYUI_PATH" in env + + def test_captures_stderr_via_pipe(self, _cm_cli_env): + """Verify stderr is captured via PIPE (not inherited) to avoid swallowing errors.""" + cm_cli_util.execute_cm_cli(["show"]) + kwargs = _cm_cli_env["mock_popen"].call_args[1] + assert kwargs["stderr"] == subprocess.PIPE diff --git a/tests/comfy_cli/command/test_command.py b/tests/comfy_cli/command/test_command.py index 40168bba..86486137 100644 --- a/tests/comfy_cli/command/test_command.py +++ b/tests/comfy_cli/command/test_command.py @@ -50,9 +50,8 @@ def test_install_here(cmd, runner, mock_execute, mock_prompt_select_enum): assert result.exit_code == 0, result.stdout args, _ = mock_execute.call_args - url, manager_url, comfy_path, *_ = args + url, comfy_path, *_ = args assert url == "https://github.com/comfyanonymous/ComfyUI" - assert manager_url == "https://github.com/ltdrdata/ComfyUI-Manager" assert comfy_path == os.path.join(os.getcwd(), "ComfyUI") diff --git a/tests/comfy_cli/command/test_manager_gui.py b/tests/comfy_cli/command/test_manager_gui.py new file mode 100644 index 00000000..b515f066 --- /dev/null +++ b/tests/comfy_cli/command/test_manager_gui.py @@ -0,0 +1,1157 @@ +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from comfy_cli import constants +from comfy_cli.command.launch import _get_manager_flags + + +@pytest.fixture() +def mock_config_manager(): + with patch("comfy_cli.command.custom_nodes.command.ConfigManager") as mock_cls: + instance = MagicMock() + mock_cls.return_value = instance + yield instance + + +@pytest.fixture() +def mock_launch_config_manager(): + with patch("comfy_cli.command.launch.ConfigManager") as mock_cls: + instance = MagicMock() + mock_cls.return_value = instance + yield instance + + +class TestManagerCommands: + def test_disable_manager_sets_config(self, mock_config_manager): + from comfy_cli.command.custom_nodes.command import disable_manager + + disable_manager() + + mock_config_manager.set.assert_called_once_with(constants.CONFIG_KEY_MANAGER_GUI_MODE, "disable") + + def test_enable_gui_sets_config(self, mock_config_manager): + from comfy_cli.command.custom_nodes.command import enable_gui + + enable_gui() + + mock_config_manager.set.assert_called_once_with(constants.CONFIG_KEY_MANAGER_GUI_MODE, "enable-gui") + + def test_disable_gui_sets_config(self, mock_config_manager): + from comfy_cli.command.custom_nodes.command import disable_gui + + disable_gui() + + mock_config_manager.set.assert_called_once_with(constants.CONFIG_KEY_MANAGER_GUI_MODE, "disable-gui") + + def test_enable_legacy_gui_sets_config(self, mock_config_manager): + from comfy_cli.command.custom_nodes.command import enable_legacy_gui + + enable_legacy_gui() + + mock_config_manager.set.assert_called_once_with(constants.CONFIG_KEY_MANAGER_GUI_MODE, "enable-legacy-gui") + + +class TestGetManagerFlags: + @patch("comfy_cli.command.launch.resolve_manager_gui_mode", return_value="disable") + def test_disable_mode_returns_empty(self, mock_resolve): + result = _get_manager_flags() + assert result == [] + + @patch("comfy_cli.command.launch.find_cm_cli", return_value=True) + @patch("comfy_cli.command.launch.resolve_manager_gui_mode", return_value="enable-gui") + def test_enable_gui_mode_returns_enable_manager(self, mock_resolve, mock_find): + result = _get_manager_flags() + assert result == ["--enable-manager"] + + @patch("comfy_cli.command.launch.find_cm_cli", return_value=True) + @patch("comfy_cli.command.launch.resolve_manager_gui_mode", return_value="disable-gui") + def test_disable_gui_mode_returns_both_flags(self, mock_resolve, mock_find): + result = _get_manager_flags() + assert result == ["--enable-manager", "--disable-manager-ui"] + + @patch("comfy_cli.command.launch.find_cm_cli", return_value=True) + @patch("comfy_cli.command.launch.resolve_manager_gui_mode", return_value="enable-legacy-gui") + def test_enable_legacy_gui_mode_returns_legacy_flags(self, mock_resolve, mock_find): + result = _get_manager_flags() + assert result == ["--enable-manager", "--enable-manager-legacy-ui"] + + @patch("comfy_cli.command.launch.find_cm_cli", return_value=True) + @patch("comfy_cli.command.launch.resolve_manager_gui_mode", return_value="unknown-mode") + def test_unknown_mode_returns_default_with_warning(self, mock_resolve, mock_find, capsys): + result = _get_manager_flags() + assert result == ["--enable-manager"] + captured = capsys.readouterr() + assert "unknown-mode" in captured.out.lower() or "Unknown manager mode" in captured.out + + @patch("comfy_cli.command.launch.find_cm_cli", return_value=False) + @patch("comfy_cli.command.launch.resolve_manager_gui_mode", return_value="enable-gui") + def test_enable_mode_without_cmcli_returns_empty(self, mock_resolve, mock_find): + """When config is enable-* but cm-cli is not available, return empty list.""" + result = _get_manager_flags() + assert result == [] + mock_find.assert_called_once() + + @patch("comfy_cli.command.launch.resolve_manager_gui_mode", return_value=None) + def test_not_installed_returns_empty(self, mock_resolve): + """When resolve returns None (not installed), return empty list.""" + result = _get_manager_flags() + assert result == [] + + +class TestResolveManagerGuiMode: + """Tests for resolve_manager_gui_mode shared helper.""" + + @patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") + def test_returns_configured_mode(self, mock_cm_cls): + instance = MagicMock() + mock_cm_cls.return_value = instance + instance.get.return_value = "disable-gui" + + from comfy_cli.command.custom_nodes.cm_cli_util import resolve_manager_gui_mode + + assert resolve_manager_gui_mode() == "disable-gui" + + @patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli", return_value=True) + @patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") + def test_old_config_false_migrates_to_disable(self, mock_cm_cls, mock_find): + instance = MagicMock() + mock_cm_cls.return_value = instance + instance.get.side_effect = lambda key: { + constants.CONFIG_KEY_MANAGER_GUI_MODE: None, + constants.CONFIG_KEY_MANAGER_GUI_ENABLED: "False", + }.get(key) + + from comfy_cli.command.custom_nodes.cm_cli_util import resolve_manager_gui_mode + + assert resolve_manager_gui_mode() == "disable" + + @patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli", return_value=True) + @patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") + def test_old_config_true_migrates_to_enable_gui(self, mock_cm_cls, mock_find): + instance = MagicMock() + mock_cm_cls.return_value = instance + instance.get.side_effect = lambda key: { + constants.CONFIG_KEY_MANAGER_GUI_MODE: None, + constants.CONFIG_KEY_MANAGER_GUI_ENABLED: "True", + }.get(key) + + from comfy_cli.command.custom_nodes.cm_cli_util import resolve_manager_gui_mode + + assert resolve_manager_gui_mode() == "enable-gui" + + @patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli", return_value=True) + @patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") + def test_no_config_with_cmcli_defaults_to_enable_gui(self, mock_cm_cls, mock_find): + instance = MagicMock() + mock_cm_cls.return_value = instance + instance.get.return_value = None + + from comfy_cli.command.custom_nodes.cm_cli_util import resolve_manager_gui_mode + + assert resolve_manager_gui_mode() == "enable-gui" + + @patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli", return_value=False) + @patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") + def test_no_config_no_cmcli_returns_not_installed_value(self, mock_cm_cls, mock_find): + instance = MagicMock() + mock_cm_cls.return_value = instance + instance.get.return_value = None + + from comfy_cli.command.custom_nodes.cm_cli_util import resolve_manager_gui_mode + + assert resolve_manager_gui_mode(not_installed_value=None) is None + assert resolve_manager_gui_mode(not_installed_value="not-installed") == "not-installed" + + @patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") + def test_old_config_boolean_false_migrates_to_disable(self, mock_cm_cls): + """Test backward compatibility with actual boolean False value.""" + instance = MagicMock() + mock_cm_cls.return_value = instance + instance.get.side_effect = lambda key: { + constants.CONFIG_KEY_MANAGER_GUI_MODE: None, + constants.CONFIG_KEY_MANAGER_GUI_ENABLED: False, + }.get(key) + + from comfy_cli.command.custom_nodes.cm_cli_util import resolve_manager_gui_mode + + assert resolve_manager_gui_mode() == "disable" + + @patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli", return_value=True) + @patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") + def test_old_config_boolean_true_migrates_to_enable_gui(self, mock_cm_cls, mock_find): + """Test backward compatibility with actual boolean True value.""" + instance = MagicMock() + mock_cm_cls.return_value = instance + instance.get.side_effect = lambda key: { + constants.CONFIG_KEY_MANAGER_GUI_MODE: None, + constants.CONFIG_KEY_MANAGER_GUI_ENABLED: True, + }.get(key) + + from comfy_cli.command.custom_nodes.cm_cli_util import resolve_manager_gui_mode + + assert resolve_manager_gui_mode() == "enable-gui" + + +class TestLaunchManagerFlagInjection: + @patch("comfy_cli.command.launch.launch_comfyui") + @patch("comfy_cli.command.launch._get_manager_flags", return_value=["--enable-manager"]) + @patch("comfy_cli.command.launch.workspace_manager") + @patch("comfy_cli.command.launch.check_for_updates") + @patch("os.chdir") + def test_launch_injects_enable_manager( + self, mock_chdir, mock_updates, mock_ws, mock_get_flags, mock_launch_comfyui + ): + mock_ws.workspace_path = "/fake/workspace" + mock_ws.workspace_type = "default" + mock_ws.config_manager.config = {"DEFAULT": {}} + + from comfy_cli.command.launch import launch + + launch(background=False, extra=["--port", "8188"]) + + args, kwargs = mock_launch_comfyui.call_args + extra_arg = args[0] + assert "--enable-manager" in extra_arg + assert "--port" in extra_arg + + @patch("comfy_cli.command.launch.launch_comfyui") + @patch("comfy_cli.command.launch._get_manager_flags", return_value=[]) + @patch("comfy_cli.command.launch.workspace_manager") + @patch("comfy_cli.command.launch.check_for_updates") + @patch("os.chdir") + def test_launch_no_inject_when_disabled( + self, mock_chdir, mock_updates, mock_ws, mock_get_flags, mock_launch_comfyui + ): + mock_ws.workspace_path = "/fake/workspace" + mock_ws.workspace_type = "default" + mock_ws.config_manager.config = {"DEFAULT": {}} + + from comfy_cli.command.launch import launch + + launch(background=False, extra=["--port", "8188"]) + + args, kwargs = mock_launch_comfyui.call_args + extra_arg = args[0] + assert "--enable-manager" not in extra_arg + + @patch("comfy_cli.command.launch.launch_comfyui") + @patch("comfy_cli.command.launch._get_manager_flags", return_value=["--enable-manager"]) + @patch("comfy_cli.command.launch.workspace_manager") + @patch("comfy_cli.command.launch.check_for_updates") + @patch("os.chdir") + def test_launch_injects_when_extra_is_none( + self, mock_chdir, mock_updates, mock_ws, mock_get_flags, mock_launch_comfyui + ): + mock_ws.workspace_path = "/fake/workspace" + mock_ws.workspace_type = "not_default" + mock_ws.config_manager.config = {"DEFAULT": {}} + + from comfy_cli.command.launch import launch + + launch(background=False, extra=None) + + args, kwargs = mock_launch_comfyui.call_args + extra_arg = args[0] + assert extra_arg == ["--enable-manager"] + + @patch("comfy_cli.command.launch.launch_comfyui") + @patch("comfy_cli.command.launch._get_manager_flags", return_value=["--enable-manager", "--disable-manager-ui"]) + @patch("comfy_cli.command.launch.workspace_manager") + @patch("comfy_cli.command.launch.check_for_updates") + @patch("os.chdir") + def test_launch_injects_disable_gui_flags( + self, mock_chdir, mock_updates, mock_ws, mock_get_flags, mock_launch_comfyui + ): + mock_ws.workspace_path = "/fake/workspace" + mock_ws.workspace_type = "not_default" + mock_ws.config_manager.config = {"DEFAULT": {}} + + from comfy_cli.command.launch import launch + + launch(background=False, extra=None) + + args, kwargs = mock_launch_comfyui.call_args + extra_arg = args[0] + assert "--enable-manager" in extra_arg + assert "--disable-manager-ui" in extra_arg + + @patch("comfy_cli.command.launch.launch_comfyui") + @patch( + "comfy_cli.command.launch._get_manager_flags", return_value=["--enable-manager", "--enable-manager-legacy-ui"] + ) + @patch("comfy_cli.command.launch.workspace_manager") + @patch("comfy_cli.command.launch.check_for_updates") + @patch("os.chdir") + def test_launch_injects_legacy_gui_flags( + self, mock_chdir, mock_updates, mock_ws, mock_get_flags, mock_launch_comfyui + ): + mock_ws.workspace_path = "/fake/workspace" + mock_ws.workspace_type = "not_default" + mock_ws.config_manager.config = {"DEFAULT": {}} + + from comfy_cli.command.launch import launch + + launch(background=False, extra=None) + + args, kwargs = mock_launch_comfyui.call_args + extra_arg = args[0] + assert "--enable-manager" in extra_arg + assert "--enable-manager-legacy-ui" in extra_arg + + +class TestMigrateLegacy: + @patch("comfy_cli.command.custom_nodes.command.workspace_manager") + def test_migrate_legacy_no_workspace_exits(self, mock_ws, mock_config_manager): + """When workspace is not set, migrate-legacy should exit with error.""" + mock_ws.workspace_path = None + + from comfy_cli.command.custom_nodes.command import migrate_legacy + + with pytest.raises(typer.Exit) as exc_info: + migrate_legacy(yes=True) + + assert exc_info.value.exit_code == 1 + mock_config_manager.set.assert_not_called() + + @patch("comfy_cli.command.custom_nodes.command.workspace_manager") + def test_migrate_legacy_with_cli_only_mode(self, mock_ws, mock_config_manager, tmp_path): + # Setup: create legacy manager with .enable-cli-only-mode and .git + custom_nodes = tmp_path / "custom_nodes" + legacy_manager = custom_nodes / "ComfyUI-Manager" + legacy_manager.mkdir(parents=True) + (legacy_manager / ".git").mkdir() + (legacy_manager / ".enable-cli-only-mode").touch() + + mock_ws.workspace_path = str(tmp_path) + + from comfy_cli.command.custom_nodes.command import migrate_legacy + + migrate_legacy(yes=True) + + mock_config_manager.set.assert_called_with(constants.CONFIG_KEY_MANAGER_GUI_MODE, "disable") + # Verify moved to .disabled + assert not legacy_manager.exists() + assert (custom_nodes / ".disabled" / "ComfyUI-Manager").exists() + + @patch("comfy_cli.command.custom_nodes.command.subprocess.run") + @patch("comfy_cli.command.custom_nodes.command.workspace_manager") + def test_migrate_legacy_without_cli_only_mode(self, mock_ws, mock_subprocess_run, mock_config_manager, tmp_path): + # Setup: create legacy manager with .git but without .enable-cli-only-mode + custom_nodes = tmp_path / "custom_nodes" + legacy_manager = custom_nodes / "ComfyUI-Manager" + legacy_manager.mkdir(parents=True) + (legacy_manager / ".git").mkdir() + # Create manager_requirements.txt for successful install + (tmp_path / constants.MANAGER_REQUIREMENTS_FILE).write_text("comfyui-manager>=4.1b1") + + mock_ws.workspace_path = str(tmp_path) + mock_subprocess_run.return_value = MagicMock(returncode=0) + + from comfy_cli.command.custom_nodes.command import migrate_legacy + + migrate_legacy(yes=True) + + mock_config_manager.set.assert_called_with(constants.CONFIG_KEY_MANAGER_GUI_MODE, "enable-gui") + # Verify moved to .disabled + assert not legacy_manager.exists() + assert (custom_nodes / ".disabled" / "ComfyUI-Manager").exists() + + @patch("comfy_cli.command.custom_nodes.command.workspace_manager") + def test_migrate_legacy_no_legacy_manager(self, mock_ws, mock_config_manager, tmp_path): + # Setup: no legacy manager + custom_nodes = tmp_path / "custom_nodes" + custom_nodes.mkdir(parents=True) + + mock_ws.workspace_path = str(tmp_path) + + from comfy_cli.command.custom_nodes.command import migrate_legacy + + migrate_legacy(yes=True) + + # Should not call set when no legacy manager found + mock_config_manager.set.assert_not_called() + + @patch("comfy_cli.command.custom_nodes.command.workspace_manager") + def test_migrate_legacy_target_exists(self, mock_ws, mock_config_manager, tmp_path): + # Setup: both source and target exist + custom_nodes = tmp_path / "custom_nodes" + legacy_manager = custom_nodes / "ComfyUI-Manager" + legacy_manager.mkdir(parents=True) + (legacy_manager / ".git").mkdir() + (custom_nodes / ".disabled" / "ComfyUI-Manager").mkdir(parents=True) + + mock_ws.workspace_path = str(tmp_path) + + from comfy_cli.command.custom_nodes.command import migrate_legacy + + with pytest.raises(typer.Exit): + migrate_legacy(yes=True) + + @patch("comfy_cli.command.custom_nodes.command.subprocess.run") + @patch("comfy_cli.command.custom_nodes.command.workspace_manager") + def test_migrate_legacy_lowercase_directory(self, mock_ws, mock_subprocess_run, mock_config_manager, tmp_path): + # Setup: create legacy manager with lowercase name and .git + custom_nodes = tmp_path / "custom_nodes" + legacy_manager = custom_nodes / "comfyui-manager" # lowercase + legacy_manager.mkdir(parents=True) + (legacy_manager / ".git").mkdir() + # Create manager_requirements.txt for successful install + (tmp_path / constants.MANAGER_REQUIREMENTS_FILE).write_text("comfyui-manager>=4.1b1") + + mock_ws.workspace_path = str(tmp_path) + mock_subprocess_run.return_value = MagicMock(returncode=0) + + from comfy_cli.command.custom_nodes.command import migrate_legacy + + migrate_legacy(yes=True) + + mock_config_manager.set.assert_called_with(constants.CONFIG_KEY_MANAGER_GUI_MODE, "enable-gui") + # Verify moved to .disabled (preserving original case) + assert not legacy_manager.exists() + assert (custom_nodes / ".disabled" / "comfyui-manager").exists() + + @patch("comfy_cli.command.custom_nodes.command.resolve_workspace_python", return_value="/workspace/venv/python") + @patch("comfy_cli.command.custom_nodes.command.subprocess.run") + @patch("comfy_cli.command.custom_nodes.command.workspace_manager") + def test_migrate_legacy_installs_manager_requirements( + self, mock_ws, mock_subprocess_run, mock_resolve_python, mock_config_manager, tmp_path + ): + # Setup: create legacy manager with .git and manager_requirements.txt + custom_nodes = tmp_path / "custom_nodes" + legacy_manager = custom_nodes / "ComfyUI-Manager" + legacy_manager.mkdir(parents=True) + (legacy_manager / ".git").mkdir() + # Create manager_requirements.txt in workspace root + (tmp_path / constants.MANAGER_REQUIREMENTS_FILE).write_text("comfyui-manager>=4.1b1") + + mock_ws.workspace_path = str(tmp_path) + mock_subprocess_run.return_value = MagicMock(returncode=0) + + from comfy_cli.command.custom_nodes.command import migrate_legacy + + migrate_legacy(yes=True) + + # Verify pip install was called with workspace Python, NOT sys.executable + mock_subprocess_run.assert_called_once() + call_args = mock_subprocess_run.call_args[0][0] + assert call_args[0] == "/workspace/venv/python" + assert "-m" in call_args + assert "pip" in call_args + assert "install" in call_args + assert "-r" in call_args + # Verify the requirements file path is included + assert any(constants.MANAGER_REQUIREMENTS_FILE in str(arg) for arg in call_args) + + @patch("comfy_cli.command.custom_nodes.command.subprocess.run") + @patch("comfy_cli.command.custom_nodes.command.workspace_manager") + def test_migrate_legacy_no_requirements_file(self, mock_ws, mock_subprocess_run, mock_config_manager, tmp_path): + # Setup: create legacy manager with .git but NO manager_requirements.txt + custom_nodes = tmp_path / "custom_nodes" + legacy_manager = custom_nodes / "ComfyUI-Manager" + legacy_manager.mkdir(parents=True) + (legacy_manager / ".git").mkdir() + + mock_ws.workspace_path = str(tmp_path) + + from comfy_cli.command.custom_nodes.command import migrate_legacy + + migrate_legacy(yes=True) + + # Verify pip install was NOT called (no requirements file) + mock_subprocess_run.assert_not_called() + # When requirements file is missing, install fails → set to disable + mock_config_manager.set.assert_called_with(constants.CONFIG_KEY_MANAGER_GUI_MODE, "disable") + + @patch("comfy_cli.command.custom_nodes.command.workspace_manager") + def test_migrate_legacy_not_git_repo(self, mock_ws, mock_config_manager, tmp_path): + # Setup: create directory without .git (not a git repo) + custom_nodes = tmp_path / "custom_nodes" + legacy_manager = custom_nodes / "ComfyUI-Manager" + legacy_manager.mkdir(parents=True) + # No .git directory + + mock_ws.workspace_path = str(tmp_path) + + from comfy_cli.command.custom_nodes.command import migrate_legacy + + migrate_legacy(yes=True) + + # Should not migrate non-git directories + mock_config_manager.set.assert_not_called() + # Directory should still exist (not moved) + assert legacy_manager.exists() + + @patch("comfy_cli.command.custom_nodes.command.workspace_manager") + def test_migrate_legacy_skips_symlink(self, mock_ws, mock_config_manager, tmp_path): + # Setup: create a symlink instead of real directory + custom_nodes = tmp_path / "custom_nodes" + custom_nodes.mkdir(parents=True) + real_dir = tmp_path / "real-manager" + real_dir.mkdir() + (real_dir / ".git").mkdir() + symlink_path = custom_nodes / "ComfyUI-Manager" + symlink_path.symlink_to(real_dir) + + mock_ws.workspace_path = str(tmp_path) + + from comfy_cli.command.custom_nodes.command import migrate_legacy + + migrate_legacy(yes=True) + + # Should not migrate symlinks + mock_config_manager.set.assert_not_called() + # Symlink should still exist + assert symlink_path.is_symlink() + + @patch("comfy_cli.command.custom_nodes.command.shutil.move") + @patch("comfy_cli.command.custom_nodes.command.workspace_manager") + def test_migrate_legacy_move_error(self, mock_ws, mock_move, mock_config_manager, tmp_path): + # Setup: create legacy manager with .git + custom_nodes = tmp_path / "custom_nodes" + legacy_manager = custom_nodes / "ComfyUI-Manager" + legacy_manager.mkdir(parents=True) + (legacy_manager / ".git").mkdir() + (custom_nodes / ".disabled").mkdir() + + mock_ws.workspace_path = str(tmp_path) + mock_move.side_effect = OSError("Permission denied") + + from comfy_cli.command.custom_nodes.command import migrate_legacy + + with pytest.raises(typer.Exit): + migrate_legacy(yes=True) + + # Config should not be set on move failure + mock_config_manager.set.assert_not_called() + + @patch("comfy_cli.command.custom_nodes.command.ui.prompt_confirm_action") + @patch("comfy_cli.command.custom_nodes.command.workspace_manager") + def test_migrate_legacy_user_cancels(self, mock_ws, mock_confirm, mock_config_manager, tmp_path): + # Setup: create legacy manager with .git + custom_nodes = tmp_path / "custom_nodes" + legacy_manager = custom_nodes / "ComfyUI-Manager" + legacy_manager.mkdir(parents=True) + (legacy_manager / ".git").mkdir() + + mock_ws.workspace_path = str(tmp_path) + mock_confirm.return_value = False # User cancels + + from comfy_cli.command.custom_nodes.command import migrate_legacy + + migrate_legacy(yes=False) + + # Should not migrate when user cancels + mock_config_manager.set.assert_not_called() + # Directory should still exist + assert legacy_manager.exists() + + +class TestInstallSkipManager: + """Tests for --skip-manager flag setting config to disable.""" + + @patch("comfy_cli.command.install.update_node_id_cache") + @patch("comfy_cli.command.install.pip_install_manager") + @patch("comfy_cli.command.install.pip_install_comfyui_dependencies") + @patch("comfy_cli.command.install.workspace_manager") + @patch("comfy_cli.command.install.WorkspaceManager") + @patch("comfy_cli.command.install.check_comfy_repo") + @patch("comfy_cli.command.install.clone_comfyui") + @patch("comfy_cli.command.install.ui.prompt_confirm_action") + @patch("comfy_cli.config_manager.ConfigManager") + @patch("os.path.exists") + @patch("os.makedirs") + @patch("os.chdir") + @patch("comfy_cli.command.install.ensure_workspace_python", return_value="/fake/python") + def test_skip_manager_sets_disable_config( + self, + mock_ensure_python, + mock_chdir, + mock_makedirs, + mock_exists, + mock_config_manager_cls, + mock_confirm, + mock_clone, + mock_check_repo, + mock_ws_cls, + mock_ws, + mock_pip_deps, + mock_pip_manager, + mock_update_cache, + ): + """When --skip-manager is used, config should be set to disable.""" + # Setup mocks + mock_exists.side_effect = lambda p: p == "/fake/comfy" # repo exists + mock_check_repo.return_value = (True, None) + mock_ws.skip_prompting = True + mock_config_manager = MagicMock() + mock_config_manager_cls.return_value = mock_config_manager + + from comfy_cli.command.install import execute + + execute( + url="https://github.com/comfyanonymous/ComfyUI", + comfy_path="/fake/comfy", + restore=False, + skip_manager=True, # Key flag + version="nightly", + ) + + # Verify config was set to disable + mock_config_manager.set.assert_called_with(constants.CONFIG_KEY_MANAGER_GUI_MODE, "disable") + # Verify pip_install_manager was NOT called + mock_pip_manager.assert_not_called() + + +class TestInstallManagerFailure: + """Tests for pip_install_manager failure handling.""" + + @patch("comfy_cli.command.install.update_node_id_cache") + @patch("comfy_cli.command.install.pip_install_manager") + @patch("comfy_cli.command.install.pip_install_comfyui_dependencies") + @patch("comfy_cli.command.install.workspace_manager") + @patch("comfy_cli.command.install.WorkspaceManager") + @patch("comfy_cli.command.install.check_comfy_repo") + @patch("comfy_cli.command.install.clone_comfyui") + @patch("comfy_cli.command.install.ui.prompt_confirm_action") + @patch("comfy_cli.config_manager.ConfigManager") + @patch("os.path.exists") + @patch("os.makedirs") + @patch("os.chdir") + @patch("comfy_cli.command.install.ensure_workspace_python", return_value="/fake/python") + def test_manager_install_failure_sets_disable_config( + self, + mock_ensure_python, + mock_chdir, + mock_makedirs, + mock_exists, + mock_config_manager_cls, + mock_confirm, + mock_clone, + mock_check_repo, + mock_ws_cls, + mock_ws, + mock_pip_deps, + mock_pip_manager, + mock_update_cache, + ): + """When pip_install_manager fails, config should be set to disable.""" + # Setup mocks + mock_exists.side_effect = lambda p: p == "/fake/comfy" # repo exists + mock_check_repo.return_value = (True, None) + mock_ws.skip_prompting = True + mock_pip_manager.return_value = False # Manager installation fails + mock_config_manager = MagicMock() + mock_config_manager_cls.return_value = mock_config_manager + + from comfy_cli.command.install import execute + + execute( + url="https://github.com/comfyanonymous/ComfyUI", + comfy_path="/fake/comfy", + restore=False, + skip_manager=False, # Try to install manager + version="nightly", + ) + + # Verify pip_install_manager was called + mock_pip_manager.assert_called_once() + # Verify config was set to disable due to failure + mock_config_manager.set.assert_called_with(constants.CONFIG_KEY_MANAGER_GUI_MODE, "disable") + + @patch("comfy_cli.command.install.update_node_id_cache") + @patch("comfy_cli.command.install.pip_install_manager") + @patch("comfy_cli.command.install.pip_install_comfyui_dependencies") + @patch("comfy_cli.command.install.workspace_manager") + @patch("comfy_cli.command.install.WorkspaceManager") + @patch("comfy_cli.command.install.check_comfy_repo") + @patch("comfy_cli.command.install.clone_comfyui") + @patch("comfy_cli.command.install.ui.prompt_confirm_action") + @patch("comfy_cli.config_manager.ConfigManager") + @patch("os.path.exists") + @patch("os.makedirs") + @patch("os.chdir") + @patch("comfy_cli.command.install.ensure_workspace_python", return_value="/fake/python") + def test_manager_install_success_does_not_set_disable( + self, + mock_ensure_python, + mock_chdir, + mock_makedirs, + mock_exists, + mock_config_manager_cls, + mock_confirm, + mock_clone, + mock_check_repo, + mock_ws_cls, + mock_ws, + mock_pip_deps, + mock_pip_manager, + mock_update_cache, + ): + """When pip_install_manager succeeds, config should NOT be set to disable.""" + # Setup mocks + mock_exists.side_effect = lambda p: p == "/fake/comfy" # repo exists + mock_check_repo.return_value = (True, None) + mock_ws.skip_prompting = True + mock_pip_manager.return_value = True # Manager installation succeeds + mock_config_manager = MagicMock() + mock_config_manager_cls.return_value = mock_config_manager + + from comfy_cli.command.install import execute + + execute( + url="https://github.com/comfyanonymous/ComfyUI", + comfy_path="/fake/comfy", + restore=False, + skip_manager=False, + version="nightly", + ) + + # Verify pip_install_manager was called + mock_pip_manager.assert_called_once() + # Verify config was NOT set to disable + mock_config_manager.set.assert_not_called() + + @patch("comfy_cli.command.install.DependencyCompiler") + @patch("comfy_cli.command.install.update_node_id_cache") + @patch("comfy_cli.command.install.pip_install_manager") + @patch("comfy_cli.command.install.pip_install_comfyui_dependencies") + @patch("comfy_cli.command.install.workspace_manager") + @patch("comfy_cli.command.install.WorkspaceManager") + @patch("comfy_cli.command.install.check_comfy_repo") + @patch("comfy_cli.command.install.clone_comfyui") + @patch("comfy_cli.command.install.ui.prompt_confirm_action") + @patch("comfy_cli.config_manager.ConfigManager") + @patch("os.path.exists") + @patch("os.makedirs") + @patch("os.chdir") + @patch("comfy_cli.command.install.ensure_workspace_python", return_value="/fake/python") + def test_fast_deps_manager_failure_sets_disable_config( + self, + mock_ensure_python, + mock_chdir, + mock_makedirs, + mock_exists, + mock_config_manager_cls, + mock_confirm, + mock_clone, + mock_check_repo, + mock_ws_cls, + mock_ws, + mock_pip_deps, + mock_pip_manager, + mock_update_cache, + mock_dep_compiler, + ): + """When fast_deps=True and pip_install_manager fails, config should be set to disable.""" + # Setup mocks + mock_exists.side_effect = lambda p: p == "/fake/comfy" + mock_check_repo.return_value = (True, None) + mock_ws.skip_prompting = True + mock_pip_manager.return_value = False # Manager installation fails + mock_config_manager = MagicMock() + mock_config_manager_cls.return_value = mock_config_manager + mock_dep_compiler_instance = MagicMock() + mock_dep_compiler.return_value = mock_dep_compiler_instance + + from comfy_cli.command.install import execute + + execute( + url="https://github.com/comfyanonymous/ComfyUI", + comfy_path="/fake/comfy", + restore=False, + skip_manager=False, + version="nightly", + fast_deps=True, # Use fast_deps path + ) + + # Verify pip_install_manager was called (fast_deps path) + mock_pip_manager.assert_called_once() + # Verify config was set to disable due to failure + mock_config_manager.set.assert_called_with(constants.CONFIG_KEY_MANAGER_GUI_MODE, "disable") + + +class TestPipInstallManagerCacheClear: + """Tests for pip_install_manager cache clearing after successful install.""" + + @patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli") + @patch("comfy_cli.command.install.subprocess.run") + @patch("os.path.exists", return_value=True) + def test_pip_install_manager_clears_cache_on_success(self, mock_exists, mock_run, mock_find_cm_cli): + """When pip install succeeds, find_cm_cli cache should be cleared.""" + from comfy_cli.command.install import pip_install_manager + + # Simulate successful pip install + mock_run.return_value = MagicMock(returncode=0, stderr="") + + # Call pip_install_manager + result = pip_install_manager("/fake/repo") + + # Verify success + assert result is True + # Verify cache_clear was called on the mock + mock_find_cm_cli.cache_clear.assert_called_once() + + @patch("comfy_cli.command.install.subprocess.run") + @patch("os.path.exists", return_value=True) + def test_pip_install_manager_no_cache_clear_on_failure(self, mock_exists, mock_run): + """When pip install fails, cache should not be affected (function returns early).""" + from comfy_cli.command.install import pip_install_manager + + # Simulate failed pip install + mock_run.return_value = MagicMock(returncode=1) + + # Call pip_install_manager + result = pip_install_manager("/fake/repo") + + # Verify failure + assert result is False + + +class TestFillPrintTable: + """Tests for WorkspaceManager.fill_print_table() method.""" + + @pytest.fixture() + def mock_workspace_config_manager(self): + with patch("comfy_cli.workspace_manager.ConfigManager") as mock_cls: + instance = MagicMock() + mock_cls.return_value = instance + yield instance + + @patch("comfy_cli.command.custom_nodes.cm_cli_util.resolve_manager_gui_mode", return_value="disable") + def test_fill_print_table_disable_mode(self, mock_resolve, mock_workspace_config_manager): + """When mode is 'disable', status should show Disabled.""" + from comfy_cli.workspace_manager import WorkspaceManager + + ws = WorkspaceManager() + ws.workspace_path = "/fake/workspace" + + result = ws.fill_print_table() + + assert len(result) == 3 + assert result[0][0] == "Current selected workspace" + assert result[1][0] == "Manager" + assert "Disabled" in result[1][1] + assert result[2][0] == "UV Compile Default" + + @patch("comfy_cli.command.custom_nodes.cm_cli_util.resolve_manager_gui_mode", return_value="enable-gui") + def test_fill_print_table_enable_gui_mode(self, mock_resolve, mock_workspace_config_manager): + """When mode is 'enable-gui', status should show GUI Enabled.""" + from comfy_cli.workspace_manager import WorkspaceManager + + ws = WorkspaceManager() + ws.workspace_path = "/fake/workspace" + + result = ws.fill_print_table() + + assert result[1][0] == "Manager" + assert "GUI Enabled" in result[1][1] + + @patch("comfy_cli.command.custom_nodes.cm_cli_util.resolve_manager_gui_mode", return_value="disable-gui") + def test_fill_print_table_disable_gui_mode(self, mock_resolve, mock_workspace_config_manager): + """When mode is 'disable-gui', status should show GUI Disabled.""" + from comfy_cli.workspace_manager import WorkspaceManager + + ws = WorkspaceManager() + ws.workspace_path = "/fake/workspace" + + result = ws.fill_print_table() + + assert result[1][0] == "Manager" + assert "GUI Disabled" in result[1][1] + + @patch("comfy_cli.command.custom_nodes.cm_cli_util.resolve_manager_gui_mode", return_value="enable-legacy-gui") + def test_fill_print_table_enable_legacy_gui_mode(self, mock_resolve, mock_workspace_config_manager): + """When mode is 'enable-legacy-gui', status should show Legacy GUI.""" + from comfy_cli.workspace_manager import WorkspaceManager + + ws = WorkspaceManager() + ws.workspace_path = "/fake/workspace" + + result = ws.fill_print_table() + + assert result[1][0] == "Manager" + assert "Legacy GUI" in result[1][1] + + @patch("comfy_cli.command.custom_nodes.cm_cli_util.resolve_manager_gui_mode", return_value="not-installed") + def test_fill_print_table_not_installed(self, mock_resolve, mock_workspace_config_manager): + """When resolve returns 'not-installed', status should show Not Installed.""" + from comfy_cli.workspace_manager import WorkspaceManager + + ws = WorkspaceManager() + ws.workspace_path = "/fake/workspace" + + result = ws.fill_print_table() + + assert result[1][0] == "Manager" + assert "Not Installed" in result[1][1] + + @patch("comfy_cli.command.custom_nodes.cm_cli_util.resolve_manager_gui_mode", return_value="unknown-mode") + def test_fill_print_table_unknown_mode_defaults_to_enable(self, mock_resolve, mock_workspace_config_manager): + """When mode is unknown, status should default to GUI Enabled.""" + from comfy_cli.workspace_manager import WorkspaceManager + + ws = WorkspaceManager() + ws.workspace_path = "/fake/workspace" + + result = ws.fill_print_table() + + assert result[1][0] == "Manager" + assert "GUI Enabled" in result[1][1] + + @patch("comfy_cli.command.custom_nodes.cm_cli_util.resolve_manager_gui_mode", return_value="enable-gui") + def test_fill_print_table_uv_compile_enabled(self, mock_resolve, mock_workspace_config_manager): + """When uv_compile_default is True, status should show Enabled.""" + mock_workspace_config_manager.get.side_effect = lambda key: { + constants.CONFIG_KEY_UV_COMPILE_DEFAULT: "True", + }.get(key) + + from comfy_cli.workspace_manager import WorkspaceManager + + ws = WorkspaceManager() + ws.workspace_path = "/fake/workspace" + + result = ws.fill_print_table() + + assert result[2][0] == "UV Compile Default" + assert "Enabled" in result[2][1] + + @patch("comfy_cli.command.custom_nodes.cm_cli_util.resolve_manager_gui_mode", return_value="enable-gui") + def test_fill_print_table_uv_compile_disabled(self, mock_resolve, mock_workspace_config_manager): + """When uv_compile_default is not set, status should show Disabled.""" + mock_workspace_config_manager.get.return_value = None + + from comfy_cli.workspace_manager import WorkspaceManager + + ws = WorkspaceManager() + ws.workspace_path = "/fake/workspace" + + result = ws.fill_print_table() + + assert result[2][0] == "UV Compile Default" + assert "Disabled" in result[2][1] + + @patch("comfy_cli.command.custom_nodes.cm_cli_util.resolve_manager_gui_mode", return_value="enable-gui") + def test_fill_print_table_uv_compile_lowercase_true(self, mock_resolve, mock_workspace_config_manager): + """When uv_compile_default is 'true' (lowercase), status should show Enabled.""" + mock_workspace_config_manager.get.return_value = "true" + + from comfy_cli.workspace_manager import WorkspaceManager + + ws = WorkspaceManager() + ws.workspace_path = "/fake/workspace" + + result = ws.fill_print_table() + + assert result[2][0] == "UV Compile Default" + assert "Enabled" in result[2][1] + + @patch("comfy_cli.command.custom_nodes.cm_cli_util.resolve_manager_gui_mode", return_value="enable-gui") + def test_fill_print_table_uv_compile_explicit_false(self, mock_resolve, mock_workspace_config_manager): + """When uv_compile_default is 'False', status should show Disabled.""" + mock_workspace_config_manager.get.return_value = "False" + + from comfy_cli.workspace_manager import WorkspaceManager + + ws = WorkspaceManager() + ws.workspace_path = "/fake/workspace" + + result = ws.fill_print_table() + + assert result[2][0] == "UV Compile Default" + assert "Disabled" in result[2][1] + + +class TestResolveUvCompile: + """Tests for _resolve_uv_compile() helper function.""" + + @pytest.fixture() + def mock_resolve_config_manager(self): + with patch("comfy_cli.command.custom_nodes.command.ConfigManager") as mock_cls: + instance = MagicMock() + mock_cls.return_value = instance + yield instance + + def test_explicit_true_returns_true(self, mock_resolve_config_manager): + """Explicit --uv-compile overrides everything.""" + from comfy_cli.command.custom_nodes.command import _resolve_uv_compile + + assert _resolve_uv_compile(True) is True + + def test_explicit_false_returns_false(self, mock_resolve_config_manager): + """Explicit --no-uv-compile overrides everything.""" + from comfy_cli.command.custom_nodes.command import _resolve_uv_compile + + assert _resolve_uv_compile(False) is False + + def test_explicit_true_ignores_config(self, mock_resolve_config_manager): + """Explicit flag takes priority over config default.""" + mock_resolve_config_manager.get.return_value = "False" + from comfy_cli.command.custom_nodes.command import _resolve_uv_compile + + assert _resolve_uv_compile(True) is True + mock_resolve_config_manager.get.assert_not_called() + + def test_none_with_config_true(self, mock_resolve_config_manager): + """None (no flag) + config True → True.""" + mock_resolve_config_manager.get.return_value = "True" + from comfy_cli.command.custom_nodes.command import _resolve_uv_compile + + assert _resolve_uv_compile(None) is True + + def test_none_with_config_false(self, mock_resolve_config_manager): + """None (no flag) + config False → False.""" + mock_resolve_config_manager.get.return_value = "False" + from comfy_cli.command.custom_nodes.command import _resolve_uv_compile + + assert _resolve_uv_compile(None) is False + + def test_none_with_no_config(self, mock_resolve_config_manager): + """None (no flag) + no config → False.""" + mock_resolve_config_manager.get.return_value = None + from comfy_cli.command.custom_nodes.command import _resolve_uv_compile + + assert _resolve_uv_compile(None) is False + + def test_config_true_overridden_by_fast_deps(self, mock_resolve_config_manager): + """Config True + --fast-deps → False (silent override).""" + mock_resolve_config_manager.get.return_value = "True" + from comfy_cli.command.custom_nodes.command import _resolve_uv_compile + + assert _resolve_uv_compile(None, fast_deps=True) is False + + def test_config_true_overridden_by_no_deps(self, mock_resolve_config_manager): + """Config True + --no-deps → False (silent override).""" + mock_resolve_config_manager.get.return_value = "True" + from comfy_cli.command.custom_nodes.command import _resolve_uv_compile + + assert _resolve_uv_compile(None, no_deps=True) is False + + def test_config_false_with_fast_deps_stays_false(self, mock_resolve_config_manager): + """Config False + --fast-deps → False (no change).""" + mock_resolve_config_manager.get.return_value = "False" + from comfy_cli.command.custom_nodes.command import _resolve_uv_compile + + assert _resolve_uv_compile(None, fast_deps=True) is False + + def test_explicit_true_not_affected_by_fast_deps(self, mock_resolve_config_manager): + """Explicit --uv-compile is not silently overridden (mutual exclusivity handled elsewhere).""" + from comfy_cli.command.custom_nodes.command import _resolve_uv_compile + + assert _resolve_uv_compile(True, fast_deps=True) is True + + +class TestUvCompileDefaultCommand: + """Tests for comfy manager uv-compile-default command.""" + + def test_uv_compile_default_enable(self, mock_config_manager): + from comfy_cli.command.custom_nodes.command import uv_compile_default + + uv_compile_default(enabled=True) + + mock_config_manager.set.assert_called_once_with(constants.CONFIG_KEY_UV_COMPILE_DEFAULT, "True") + + def test_uv_compile_default_disable(self, mock_config_manager): + from comfy_cli.command.custom_nodes.command import uv_compile_default + + uv_compile_default(enabled=False) + + mock_config_manager.set.assert_called_once_with(constants.CONFIG_KEY_UV_COMPILE_DEFAULT, "False") + + +class TestFindCmCli: + """Tests for find_cm_cli() function.""" + + def test_find_cm_cli_module_found(self): + """When cm_cli module exists, should return True.""" + with patch("importlib.util.find_spec") as mock_find_spec: + mock_find_spec.return_value = MagicMock() # Non-None means module exists + # Clear cache before test + from comfy_cli.command.custom_nodes.cm_cli_util import find_cm_cli + + find_cm_cli.cache_clear() + + result = find_cm_cli() + + assert result is True + mock_find_spec.assert_called_with("cm_cli") + + def test_find_cm_cli_module_not_found(self): + """When cm_cli module doesn't exist, should return False.""" + from comfy_cli.command.custom_nodes import cm_cli_util as _cm_cli_util + + with ( + patch("importlib.util.find_spec") as mock_find_spec, + patch.object(_cm_cli_util.workspace_manager, "workspace_path", None), + ): + mock_find_spec.return_value = None # None means module not found + from comfy_cli.command.custom_nodes.cm_cli_util import find_cm_cli + + find_cm_cli.cache_clear() + + result = find_cm_cli() + + assert result is False + mock_find_spec.assert_called_with("cm_cli") + + def test_find_cm_cli_cache_behavior(self): + """find_cm_cli should cache results and not call find_spec repeatedly.""" + with patch("importlib.util.find_spec") as mock_find_spec: + mock_find_spec.return_value = MagicMock() + from comfy_cli.command.custom_nodes.cm_cli_util import find_cm_cli + + find_cm_cli.cache_clear() + + # Call multiple times + result1 = find_cm_cli() + result2 = find_cm_cli() + result3 = find_cm_cli() + + # All should return True + assert result1 is True + assert result2 is True + assert result3 is True + # find_spec should only be called once due to caching + assert mock_find_spec.call_count == 1 + + +class TestPipInstallManagerEdgeCases: + """Additional edge case tests for pip_install_manager().""" + + @patch("comfy_cli.command.install.subprocess.run") + @patch("os.path.exists", return_value=False) + def test_pip_install_manager_requirements_not_found(self, mock_exists, mock_run): + """When requirements file doesn't exist, should return False without calling pip.""" + from comfy_cli.command.install import pip_install_manager + + result = pip_install_manager("/fake/repo") + + assert result is False + # subprocess.run should NOT be called + mock_run.assert_not_called() + + +class TestValidateComfyuiManager: + """Tests for validate_comfyui_manager() function.""" + + @patch("comfy_cli.command.custom_nodes.command.find_cm_cli", return_value=False) + def test_validate_comfyui_manager_exits_when_not_found(self, mock_find_cm_cli): + """When cm-cli is not found, should raise typer.Exit with code 1.""" + from comfy_cli.command.custom_nodes.command import validate_comfyui_manager + + with pytest.raises(typer.Exit) as exc_info: + validate_comfyui_manager() + + assert exc_info.value.exit_code == 1 + mock_find_cm_cli.assert_called_once() + + @patch("comfy_cli.command.custom_nodes.command.find_cm_cli", return_value=True) + def test_validate_comfyui_manager_passes_when_found(self, mock_find_cm_cli): + """When cm-cli is found, should not raise any exception.""" + from comfy_cli.command.custom_nodes.command import validate_comfyui_manager + + # Should not raise + validate_comfyui_manager() + + mock_find_cm_cli.assert_called_once() diff --git a/tests/comfy_cli/test_cm_cli_python_resolution.py b/tests/comfy_cli/test_cm_cli_python_resolution.py index b129f132..8d9ccfc6 100644 --- a/tests/comfy_cli/test_cm_cli_python_resolution.py +++ b/tests/comfy_cli/test_cm_cli_python_resolution.py @@ -10,16 +10,27 @@ def _setup_cm_cli(tmp_path, script_body): - """Create a stub cm-cli.py with the given body and patch workspace to tmp_path.""" - cm_cli_path = tmp_path / "custom_nodes" / "ComfyUI-Manager" / "cm-cli.py" - cm_cli_path.parent.mkdir(parents=True) - cm_cli_path.write_text(textwrap.dedent(script_body)) + """Create a stub script and return its path.""" + stub_script = tmp_path / "stub_cm_cli.py" + stub_script.write_text(textwrap.dedent(script_body)) (tmp_path / "config").mkdir(exist_ok=True) - return tmp_path + return stub_script def _run(tmp_path, args, *, fast_deps=False, raise_on_error=False): - """Call execute_cm_cli with standard patches for workspace/config.""" + """Call execute_cm_cli with standard patches for workspace/config. + + Patches the cmd construction to run the stub script instead of `python -m cm_cli`. + """ + stub_script = tmp_path / "stub_cm_cli.py" + original_popen = subprocess.Popen + + def _patched_popen(cmd, **kwargs): + # Replace `python -m cm_cli ` with `python ` + if len(cmd) >= 3 and cmd[1] == "-m" and cmd[2] == "cm_cli": + cmd = [cmd[0], str(stub_script)] + cmd[3:] + return original_popen(cmd, **kwargs) + with ( patch( "comfy_cli.command.custom_nodes.cm_cli_util.resolve_workspace_python", @@ -28,6 +39,9 @@ def _run(tmp_path, args, *, fast_deps=False, raise_on_error=False): patch.object(cm_cli_util.workspace_manager, "workspace_path", str(tmp_path)), patch.object(cm_cli_util.workspace_manager, "set_recent_workspace"), patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") as MockConfig, + patch("comfy_cli.command.custom_nodes.cm_cli_util.check_comfy_repo", return_value=(True, None)), + patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli", return_value=True), + patch("comfy_cli.command.custom_nodes.cm_cli_util.subprocess.Popen", side_effect=_patched_popen), ): MockConfig.return_value.get_config_path.return_value = str(tmp_path / "config") return cm_cli_util.execute_cm_cli(args, fast_deps=fast_deps, raise_on_error=raise_on_error) @@ -48,6 +62,8 @@ def test_uses_resolved_python(self, tmp_path): patch.object(cm_cli_util.workspace_manager, "workspace_path", str(tmp_path)), patch.object(cm_cli_util.workspace_manager, "set_recent_workspace"), patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") as MockConfig, + patch("comfy_cli.command.custom_nodes.cm_cli_util.check_comfy_repo", return_value=(True, None)), + patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli", return_value=True), patch("comfy_cli.command.custom_nodes.cm_cli_util.subprocess.Popen", return_value=mock_proc) as mock_popen, ): MockConfig.return_value.get_config_path.return_value = str(tmp_path / "config") @@ -71,6 +87,8 @@ def test_fast_deps_passes_python_to_compiler(self, tmp_path): patch.object(cm_cli_util.workspace_manager, "workspace_path", str(tmp_path)), patch.object(cm_cli_util.workspace_manager, "set_recent_workspace"), patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") as MockConfig, + patch("comfy_cli.command.custom_nodes.cm_cli_util.check_comfy_repo", return_value=(True, None)), + patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli", return_value=True), patch("comfy_cli.command.custom_nodes.cm_cli_util.subprocess.Popen", return_value=mock_proc), patch("comfy_cli.command.custom_nodes.cm_cli_util.DependencyCompiler") as MockCompiler, ): @@ -176,6 +194,8 @@ def test_pythonunbuffered_set_in_env(self, tmp_path): patch.object(cm_cli_util.workspace_manager, "workspace_path", str(tmp_path)), patch.object(cm_cli_util.workspace_manager, "set_recent_workspace"), patch("comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager") as MockConfig, + patch("comfy_cli.command.custom_nodes.cm_cli_util.check_comfy_repo", return_value=(True, None)), + patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli", return_value=True), patch("comfy_cli.command.custom_nodes.cm_cli_util.subprocess.Popen", return_value=mock_proc) as mock_popen, ): MockConfig.return_value.get_config_path.return_value = str(tmp_path / "config") diff --git a/tests/comfy_cli/test_install.py b/tests/comfy_cli/test_install.py index 1c5d13de..2aee5d1c 100644 --- a/tests/comfy_cli/test_install.py +++ b/tests/comfy_cli/test_install.py @@ -1,6 +1,8 @@ +from unittest.mock import MagicMock, patch + import pytest -from comfy_cli.command.install import validate_version +from comfy_cli.command.install import pip_install_manager, validate_version def test_validate_version_nightly(): @@ -29,6 +31,36 @@ def test_validate_version_empty(): validate_version("") +class TestPipInstallManager: + @patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli") + @patch("comfy_cli.command.install.subprocess.run") + @patch("os.path.exists", return_value=True) + def test_success(self, mock_exists, mock_run, mock_find): + mock_run.return_value = MagicMock(returncode=0) + result = pip_install_manager("/fake/repo") + assert result is True + mock_run.assert_called_once() + + @patch("os.path.exists", return_value=False) + def test_missing_requirements_file(self, mock_exists): + result = pip_install_manager("/fake/repo") + assert result is False + + @patch("comfy_cli.command.install.subprocess.run") + @patch("os.path.exists", return_value=True) + def test_pip_failure(self, mock_exists, mock_run): + mock_run.return_value = MagicMock(returncode=1, stderr="some error") + result = pip_install_manager("/fake/repo") + assert result is False + + @patch("comfy_cli.command.install.subprocess.run") + @patch("os.path.exists", return_value=True) + def test_pip_failure_no_stderr(self, mock_exists, mock_run): + mock_run.return_value = MagicMock(returncode=1, stderr="") + result = pip_install_manager("/fake/repo") + assert result is False + + # Run the tests if __name__ == "__main__": pytest.main([__file__]) diff --git a/tests/comfy_cli/test_install_python_resolution.py b/tests/comfy_cli/test_install_python_resolution.py index 749b19e1..7d7473d8 100644 --- a/tests/comfy_cli/test_install_python_resolution.py +++ b/tests/comfy_cli/test_install_python_resolution.py @@ -30,14 +30,16 @@ def test_uses_python_param_cpu(self, tmp_path): assert cmd[0] != sys.executable -class TestPipInstallManagerDependencies: +class TestPipInstallManager: def test_uses_python_param(self, tmp_path): - manager_dir = tmp_path / "custom_nodes" / "ComfyUI-Manager" - manager_dir.mkdir(parents=True) - (manager_dir / "requirements.txt").write_text("some-package\n") + (tmp_path / "manager_requirements.txt").write_text("comfyui-manager\n") - with patch("comfy_cli.command.install.subprocess.run") as mock_run: - install.pip_install_manager_dependencies(str(tmp_path), python="/resolved/python") + with ( + patch("comfy_cli.command.install.subprocess.run", return_value=MagicMock(returncode=0)) as mock_run, + patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli") as mock_find, + ): + mock_find.cache_clear = MagicMock() + install.pip_install_manager(str(tmp_path), python="/resolved/python") cmd = mock_run.call_args[0][0] assert cmd[0] == "/resolved/python" @@ -58,7 +60,6 @@ def test_calls_ensure_and_passes_resolved_python(self, tmp_path): ): install.execute( url="https://github.com/test/test.git", - manager_url="https://github.com/test/manager.git", comfy_path=repo_dir, restore=False, skip_manager=True, @@ -87,7 +88,6 @@ def test_fast_deps_passes_python_to_dependency_compiler(self, tmp_path): install.execute( url="https://github.com/test/test.git", - manager_url="https://github.com/test/manager.git", comfy_path=repo_dir, restore=False, skip_manager=True, diff --git a/tests/e2e/test_e2e.py b/tests/e2e/test_e2e.py index 379d0b4b..d05000ac 100644 --- a/tests/e2e/test_e2e.py +++ b/tests/e2e/test_e2e.py @@ -5,6 +5,8 @@ import pytest +from comfy_cli.resolve_python import resolve_workspace_python + def e2e_test(func): return pytest.mark.skipif( @@ -33,21 +35,28 @@ def exec(cmd: str, **kwargs) -> subprocess.CompletedProcess[str]: @pytest.fixture(scope="module") def workspace(): ws = os.path.join(os.getcwd(), f"comfy-{datetime.now().timestamp()}") + install_flags = os.getenv("TEST_E2E_COMFY_INSTALL_FLAGS", "--cpu") + comfy_url = os.getenv("TEST_E2E_COMFY_URL", "") + url_flag = f"--url {comfy_url}" if comfy_url else "" proc = exec( f""" - comfy --skip-prompt --workspace {ws} install {os.getenv("TEST_E2E_COMFY_INSTALL_FLAGS", "--cpu")} + comfy --skip-prompt --workspace {ws} install {url_flag} {install_flags} comfy --skip-prompt set-default {ws} comfy --skip-prompt --no-enable-telemetry env """ ) - assert 0 == proc.returncode + assert proc.returncode == 0 + + # Populate Manager cache before any node operations (blocking fetch). + proc = exec(f"comfy --workspace {ws} node update-cache") + assert proc.returncode == 0, f"update-cache failed:\n{proc.stderr}" proc = exec( f""" comfy --workspace {ws} launch --background -- {os.getenv("TEST_E2E_COMFY_LAUNCH_FLAGS_EXTRA", "--cpu")} """ ) - assert 0 == proc.returncode + assert proc.returncode == 0 yield ws @@ -56,7 +65,7 @@ def workspace(): comfy --workspace {ws} stop """ ) - assert 0 == proc.returncode + assert proc.returncode == 0 @pytest.fixture() @@ -74,14 +83,14 @@ def test_model(comfy_cli): {comfy_cli} model download --url {url} --relative-path {path} --filename animatediff_models """ ) - assert 0 == proc.returncode + assert proc.returncode == 0 proc = exec( f""" {comfy_cli} model list --relative-path {path} """ ) - assert 0 == proc.returncode + assert proc.returncode == 0 assert "animatediff_models" in proc.stdout proc = exec( @@ -89,7 +98,7 @@ def test_model(comfy_cli): {comfy_cli} model remove --relative-path {path} --model-names animatediff_models --confirm """ ) - assert 0 == proc.returncode + assert proc.returncode == 0 @e2e_test @@ -134,21 +143,21 @@ def test_node(comfy_cli, workspace): {comfy_cli} node update {node} """ ) - assert 0 == proc.returncode + assert proc.returncode == 0 proc = exec( f""" {comfy_cli} node disable {node} """ ) - assert 0 == proc.returncode + assert proc.returncode == 0 proc = exec( f""" {comfy_cli} node enable {node} """ ) - assert 0 == proc.returncode + assert proc.returncode == 0 pubID = "comfytest123" pubToken = "6075cf7b-47e7-4c58-a3de-38f59a9bcc22" @@ -162,6 +171,78 @@ def test_node(comfy_cli, workspace): ) +@e2e_test +def test_manager_installed(comfy_cli, workspace): + """Verify ComfyUI-Manager was installed via manager_requirements.txt.""" + proc = exec( + f""" + {comfy_cli} node show all + """ + ) + assert proc.returncode == 0, f"node show all failed: {proc.stderr}" + + # Check cm_cli is importable (Manager v4 installed as pip package) + ws_python = resolve_workspace_python(workspace) + proc = exec( + f""" + {ws_python} -c "import cm_cli; print('cm_cli OK')" + """ + ) + assert proc.returncode == 0, f"cm_cli import failed: {proc.stderr}" + assert "cm_cli OK" in proc.stdout + + +@e2e_test +def test_node_uv_compile(comfy_cli): + """Test --uv-compile flag for node install (requires Manager v4.1+).""" + node = "comfyui-impact-pack" + proc = exec( + f""" + {comfy_cli} node install --uv-compile {node} + """ + ) + assert proc.returncode == 0 + + # Standalone uv-sync command + proc = exec( + f""" + {comfy_cli} node uv-sync + """ + ) + assert proc.returncode == 0 + + +@e2e_test +def test_uv_compile_default_config(comfy_cli): + """Test comfy manager uv-compile-default config command.""" + proc = exec( + f""" + {comfy_cli} manager uv-compile-default true + """ + ) + assert proc.returncode == 0 + assert "enabled" in proc.stdout.lower() + + # Verify it shows in env + proc = exec( + """ + comfy --skip-prompt --no-enable-telemetry env + """ + ) + assert proc.returncode == 0 + assert "UV Compile Default" in proc.stdout + assert "Enabled" in proc.stdout + + # Disable it back + proc = exec( + f""" + {comfy_cli} manager uv-compile-default false + """ + ) + assert proc.returncode == 0 + assert "disabled" in proc.stdout.lower() + + @e2e_test def test_run(comfy_cli): url = "https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors?download=true" @@ -172,7 +253,7 @@ def test_run(comfy_cli): {comfy_cli} model download --url {url} --relative-path {path} --filename {name} """ ) - assert 0 == proc.returncode + assert proc.returncode == 0 workflow = os.path.join(os.path.dirname(os.path.realpath(__file__)), "workflow.json") proc = exec( @@ -180,4 +261,4 @@ def test_run(comfy_cli): {comfy_cli} run --workflow {workflow} --wait --timeout 180 """ ) - assert 0 == proc.returncode + assert proc.returncode == 0 diff --git a/tests/e2e/test_e2e_uv_compile.py b/tests/e2e/test_e2e_uv_compile.py new file mode 100644 index 00000000..b6c293c7 --- /dev/null +++ b/tests/e2e/test_e2e_uv_compile.py @@ -0,0 +1,382 @@ +"""E2E tests for comfy-cli uv-compile support (requires Manager v4.1+). + +Tests the full stack: comfy node → execute_cm_cli → cm_cli subprocess. +Uses ltdrdata's dedicated test packs (nodepack-test1-do-not-install, +nodepack-test2-do-not-install) which intentionally conflict on ansible +versions and contain no executable code. + +Supply-chain safety policy: + Only node packs from verified, controllable authors (ltdrdata, + comfyanonymous) are used. Adding packs from unverified sources + is prohibited. + +Usage: + TEST_E2E=true \\ + TEST_E2E_COMFY_URL="https://github.com/ltdrdata/ComfyUI.git@dr-bump-manager" \\ + pytest tests/e2e/test_e2e_uv_compile.py -v +""" + +import os +import shutil +import subprocess +import sys +from datetime import datetime +from textwrap import dedent + +import pytest + +# Real node packs for normal installation testing +PACK_IMPACT = "comfyui-impact-pack" +PACK_INSPIRE = "comfyui-inspire-pack" + +# Test node packs from ltdrdata — intentionally conflict on ansible versions +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" + + +def _e2e_enabled(): + return os.getenv("TEST_E2E", "false") == "true" + + +pytestmark = [ + pytest.mark.skipif(not _e2e_enabled(), reason="TEST_E2E not enabled"), +] + + +def exec(cmd: str, timeout: int = 600, **kwargs) -> subprocess.CompletedProcess[str]: + cmd = dedent(cmd).strip() + print(f"cmd: {cmd}") + try: + proc = subprocess.run( + args=cmd, + capture_output=True, + text=True, + shell=True, + encoding="utf-8", + check=False, + timeout=timeout, + **kwargs, + ) + except subprocess.TimeoutExpired as e: + print(f"[exec] TIMEOUT after {timeout}s: {cmd}", flush=True) + # Return a synthetic failed result so tests get a clear failure message + return subprocess.CompletedProcess( + args=cmd, + returncode=124, + stdout=e.stdout or "", + stderr=e.stderr or f"Timed out after {timeout}s", + ) + print(proc.stdout, proc.stderr) + return proc + + +def _rmtree_retry(path, retries=5, delay=2.0): + """Remove directory with retries for Windows file lock delays. + + On Windows, .git/objects/pack/* files may be briefly locked after + git clone exits. Retries with read-only file handling. + """ + import stat + import time + + def _on_rm_error(func, fpath, _exc_info): + """Handle read-only files on Windows (e.g. .git/objects/pack/*.idx).""" + try: + os.chmod(fpath, stat.S_IWRITE) + func(fpath) + except OSError: + pass + + for attempt in range(retries): + try: + shutil.rmtree(path, onerror=_on_rm_error) + return + except (PermissionError, OSError): + if attempt < retries - 1: + time.sleep(delay) + shutil.rmtree(path, ignore_errors=True) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def workspace(): + """Install ComfyUI (with Manager v4) and launch in background.""" + ws = os.path.join(os.getcwd(), f"comfy-uv-{datetime.now().timestamp()}") + install_flags = os.getenv("TEST_E2E_COMFY_INSTALL_FLAGS", "--cpu") + comfy_url = os.getenv("TEST_E2E_COMFY_URL", "") + url_flag = f"--url {comfy_url}" if comfy_url else "" + + proc = exec( + f""" + comfy --skip-prompt --workspace {ws} install {url_flag} {install_flags} + comfy --skip-prompt set-default {ws} + comfy --skip-prompt --no-enable-telemetry env + """ + ) + assert proc.returncode == 0 + + # Override Manager if MANAGER_OVERRIDE is set (skip PyPI/Core PR cycle). + # Accepts: "4.1b7" (PyPI) or "Comfy-Org/ComfyUI-Manager@branch" (git clone + uv) + venv_dir = os.path.join(ws, ".venv") + if sys.platform == "win32": + venv_python = os.path.join(venv_dir, "Scripts", "python.exe") + else: + venv_python = os.path.join(venv_dir, "bin", "python") + + manager_override = os.getenv("MANAGER_OVERRIDE", "") + if manager_override: + if "@" in manager_override and "/" in manager_override: + # Branch install: "Comfy-Org/ComfyUI-Manager@fix-branch" + # Uses uv (not pip) because Manager repo has flat-layout incompatible with setuptools. + repo_spec, branch = manager_override.rsplit("@", 1) + clone_dir = os.path.join(ws, "_manager_override") + if os.path.isdir(clone_dir): + shutil.rmtree(clone_dir) + proc = exec(f"git clone --branch {branch} --depth 1 https://github.com/{repo_spec}.git {clone_dir}") + assert proc.returncode == 0, f"Manager clone failed:\n{proc.stderr}" + proc = exec(f"uv pip install {clone_dir} --reinstall-package comfyui-manager --python {venv_dir}") + assert proc.returncode == 0, f"Manager override install failed:\n{proc.stderr}" + else: + # PyPI version: "4.1b7" + proc = exec( + f"{venv_python} -m pip install comfyui-manager=={manager_override} --pre --force-reinstall --no-deps" + ) + assert proc.returncode == 0, f"Manager override failed:\n{proc.stderr}" + + # Populate Manager cache before any node operations (blocking fetch). + proc = exec(f"comfy --workspace {ws} node update-cache") + assert proc.returncode == 0, f"update-cache failed:\n{proc.stderr}" + + # NOTE: No 'comfy launch --background' here. These tests only exercise + # cm_cli commands (node install/reinstall/update/fix/uv-sync) and don't + # need a running ComfyUI server. Launching ComfyUI in background causes + # Windows file lock issues: ComfyUI scans custom_nodes/, holds handles + # on .git/objects/pack/*.idx, and prevents cleanup between tests. + + yield ws + + +@pytest.fixture() +def comfy_cli(workspace): + return f"comfy --workspace {workspace}" + + +@pytest.fixture(autouse=True) +def _clean_test_packs(workspace): + """Remove test node packs before and after each test.""" + custom_nodes = os.path.join(workspace, "custom_nodes") + + def _remove(name): + path = os.path.join(custom_nodes, name) + if os.path.islink(path): + os.unlink(path) + elif os.path.isdir(path): + _rmtree_retry(path) + + _remove(PACK_TEST1) + _remove(PACK_TEST2) + yield + _remove(PACK_TEST1) + _remove(PACK_TEST2) + + +# --------------------------------------------------------------------------- +# Normal installation with real packs +# --------------------------------------------------------------------------- + + +def test_real_packs_sequential_no_conflict(comfy_cli): + """Sequential install of two real packs with --uv-compile — no conflicts.""" + proc = exec(f"{comfy_cli} node install --uv-compile {PACK_IMPACT}") + combined = proc.stdout + proc.stderr + + assert proc.returncode == 0 + assert "Resolving dependencies for" in combined + + proc = exec(f"{comfy_cli} node install --uv-compile {PACK_INSPIRE}") + combined = proc.stdout + proc.stderr + + assert proc.returncode == 0 + assert "Resolving dependencies for" in combined + assert "Conflicting packages" not in combined + + +def test_real_packs_simultaneous_no_conflict(comfy_cli): + """Simultaneous install of two real packs with --uv-compile — no conflicts.""" + proc = exec(f"{comfy_cli} node install --uv-compile {PACK_IMPACT} {PACK_INSPIRE}") + combined = proc.stdout + proc.stderr + + assert proc.returncode == 0 + assert "Resolving dependencies for" in combined + assert "Conflicting packages" not in combined + + +# --------------------------------------------------------------------------- +# Progressive conflict (real packs + conflict packs) +# --------------------------------------------------------------------------- + + +def test_progressive_conflict(comfy_cli): + """Real packs installed → +conflict-pack-1 OK → +conflict-pack-2 CONFLICT.""" + # Step 1: Install real packs — no conflict + proc = exec(f"{comfy_cli} node install --uv-compile {PACK_IMPACT} {PACK_INSPIRE}") + combined = proc.stdout + proc.stderr + assert proc.returncode == 0 + assert "Conflicting packages" not in combined + + # Step 2: Add first conflict test pack — still no conflict + proc = exec(f"{comfy_cli} node install --uv-compile {REPO_TEST1}") + combined = proc.stdout + proc.stderr + assert proc.returncode == 0 + assert "Conflicting packages" not in combined + + # Step 3: Add second conflict test pack — conflict between test packs + proc = exec(f"{comfy_cli} node install --uv-compile {REPO_TEST2}") + combined = proc.stdout + proc.stderr + assert "Conflicting packages (by node pack):" in combined + assert PACK_TEST1 in combined + assert PACK_TEST2 in combined + + +# --------------------------------------------------------------------------- +# Reinstall / Update / Fix with --uv-compile +# --------------------------------------------------------------------------- + + +def test_node_reinstall_uv_compile(comfy_cli): + """Reinstall with --uv-compile → resolution runs.""" + setup = exec(f"{comfy_cli} node install {REPO_TEST1}") + assert setup.returncode == 0, f"Setup install failed: {setup.stderr}" + + proc = exec(f"{comfy_cli} node reinstall --uv-compile {REPO_TEST1}") + combined = proc.stdout + proc.stderr + + assert proc.returncode == 0, f"reinstall failed: {proc.stderr}" + assert "Resolving dependencies for" in combined + + +def test_node_update_uv_compile(comfy_cli): + """Update with --uv-compile → resolution runs.""" + setup = exec(f"{comfy_cli} node install {REPO_TEST1}") + assert setup.returncode == 0, f"Setup install failed: {setup.stderr}" + + proc = exec(f"{comfy_cli} node update --uv-compile {REPO_TEST1}") + combined = proc.stdout + proc.stderr + + assert proc.returncode == 0, f"update failed: {proc.stderr}" + assert "Resolving dependencies for" in combined + + +def test_node_fix_uv_compile(comfy_cli): + """Fix with --uv-compile → resolution runs.""" + setup = exec(f"{comfy_cli} node install {REPO_TEST1}") + assert setup.returncode == 0, f"Setup install failed: {setup.stderr}" + + proc = exec(f"{comfy_cli} node fix --uv-compile {REPO_TEST1}") + combined = proc.stdout + proc.stderr + + assert proc.returncode == 0, f"fix failed: {proc.stderr}" + assert "Resolving dependencies for" in combined + + +def test_node_restore_deps_uv_compile(comfy_cli): + """restore-dependencies --uv-compile → resolution runs.""" + setup = exec(f"{comfy_cli} node install {REPO_TEST1}") + assert setup.returncode == 0, f"Setup install failed: {setup.stderr}" + + proc = exec(f"{comfy_cli} node restore-dependencies --uv-compile") + combined = proc.stdout + proc.stderr + + assert proc.returncode == 0, f"restore-dependencies failed: {proc.stderr}" + assert "Resolving dependencies for" in combined + + +# --------------------------------------------------------------------------- +# Standalone uv-sync +# --------------------------------------------------------------------------- + + +def test_node_uv_sync_standalone(comfy_cli): + """Standalone comfy node uv-sync with installed pack.""" + setup = exec(f"{comfy_cli} node install {REPO_TEST1}") + assert setup.returncode == 0, f"Setup install failed: {setup.stderr}" + + proc = exec(f"{comfy_cli} node uv-sync") + combined = proc.stdout + proc.stderr + + assert proc.returncode == 0 + assert "Resolving dependencies for" in combined + + +def test_node_uv_sync_standalone_conflict(comfy_cli): + """Standalone uv-sync with conflicting packs → conflict attribution.""" + setup1 = exec(f"{comfy_cli} node install {REPO_TEST1}") + assert setup1.returncode == 0, f"Setup install test1 failed: {setup1.stderr}" + setup2 = exec(f"{comfy_cli} node install {REPO_TEST2}") + assert setup2.returncode == 0, f"Setup install test2 failed: {setup2.stderr}" + + proc = exec(f"{comfy_cli} node uv-sync") + combined = proc.stdout + proc.stderr + + assert "Conflicting packages (by node pack):" in combined + assert PACK_TEST1 in combined + assert PACK_TEST2 in combined + + +# --------------------------------------------------------------------------- +# Config default +# --------------------------------------------------------------------------- + + +def test_uv_compile_config_default(comfy_cli): + """Config default true → install without flag triggers resolution.""" + proc = exec(f"{comfy_cli} manager uv-compile-default true") + assert proc.returncode == 0 + + try: + proc = exec(f"{comfy_cli} node install {REPO_TEST1}") + combined = proc.stdout + proc.stderr + + assert proc.returncode == 0, f"install failed: {proc.stderr}" + assert "Resolving dependencies for" in combined + finally: + exec(f"{comfy_cli} manager uv-compile-default false") + + +def test_no_uv_compile_overrides_config(comfy_cli): + """--no-uv-compile overrides config default.""" + proc = exec(f"{comfy_cli} manager uv-compile-default true") + assert proc.returncode == 0 + + try: + proc = exec(f"{comfy_cli} node install --no-uv-compile {REPO_TEST1}") + combined = proc.stdout + proc.stderr + + assert proc.returncode == 0, f"install failed: {proc.stderr}" + assert "Resolving dependencies for" not in combined + finally: + exec(f"{comfy_cli} manager uv-compile-default false") + + +# --------------------------------------------------------------------------- +# Mutual exclusivity +# --------------------------------------------------------------------------- + + +def test_uv_compile_mutual_exclusivity(comfy_cli): + """--uv-compile cannot be used with --fast-deps or --no-deps.""" + # --uv-compile + --fast-deps + proc = exec(f"{comfy_cli} node install --uv-compile --fast-deps {REPO_TEST1}") + assert proc.returncode != 0 + assert "Cannot use" in (proc.stdout + proc.stderr) + + # --uv-compile + --no-deps + proc = exec(f"{comfy_cli} node install --uv-compile --no-deps {REPO_TEST1}") + assert proc.returncode != 0 + assert "Cannot use" in (proc.stdout + proc.stderr)