mirror of
https://github.com/Comfy-Org/ComfyUI-Manager.git
synced 2026-01-27 22:40:22 +08:00
Compare commits
104 Commits
ebe15ae57a
...
ec8d004839
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec8d004839 | ||
|
|
43b200dc91 | ||
|
|
29014699bb | ||
|
|
5576672957 | ||
|
|
5002606861 | ||
|
|
ba0fb343ff | ||
|
|
17e5ae6bc2 | ||
|
|
7a0186efc8 | ||
|
|
de64af4a68 | ||
|
|
4a852ac8a8 | ||
|
|
6784bfb98c | ||
|
|
c8f246d344 | ||
|
|
8b3d31a936 | ||
|
|
5e88d6445b | ||
|
|
fd7dff88df | ||
|
|
8cfee1f483 | ||
|
|
cf4d8e6125 | ||
|
|
c0e8a41d2a | ||
|
|
a02c27b1af | ||
|
|
712e1bac0d | ||
|
|
513ea46cbe | ||
|
|
b1919b6f95 | ||
|
|
43561d209b | ||
|
|
16dcbc5412 | ||
|
|
c8dd2d5cad | ||
|
|
4b37777066 | ||
|
|
95ecd85a12 | ||
|
|
5c475e3c15 | ||
|
|
f705ee6863 | ||
|
|
1f67c18989 | ||
|
|
de6d451c5b | ||
|
|
580296d6f3 | ||
|
|
a9e28fbce3 | ||
|
|
311779cb20 | ||
|
|
d2f8a89e87 | ||
|
|
84c95bf322 | ||
|
|
f75c801955 | ||
|
|
faa2f54371 | ||
|
|
4249ac193a | ||
|
|
c709274a28 | ||
|
|
c8f05e79db | ||
|
|
4d2887e99f | ||
|
|
29256a5154 | ||
|
|
82d42e4094 | ||
|
|
53850fb627 | ||
|
|
34b4c8ce46 | ||
|
|
e944841054 | ||
|
|
f6a5ff5552 | ||
|
|
01763b59d4 | ||
|
|
044173b2a1 | ||
|
|
99e7a88dbd | ||
|
|
01cd9fbb0e | ||
|
|
aaed1dc3d5 | ||
|
|
c8dce94c03 | ||
|
|
06496d07b3 | ||
|
|
a97f98c9cc | ||
|
|
8d0406f74f | ||
|
|
c64d14701d | ||
|
|
00332ae444 | ||
|
|
e8deb3d8fe | ||
|
|
8b234c99cf | ||
|
|
1f986d9c45 | ||
|
|
bacb8fb3cd | ||
|
|
e4a90089ab | ||
|
|
674b9f3705 | ||
|
|
4941fb8aa0 | ||
|
|
183af0dfa5 | ||
|
|
45ac5429f8 | ||
|
|
c771977a95 | ||
|
|
668d7bbb2c | ||
|
|
926cfabb58 | ||
|
|
a9a8d05115 | ||
|
|
e368f4366a | ||
|
|
dc5bddbc17 | ||
|
|
358a480408 | ||
|
|
c96fdb3c7a | ||
|
|
c090abcc02 | ||
|
|
1ff02be35f | ||
|
|
10fbfb88f7 | ||
|
|
9753df72ed | ||
|
|
095cc3f792 | ||
|
|
656171037b | ||
|
|
7ac10f9442 | ||
|
|
3925ba27b4 | ||
|
|
44ba79aa31 | ||
|
|
14d0e31268 | ||
|
|
033acffad1 | ||
|
|
d29ff808a5 | ||
|
|
dc9b6d655b | ||
|
|
d340c85013 | ||
|
|
e328353664 | ||
|
|
02785af8fd | ||
|
|
736ae5d63e | ||
|
|
e1eeb617d2 | ||
|
|
23b6c7f0de | ||
|
|
997f97e1fc | ||
|
|
ff335ff1a0 | ||
|
|
cb3036ef81 | ||
|
|
f762906188 | ||
|
|
dde7920f8c | ||
|
|
1a0d24110a | ||
|
|
e79f6c4471 | ||
|
|
a8a7024a84 | ||
|
|
da87651e53 |
32
README.md
32
README.md
@ -5,6 +5,7 @@
|
||||

|
||||
|
||||
## NOTICE
|
||||
* V3.38: **Security patch** - Manager data migrated to protected path. See [Migration Guide](docs/en/v3.38-userdata-security-migration.md).
|
||||
* V3.16: Support for `uv` has been added. Set `use_uv` in `config.ini`.
|
||||
* V3.10: `double-click feature` is removed
|
||||
* This feature has been moved to https://github.com/ltdrdata/comfyui-connection-helper
|
||||
@ -140,20 +141,27 @@ This repository provides Colab notebooks that allow you to install and use Comfy
|
||||
|
||||
|
||||
## Paths
|
||||
In `ComfyUI-Manager` V3.0 and later, configuration files and dynamically generated files are located under `<USER_DIRECTORY>/default/ComfyUI-Manager/`.
|
||||
Starting from V3.38, Manager uses a protected system path for enhanced security.
|
||||
|
||||
* <USER_DIRECTORY>
|
||||
* If executed without any options, the path defaults to ComfyUI/user.
|
||||
* It can be set using --user-directory <USER_DIRECTORY>.
|
||||
* <USER_DIRECTORY>
|
||||
* If executed without any options, the path defaults to ComfyUI/user.
|
||||
* It can be set using --user-directory <USER_DIRECTORY>.
|
||||
|
||||
* Basic config files: `<USER_DIRECTORY>/default/ComfyUI-Manager/config.ini`
|
||||
* Configurable channel lists: `<USER_DIRECTORY>/default/ComfyUI-Manager/channels.ini`
|
||||
* Configurable pip overrides: `<USER_DIRECTORY>/default/ComfyUI-Manager/pip_overrides.json`
|
||||
* Configurable pip blacklist: `<USER_DIRECTORY>/default/ComfyUI-Manager/pip_blacklist.list`
|
||||
* Configurable pip auto fix: `<USER_DIRECTORY>/default/ComfyUI-Manager/pip_auto_fix.list`
|
||||
* Saved snapshot files: `<USER_DIRECTORY>/default/ComfyUI-Manager/snapshots`
|
||||
* Startup script files: `<USER_DIRECTORY>/default/ComfyUI-Manager/startup-scripts`
|
||||
* Component files: `<USER_DIRECTORY>/default/ComfyUI-Manager/components`
|
||||
| ComfyUI Version | Manager Path |
|
||||
|-----------------|--------------|
|
||||
| v0.3.76+ (with System User API) | `<USER_DIRECTORY>/__manager/` |
|
||||
| Older versions | `<USER_DIRECTORY>/default/ComfyUI-Manager/` |
|
||||
|
||||
* Basic config files: `config.ini`
|
||||
* Configurable channel lists: `channels.list`
|
||||
* Configurable pip overrides: `pip_overrides.json`
|
||||
* Configurable pip blacklist: `pip_blacklist.list`
|
||||
* Configurable pip auto fix: `pip_auto_fix.list`
|
||||
* Saved snapshot files: `snapshots/`
|
||||
* Startup script files: `startup-scripts/`
|
||||
* Component files: `components/`
|
||||
|
||||
> **Note**: See [Migration Guide](docs/en/v3.38-userdata-security-migration.md) for upgrade details.
|
||||
|
||||
|
||||
## `extra_model_paths.yaml` Configuration
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
230
docs/en/v3.38-userdata-security-migration.md
Normal file
230
docs/en/v3.38-userdata-security-migration.md
Normal file
@ -0,0 +1,230 @@
|
||||
# ComfyUI-Manager V3.38: Userdata Security Migration Guide
|
||||
|
||||
## Introduction
|
||||
|
||||
ComfyUI-Manager V3.38 introduces a **security patch** that migrates Manager's configuration and data to a protected system path. This change leverages ComfyUI's new System User Protection API (PR #10966) to provide enhanced security isolation.
|
||||
|
||||
This guide explains what happens during the migration and how to handle various situations.
|
||||
|
||||
---
|
||||
|
||||
## What Changed
|
||||
|
||||
### Finding Your Paths
|
||||
|
||||
When ComfyUI starts, it displays the full paths in the terminal:
|
||||
|
||||
```
|
||||
** User directory: /path/to/ComfyUI/user
|
||||
** ComfyUI-Manager config path: /path/to/ComfyUI/user/__manager/config.ini
|
||||
```
|
||||
|
||||
Look for these lines in your startup log to find the exact location on your system. In this guide, paths are shown relative to the `user` directory.
|
||||
|
||||
### Path Migration
|
||||
|
||||
| Data | Legacy Path | New Path |
|
||||
|------|-------------|----------|
|
||||
| Configuration | `user/default/ComfyUI-Manager/` | `user/__manager/` |
|
||||
| Snapshots | `user/default/ComfyUI-Manager/snapshots/` | `user/__manager/snapshots/` |
|
||||
|
||||
### Why This Change
|
||||
|
||||
In older ComfyUI versions, the `default/` directory was **unprotected** and accessible via web APIs. If you ran ComfyUI with `--listen 0.0.0.0` or similar options to allow external connections, this data **may have been tampered with** by malicious actors.
|
||||
|
||||
**Note:** If you only used ComfyUI locally (without `--listen` or with `--listen 127.0.0.1`), your data was not exposed to this vulnerability.
|
||||
|
||||
The new `__manager` path uses ComfyUI's protected system directory, which:
|
||||
- **Cannot be accessed** from outside (protected by ComfyUI)
|
||||
- Isolates system settings from user data
|
||||
- Enables stricter security for remote access
|
||||
|
||||
**This is why only `config.ini` is automatically migrated** - other files (snapshots) may have been compromised and should be manually verified before copying.
|
||||
|
||||
---
|
||||
|
||||
## Automatic Migration
|
||||
|
||||
When you start ComfyUI with the new System User Protection API, Manager automatically handles the migration:
|
||||
|
||||
### Step 1: Configuration Migration
|
||||
|
||||
Only `config.ini` is migrated automatically.
|
||||
|
||||
**Important**: Snapshots are **NOT** automatically migrated. You must copy them manually if needed.
|
||||
|
||||
### Step 2: Security Level Check
|
||||
|
||||
During migration, if your security level is below `normal` (i.e., `weak` or `normal-`), it will be automatically raised to `normal`. This is a safety measure because the security level setting itself may have been tampered with in the old version.
|
||||
|
||||
```
|
||||
======================================================================
|
||||
[ComfyUI-Manager] WARNING: Security level adjusted
|
||||
- Previous: 'weak' → New: 'normal'
|
||||
- Raised to prevent unauthorized remote access.
|
||||
======================================================================
|
||||
```
|
||||
|
||||
If you need a lower security level, you can manually edit the config after migration.
|
||||
|
||||
### Step 3: Legacy Backup
|
||||
|
||||
Your entire legacy directory is moved to a backup location:
|
||||
```
|
||||
user/__manager/.legacy-manager-backup/
|
||||
```
|
||||
|
||||
This backup is preserved until you manually delete it.
|
||||
|
||||
---
|
||||
|
||||
## Persistent Backup Notification
|
||||
|
||||
As long as the backup exists, Manager will remind you on **every startup**:
|
||||
|
||||
```
|
||||
----------------------------------------------------------------------
|
||||
[ComfyUI-Manager] NOTICE: Legacy backup exists
|
||||
- Your old Manager data was backed up to:
|
||||
/path/to/ComfyUI/user/__manager/.legacy-manager-backup
|
||||
- Please verify and remove it when no longer needed.
|
||||
----------------------------------------------------------------------
|
||||
```
|
||||
|
||||
**To stop this notification**: Delete the `.legacy-manager-backup` folder inside `user/__manager/` after confirming you don't need any data from it.
|
||||
|
||||
---
|
||||
|
||||
## Recovering Old Data
|
||||
|
||||
### Snapshots
|
||||
|
||||
If you need your old snapshots, copy the contents of `.legacy-manager-backup/snapshots/` to `user/__manager/snapshots/`.
|
||||
|
||||
---
|
||||
|
||||
## Outdated ComfyUI Warning
|
||||
|
||||
If you're running an older version of ComfyUI without the System User Protection API, Manager will:
|
||||
|
||||
1. **Force security level to `strong`** - All installations are blocked
|
||||
2. **Display warning message**:
|
||||
|
||||
```
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
[ComfyUI-Manager] ERROR: ComfyUI version is outdated!
|
||||
- Most operations are blocked for security.
|
||||
- ComfyUI update is still allowed.
|
||||
- Please update ComfyUI to use Manager normally.
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
```
|
||||
|
||||
**Solution**: Update ComfyUI to v0.3.76 or later.
|
||||
|
||||
---
|
||||
|
||||
## Security Levels
|
||||
|
||||
| Level | What's Allowed |
|
||||
|-------|----------------|
|
||||
| `strong` | ComfyUI update only. All other installations blocked. |
|
||||
| `normal` | Install/update/remove registered custom nodes and models. |
|
||||
| `normal-` | Above + Install via Git URL or pip (localhost only). |
|
||||
| `weak` | All operations allowed, including from remote connections. |
|
||||
|
||||
**Notes:**
|
||||
- `strong` is forced on outdated ComfyUI versions.
|
||||
- `normal` is the default and recommended for most users.
|
||||
- `normal-` is for developers who need to install unregistered nodes locally.
|
||||
- `weak` should only be used in isolated development environments.
|
||||
|
||||
### Changing Security Level
|
||||
|
||||
Edit `user/__manager/config.ini`:
|
||||
```ini
|
||||
[default]
|
||||
security_level = normal
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Messages
|
||||
|
||||
### "comfyui_outdated" (HTTP 403)
|
||||
|
||||
This error appears when:
|
||||
- Your ComfyUI doesn't have the System User Protection API
|
||||
- All installations are blocked until you update ComfyUI
|
||||
|
||||
**Solution**: Update ComfyUI to the latest version.
|
||||
|
||||
### "security_level" (HTTP 403)
|
||||
|
||||
This error appears when:
|
||||
- Your security level blocks the requested operation
|
||||
- For example, `strong` level blocks all installations
|
||||
|
||||
**Solution**: Lower your security level in config.ini if appropriate for your use case.
|
||||
|
||||
---
|
||||
|
||||
## Security Warning: Suspicious Path
|
||||
|
||||
If you see this error on an **older** ComfyUI:
|
||||
|
||||
```
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
[ComfyUI-Manager] ERROR: Suspicious path detected!
|
||||
- '__manager' exists with low security level: 'weak'
|
||||
- Please verify manually:
|
||||
/path/to/ComfyUI/user/__manager/config.ini
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
```
|
||||
|
||||
On older ComfyUI versions, the `__manager` directory is not normally created. If this directory exists, it may have been created externally. For safety, manually verify the contents of this directory before updating ComfyUI.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### All my installations are blocked
|
||||
|
||||
**Check 1**: Is your ComfyUI updated?
|
||||
- Old ComfyUI forces `security_level = strong`
|
||||
- Update ComfyUI to resolve
|
||||
|
||||
**Check 2**: What's your security level?
|
||||
- Check `user/__manager/config.ini`
|
||||
- `security_level = strong` blocks all installations
|
||||
|
||||
### My snapshots are missing
|
||||
|
||||
Snapshots are not automatically migrated. You need to manually copy the `snapshots` folder from inside `.legacy-manager-backup` to the `user/__manager/` directory.
|
||||
|
||||
### I keep seeing the backup notification
|
||||
|
||||
Delete the `.legacy-manager-backup` folder inside `user/__manager/` after confirming you don't need any data from it.
|
||||
|
||||
### Snapshot restore is blocked
|
||||
|
||||
On old ComfyUI (without System User API), snapshot restore is blocked because security is forced to `strong`. Update ComfyUI to enable snapshot restore.
|
||||
|
||||
---
|
||||
|
||||
## File Structure Reference
|
||||
|
||||
```
|
||||
user/
|
||||
└── __manager/
|
||||
├── config.ini # Manager configuration
|
||||
├── channels.list # Custom node channels
|
||||
├── snapshots/ # Environment snapshots
|
||||
└── .legacy-manager-backup/ # Backup of old Manager data (temporary)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- **ComfyUI**: v0.3.76 or later (with System User Protection API)
|
||||
- **ComfyUI-Manager**: V3.38 or later
|
||||
File diff suppressed because it is too large
Load Diff
11192
github-stats.json
11192
github-stats.json
File diff suppressed because it is too large
Load Diff
@ -40,10 +40,11 @@ import cnr_utils
|
||||
import manager_util
|
||||
import git_utils
|
||||
import manager_downloader
|
||||
import manager_migration
|
||||
from node_package import InstalledNodePackage
|
||||
|
||||
|
||||
version_code = [3, 37, 1]
|
||||
version_code = [3, 38, 1]
|
||||
version_str = f"V{version_code[0]}.{version_code[1]}" + (f'.{version_code[2]}' if len(version_code) > 2 else '')
|
||||
|
||||
|
||||
@ -214,9 +215,10 @@ def update_user_directory(user_dir):
|
||||
global manager_pip_blacklist_path
|
||||
global manager_components_path
|
||||
|
||||
manager_files_path = os.path.abspath(os.path.join(user_dir, 'default', 'ComfyUI-Manager'))
|
||||
manager_files_path = manager_migration.get_manager_path(user_dir)
|
||||
if not os.path.exists(manager_files_path):
|
||||
os.makedirs(manager_files_path)
|
||||
manager_migration.run_migration_checks(user_dir, manager_files_path)
|
||||
|
||||
manager_snapshot_path = os.path.join(manager_files_path, "snapshots")
|
||||
if not os.path.exists(manager_snapshot_path):
|
||||
@ -1719,7 +1721,7 @@ def read_config():
|
||||
manager_util.use_uv = default_conf['use_uv'].lower() == 'true' if 'use_uv' in default_conf else False
|
||||
manager_util.bypass_ssl = get_bool('bypass_ssl', False)
|
||||
|
||||
return {
|
||||
result = {
|
||||
'http_channel_enabled': get_bool('http_channel_enabled', False),
|
||||
'preview_method': default_conf.get('preview_method', manager_funcs.get_current_preview_method()).lower(),
|
||||
'git_exe': default_conf.get('git_exe', ''),
|
||||
@ -1739,6 +1741,8 @@ def read_config():
|
||||
'security_level': default_conf.get('security_level', 'normal').lower(),
|
||||
'db_mode': default_conf.get('db_mode', 'cache').lower(),
|
||||
}
|
||||
manager_migration.force_security_level_if_needed(result)
|
||||
return result
|
||||
|
||||
except Exception:
|
||||
import importlib.util
|
||||
@ -1746,7 +1750,7 @@ def read_config():
|
||||
manager_util.use_uv = importlib.util.find_spec("uv") is not None and platform.system() != "Windows"
|
||||
manager_util.bypass_ssl = False
|
||||
|
||||
return {
|
||||
result = {
|
||||
'http_channel_enabled': False,
|
||||
'preview_method': manager_funcs.get_current_preview_method(),
|
||||
'git_exe': '',
|
||||
@ -1766,6 +1770,8 @@ def read_config():
|
||||
'security_level': 'normal', # strong | normal | normal- | weak
|
||||
'db_mode': 'cache', # local | cache | remote
|
||||
}
|
||||
manager_migration.force_security_level_if_needed(result)
|
||||
return result
|
||||
|
||||
|
||||
def get_config():
|
||||
@ -2533,6 +2539,7 @@ def update_to_stable_comfyui(repo_path):
|
||||
else:
|
||||
logging.info(f"[ComfyUI-Manager] Updating ComfyUI: {current_tag} -> {latest_tag}")
|
||||
repo.git.checkout(latest_tag)
|
||||
execute_install_script("ComfyUI", repo_path, instant_execution=False, no_deps=False)
|
||||
return 'updated', latest_tag
|
||||
except:
|
||||
traceback.print_exc()
|
||||
@ -3359,8 +3366,8 @@ def get_comfyui_versions(repo=None):
|
||||
repo = git.Repo(comfy_path)
|
||||
|
||||
try:
|
||||
remote = get_remote_name(repo)
|
||||
repo.remotes[remote].fetch()
|
||||
remote = get_remote_name(repo)
|
||||
repo.remotes[remote].fetch()
|
||||
except:
|
||||
logging.error("[ComfyUI-Manager] Failed to fetch ComfyUI")
|
||||
|
||||
|
||||
356
glob/manager_migration.py
Normal file
356
glob/manager_migration.py
Normal file
@ -0,0 +1,356 @@
|
||||
"""
|
||||
ComfyUI-Manager migration module.
|
||||
Handles migration from legacy paths to new __manager path structure.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import configparser
|
||||
|
||||
# Startup notices for notice board
|
||||
startup_notices = [] # List of (message, level) tuples
|
||||
|
||||
|
||||
def add_startup_notice(message, level='warning'):
|
||||
"""Add a notice to be displayed on Manager notice board.
|
||||
|
||||
Args:
|
||||
message: HTML-formatted message string
|
||||
level: 'warning', 'error', 'info'
|
||||
"""
|
||||
global startup_notices
|
||||
startup_notices.append((message, level))
|
||||
|
||||
|
||||
# Cache for API check (computed once per session)
|
||||
_cached_has_system_user_api = None
|
||||
|
||||
|
||||
def has_system_user_api():
|
||||
"""Check if ComfyUI has the System User Protection API (PR #10966).
|
||||
|
||||
Result is cached for performance.
|
||||
"""
|
||||
global _cached_has_system_user_api
|
||||
if _cached_has_system_user_api is None:
|
||||
try:
|
||||
import folder_paths
|
||||
_cached_has_system_user_api = hasattr(folder_paths, 'get_system_user_directory')
|
||||
except Exception:
|
||||
_cached_has_system_user_api = False
|
||||
return _cached_has_system_user_api
|
||||
|
||||
|
||||
def get_manager_path(user_dir):
|
||||
"""Get the appropriate manager files path based on ComfyUI version.
|
||||
|
||||
Returns:
|
||||
str: manager_files_path
|
||||
"""
|
||||
if has_system_user_api():
|
||||
return os.path.abspath(os.path.join(user_dir, '__manager'))
|
||||
else:
|
||||
return os.path.abspath(os.path.join(user_dir, 'default', 'ComfyUI-Manager'))
|
||||
|
||||
|
||||
def run_migration_checks(user_dir, manager_files_path):
|
||||
"""Run all migration and security checks.
|
||||
|
||||
Call this after get_manager_path() to handle:
|
||||
- Legacy config migration (new ComfyUI)
|
||||
- Legacy backup notification (every startup)
|
||||
- Suspicious directory detection (old ComfyUI)
|
||||
- Outdated ComfyUI warning (old ComfyUI)
|
||||
"""
|
||||
if has_system_user_api():
|
||||
migrated = migrate_legacy_config(user_dir, manager_files_path)
|
||||
# Only check for legacy backup if migration didn't just happen
|
||||
# (migration already shows backup location in its message)
|
||||
if not migrated:
|
||||
check_legacy_backup(manager_files_path)
|
||||
else:
|
||||
check_suspicious_manager(user_dir)
|
||||
warn_outdated_comfyui()
|
||||
|
||||
|
||||
def check_legacy_backup(manager_files_path):
|
||||
"""Check for legacy backup and notify user to verify and remove it.
|
||||
|
||||
This runs on every startup to remind users about pending legacy backup.
|
||||
"""
|
||||
backup_dir = os.path.join(manager_files_path, '.legacy-manager-backup')
|
||||
if not os.path.exists(backup_dir):
|
||||
return
|
||||
|
||||
# Terminal output
|
||||
print("\n" + "-"*70)
|
||||
print("[ComfyUI-Manager] NOTICE: Legacy backup exists")
|
||||
print(" - Your old Manager data was backed up to:")
|
||||
print(f" {backup_dir}")
|
||||
print(" - Please verify and remove it when no longer needed.")
|
||||
print("-"*70 + "\n")
|
||||
|
||||
# Notice board output
|
||||
add_startup_notice(
|
||||
"Legacy ComfyUI-Manager data backup exists. Please verify and remove when no longer needed. See terminal for details.",
|
||||
level='info'
|
||||
)
|
||||
|
||||
|
||||
def check_suspicious_manager(user_dir):
|
||||
"""Check for suspicious __manager directory on old ComfyUI.
|
||||
|
||||
On old ComfyUI without System User API, if __manager exists with low security,
|
||||
warn the user to verify manually.
|
||||
|
||||
Returns:
|
||||
bool: True if suspicious setup detected
|
||||
"""
|
||||
if has_system_user_api():
|
||||
return False # Not suspicious on new ComfyUI
|
||||
|
||||
suspicious_path = os.path.abspath(os.path.join(user_dir, '__manager'))
|
||||
if not os.path.exists(suspicious_path):
|
||||
return False
|
||||
|
||||
config_path = os.path.join(suspicious_path, 'config.ini')
|
||||
if not os.path.exists(config_path):
|
||||
return False
|
||||
|
||||
config = configparser.ConfigParser()
|
||||
config.read(config_path)
|
||||
sec_level = config.get('default', 'security_level', fallback='normal').lower()
|
||||
|
||||
if sec_level in ['weak', 'normal-']:
|
||||
# Terminal output
|
||||
print("\n" + "!"*70)
|
||||
print("[ComfyUI-Manager] ERROR: Suspicious path detected!")
|
||||
print(f" - '__manager' exists with low security level: '{sec_level}'")
|
||||
print(" - Please verify manually:")
|
||||
print(f" {config_path}")
|
||||
print("!"*70 + "\n")
|
||||
|
||||
# Notice board output
|
||||
add_startup_notice(
|
||||
"[Security Alert] Suspicious path detected. See terminal log for details.",
|
||||
level='error'
|
||||
)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def warn_outdated_comfyui():
|
||||
"""Warn user about outdated ComfyUI without System User API."""
|
||||
if has_system_user_api():
|
||||
return
|
||||
|
||||
# Terminal output
|
||||
print("\n" + "!"*70)
|
||||
print("[ComfyUI-Manager] ERROR: ComfyUI version is outdated!")
|
||||
print(" - Most operations are blocked for security.")
|
||||
print(" - ComfyUI update is still allowed.")
|
||||
print(" - Please update ComfyUI to use Manager normally.")
|
||||
print("!"*70 + "\n")
|
||||
|
||||
# Notice board output
|
||||
add_startup_notice(
|
||||
"[Security Alert] ComfyUI outdated. Installations blocked (update allowed).<BR>"
|
||||
"Update ComfyUI for normal operation.",
|
||||
level='error'
|
||||
)
|
||||
|
||||
|
||||
def migrate_legacy_config(user_dir, manager_files_path):
|
||||
"""Migrate ONLY config.ini to new __manager path if needed.
|
||||
|
||||
IMPORTANT: Only config.ini is migrated. Other files (snapshots, cache, etc.)
|
||||
are NOT migrated - users must recreate them.
|
||||
|
||||
Scenarios:
|
||||
1. Legacy exists, New doesn't exist → Migrate config.ini
|
||||
2. Legacy exists, New exists → First update after upgrade
|
||||
- Run ComfyUI dependency installation
|
||||
- Rename legacy to .backup
|
||||
3. Legacy doesn't exist → No migration needed
|
||||
|
||||
Returns:
|
||||
bool: True if migration was performed
|
||||
"""
|
||||
if not has_system_user_api():
|
||||
return False
|
||||
|
||||
legacy_dir = os.path.join(user_dir, 'default', 'ComfyUI-Manager')
|
||||
legacy_config = os.path.join(legacy_dir, 'config.ini')
|
||||
new_config = os.path.join(manager_files_path, 'config.ini')
|
||||
|
||||
if not os.path.exists(legacy_dir):
|
||||
return False # No legacy directory, nothing to migrate
|
||||
|
||||
# IMPORTANT: Check for config.ini existence, not just directory
|
||||
# (because makedirs() creates __manager before this function is called)
|
||||
|
||||
# Case: Both configs exist (first update after ComfyUI upgrade)
|
||||
# This means user ran new ComfyUI at least once, creating __manager/config.ini
|
||||
if os.path.exists(legacy_config) and os.path.exists(new_config):
|
||||
_handle_first_update_migration(user_dir, legacy_dir, manager_files_path)
|
||||
return True
|
||||
|
||||
# Case: Legacy config exists but new config doesn't (normal migration)
|
||||
# This is the first run after ComfyUI upgrade
|
||||
if os.path.exists(legacy_config) and not os.path.exists(new_config):
|
||||
pass # Continue with normal migration below
|
||||
else:
|
||||
return False
|
||||
|
||||
# Terminal output
|
||||
print("\n" + "-"*70)
|
||||
print("[ComfyUI-Manager] NOTICE: Legacy config.ini detected")
|
||||
print(f" - Old: {legacy_config}")
|
||||
print(f" - New: {new_config}")
|
||||
print(" - Migrating config.ini only (other files are NOT migrated).")
|
||||
print(" - Security level below 'normal' will be raised.")
|
||||
print("-"*70 + "\n")
|
||||
|
||||
_migrate_config_with_security_check(legacy_config, new_config)
|
||||
|
||||
# Move legacy directory to backup
|
||||
_move_legacy_to_backup(legacy_dir, manager_files_path)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _handle_first_update_migration(user_dir, legacy_dir, manager_files_path):
|
||||
"""Handle first ComfyUI update when both legacy and new directories exist.
|
||||
|
||||
This scenario happens when:
|
||||
- User was on old ComfyUI (using default/ComfyUI-Manager)
|
||||
- ComfyUI was updated (now has System User API)
|
||||
- Manager already created __manager on first new run
|
||||
- But legacy directory still exists
|
||||
|
||||
Actions:
|
||||
1. Run ComfyUI dependency installation
|
||||
2. Move legacy to __manager/.legacy-manager-backup
|
||||
"""
|
||||
# Terminal output
|
||||
print("\n" + "-"*70)
|
||||
print("[ComfyUI-Manager] NOTICE: First update after ComfyUI upgrade detected")
|
||||
print(" - Both legacy and new directories exist.")
|
||||
print(" - Running ComfyUI dependency installation...")
|
||||
print("-"*70 + "\n")
|
||||
|
||||
# Run ComfyUI dependency installation
|
||||
# Path: glob/manager_migration.py → glob → comfyui-manager → custom_nodes → ComfyUI
|
||||
try:
|
||||
comfyui_path = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
|
||||
requirements_path = os.path.join(comfyui_path, 'requirements.txt')
|
||||
if os.path.exists(requirements_path):
|
||||
subprocess.run([sys.executable, '-m', 'pip', 'install', '-r', requirements_path],
|
||||
capture_output=True, check=False)
|
||||
print("[ComfyUI-Manager] ComfyUI dependencies installation completed.")
|
||||
except Exception as e:
|
||||
print(f"[ComfyUI-Manager] WARNING: Failed to install ComfyUI dependencies: {e}")
|
||||
|
||||
# Move legacy to backup inside __manager
|
||||
_move_legacy_to_backup(legacy_dir, manager_files_path)
|
||||
|
||||
|
||||
def _move_legacy_to_backup(legacy_dir, manager_files_path):
|
||||
"""Move legacy directory to backup inside __manager.
|
||||
|
||||
Returns:
|
||||
str: Path to backup directory if successful, None if failed
|
||||
"""
|
||||
import shutil
|
||||
|
||||
backup_dir = os.path.join(manager_files_path, '.legacy-manager-backup')
|
||||
|
||||
try:
|
||||
if os.path.exists(backup_dir):
|
||||
shutil.rmtree(backup_dir) # Remove old backup if exists
|
||||
shutil.move(legacy_dir, backup_dir)
|
||||
|
||||
# Terminal output (full paths shown here only)
|
||||
print("\n" + "-"*70)
|
||||
print("[ComfyUI-Manager] NOTICE: Legacy settings migrated")
|
||||
print(f" - Old location: {legacy_dir}")
|
||||
print(f" - Backed up to: {backup_dir}")
|
||||
print(" - Please verify and remove the backup when no longer needed.")
|
||||
print("-"*70 + "\n")
|
||||
|
||||
# Notice board output (no full paths for security)
|
||||
add_startup_notice(
|
||||
"Legacy ComfyUI-Manager data migrated. See terminal for details.",
|
||||
level='info'
|
||||
)
|
||||
return backup_dir
|
||||
except Exception as e:
|
||||
print(f"[ComfyUI-Manager] WARNING: Failed to backup legacy directory: {e}")
|
||||
add_startup_notice(
|
||||
f"[MIGRATION] Failed to backup legacy directory: {e}",
|
||||
level='warning'
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _migrate_config_with_security_check(legacy_path, new_path):
|
||||
"""Migrate legacy config, raising security level only if below default."""
|
||||
config = configparser.ConfigParser()
|
||||
try:
|
||||
config.read(legacy_path)
|
||||
except Exception as e:
|
||||
print(f"[ComfyUI-Manager] WARNING: Failed to parse config.ini: {e}")
|
||||
print(" - Creating fresh config with default settings.")
|
||||
add_startup_notice(
|
||||
"[MIGRATION] Failed to parse legacy config. Using defaults.",
|
||||
level='warning'
|
||||
)
|
||||
return # Skip migration, let Manager create fresh config
|
||||
|
||||
# Security level hierarchy: strong > normal > normal- > weak
|
||||
# Default is 'normal', only raise if below default
|
||||
if 'default' in config:
|
||||
current_level = config['default'].get('security_level', 'normal').lower()
|
||||
below_default_levels = ['weak', 'normal-']
|
||||
|
||||
if current_level in below_default_levels:
|
||||
config['default']['security_level'] = 'normal'
|
||||
|
||||
# Terminal output
|
||||
print("\n" + "="*70)
|
||||
print("[ComfyUI-Manager] WARNING: Security level adjusted")
|
||||
print(f" - Previous: '{current_level}' → New: 'normal'")
|
||||
print(" - Raised to prevent unauthorized remote access.")
|
||||
print("="*70 + "\n")
|
||||
|
||||
# Notice board output
|
||||
add_startup_notice(
|
||||
f"[MIGRATION] Security level raised: '{current_level}' → 'normal'.<BR>"
|
||||
"To prevent unauthorized remote access.",
|
||||
level='warning'
|
||||
)
|
||||
else:
|
||||
print(f" - Security level: '{current_level}' (no change needed)")
|
||||
|
||||
# Ensure directory exists
|
||||
os.makedirs(os.path.dirname(new_path), exist_ok=True)
|
||||
|
||||
with open(new_path, 'w') as f:
|
||||
config.write(f)
|
||||
|
||||
|
||||
def force_security_level_if_needed(config_dict):
|
||||
"""Force security level to 'strong' if on old ComfyUI.
|
||||
|
||||
Args:
|
||||
config_dict: Configuration dictionary to modify in-place
|
||||
|
||||
Returns:
|
||||
bool: True if security level was forced
|
||||
"""
|
||||
if not has_system_user_api():
|
||||
config_dict['security_level'] = 'strong'
|
||||
return True
|
||||
return False
|
||||
@ -22,6 +22,7 @@ import asyncio
|
||||
import queue
|
||||
|
||||
import manager_downloader
|
||||
import manager_migration
|
||||
|
||||
|
||||
logging.info(f"### Loading: ComfyUI-Manager ({core.version_str})")
|
||||
@ -276,6 +277,13 @@ import zipfile
|
||||
import urllib.request
|
||||
|
||||
|
||||
def security_403_response():
|
||||
"""Return appropriate 403 response based on ComfyUI version."""
|
||||
if not manager_migration.has_system_user_api():
|
||||
return web.json_response({"error": "comfyui_outdated"}, status=403)
|
||||
return web.json_response({"error": "security_level"}, status=403)
|
||||
|
||||
|
||||
def get_model_dir(data, show_log=False):
|
||||
if 'download_model_base' in folder_paths.folder_names_and_paths:
|
||||
models_base = folder_paths.folder_names_and_paths['download_model_base'][0][0]
|
||||
@ -732,7 +740,7 @@ async def fetch_updates(request):
|
||||
async def update_all(request):
|
||||
if not is_allowed_security_level('middle'):
|
||||
logging.error(SECURITY_MESSAGE_MIDDLE_OR_BELOW)
|
||||
return web.Response(status=403)
|
||||
return security_403_response()
|
||||
|
||||
with task_worker_lock:
|
||||
is_processing = task_worker_thread is not None and task_worker_thread.is_alive()
|
||||
@ -965,7 +973,7 @@ async def get_snapshot_list(request):
|
||||
async def remove_snapshot(request):
|
||||
if not is_allowed_security_level('middle'):
|
||||
logging.error(SECURITY_MESSAGE_MIDDLE_OR_BELOW)
|
||||
return web.Response(status=403)
|
||||
return security_403_response()
|
||||
|
||||
try:
|
||||
target = request.rel_url.query["target"]
|
||||
@ -983,7 +991,7 @@ async def remove_snapshot(request):
|
||||
async def restore_snapshot(request):
|
||||
if not is_allowed_security_level('middle'):
|
||||
logging.error(SECURITY_MESSAGE_MIDDLE_OR_BELOW)
|
||||
return web.Response(status=403)
|
||||
return security_403_response()
|
||||
|
||||
try:
|
||||
target = request.rel_url.query["target"]
|
||||
@ -1302,7 +1310,7 @@ async def fix_custom_node(request):
|
||||
async def install_custom_node_git_url(request):
|
||||
if not is_allowed_security_level('high'):
|
||||
logging.error(SECURITY_MESSAGE_NORMAL_MINUS)
|
||||
return web.Response(status=403)
|
||||
return security_403_response()
|
||||
|
||||
url = await request.text()
|
||||
res = await core.gitclone_install(url)
|
||||
@ -1322,7 +1330,7 @@ async def install_custom_node_git_url(request):
|
||||
async def install_custom_node_pip(request):
|
||||
if not is_allowed_security_level('high'):
|
||||
logging.error(SECURITY_MESSAGE_NORMAL_MINUS)
|
||||
return web.Response(status=403)
|
||||
return security_403_response()
|
||||
|
||||
packages = await request.text()
|
||||
core.pip_install(packages.split(' '))
|
||||
@ -1594,6 +1602,16 @@ async def get_notice(request):
|
||||
except:
|
||||
pass
|
||||
|
||||
# Prepend startup notices from manager_migration
|
||||
for message, level in reversed(manager_migration.startup_notices):
|
||||
if level == 'error':
|
||||
style = 'color:red; background-color:white; font-weight:bold'
|
||||
elif level == 'warning':
|
||||
style = 'color:orange; background-color:white; font-weight:bold'
|
||||
else:
|
||||
style = 'color:blue; background-color:white'
|
||||
markdown_content = f'<P style="{style}">{message}</P>' + markdown_content
|
||||
|
||||
return web.Response(text=markdown_content, status=200)
|
||||
else:
|
||||
return web.Response(text="Unable to retrieve Notice", status=200)
|
||||
@ -1601,11 +1619,35 @@ async def get_notice(request):
|
||||
return web.Response(text="Unable to retrieve Notice", status=200)
|
||||
|
||||
|
||||
@routes.get("/manager/startup_alerts")
|
||||
async def get_startup_alerts(request):
|
||||
"""Return startup alerts for customAlert display on page load.
|
||||
|
||||
Returns JSON array of alerts that should be shown to user immediately.
|
||||
All startup notices (error, warning, info) are returned.
|
||||
"""
|
||||
alerts = []
|
||||
|
||||
# Return all startup notices for alert display
|
||||
for message, level in manager_migration.startup_notices:
|
||||
# Convert HTML BR to newlines for customAlert
|
||||
text = message.replace('<BR>', '\n').replace('<br>', '\n')
|
||||
# Add [ComfyUI-Manager] prefix for customAlert (notice board shows in Manager UI anyway)
|
||||
text = text.replace('[Security Alert]', '[ComfyUI-Manager] Security Alert:')
|
||||
text = text.replace('[MIGRATION]', '[ComfyUI-Manager] Migration:')
|
||||
alerts.append({
|
||||
'message': text,
|
||||
'level': level
|
||||
})
|
||||
|
||||
return web.json_response(alerts)
|
||||
|
||||
|
||||
@routes.get("/manager/reboot")
|
||||
def restart(self):
|
||||
if not is_allowed_security_level('middle'):
|
||||
logging.error(SECURITY_MESSAGE_MIDDLE_OR_BELOW)
|
||||
return web.Response(status=403)
|
||||
return security_403_response()
|
||||
|
||||
try:
|
||||
sys.stdout.close_log()
|
||||
|
||||
@ -55,7 +55,7 @@ def get_pip_cmd(force_uv=False):
|
||||
subprocess.check_output(test_cmd, stderr=subprocess.DEVNULL, timeout=5)
|
||||
return [sys.executable] + (['-s'] if embedded else []) + ['-m', 'pip']
|
||||
except Exception:
|
||||
logging.warning("[ComfyUI-Manager] python -m pip not available. Falling back to uv.")
|
||||
logging.warning("[ComfyUI-Manager] `python -m pip` not available. Falling back to `uv`.")
|
||||
|
||||
# Try uv (either forced or pip failed)
|
||||
import shutil
|
||||
@ -64,19 +64,19 @@ def get_pip_cmd(force_uv=False):
|
||||
try:
|
||||
test_cmd = [sys.executable] + (['-s'] if embedded else []) + ['-m', 'uv', '--version']
|
||||
subprocess.check_output(test_cmd, stderr=subprocess.DEVNULL, timeout=5)
|
||||
logging.info("[ComfyUI-Manager] Using uv as Python module for pip operations.")
|
||||
logging.info("[ComfyUI-Manager] Using `uv` as Python module for pip operations.")
|
||||
return [sys.executable] + (['-s'] if embedded else []) + ['-m', 'uv', 'pip']
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Try standalone uv
|
||||
if shutil.which('uv'):
|
||||
logging.info("[ComfyUI-Manager] Using standalone uv for pip operations.")
|
||||
logging.info("[ComfyUI-Manager] Using standalone `uv` for pip operations.")
|
||||
return ['uv', 'pip']
|
||||
|
||||
# Nothing worked
|
||||
logging.error("[ComfyUI-Manager] Neither python -m pip nor uv are available. Cannot proceed with package operations.")
|
||||
raise Exception("Neither pip nor uv are available for package management")
|
||||
logging.error("[ComfyUI-Manager] Neither `python -m pip` nor `uv` are available. Cannot proceed with package operations.")
|
||||
raise Exception("Neither `pip` nor `uv` are available for package management")
|
||||
|
||||
|
||||
def make_pip_cmd(cmd):
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { api } from "../../scripts/api.js";
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { sleep, customConfirm, customAlert } from "./common.js";
|
||||
import { sleep, customConfirm, customAlert, handle403Response, show_message } from "./common.js";
|
||||
|
||||
async function tryInstallCustomNode(event) {
|
||||
let msg = '-= [ComfyUI Manager] extension installation request =-\n\n';
|
||||
@ -42,7 +42,7 @@ async function tryInstallCustomNode(event) {
|
||||
});
|
||||
|
||||
if(response.status == 403) {
|
||||
show_message('This action is not allowed with this security level configuration.');
|
||||
await handle403Response(response);
|
||||
return false;
|
||||
}
|
||||
else if(response.status == 400) {
|
||||
@ -54,7 +54,7 @@ async function tryInstallCustomNode(event) {
|
||||
|
||||
let response = await api.fetchApi("/manager/reboot");
|
||||
if(response.status == 403) {
|
||||
show_message('This action is not allowed with this security level configuration.');
|
||||
await handle403Response(response);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@ -14,7 +14,7 @@ import { OpenArtShareDialog } from "./comfyui-share-openart.js";
|
||||
import {
|
||||
free_models, install_pip, install_via_git_url, manager_instance,
|
||||
rebootAPI, setManagerInstance, show_message, customAlert, customPrompt,
|
||||
infoToast, showTerminal, setNeedRestart
|
||||
infoToast, showTerminal, setNeedRestart, handle403Response
|
||||
} from "./common.js";
|
||||
import { ComponentBuilderDialog, getPureName, load_components, set_component_policy } from "./components-manager.js";
|
||||
import { CustomNodesManager } from "./custom-nodes-manager.js";
|
||||
@ -753,9 +753,9 @@ async function onQueueStatus(event) {
|
||||
|
||||
const rebootButton = document.getElementById('cm-reboot-button5');
|
||||
rebootButton?.addEventListener("click",
|
||||
function() {
|
||||
if(rebootAPI()) {
|
||||
manager_dialog.close();
|
||||
async function() {
|
||||
if(await rebootAPI()) {
|
||||
manager_instance.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -780,8 +780,13 @@ async function updateAll(update_comfyui) {
|
||||
|
||||
const response = await api.fetchApi(`/manager/queue/update_all?mode=${mode}`);
|
||||
|
||||
if (response.status == 401) {
|
||||
if (response.status == 403) {
|
||||
await handle403Response(response);
|
||||
reset_action_buttons();
|
||||
}
|
||||
else if (response.status == 401) {
|
||||
customAlert('Another task is already in progress. Please stop the ongoing task first.');
|
||||
reset_action_buttons();
|
||||
}
|
||||
else if(response.status == 200) {
|
||||
is_updating = true;
|
||||
@ -1453,6 +1458,31 @@ app.registerExtension({
|
||||
|
||||
load_components();
|
||||
|
||||
// Fetch and show startup alerts (critical errors like outdated ComfyUI)
|
||||
// Poll until extensionManager.toast is ready (set in Vue onMounted)
|
||||
const showStartupAlerts = async () => {
|
||||
let toastWaitCount = 0;
|
||||
const waitForToast = () => {
|
||||
if (window['app']?.extensionManager?.toast) {
|
||||
fetch('/manager/startup_alerts')
|
||||
.then(response => response.ok ? response.json() : [])
|
||||
.then(alerts => {
|
||||
for (const alert of alerts) {
|
||||
customAlert(alert.message);
|
||||
}
|
||||
})
|
||||
.catch(e => console.warn('[ComfyUI-Manager] Failed to fetch startup alerts:', e));
|
||||
} else if (toastWaitCount < 300) { // Max 30 seconds (300 * 100ms)
|
||||
toastWaitCount++;
|
||||
setTimeout(waitForToast, 100);
|
||||
} else {
|
||||
console.warn('[ComfyUI-Manager] Timeout waiting for toast. Startup alerts skipped.');
|
||||
}
|
||||
};
|
||||
waitForToast();
|
||||
};
|
||||
showStartupAlerts();
|
||||
|
||||
const menu = document.querySelector(".comfy-menu");
|
||||
const separator = document.createElement("hr");
|
||||
|
||||
|
||||
40
js/common.js
40
js/common.js
@ -100,6 +100,19 @@ export function show_message(msg) {
|
||||
app.ui.dialog.element.style.zIndex = 1100;
|
||||
}
|
||||
|
||||
export async function handle403Response(res, defaultMessage) {
|
||||
try {
|
||||
const data = await res.json();
|
||||
if(data.error === 'comfyui_outdated') {
|
||||
show_message('ComfyUI version is outdated.<BR>Please update ComfyUI to use Manager normally.');
|
||||
} else {
|
||||
show_message(defaultMessage || 'This action is not allowed with this security level configuration.');
|
||||
}
|
||||
} catch {
|
||||
show_message(defaultMessage || 'This action is not allowed with this security level configuration.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
@ -163,20 +176,23 @@ export async function customPrompt(title, message) {
|
||||
}
|
||||
|
||||
|
||||
export function rebootAPI() {
|
||||
export async function rebootAPI() {
|
||||
if ('electronAPI' in window) {
|
||||
window.electronAPI.restartApp();
|
||||
return true;
|
||||
}
|
||||
|
||||
customConfirm("Are you sure you'd like to reboot the server?").then((isConfirmed) => {
|
||||
if (isConfirmed) {
|
||||
try {
|
||||
api.fetchApi("/manager/reboot");
|
||||
const isConfirmed = await customConfirm("Are you sure you'd like to reboot the server?");
|
||||
if (isConfirmed) {
|
||||
try {
|
||||
const response = await api.fetchApi("/manager/reboot");
|
||||
if (response.status == 403) {
|
||||
await handle403Response(response);
|
||||
return false;
|
||||
}
|
||||
catch(exception) {}
|
||||
}
|
||||
});
|
||||
catch(exception) {}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@ -216,7 +232,7 @@ export async function install_pip(packages) {
|
||||
});
|
||||
|
||||
if(res.status == 403) {
|
||||
show_message('This action is not allowed with this security level configuration.');
|
||||
await handle403Response(res);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -251,7 +267,7 @@ export async function install_via_git_url(url, manager_dialog) {
|
||||
});
|
||||
|
||||
if(res.status == 403) {
|
||||
show_message('This action is not allowed with this security level configuration.');
|
||||
await handle403Response(res);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -262,9 +278,9 @@ export async function install_via_git_url(url, manager_dialog) {
|
||||
const self = this;
|
||||
|
||||
rebootButton.addEventListener("click",
|
||||
function() {
|
||||
if(rebootAPI()) {
|
||||
manager_dialog.close();
|
||||
async function() {
|
||||
if(await rebootAPI()) {
|
||||
manager_instance.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@ import {
|
||||
fetchData, md5, icons, show_message, customConfirm, customAlert, customPrompt,
|
||||
sanitizeHTML, infoToast, showTerminal, setNeedRestart,
|
||||
storeColumnWidth, restoreColumnWidth, getTimeAgo, copyText, loadCss,
|
||||
showPopover, hidePopover
|
||||
showPopover, hidePopover, handle403Response
|
||||
} from "./common.js";
|
||||
|
||||
// https://cenfun.github.io/turbogrid/api.html
|
||||
@ -1528,7 +1528,16 @@ export class CustomNodesManager {
|
||||
errorMsg = `'${item.title}': `;
|
||||
|
||||
if(res.status == 403) {
|
||||
errorMsg += `This action is not allowed with this security level configuration.\n`;
|
||||
try {
|
||||
const data = await res.json();
|
||||
if(data.error === 'comfyui_outdated') {
|
||||
errorMsg += `ComfyUI version is outdated. Please update ComfyUI to use Manager normally.\n`;
|
||||
} else {
|
||||
errorMsg += `This action is not allowed with this security level configuration.\n`;
|
||||
}
|
||||
} catch {
|
||||
errorMsg += `This action is not allowed with this security level configuration.\n`;
|
||||
}
|
||||
} else if(res.status == 404) {
|
||||
errorMsg += `With the current security level configuration, only custom nodes from the <B>"default channel"</B> can be installed.\n`;
|
||||
} else {
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { $el } from "../../scripts/ui.js";
|
||||
import {
|
||||
manager_instance, rebootAPI,
|
||||
import {
|
||||
manager_instance, rebootAPI,
|
||||
fetchData, md5, icons, show_message, customAlert, infoToast, showTerminal,
|
||||
storeColumnWidth, restoreColumnWidth, loadCss
|
||||
storeColumnWidth, restoreColumnWidth, loadCss, handle403Response
|
||||
} from "./common.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
|
||||
@ -477,7 +477,16 @@ export class ModelManager {
|
||||
errorMsg = `'${item.name}': `;
|
||||
|
||||
if(res.status == 403) {
|
||||
errorMsg += `This action is not allowed with this security level configuration.\n`;
|
||||
try {
|
||||
const data = await res.json();
|
||||
if(data.error === 'comfyui_outdated') {
|
||||
errorMsg += `ComfyUI version is outdated. Please update ComfyUI to use Manager normally.\n`;
|
||||
} else {
|
||||
errorMsg += `This action is not allowed with this security level configuration.\n`;
|
||||
}
|
||||
} catch {
|
||||
errorMsg += `This action is not allowed with this security level configuration.\n`;
|
||||
}
|
||||
} else {
|
||||
errorMsg += await res.text() + '\n';
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { api } from "../../scripts/api.js"
|
||||
import { ComfyDialog, $el } from "../../scripts/ui.js";
|
||||
import { manager_instance, rebootAPI, show_message } from "./common.js";
|
||||
import { manager_instance, rebootAPI, show_message, handle403Response } from "./common.js";
|
||||
|
||||
|
||||
async function restore_snapshot(target) {
|
||||
@ -10,7 +10,7 @@ async function restore_snapshot(target) {
|
||||
const response = await api.fetchApi(`/snapshot/restore?target=${target}`, { cache: "no-store" });
|
||||
|
||||
if(response.status == 403) {
|
||||
show_message('This action is not allowed with this security level configuration.');
|
||||
await handle403Response(response);
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -38,7 +38,7 @@ async function remove_snapshot(target) {
|
||||
const response = await api.fetchApi(`/snapshot/remove?target=${target}`, { cache: "no-store" });
|
||||
|
||||
if(response.status == 403) {
|
||||
show_message('This action is not allowed with this security level configuration.');
|
||||
await handle403Response(response);
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -145,8 +145,8 @@ export class SnapshotManager extends ComfyDialog {
|
||||
if(btn_id) {
|
||||
const rebootButton = document.getElementById(btn_id);
|
||||
const self = this;
|
||||
rebootButton.onclick = function() {
|
||||
if(rebootAPI()) {
|
||||
rebootButton.onclick = async function() {
|
||||
if(await rebootAPI()) {
|
||||
self.close();
|
||||
self.manager_dialog.close();
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -169,6 +169,16 @@
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A fork of KJNodes for ComfyUI.\nVarious quality of life -nodes for ComfyUI, mostly just visual stuff to improve usability"
|
||||
},
|
||||
{
|
||||
"author": "huixingyun",
|
||||
"title": "ComfyUI-SoundFlow",
|
||||
"reference": "https://github.com/huixingyun/ComfyUI-SoundFlow",
|
||||
"files": [
|
||||
"https://github.com/huixingyun/ComfyUI-SoundFlow"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "forked from https://github.com/fredconex/ComfyUI-SoundFlow (removed)"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -1,5 +1,255 @@
|
||||
{
|
||||
"custom_nodes": [
|
||||
{
|
||||
"author": "geltz",
|
||||
"title": "ComfyUI-geltz [REMOVED]",
|
||||
"reference": "https://github.com/geltz/ComfyUI-geltz",
|
||||
"files": [
|
||||
"https://github.com/geltz/ComfyUI-geltz"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Various custom nodes; guidance, latents, sampling, tokenization, etc."
|
||||
},
|
||||
{
|
||||
"author": "anilsathyan7",
|
||||
"title": "ComfyUI-Crystal-Upscaler [REMOVED]",
|
||||
"reference": "https://github.com/anilsathyan7/ComfyUI-Crystal-Upscaler",
|
||||
"files": [
|
||||
"https://github.com/anilsathyan7/ComfyUI-Crystal-Upscaler"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "ComfyUI custom node for image upscaling using crystal upscaling technology. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "nohikomiso",
|
||||
"title": "ComfyUI-ImageFolderPicker [REMOVED/UNSAFE]",
|
||||
"reference": "https://github.com/nohikomiso/ComfyUI-ImageFolderPicker",
|
||||
"files": [
|
||||
"https://github.com/nohikomiso/ComfyUI-ImageFolderPicker"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Custom ComfyUI node for browsing local server folders and selecting images via thumbnail display in a grid interface. (Description by CC)[w/This nodepack has a vulnerability that allows it to retrieve a list of files from arbitrary paths.]"
|
||||
},
|
||||
{
|
||||
"author": "rzasharp79",
|
||||
"title": "ComfyUI--SolarFlare [REMOVED]",
|
||||
"reference": "https://github.com/rzasharp79/ComfyUI--SolarFlare",
|
||||
"files": [
|
||||
"https://github.com/rzasharp79/ComfyUI--SolarFlare"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "NODES: Qwen Image, ..."
|
||||
},
|
||||
{
|
||||
"author": "shinich39",
|
||||
"title": "comfyui-no-one-above-me [REMOVED]",
|
||||
"reference": "https://github.com/shinich39/comfyui-no-one-above-me",
|
||||
"files": [
|
||||
"https://github.com/shinich39/comfyui-no-one-above-me"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Fix node to top."
|
||||
},
|
||||
{
|
||||
"author": "octapus8085",
|
||||
"title": "OpenAI-comfyui-O [REMOVED]",
|
||||
"reference": "https://github.com/Spicely/Comfyui-File-Utils",
|
||||
"files": [
|
||||
"https://github.com/Spicely/Comfyui-File-Utils"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "This plugin provides multiple file-handling and utility nodes for ComfyUI, including: image saving, audio saving, video saving, video composition, audio-to-subtitle conversion, and random number generation nodes. These nodes not only process files but also return their absolute file paths.\nNOTE: The files in the repo are not organized.[w/This nodepack contains a node that has a vulnerability allowing write to arbitrary file paths.]"
|
||||
},
|
||||
{
|
||||
"author": "yemanou",
|
||||
"title": "NABA Image (Gemini REST) Node [REMOVED]",
|
||||
"reference": "https://github.com/yemanou/ComfyUI-NABA",
|
||||
"files": [
|
||||
"https://github.com/yemanou/ComfyUI-NABA"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Simplified Gemini 2.5 Flash Image Preview node for ComfyUI. REST-only for stability, two optional reference images, padded aspect ratio resizing (no stretching), and basic sampling controls. All extra debug layers, SDK path, multi-seed, and legacy compatibility code removed to avoid crashes."
|
||||
},
|
||||
{
|
||||
"author": "comrender",
|
||||
"title": "ComfyUI-Nano-Banana-Resizer [REMOVED]",
|
||||
"reference": "https://github.com/comrender/ComfyUI-Nano-Banana-Resizer",
|
||||
"files": [
|
||||
"https://github.com/comrender/ComfyUI-Nano-Banana-Resizer"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A ComfyUI custom node that automatically calculates optimal output dimensions for Google's Nano Banana image editing model, supporting 22 aspect ratio buckets and ensuring pixel-perfect outputs without shifting or cropping."
|
||||
},
|
||||
{
|
||||
"author": "comrender",
|
||||
"title": "ComfyUI-edge-match-checker [REMOVED]",
|
||||
"reference": "https://github.com/comrender/ComfyUI-edge-match-checker",
|
||||
"files": [
|
||||
"https://github.com/comrender/ComfyUI-edge-match-checker"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Node comparing two image masks or images with adjustable overlap threshold (default 95%) for detecting minor shifts and mismatches in proportions, suitable for automated post-processing validation. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "comrender",
|
||||
"title": "ComfyUI-gpt5_image_text [REMOVED]",
|
||||
"reference": "https://github.com/comrender/ComfyUI-gpt5_image_text",
|
||||
"files": [
|
||||
"https://github.com/comrender/ComfyUI-gpt5_image_text"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A ComfyUI custom node for vision + text analysis using GPT-5 and GPT-4o with direct API key input, system prompt, temperature, max tokens, and multi-image support."
|
||||
},
|
||||
{
|
||||
"author": "PozzettiAndrea",
|
||||
"title": "ComfyUI-CameraAnalysis [REMOVED]",
|
||||
"reference": "https://github.com/PozzettiAndrea/ComfyUI-CameraAnalysis",
|
||||
"files": [
|
||||
"https://github.com/PozzettiAndrea/ComfyUI-CameraAnalysis"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Extracts camera intrinsic parameters from image EXIF data."
|
||||
},
|
||||
{
|
||||
"author": "fuzr0dah",
|
||||
"title": "comfyui-sceneassembly [REMOVED]",
|
||||
"reference": "https://github.com/fuzr0dah/comfyui-sceneassembly",
|
||||
"files": [
|
||||
"https://github.com/fuzr0dah/comfyui-sceneassembly"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A bunch of nodes I created that I also find useful."
|
||||
},
|
||||
{
|
||||
"author": "rslosch",
|
||||
"title": "ComfyUI-EZ_Prompts [REMOVED]",
|
||||
"reference": "https://github.com/rslosch/ComfyUI-EZ_Prompts",
|
||||
"files": [
|
||||
"https://github.com/rslosch/ComfyUI-EZ_Prompts"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A ComfyUI custom node extension that provides easy-to-use prompt templates and wildcards for AI image generation."
|
||||
},
|
||||
{
|
||||
"author": "hvppycoding",
|
||||
"title": "hvppyflow [REMOVED]",
|
||||
"reference": "https://github.com/hvppycoding/hvppyflow",
|
||||
"files": [
|
||||
"https://github.com/hvppycoding/hvppyflow"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "ComfyUI nodes for Automated Workflow"
|
||||
},
|
||||
{
|
||||
"author": "cedarconnor",
|
||||
"title": "ComfyUI-GEN3C-Gsplat [REMOVED]",
|
||||
"reference": "https://github.com/cedarconnor/ComfyUI-GEN3C-Gsplat",
|
||||
"files": [
|
||||
"https://github.com/cedarconnor/ComfyUI-GEN3C-Gsplat"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A custom ComfyUI node pack that bridges Cosmos/GEN3C video generation with in-graph Gaussian Splat (3DGS) training. It adds camera/trajectory tooling, dataset exporters, and two training backends (Nerfstudio CLI wrapper and an in-process gsplat optimizer) so artists can go from prompt to splat entirely inside ComfyUI.\nNOTE: The files in the repo are not organized."
|
||||
},
|
||||
{
|
||||
"author": "dowa-git",
|
||||
"title": "comfyui-dowa [REMOVED]",
|
||||
"reference": "https://github.com/dowa-git/comfyui-dowa",
|
||||
"files": [
|
||||
"https://github.com/dowa-git/comfyui-dowa"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Professional navigation bar widget for ComfyUI with JWT-based user authentication, workflow templates, and team collaboration features in a purple gradient design."
|
||||
},
|
||||
{
|
||||
"author": "Fablestarexpanse",
|
||||
"title": "Timer-Node-Comfyui [REMOVED]",
|
||||
"reference": "https://github.com/Fablestarexpanse/Timer-Node-Comfyui",
|
||||
"files": [
|
||||
"https://github.com/Fablestarexpanse/Timer-Node-Comfyui"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A custom ComfyUI node that displays live processing time in a red digital countdown clock format, perfect for monitoring image generation times and tracking performance between workflow nodes."
|
||||
},
|
||||
{
|
||||
"author": "cedarconnor",
|
||||
"title": "ComfyUI-OmniX [REMOVED]",
|
||||
"reference": "https://github.com/cedarconnor/ComfyUI-OmniX",
|
||||
"files": [
|
||||
"https://github.com/cedarconnor/ComfyUI-OmniX"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Extract comprehensive scene properties from 360-degree equirectangular panoramas, including depth, normals, and PBR materials, using OmniX adapters with Flux."
|
||||
},
|
||||
{
|
||||
"author": "cedarconnor",
|
||||
"title": "ComfyUI-DiT360 [REMOVED]",
|
||||
"reference": "https://github.com/cedarconnor/ComfyUI-DiT360",
|
||||
"files": [
|
||||
"https://github.com/cedarconnor/ComfyUI-DiT360"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Generate high-fidelity 360-degree panoramic images using the DiT360 diffusion transformer model in ComfyUI."
|
||||
},
|
||||
{
|
||||
"author": "PozzettiAndrea",
|
||||
"title": "ComfyUI-AnyTop [REMOVED]",
|
||||
"reference": "https://github.com/PozzettiAndrea/ComfyUI-AnyTop",
|
||||
"files": [
|
||||
"https://github.com/PozzettiAndrea/ComfyUI-AnyTop"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Standalone ComfyUI custom nodes for AnyTop - Universal Motion Generation for Any Skeleton Topology."
|
||||
},
|
||||
{
|
||||
"author": "penposs",
|
||||
"title": "ComfyUI-Banana-Node [REMOVED]",
|
||||
"reference": "https://github.com/penposs/ComfyUI-Banana-Node",
|
||||
"files": [
|
||||
"https://github.com/penposs/ComfyUI-Banana-Node"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A custom node for ComfyUI that generates images using Google’s Gemini 2.5 Flash Image Preview API."
|
||||
},
|
||||
{
|
||||
"author": "spiralmountain",
|
||||
"title": "ComfyUI_HDNodes [REMOVED]",
|
||||
"reference": "https://github.com/spiralmountain/ComfyUI_HDNodes",
|
||||
"files": [
|
||||
"https://github.com/spiralmountain/ComfyUI_HDNodes"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Custom nodes for ComfyUI that enable video generation using ByteDance's Seedance model via [a/Fal.ai](https://fal.ai/)."
|
||||
},
|
||||
{
|
||||
"author": "fredconex",
|
||||
"title": "Sync Edit [REMOVED]",
|
||||
"reference": "https://github.com/fredconex/ComfyUI-SyncEdit",
|
||||
"files": [
|
||||
"https://github.com/fredconex/ComfyUI-SyncEdit"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "This node allow to intercept changes on the input string and choose between use the current one or sync with incoming new one."
|
||||
},
|
||||
{
|
||||
"author": "fredconex",
|
||||
"title": "ComfyUI-SoundFlow [REMOVED]",
|
||||
"reference": "https://github.com/fredconex/ComfyUI-SoundFlow",
|
||||
"files": [
|
||||
"https://github.com/fredconex/ComfyUI-SoundFlow"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "This is a bunch of nodes for ComfyUI to help with sound work."
|
||||
},
|
||||
{
|
||||
"author": "fredconex",
|
||||
"title": "SongBloom [REMOVED]",
|
||||
"reference": "https://github.com/fredconex/ComfyUI-SongBloom",
|
||||
"files": [
|
||||
"https://github.com/fredconex/ComfyUI-SongBloom"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "ComfyUI Nodes for SongBloom"
|
||||
},
|
||||
{
|
||||
"author": "EQXai",
|
||||
"title": "ComfyUI_EQX [REMOVED]",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -85,7 +85,15 @@ cm_global.register_api('cm.is_import_failed_extension', is_import_failed_extensi
|
||||
comfyui_manager_path = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
custom_nodes_base_path = folder_paths.get_folder_paths('custom_nodes')[0]
|
||||
manager_files_path = os.path.abspath(os.path.join(folder_paths.get_user_directory(), 'default', 'ComfyUI-Manager'))
|
||||
|
||||
# Check for System User API availability (PR #10966)
|
||||
_has_system_user_api = hasattr(folder_paths, 'get_system_user_directory')
|
||||
|
||||
if _has_system_user_api:
|
||||
manager_files_path = os.path.abspath(os.path.join(folder_paths.get_user_directory(), '__manager'))
|
||||
else:
|
||||
manager_files_path = os.path.abspath(os.path.join(folder_paths.get_user_directory(), 'default', 'ComfyUI-Manager'))
|
||||
|
||||
manager_pip_overrides_path = os.path.join(manager_files_path, "pip_overrides.json")
|
||||
manager_pip_blacklist_path = os.path.join(manager_files_path, "pip_blacklist.list")
|
||||
restore_snapshot_path = os.path.join(manager_files_path, "startup-scripts", "restore-snapshot.json")
|
||||
@ -516,7 +524,8 @@ check_bypass_ssl()
|
||||
|
||||
# Perform install
|
||||
processed_install = set()
|
||||
script_list_path = os.path.join(folder_paths.user_directory, "default", "ComfyUI-Manager", "startup-scripts", "install-scripts.txt")
|
||||
# Use manager_files_path for consistency (fixes path inconsistency bug)
|
||||
script_list_path = os.path.join(manager_files_path, "startup-scripts", "install-scripts.txt")
|
||||
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, manager_files_path)
|
||||
|
||||
|
||||
@ -793,7 +802,11 @@ def execute_startup_script():
|
||||
|
||||
|
||||
# Check if script_list_path exists
|
||||
if os.path.exists(script_list_path):
|
||||
# Block startup-scripts on old ComfyUI (security measure)
|
||||
if not _has_system_user_api:
|
||||
if os.path.exists(script_list_path):
|
||||
print("[ComfyUI-Manager] Startup scripts blocked on old ComfyUI version.")
|
||||
elif os.path.exists(script_list_path):
|
||||
execute_startup_script()
|
||||
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-manager"
|
||||
description = "ComfyUI-Manager provides features to install and manage custom nodes for ComfyUI, as well as various functionalities to assist with ComfyUI."
|
||||
version = "3.37.1"
|
||||
version = "3.38.1"
|
||||
license = { file = "LICENSE.txt" }
|
||||
dependencies = ["GitPython", "PyGithub", "matrix-nio", "transformers", "huggingface-hub>0.20", "typer", "rich", "typing-extensions", "toml", "uv", "chardet"]
|
||||
|
||||
|
||||
132
scanner.py
132
scanner.py
@ -78,36 +78,14 @@ Examples:
|
||||
return args
|
||||
|
||||
|
||||
# Parse arguments
|
||||
args = parse_arguments()
|
||||
|
||||
# Determine mode
|
||||
scan_only_mode = args.scan_only is not None
|
||||
url_list_file = args.scan_only if scan_only_mode else None
|
||||
|
||||
# Determine temp_dir
|
||||
if args.temp_dir:
|
||||
temp_dir = args.temp_dir
|
||||
elif args.temp_dir_positional:
|
||||
temp_dir = args.temp_dir_positional
|
||||
else:
|
||||
temp_dir = os.path.join(os.getcwd(), ".tmp")
|
||||
|
||||
if not os.path.exists(temp_dir):
|
||||
os.makedirs(temp_dir)
|
||||
|
||||
# Determine skip flags
|
||||
skip_update = args.skip_update or args.skip_all
|
||||
skip_stat_update = args.skip_stat_update or args.skip_all or scan_only_mode
|
||||
|
||||
if not skip_stat_update:
|
||||
auth = Auth.Token(os.environ.get('GITHUB_TOKEN'))
|
||||
g = Github(auth=auth)
|
||||
else:
|
||||
g = None
|
||||
|
||||
|
||||
print(f"TEMP DIR: {temp_dir}")
|
||||
# Module-level variables (will be set in main if running as script)
|
||||
args = None
|
||||
scan_only_mode = False
|
||||
url_list_file = None
|
||||
temp_dir = None
|
||||
skip_update = False
|
||||
skip_stat_update = True
|
||||
g = None
|
||||
|
||||
|
||||
parse_cnt = 0
|
||||
@ -127,10 +105,17 @@ def extract_nodes(code_text):
|
||||
warnings.filterwarnings('ignore', category=DeprecationWarning)
|
||||
parsed_code = ast.parse(code_text)
|
||||
|
||||
assignments = (node for node in parsed_code.body if isinstance(node, ast.Assign))
|
||||
# Support both ast.Assign and ast.AnnAssign (for type-annotated assignments)
|
||||
assignments = (node for node in parsed_code.body if isinstance(node, (ast.Assign, ast.AnnAssign)))
|
||||
|
||||
for assignment in assignments:
|
||||
if isinstance(assignment.targets[0], ast.Name) and assignment.targets[0].id in ['NODE_CONFIG', 'NODE_CLASS_MAPPINGS']:
|
||||
# Handle ast.AnnAssign (e.g., NODE_CLASS_MAPPINGS: Type = {...})
|
||||
if isinstance(assignment, ast.AnnAssign):
|
||||
if isinstance(assignment.target, ast.Name) and assignment.target.id in ['NODE_CONFIG', 'NODE_CLASS_MAPPINGS']:
|
||||
node_class_mappings = assignment.value
|
||||
break
|
||||
# Handle ast.Assign (e.g., NODE_CLASS_MAPPINGS = {...})
|
||||
elif isinstance(assignment.targets[0], ast.Name) and assignment.targets[0].id in ['NODE_CONFIG', 'NODE_CLASS_MAPPINGS']:
|
||||
node_class_mappings = assignment.value
|
||||
break
|
||||
else:
|
||||
@ -250,7 +235,8 @@ def scan_in_file(filename, is_builtin=False):
|
||||
with open(filename, encoding='utf-8', errors='ignore') as file:
|
||||
code = file.read()
|
||||
|
||||
pattern = r"_CLASS_MAPPINGS\s*=\s*{([^}]*)}"
|
||||
# Support type annotations (e.g., NODE_CLASS_MAPPINGS: Type = {...}) and line continuations (\)
|
||||
pattern = r"_CLASS_MAPPINGS\s*(?::\s*\w+\s*)?=\s*(?:\\\s*)?{([^}]*)}"
|
||||
regex = re.compile(pattern, re.MULTILINE | re.DOTALL)
|
||||
|
||||
nodes = set()
|
||||
@ -482,21 +468,21 @@ def update_custom_nodes(scan_only_mode=False, url_list_file=None):
|
||||
raise ValueError("url_list_file is required in scan-only mode")
|
||||
|
||||
git_url_titles_preemptions = get_urls_from_list_file(url_list_file)
|
||||
print(f"\n[Scan-Only Mode]")
|
||||
print("\n[Scan-Only Mode]")
|
||||
print(f" - URL source: {url_list_file}")
|
||||
print(f" - GitHub stats: DISABLED")
|
||||
print(" - GitHub stats: DISABLED")
|
||||
print(f" - Git clone/pull: {'ENABLED' if not skip_update else 'DISABLED'}")
|
||||
print(f" - Metadata: EMPTY")
|
||||
print(" - Metadata: EMPTY")
|
||||
else:
|
||||
if not os.path.exists('custom-node-list.json'):
|
||||
raise FileNotFoundError("custom-node-list.json not found")
|
||||
|
||||
git_url_titles_preemptions = get_git_urls_from_json('custom-node-list.json')
|
||||
print(f"\n[Standard Mode]")
|
||||
print(f" - URL source: custom-node-list.json")
|
||||
print("\n[Standard Mode]")
|
||||
print(" - URL source: custom-node-list.json")
|
||||
print(f" - GitHub stats: {'ENABLED' if not skip_stat_update else 'DISABLED'}")
|
||||
print(f" - Git clone/pull: {'ENABLED' if not skip_update else 'DISABLED'}")
|
||||
print(f" - Metadata: FULL")
|
||||
print(" - Metadata: FULL")
|
||||
|
||||
def process_git_url_title(url, title, preemptions, node_pattern):
|
||||
name = os.path.basename(url)
|
||||
@ -689,7 +675,14 @@ def gen_json(node_info, scan_only_mode=False):
|
||||
|
||||
data[git_url] = (nodes, metadata)
|
||||
else:
|
||||
print(f"WARN: {dirname} is removed from custom-node-list.json")
|
||||
# Scan-only mode: Repository not in node_info (expected behavior)
|
||||
# Construct URL from dirname (author_repo format)
|
||||
if '_' in dirname:
|
||||
parts = dirname.split('_', 1)
|
||||
git_url = f"https://github.com/{parts[0]}/{parts[1]}"
|
||||
data[git_url] = (nodes, metadata)
|
||||
else:
|
||||
print(f"WARN: {dirname} is removed from custom-node-list.json")
|
||||
|
||||
for file in node_files:
|
||||
nodes, metadata = scan_in_file(file)
|
||||
@ -775,24 +768,53 @@ def gen_json(node_info, scan_only_mode=False):
|
||||
json.dump(data, file, indent=4, sort_keys=True)
|
||||
|
||||
|
||||
print("### ComfyUI Manager Node Scanner ###")
|
||||
if __name__ == "__main__":
|
||||
# Parse arguments
|
||||
args = parse_arguments()
|
||||
|
||||
if scan_only_mode:
|
||||
print(f"\n# [Scan-Only Mode] Processing URL list: {url_list_file}\n")
|
||||
else:
|
||||
print("\n# [Standard Mode] Updating extensions\n")
|
||||
# Determine mode
|
||||
scan_only_mode = args.scan_only is not None
|
||||
url_list_file = args.scan_only if scan_only_mode else None
|
||||
|
||||
# Update/clone repositories and collect node info
|
||||
updated_node_info = update_custom_nodes(scan_only_mode, url_list_file)
|
||||
# Determine temp_dir
|
||||
if args.temp_dir:
|
||||
temp_dir = args.temp_dir
|
||||
elif args.temp_dir_positional:
|
||||
temp_dir = args.temp_dir_positional
|
||||
else:
|
||||
temp_dir = os.path.join(os.getcwd(), ".tmp")
|
||||
|
||||
print("\n# Generating 'extension-node-map.json'...\n")
|
||||
if not os.path.exists(temp_dir):
|
||||
os.makedirs(temp_dir)
|
||||
|
||||
# Generate extension-node-map.json
|
||||
gen_json(updated_node_info, scan_only_mode)
|
||||
# Determine skip flags
|
||||
skip_update = args.skip_update or args.skip_all
|
||||
skip_stat_update = args.skip_stat_update or args.skip_all or scan_only_mode
|
||||
|
||||
print("\n✅ DONE.\n")
|
||||
if not skip_stat_update:
|
||||
auth = Auth.Token(os.environ.get('GITHUB_TOKEN'))
|
||||
g = Github(auth=auth)
|
||||
else:
|
||||
g = None
|
||||
|
||||
if scan_only_mode:
|
||||
print("Output: extension-node-map.json (node mappings only)")
|
||||
else:
|
||||
print("Output: extension-node-map.json (full metadata)")
|
||||
print("### ComfyUI Manager Node Scanner ###")
|
||||
|
||||
if scan_only_mode:
|
||||
print(f"\n# [Scan-Only Mode] Processing URL list: {url_list_file}\n")
|
||||
else:
|
||||
print("\n# [Standard Mode] Updating extensions\n")
|
||||
|
||||
# Update/clone repositories and collect node info
|
||||
updated_node_info = update_custom_nodes(scan_only_mode, url_list_file)
|
||||
|
||||
print("\n# Generating 'extension-node-map.json'...\n")
|
||||
|
||||
# Generate extension-node-map.json
|
||||
gen_json(updated_node_info, scan_only_mode)
|
||||
|
||||
print("\n✅ DONE.\n")
|
||||
|
||||
if scan_only_mode:
|
||||
print("Output: extension-node-map.json (node mappings only)")
|
||||
else:
|
||||
print("Output: extension-node-map.json (full metadata)")
|
||||
19
tests-api/.gitignore
vendored
Normal file
19
tests-api/.gitignore
vendored
Normal file
@ -0,0 +1,19 @@
|
||||
# Python cache files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# Pytest cache
|
||||
.pytest_cache/
|
||||
|
||||
# Coverage reports
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
|
||||
# Test-specific resources
|
||||
resources/tmp/
|
||||
91
tests-api/README.md
Normal file
91
tests-api/README.md
Normal file
@ -0,0 +1,91 @@
|
||||
# ComfyUI-Manager API Tests
|
||||
|
||||
This directory contains tests for the ComfyUI-Manager API endpoints, validating the OpenAPI specification and ensuring API functionality.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Install test dependencies:
|
||||
|
||||
```bash
|
||||
pip install -r requirements-test.txt
|
||||
```
|
||||
|
||||
2. Ensure ComfyUI is running with ComfyUI-Manager installed:
|
||||
|
||||
```bash
|
||||
# Start ComfyUI with the default server
|
||||
python main.py
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Run all tests
|
||||
|
||||
```bash
|
||||
pytest -xvs
|
||||
```
|
||||
|
||||
### Run specific test files
|
||||
|
||||
```bash
|
||||
# Run only the spec validation tests
|
||||
pytest -xvs test_spec_validation.py
|
||||
|
||||
# Run only the custom node API tests
|
||||
pytest -xvs test_customnode_api.py
|
||||
```
|
||||
|
||||
### Run specific test functions
|
||||
|
||||
```bash
|
||||
# Run a specific test
|
||||
pytest -xvs test_customnode_api.py::test_get_custom_node_list
|
||||
```
|
||||
|
||||
## Test Configuration
|
||||
|
||||
The tests use the following default configuration:
|
||||
|
||||
- Server URL: `http://localhost:8188`
|
||||
- Server timeout: 2 seconds
|
||||
- Wait between requests: 0.5 seconds
|
||||
- Maximum retries: 3
|
||||
|
||||
You can override these settings with environment variables:
|
||||
|
||||
```bash
|
||||
# Use a different server URL
|
||||
COMFYUI_SERVER_URL=http://localhost:8189 pytest -xvs
|
||||
```
|
||||
|
||||
## Test Categories
|
||||
|
||||
The tests are organized into the following categories:
|
||||
|
||||
1. **Spec Validation** (`test_spec_validation.py`): Validates that the OpenAPI specification is correct and complete.
|
||||
2. **Custom Node API** (`test_customnode_api.py`): Tests for custom node management endpoints.
|
||||
3. **Snapshot API** (`test_snapshot_api.py`): Tests for snapshot management endpoints.
|
||||
4. **Queue API** (`test_queue_api.py`): Tests for queue management endpoints.
|
||||
5. **Config API** (`test_config_api.py`): Tests for configuration endpoints.
|
||||
6. **Model API** (`test_model_api.py`): Tests for model management endpoints (minimal as these are being deprecated).
|
||||
|
||||
## Test Implementation Details
|
||||
|
||||
### Fixtures
|
||||
|
||||
- `test_config`: Provides the test configuration
|
||||
- `server_url`: Returns the server URL from the configuration
|
||||
- `openapi_spec`: Loads the OpenAPI specification
|
||||
- `api_client`: Creates a requests Session for API calls
|
||||
- `api_request`: Helper function for making consistent API requests
|
||||
|
||||
### Utilities
|
||||
|
||||
- `validation.py`: Functions for validating responses against the OpenAPI schema
|
||||
- `schema_utils.py`: Utilities for extracting and manipulating schemas
|
||||
|
||||
## Notes
|
||||
|
||||
- Some tests are skipped with `@pytest.mark.skip` to avoid modifying state in automated testing
|
||||
- Security-level restricted endpoints have minimal tests to avoid security issues
|
||||
- Tests focus on read operations rather than write operations where possible
|
||||
1
tests-api/__init__.py
Normal file
1
tests-api/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Make tests-api directory a proper package
|
||||
237
tests-api/conftest.py
Normal file
237
tests-api/conftest.py
Normal file
@ -0,0 +1,237 @@
|
||||
"""
|
||||
PyTest configuration and fixtures for API tests.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import pytest
|
||||
import requests
|
||||
import tempfile
|
||||
import time
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from typing import Dict, Generator, Optional, Tuple
|
||||
|
||||
# Import test utilities
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Get the absolute path to the current file (conftest.py)
|
||||
current_file = Path(os.path.abspath(__file__))
|
||||
|
||||
# Get the directory containing the current file (the tests-api directory)
|
||||
tests_api_dir = current_file.parent
|
||||
|
||||
# Add the tests-api directory to the Python path
|
||||
if str(tests_api_dir) not in sys.path:
|
||||
sys.path.insert(0, str(tests_api_dir))
|
||||
|
||||
# Apply mocks for ComfyUI imports
|
||||
from mocks.patch import apply_mocks
|
||||
apply_mocks()
|
||||
|
||||
# Now we can import from utils.validation
|
||||
from utils.validation import load_openapi_spec
|
||||
|
||||
|
||||
# Default test configuration
|
||||
DEFAULT_TEST_CONFIG = {
|
||||
"server_url": "http://localhost:8188",
|
||||
"server_timeout": 2, # seconds
|
||||
"wait_between_requests": 0.5, # seconds
|
||||
"max_retries": 3,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def test_config() -> Dict:
|
||||
"""
|
||||
Load test configuration from environment variables or use defaults.
|
||||
"""
|
||||
config = DEFAULT_TEST_CONFIG.copy()
|
||||
|
||||
# Override from environment variables if present
|
||||
if "COMFYUI_SERVER_URL" in os.environ:
|
||||
config["server_url"] = os.environ["COMFYUI_SERVER_URL"]
|
||||
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def server_url(test_config: Dict) -> str:
|
||||
"""
|
||||
Get the server URL from the test configuration.
|
||||
"""
|
||||
return test_config["server_url"]
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def openapi_spec() -> Dict:
|
||||
"""
|
||||
Load the OpenAPI specification.
|
||||
"""
|
||||
return load_openapi_spec()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def api_client(server_url: str, test_config: Dict) -> requests.Session:
|
||||
"""
|
||||
Create a requests Session for API calls.
|
||||
"""
|
||||
session = requests.Session()
|
||||
|
||||
# Check if the server is running
|
||||
try:
|
||||
response = session.get(f"{server_url}/", timeout=test_config["server_timeout"])
|
||||
response.raise_for_status()
|
||||
except (requests.ConnectionError, requests.Timeout, requests.HTTPError):
|
||||
pytest.skip("ComfyUI server is not running or not accessible")
|
||||
|
||||
return session
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def temp_dir() -> Generator[Path, None, None]:
|
||||
"""
|
||||
Create a temporary directory for test files.
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
yield Path(temp_dir)
|
||||
|
||||
|
||||
class SecurityLevelContext:
|
||||
"""
|
||||
Context manager for setting and restoring security levels.
|
||||
"""
|
||||
def __init__(self, api_client: requests.Session, server_url: str, security_level: str):
|
||||
self.api_client = api_client
|
||||
self.server_url = server_url
|
||||
self.security_level = security_level
|
||||
self.original_level = None
|
||||
|
||||
async def __aenter__(self):
|
||||
# Get the current security level (not directly exposed in API, would require more setup)
|
||||
# For now, we'll just set the new level
|
||||
|
||||
# Set the new security level
|
||||
# Note: In a real implementation, we would need a way to set this
|
||||
# This is a placeholder - the actual implementation would depend on how
|
||||
# security levels are managed in ComfyUI-Manager
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
# Restore the original security level if needed
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def security_level_context(api_client: requests.Session, server_url: str):
|
||||
"""
|
||||
Create a context manager for setting security levels.
|
||||
"""
|
||||
return lambda level: SecurityLevelContext(api_client, server_url, level)
|
||||
|
||||
|
||||
def make_api_url(server_url: str, path: str) -> str:
|
||||
"""
|
||||
Construct a full API URL from the server URL and path.
|
||||
"""
|
||||
# Ensure the path starts with a slash
|
||||
if not path.startswith("/"):
|
||||
path = f"/{path}"
|
||||
|
||||
# Remove trailing slash from server_url if present
|
||||
if server_url.endswith("/"):
|
||||
server_url = server_url[:-1]
|
||||
|
||||
return f"{server_url}{path}"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_request(api_client: requests.Session, server_url: str, test_config: Dict):
|
||||
"""
|
||||
Helper function for making API requests with consistent behavior.
|
||||
"""
|
||||
def _request(
|
||||
method: str,
|
||||
path: str,
|
||||
params: Optional[Dict] = None,
|
||||
json_data: Optional[Dict] = None,
|
||||
headers: Optional[Dict] = None,
|
||||
expected_status: int = 200,
|
||||
retry_on_error: bool = True,
|
||||
) -> Tuple[requests.Response, Optional[Dict]]:
|
||||
"""
|
||||
Make an API request with automatic validation.
|
||||
|
||||
Args:
|
||||
method: HTTP method
|
||||
path: API path
|
||||
params: Query parameters
|
||||
json_data: JSON request body
|
||||
headers: HTTP headers
|
||||
expected_status: Expected HTTP status code
|
||||
retry_on_error: Whether to retry on connection errors
|
||||
|
||||
Returns:
|
||||
Tuple of (Response object, JSON response data or None)
|
||||
"""
|
||||
method = method.lower()
|
||||
url = make_api_url(server_url, path)
|
||||
|
||||
if headers is None:
|
||||
headers = {}
|
||||
|
||||
# Add common headers
|
||||
headers.setdefault("Accept", "application/json")
|
||||
|
||||
# Sleep between requests to avoid overwhelming the server
|
||||
time.sleep(test_config["wait_between_requests"])
|
||||
|
||||
retries = test_config["max_retries"] if retry_on_error else 0
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(retries + 1):
|
||||
try:
|
||||
if method == "get":
|
||||
response = api_client.get(url, params=params, headers=headers)
|
||||
elif method == "post":
|
||||
response = api_client.post(url, params=params, json=json_data, headers=headers)
|
||||
elif method == "put":
|
||||
response = api_client.put(url, params=params, json=json_data, headers=headers)
|
||||
elif method == "delete":
|
||||
response = api_client.delete(url, params=params, headers=headers)
|
||||
else:
|
||||
raise ValueError(f"Unsupported HTTP method: {method}")
|
||||
|
||||
# Check status code
|
||||
assert response.status_code == expected_status, (
|
||||
f"Expected status code {expected_status}, got {response.status_code}"
|
||||
)
|
||||
|
||||
# Parse JSON response if possible
|
||||
json_response = None
|
||||
if response.headers.get("Content-Type", "").startswith("application/json"):
|
||||
try:
|
||||
json_response = response.json()
|
||||
except json.JSONDecodeError:
|
||||
if expected_status == 200:
|
||||
raise ValueError("Response was not valid JSON")
|
||||
|
||||
return response, json_response
|
||||
|
||||
except (requests.ConnectionError, requests.Timeout) as e:
|
||||
last_exception = e
|
||||
if attempt < retries:
|
||||
# Wait before retrying
|
||||
time.sleep(1)
|
||||
continue
|
||||
break
|
||||
|
||||
if last_exception:
|
||||
raise last_exception
|
||||
|
||||
raise RuntimeError("Failed to make API request")
|
||||
|
||||
return _request
|
||||
1
tests-api/mocks/__init__.py
Normal file
1
tests-api/mocks/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Make tests-api/mocks directory a proper package
|
||||
26
tests-api/mocks/custom_node_manager.py
Normal file
26
tests-api/mocks/custom_node_manager.py
Normal file
@ -0,0 +1,26 @@
|
||||
"""
|
||||
Mock CustomNodeManager for testing purposes
|
||||
"""
|
||||
|
||||
class CustomNodeManager:
|
||||
"""
|
||||
Mock implementation of the CustomNodeManager class
|
||||
"""
|
||||
instance = None
|
||||
|
||||
def __init__(self):
|
||||
self.custom_nodes = {}
|
||||
self.node_paths = []
|
||||
self.refresh_timeout = None
|
||||
|
||||
def get_node_path(self, node_class):
|
||||
"""
|
||||
Mock implementation to get the path for a node class
|
||||
"""
|
||||
return self.custom_nodes.get(node_class, None)
|
||||
|
||||
def update_node_paths(self):
|
||||
"""
|
||||
Mock implementation to update node paths
|
||||
"""
|
||||
pass
|
||||
116
tests-api/mocks/patch.py
Normal file
116
tests-api/mocks/patch.py
Normal file
@ -0,0 +1,116 @@
|
||||
"""
|
||||
Patch module to mock imports for testing
|
||||
"""
|
||||
import sys
|
||||
import importlib.util
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Import mock modules
|
||||
from mocks.prompt_server import PromptServer
|
||||
from mocks.custom_node_manager import CustomNodeManager
|
||||
|
||||
# Current directory
|
||||
current_dir = Path(__file__).parent.parent # tests-api directory
|
||||
|
||||
# Define mocks
|
||||
class MockModule:
|
||||
"""Base class for mock modules"""
|
||||
pass
|
||||
|
||||
# Create server mock module with PromptServer
|
||||
server_mock = MockModule()
|
||||
server_mock.PromptServer = PromptServer
|
||||
prompt_server_instance = PromptServer()
|
||||
server_mock.PromptServer.instance = prompt_server_instance
|
||||
server_mock.PromptServer.inst = prompt_server_instance
|
||||
|
||||
# Create app mock module with custom_node_manager submodule
|
||||
app_mock = MockModule()
|
||||
app_custom_node_manager = MockModule()
|
||||
app_custom_node_manager.CustomNodeManager = CustomNodeManager
|
||||
app_custom_node_manager.CustomNodeManager.instance = CustomNodeManager()
|
||||
|
||||
# Create utils mock module with json_util submodule
|
||||
utils_mock = MockModule()
|
||||
utils_json_util = MockModule()
|
||||
|
||||
# Create utils.validation and utils.schema_utils submodules
|
||||
utils_validation = MockModule()
|
||||
utils_schema_utils = MockModule()
|
||||
|
||||
# Import actual modules (make sure path is set up correctly)
|
||||
sys.path.insert(0, str(current_dir))
|
||||
|
||||
try:
|
||||
# Import the validation module
|
||||
from utils.validation import load_openapi_spec
|
||||
utils_validation.load_openapi_spec = load_openapi_spec
|
||||
|
||||
# Import all schema_utils functions
|
||||
from utils.schema_utils import (
|
||||
get_all_paths,
|
||||
get_grouped_paths,
|
||||
get_methods_for_path,
|
||||
find_paths_with_security,
|
||||
get_content_types_for_response,
|
||||
get_required_parameters
|
||||
)
|
||||
|
||||
utils_schema_utils.get_all_paths = get_all_paths
|
||||
utils_schema_utils.get_grouped_paths = get_grouped_paths
|
||||
utils_schema_utils.get_methods_for_path = get_methods_for_path
|
||||
utils_schema_utils.find_paths_with_security = find_paths_with_security
|
||||
utils_schema_utils.get_content_types_for_response = get_content_types_for_response
|
||||
utils_schema_utils.get_required_parameters = get_required_parameters
|
||||
|
||||
except ImportError as e:
|
||||
print(f"Error importing test utilities: {e}")
|
||||
# Define dummy functions if imports fail
|
||||
def dummy_load_openapi_spec():
|
||||
"""Dummy function for testing"""
|
||||
return {"paths": {}}
|
||||
utils_validation.load_openapi_spec = dummy_load_openapi_spec
|
||||
|
||||
def dummy_get_all_paths(spec):
|
||||
return list(spec.get("paths", {}).keys())
|
||||
utils_schema_utils.get_all_paths = dummy_get_all_paths
|
||||
|
||||
def dummy_get_grouped_paths(spec):
|
||||
return {}
|
||||
utils_schema_utils.get_grouped_paths = dummy_get_grouped_paths
|
||||
|
||||
def dummy_get_methods_for_path(spec, path):
|
||||
return []
|
||||
utils_schema_utils.get_methods_for_path = dummy_get_methods_for_path
|
||||
|
||||
def dummy_find_paths_with_security(spec, security_scheme=None):
|
||||
return []
|
||||
utils_schema_utils.find_paths_with_security = dummy_find_paths_with_security
|
||||
|
||||
def dummy_get_content_types_for_response(spec, path, method, status_code="200"):
|
||||
return []
|
||||
utils_schema_utils.get_content_types_for_response = dummy_get_content_types_for_response
|
||||
|
||||
def dummy_get_required_parameters(spec, path, method):
|
||||
return []
|
||||
utils_schema_utils.get_required_parameters = dummy_get_required_parameters
|
||||
|
||||
# Add merge_json_recursive from our mock utils
|
||||
from mocks.utils import merge_json_recursive
|
||||
utils_json_util.merge_json_recursive = merge_json_recursive
|
||||
|
||||
# Apply the mocks to sys.modules
|
||||
def apply_mocks():
|
||||
"""Apply all mocks to sys.modules"""
|
||||
sys.modules['server'] = server_mock
|
||||
sys.modules['app'] = app_mock
|
||||
sys.modules['app.custom_node_manager'] = app_custom_node_manager
|
||||
sys.modules['utils'] = utils_mock
|
||||
sys.modules['utils.json_util'] = utils_json_util
|
||||
sys.modules['utils.validation'] = utils_validation
|
||||
sys.modules['utils.schema_utils'] = utils_schema_utils
|
||||
|
||||
# Make sure our actual utils module is importable
|
||||
if current_dir not in sys.path:
|
||||
sys.path.insert(0, str(current_dir))
|
||||
71
tests-api/mocks/prompt_server.py
Normal file
71
tests-api/mocks/prompt_server.py
Normal file
@ -0,0 +1,71 @@
|
||||
"""
|
||||
Mock PromptServer for testing purposes
|
||||
"""
|
||||
|
||||
class MockRoutes:
|
||||
"""
|
||||
Mock routing class with method decorators
|
||||
"""
|
||||
def __init__(self):
|
||||
self.routes = {}
|
||||
|
||||
def get(self, path):
|
||||
"""Decorator for GET routes"""
|
||||
def decorator(f):
|
||||
self.routes[('GET', path)] = f
|
||||
return f
|
||||
return decorator
|
||||
|
||||
def post(self, path):
|
||||
"""Decorator for POST routes"""
|
||||
def decorator(f):
|
||||
self.routes[('POST', path)] = f
|
||||
return f
|
||||
return decorator
|
||||
|
||||
def put(self, path):
|
||||
"""Decorator for PUT routes"""
|
||||
def decorator(f):
|
||||
self.routes[('PUT', path)] = f
|
||||
return f
|
||||
return decorator
|
||||
|
||||
def delete(self, path):
|
||||
"""Decorator for DELETE routes"""
|
||||
def decorator(f):
|
||||
self.routes[('DELETE', path)] = f
|
||||
return f
|
||||
return decorator
|
||||
|
||||
|
||||
class PromptServer:
|
||||
"""
|
||||
Mock implementation of the PromptServer class
|
||||
"""
|
||||
instance = None
|
||||
inst = None
|
||||
|
||||
def __init__(self):
|
||||
self.routes = MockRoutes()
|
||||
self.registered_paths = set()
|
||||
self.base_url = "http://127.0.0.1:8188" # Assuming server is running on default port
|
||||
self.queue_lock = None
|
||||
|
||||
def add_route(self, method, path, handler, *args, **kwargs):
|
||||
"""
|
||||
Add a mock route to the server
|
||||
"""
|
||||
self.routes.routes[(method.upper(), path)] = handler
|
||||
self.registered_paths.add(path)
|
||||
|
||||
async def send_msg(self, message, data=None):
|
||||
"""
|
||||
Mock send_msg method (does nothing in the mock)
|
||||
"""
|
||||
pass
|
||||
|
||||
def send_sync(self, message, data=None):
|
||||
"""
|
||||
Mock send_sync method (does nothing in the mock)
|
||||
"""
|
||||
pass
|
||||
20
tests-api/mocks/utils.py
Normal file
20
tests-api/mocks/utils.py
Normal file
@ -0,0 +1,20 @@
|
||||
"""
|
||||
Mock utils module for testing purposes
|
||||
"""
|
||||
|
||||
def merge_json_recursive(a, b):
|
||||
"""
|
||||
Mock implementation of merge_json_recursive
|
||||
"""
|
||||
if isinstance(a, dict) and isinstance(b, dict):
|
||||
result = a.copy()
|
||||
for key, value in b.items():
|
||||
if key in result and isinstance(result[key], (dict, list)) and isinstance(value, (dict, list)):
|
||||
result[key] = merge_json_recursive(result[key], value)
|
||||
else:
|
||||
result[key] = value
|
||||
return result
|
||||
elif isinstance(a, list) and isinstance(b, list):
|
||||
return a + b
|
||||
else:
|
||||
return b
|
||||
382
tests-api/openapi.yaml
Normal file
382
tests-api/openapi.yaml
Normal file
@ -0,0 +1,382 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: ComfyUI-Manager API
|
||||
description: API for managing ComfyUI extensions, custom nodes, and models
|
||||
version: 1.0.0
|
||||
contact:
|
||||
name: ComfyUI Community
|
||||
url: https://github.com/comfyanonymous/ComfyUI
|
||||
|
||||
servers:
|
||||
- url: http://localhost:8188
|
||||
description: Local ComfyUI server
|
||||
|
||||
paths:
|
||||
/customnode/getlist:
|
||||
get:
|
||||
summary: Get the list of custom nodes
|
||||
description: Returns the list of custom nodes from all configured channels
|
||||
parameters:
|
||||
- name: mode
|
||||
in: query
|
||||
description: "The mode to retrieve (local=installed nodes, remote=available nodes)"
|
||||
schema:
|
||||
type: string
|
||||
enum: [local, remote]
|
||||
default: remote
|
||||
responses:
|
||||
'200':
|
||||
description: List of custom nodes
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
nodes:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/CustomNode'
|
||||
'500':
|
||||
description: Server error
|
||||
|
||||
/customnode/get_node_mappings:
|
||||
get:
|
||||
summary: Get mappings between node class names and their custom nodes
|
||||
description: Returns mappings that help identify which custom node package provides specific node classes
|
||||
parameters:
|
||||
- name: mode
|
||||
in: query
|
||||
description: "The mode for mappings (local=installed nodes, nickname=node nicknames)"
|
||||
schema:
|
||||
type: string
|
||||
enum: [local, nickname]
|
||||
default: local
|
||||
required: true
|
||||
responses:
|
||||
'200':
|
||||
description: Node mappings
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
'500':
|
||||
description: Server error
|
||||
|
||||
/customnode/get_node_alternatives:
|
||||
get:
|
||||
summary: Get alternative nodes for specific node classes
|
||||
description: Returns alternative implementations of node classes from different custom node packages
|
||||
parameters:
|
||||
- name: mode
|
||||
in: query
|
||||
description: "The mode to retrieve alternatives (local=installed nodes, remote=all available nodes)"
|
||||
schema:
|
||||
type: string
|
||||
enum: [local, remote]
|
||||
default: remote
|
||||
responses:
|
||||
'200':
|
||||
description: Node alternatives
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
'500':
|
||||
description: Server error
|
||||
|
||||
/externalmodel/getlist:
|
||||
get:
|
||||
summary: Get the list of external models
|
||||
description: Returns the list of models from all configured channels
|
||||
parameters:
|
||||
- name: mode
|
||||
in: query
|
||||
description: "The mode to retrieve (local=installed models, remote=available models)"
|
||||
schema:
|
||||
type: string
|
||||
enum: [local, remote]
|
||||
default: remote
|
||||
responses:
|
||||
'200':
|
||||
description: List of external models
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
models:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ExternalModel'
|
||||
'500':
|
||||
description: Server error
|
||||
|
||||
/manager/get_config:
|
||||
get:
|
||||
summary: Get manager configuration
|
||||
description: Returns the current configuration of ComfyUI-Manager
|
||||
parameters:
|
||||
- name: key
|
||||
in: query
|
||||
description: "The configuration key to retrieve"
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
responses:
|
||||
'200':
|
||||
description: Configuration value
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
value:
|
||||
type: string
|
||||
'400':
|
||||
description: Invalid key or missing parameter
|
||||
'500':
|
||||
description: Server error
|
||||
|
||||
/manager/set_config:
|
||||
post:
|
||||
summary: Set manager configuration
|
||||
description: Updates the configuration of ComfyUI-Manager
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- key
|
||||
- value
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
description: "The configuration key to update"
|
||||
value:
|
||||
type: string
|
||||
description: "The new value for the configuration key"
|
||||
responses:
|
||||
'200':
|
||||
description: Configuration updated successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
'400':
|
||||
description: Invalid key or value
|
||||
'500':
|
||||
description: Server error
|
||||
|
||||
/snapshot/getlist:
|
||||
get:
|
||||
summary: Get the list of snapshots
|
||||
description: Returns the list of saved snapshots
|
||||
responses:
|
||||
'200':
|
||||
description: List of snapshots
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
snapshots:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Snapshot'
|
||||
'500':
|
||||
description: Server error
|
||||
|
||||
/comfyui_manager/queue/status:
|
||||
get:
|
||||
summary: Get queue status
|
||||
description: Returns the current status of the operation queue
|
||||
responses:
|
||||
'200':
|
||||
description: Queue status
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/QueueStatus'
|
||||
'500':
|
||||
description: Server error
|
||||
|
||||
components:
|
||||
schemas:
|
||||
CustomNode:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
- title
|
||||
- reference
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: "Internal name/ID of the custom node"
|
||||
title:
|
||||
type: string
|
||||
description: "Display title of the custom node"
|
||||
reference:
|
||||
type: string
|
||||
description: "Reference URL (usually GitHub repository URL)"
|
||||
description:
|
||||
type: string
|
||||
description: "Description of what the custom node does"
|
||||
install_type:
|
||||
type: string
|
||||
enum: [git, pip, copy]
|
||||
description: "Installation method for the custom node"
|
||||
files:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: "List of files provided by this custom node"
|
||||
node_class_names:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: "List of node class names provided by this custom node"
|
||||
installed:
|
||||
type: boolean
|
||||
description: "Whether the custom node is installed"
|
||||
version:
|
||||
type: string
|
||||
description: "Version of the custom node"
|
||||
tags:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: "Tags associated with the custom node"
|
||||
|
||||
ExternalModel:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
- type
|
||||
- url
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: "Name of the model"
|
||||
type:
|
||||
type: string
|
||||
description: "Type of the model (checkpoint, lora, embedding, etc.)"
|
||||
url:
|
||||
type: string
|
||||
description: "Download URL for the model"
|
||||
description:
|
||||
type: string
|
||||
description: "Description of the model"
|
||||
size:
|
||||
type: integer
|
||||
description: "Size of the model in bytes"
|
||||
installed:
|
||||
type: boolean
|
||||
description: "Whether the model is installed"
|
||||
version:
|
||||
type: string
|
||||
description: "Version of the model"
|
||||
tags:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: "Tags associated with the model"
|
||||
|
||||
Snapshot:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
- date
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: "Name of the snapshot"
|
||||
date:
|
||||
type: string
|
||||
format: date-time
|
||||
description: "Date when the snapshot was created"
|
||||
description:
|
||||
type: string
|
||||
description: "Description of the snapshot"
|
||||
nodes:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: "List of custom nodes in the snapshot"
|
||||
models:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: "List of models in the snapshot"
|
||||
|
||||
QueueStatus:
|
||||
type: object
|
||||
properties:
|
||||
pending:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/QueueItem'
|
||||
description: "List of pending operations in the queue"
|
||||
completed:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/QueueItem'
|
||||
description: "List of completed operations in the queue"
|
||||
failed:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/QueueItem'
|
||||
description: "List of failed operations in the queue"
|
||||
running:
|
||||
type: boolean
|
||||
description: "Whether the queue is currently running"
|
||||
|
||||
QueueItem:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- type
|
||||
- target
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: "Unique ID of the queue item"
|
||||
type:
|
||||
type: string
|
||||
enum: [install, update, uninstall]
|
||||
description: "Type of operation"
|
||||
target:
|
||||
type: string
|
||||
description: "Target of the operation (e.g., custom node name, model name)"
|
||||
status:
|
||||
type: string
|
||||
enum: [pending, processing, completed, failed]
|
||||
description: "Current status of the operation"
|
||||
error:
|
||||
type: string
|
||||
description: "Error message if the operation failed"
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: "Time when the operation was added to the queue"
|
||||
completed_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: "Time when the operation was completed"
|
||||
|
||||
securitySchemes:
|
||||
ApiKeyAuth:
|
||||
type: apiKey
|
||||
in: header
|
||||
name: X-API-Key
|
||||
description: "API key for authentication"
|
||||
6
tests-api/requirements-test.txt
Normal file
6
tests-api/requirements-test.txt
Normal file
@ -0,0 +1,6 @@
|
||||
pytest>=7.3.1
|
||||
requests>=2.31.0
|
||||
openapi-spec-validator>=0.6.0
|
||||
jsonschema>=4.17.3
|
||||
pytest-asyncio>=0.21.0
|
||||
pyyaml>=6.0
|
||||
270
tests-api/test_config_api.py
Normal file
270
tests-api/test_config_api.py
Normal file
@ -0,0 +1,270 @@
|
||||
"""
|
||||
Tests for configuration endpoints.
|
||||
"""
|
||||
import pytest
|
||||
from typing import Callable, Dict, List, Tuple
|
||||
|
||||
from utils.validation import validate_response
|
||||
|
||||
|
||||
def test_get_preview_method(
|
||||
api_request: Callable
|
||||
):
|
||||
"""
|
||||
Test getting the current preview method.
|
||||
"""
|
||||
# Make the API request
|
||||
path = "/manager/preview_method"
|
||||
response, _ = api_request(
|
||||
method="get",
|
||||
path=path,
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Verify the response is one of the valid preview methods
|
||||
assert response.text in ["auto", "latent2rgb", "taesd", "none"]
|
||||
|
||||
|
||||
def test_get_db_mode(
|
||||
api_request: Callable
|
||||
):
|
||||
"""
|
||||
Test getting the current database mode.
|
||||
"""
|
||||
# Make the API request
|
||||
path = "/manager/db_mode"
|
||||
response, _ = api_request(
|
||||
method="get",
|
||||
path=path,
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Verify the response is one of the valid database modes
|
||||
assert response.text in ["channel", "local", "remote"]
|
||||
|
||||
|
||||
def test_get_component_policy(
|
||||
api_request: Callable
|
||||
):
|
||||
"""
|
||||
Test getting the current component policy.
|
||||
"""
|
||||
# Make the API request
|
||||
path = "/manager/policy/component"
|
||||
response, _ = api_request(
|
||||
method="get",
|
||||
path=path,
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Component policy could be any string
|
||||
assert response.text is not None
|
||||
|
||||
|
||||
def test_get_update_policy(
|
||||
api_request: Callable
|
||||
):
|
||||
"""
|
||||
Test getting the current update policy.
|
||||
"""
|
||||
# Make the API request
|
||||
path = "/manager/policy/update"
|
||||
response, _ = api_request(
|
||||
method="get",
|
||||
path=path,
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Verify the response is one of the valid update policies
|
||||
assert response.text in ["stable", "nightly", "nightly-comfyui"]
|
||||
|
||||
|
||||
def test_get_channel_url_list(
|
||||
api_request: Callable,
|
||||
openapi_spec: Dict
|
||||
):
|
||||
"""
|
||||
Test getting the channel URL list.
|
||||
"""
|
||||
# Make the API request
|
||||
path = "/manager/channel_url_list"
|
||||
response, json_data = api_request(
|
||||
method="get",
|
||||
path=path,
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Validate response structure against the schema
|
||||
assert json_data is not None
|
||||
validate_response(
|
||||
response_data=json_data,
|
||||
path=path,
|
||||
method="get",
|
||||
spec=openapi_spec,
|
||||
)
|
||||
|
||||
# Verify the response contains the expected fields
|
||||
assert "selected" in json_data
|
||||
assert "list" in json_data
|
||||
assert isinstance(json_data["list"], list)
|
||||
|
||||
# Each channel should have a name and URL
|
||||
if json_data["list"]:
|
||||
first_channel = json_data["list"][0]
|
||||
assert "name" in first_channel
|
||||
assert "url" in first_channel
|
||||
|
||||
|
||||
def test_get_manager_version(
|
||||
api_request: Callable
|
||||
):
|
||||
"""
|
||||
Test getting the manager version.
|
||||
"""
|
||||
# Make the API request
|
||||
path = "/manager/version"
|
||||
response, _ = api_request(
|
||||
method="get",
|
||||
path=path,
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Verify the response is a version string
|
||||
assert response.text.startswith("V") # Version strings start with V
|
||||
|
||||
|
||||
def test_get_manager_notice(
|
||||
api_request: Callable
|
||||
):
|
||||
"""
|
||||
Test getting the manager notice.
|
||||
"""
|
||||
# Make the API request
|
||||
path = "/manager/notice"
|
||||
response, _ = api_request(
|
||||
method="get",
|
||||
path=path,
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Verify the response is HTML content
|
||||
assert response.headers.get("Content-Type", "").startswith("text/html") or "ComfyUI" in response.text
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="State-modifying operations")
|
||||
class TestConfigChanges:
|
||||
"""
|
||||
Tests for changing configuration settings.
|
||||
These are skipped to avoid modifying state in automated tests.
|
||||
"""
|
||||
|
||||
@pytest.fixture(scope="class", autouse=True)
|
||||
def save_original_config(self, api_request: Callable):
|
||||
"""
|
||||
Save the original configuration to restore after tests.
|
||||
"""
|
||||
# Save original values
|
||||
response, _ = api_request(
|
||||
method="get",
|
||||
path="/manager/preview_method",
|
||||
expected_status=200,
|
||||
)
|
||||
self.original_preview_method = response.text
|
||||
|
||||
response, _ = api_request(
|
||||
method="get",
|
||||
path="/manager/db_mode",
|
||||
expected_status=200,
|
||||
)
|
||||
self.original_db_mode = response.text
|
||||
|
||||
response, _ = api_request(
|
||||
method="get",
|
||||
path="/manager/policy/update",
|
||||
expected_status=200,
|
||||
)
|
||||
self.original_update_policy = response.text
|
||||
|
||||
yield
|
||||
|
||||
# Restore original values
|
||||
api_request(
|
||||
method="get",
|
||||
path="/manager/preview_method",
|
||||
params={"value": self.original_preview_method},
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
api_request(
|
||||
method="get",
|
||||
path="/manager/db_mode",
|
||||
params={"value": self.original_db_mode},
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
api_request(
|
||||
method="get",
|
||||
path="/manager/policy/update",
|
||||
params={"value": self.original_update_policy},
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
def test_set_preview_method(self, api_request: Callable):
|
||||
"""
|
||||
Test setting the preview method.
|
||||
"""
|
||||
# Set to a different value (taesd)
|
||||
api_request(
|
||||
method="get",
|
||||
path="/manager/preview_method",
|
||||
params={"value": "taesd"},
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Verify it was changed
|
||||
response, _ = api_request(
|
||||
method="get",
|
||||
path="/manager/preview_method",
|
||||
expected_status=200,
|
||||
)
|
||||
assert response.text == "taesd"
|
||||
|
||||
def test_set_db_mode(self, api_request: Callable):
|
||||
"""
|
||||
Test setting the database mode.
|
||||
"""
|
||||
# Set to local mode
|
||||
api_request(
|
||||
method="get",
|
||||
path="/manager/db_mode",
|
||||
params={"value": "local"},
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Verify it was changed
|
||||
response, _ = api_request(
|
||||
method="get",
|
||||
path="/manager/db_mode",
|
||||
expected_status=200,
|
||||
)
|
||||
assert response.text == "local"
|
||||
|
||||
def test_set_update_policy(self, api_request: Callable):
|
||||
"""
|
||||
Test setting the update policy.
|
||||
"""
|
||||
# Set to stable
|
||||
api_request(
|
||||
method="get",
|
||||
path="/manager/policy/update",
|
||||
params={"value": "stable"},
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Verify it was changed
|
||||
response, _ = api_request(
|
||||
method="get",
|
||||
path="/manager/policy/update",
|
||||
expected_status=200,
|
||||
)
|
||||
assert response.text == "stable"
|
||||
200
tests-api/test_customnode_api.py
Normal file
200
tests-api/test_customnode_api.py
Normal file
@ -0,0 +1,200 @@
|
||||
"""
|
||||
Tests for custom node management endpoints.
|
||||
"""
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from typing import Callable, Dict, Tuple
|
||||
|
||||
from utils.validation import validate_response
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mode",
|
||||
["local", "remote"]
|
||||
)
|
||||
def test_get_custom_node_list(
|
||||
api_request: Callable,
|
||||
openapi_spec: Dict,
|
||||
mode: str
|
||||
):
|
||||
"""
|
||||
Test the endpoint for listing custom nodes.
|
||||
"""
|
||||
# Make the API request
|
||||
path = "/customnode/getlist"
|
||||
response, json_data = api_request(
|
||||
method="get",
|
||||
path=path,
|
||||
params={"mode": mode, "skip_update": "true"},
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Validate response structure against the schema
|
||||
assert json_data is not None
|
||||
validate_response(
|
||||
response_data=json_data,
|
||||
path=path,
|
||||
method="get",
|
||||
spec=openapi_spec,
|
||||
)
|
||||
|
||||
# Verify the response contains the expected fields
|
||||
assert "channel" in json_data
|
||||
assert "node_packs" in json_data
|
||||
assert isinstance(json_data["node_packs"], dict)
|
||||
|
||||
# If there are any node packs, verify they have the expected structure
|
||||
if json_data["node_packs"]:
|
||||
# Take the first node pack to validate
|
||||
first_node_pack = next(iter(json_data["node_packs"].values()))
|
||||
assert "title" in first_node_pack
|
||||
assert "name" in first_node_pack
|
||||
|
||||
|
||||
def test_get_installed_nodes(
|
||||
api_request: Callable,
|
||||
openapi_spec: Dict
|
||||
):
|
||||
"""
|
||||
Test the endpoint for listing installed nodes.
|
||||
"""
|
||||
# Make the API request
|
||||
path = "/customnode/installed"
|
||||
response, json_data = api_request(
|
||||
method="get",
|
||||
path=path,
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Validate response structure against the schema
|
||||
assert json_data is not None
|
||||
validate_response(
|
||||
response_data=json_data,
|
||||
path=path,
|
||||
method="get",
|
||||
spec=openapi_spec,
|
||||
)
|
||||
|
||||
# Verify the response is a dictionary of node packs
|
||||
assert isinstance(json_data, dict)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mode",
|
||||
["local", "nickname"]
|
||||
)
|
||||
def test_get_node_mappings(
|
||||
api_request: Callable,
|
||||
openapi_spec: Dict,
|
||||
mode: str
|
||||
):
|
||||
"""
|
||||
Test the endpoint for getting node-to-package mappings.
|
||||
"""
|
||||
# Make the API request
|
||||
path = "/customnode/getmappings"
|
||||
response, json_data = api_request(
|
||||
method="get",
|
||||
path=path,
|
||||
params={"mode": mode},
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Validate response structure against the schema
|
||||
assert json_data is not None
|
||||
validate_response(
|
||||
response_data=json_data,
|
||||
path=path,
|
||||
method="get",
|
||||
spec=openapi_spec,
|
||||
)
|
||||
|
||||
# Verify the response is a dictionary mapping extension IDs to node info
|
||||
assert isinstance(json_data, dict)
|
||||
|
||||
# If there are any mappings, verify they have the expected structure
|
||||
if json_data:
|
||||
# Take the first mapping to validate
|
||||
first_mapping = next(iter(json_data.values()))
|
||||
assert isinstance(first_mapping, list)
|
||||
assert len(first_mapping) == 2
|
||||
assert isinstance(first_mapping[0], list) # List of node classes
|
||||
assert isinstance(first_mapping[1], dict) # Metadata
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mode",
|
||||
["local", "remote"]
|
||||
)
|
||||
def test_get_node_alternatives(
|
||||
api_request: Callable,
|
||||
openapi_spec: Dict,
|
||||
mode: str
|
||||
):
|
||||
"""
|
||||
Test the endpoint for getting alternative node options.
|
||||
"""
|
||||
# Make the API request
|
||||
path = "/customnode/alternatives"
|
||||
response, json_data = api_request(
|
||||
method="get",
|
||||
path=path,
|
||||
params={"mode": mode},
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Validate response structure against the schema
|
||||
assert json_data is not None
|
||||
validate_response(
|
||||
response_data=json_data,
|
||||
path=path,
|
||||
method="get",
|
||||
spec=openapi_spec,
|
||||
)
|
||||
|
||||
# Verify the response is a dictionary
|
||||
assert isinstance(json_data, dict)
|
||||
|
||||
|
||||
def test_fetch_updates(
|
||||
api_request: Callable
|
||||
):
|
||||
"""
|
||||
Test the endpoint for fetching updates.
|
||||
This might modify state, so we just check for a valid response.
|
||||
"""
|
||||
# Make the API request with skip_update=true to avoid actual updates
|
||||
path = "/customnode/fetch_updates"
|
||||
response, _ = api_request(
|
||||
method="get",
|
||||
path=path,
|
||||
params={"mode": "local"},
|
||||
# Don't validate JSON since this endpoint doesn't return JSON
|
||||
expected_status=200,
|
||||
retry_on_error=False, # Don't retry as this might have side effects
|
||||
)
|
||||
|
||||
# Just check the status code is as expected (covered by api_request)
|
||||
assert response.status_code in [200, 201]
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Queue endpoints are better tested with queue operations")
|
||||
def test_queue_update_all(
|
||||
api_request: Callable
|
||||
):
|
||||
"""
|
||||
Test the endpoint for queuing updates for all nodes.
|
||||
Skipping as this would actually modify the installation.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Security-restricted endpoint")
|
||||
def test_install_node_via_git_url(
|
||||
api_request: Callable
|
||||
):
|
||||
"""
|
||||
Test the endpoint for installing a node via Git URL.
|
||||
Skipping as this requires high security level and would modify the installation.
|
||||
"""
|
||||
pass
|
||||
23
tests-api/test_import.py
Normal file
23
tests-api/test_import.py
Normal file
@ -0,0 +1,23 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Print current working directory
|
||||
print(f"Current directory: {os.getcwd()}")
|
||||
|
||||
# Print module search path
|
||||
print(f"System path: {sys.path}")
|
||||
|
||||
# Try to import
|
||||
try:
|
||||
from utils.validation import load_openapi_spec
|
||||
print("Import successful!")
|
||||
except ImportError as e:
|
||||
print(f"Import error: {e}")
|
||||
|
||||
# Try direct import
|
||||
try:
|
||||
sys.path.insert(0, os.path.join(os.getcwd(), "custom_nodes/ComfyUI-Manager/tests-api"))
|
||||
from utils.validation import load_openapi_spec
|
||||
print("Direct import successful!")
|
||||
except ImportError as e:
|
||||
print(f"Direct import error: {e}")
|
||||
62
tests-api/test_model_api.py
Normal file
62
tests-api/test_model_api.py
Normal file
@ -0,0 +1,62 @@
|
||||
"""
|
||||
Tests for model management endpoints.
|
||||
These features are scheduled for deprecation, so tests are minimal.
|
||||
"""
|
||||
import pytest
|
||||
from typing import Callable, Dict
|
||||
|
||||
from utils.validation import validate_response
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mode",
|
||||
["local", "remote"]
|
||||
)
|
||||
def test_get_external_model_list(
|
||||
api_request: Callable,
|
||||
openapi_spec: Dict,
|
||||
mode: str
|
||||
):
|
||||
"""
|
||||
Test the endpoint for listing external models.
|
||||
"""
|
||||
# Make the API request
|
||||
path = "/externalmodel/getlist"
|
||||
response, json_data = api_request(
|
||||
method="get",
|
||||
path=path,
|
||||
params={"mode": mode},
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Validate response structure against the schema
|
||||
assert json_data is not None
|
||||
validate_response(
|
||||
response_data=json_data,
|
||||
path=path,
|
||||
method="get",
|
||||
spec=openapi_spec,
|
||||
)
|
||||
|
||||
# Verify the response contains the expected fields
|
||||
assert "models" in json_data
|
||||
assert isinstance(json_data["models"], list)
|
||||
|
||||
# If there are any models, verify they have the expected structure
|
||||
if json_data["models"]:
|
||||
first_model = json_data["models"][0]
|
||||
assert "name" in first_model
|
||||
assert "type" in first_model
|
||||
assert "url" in first_model
|
||||
assert "filename" in first_model
|
||||
assert "installed" in first_model
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="State-modifying operation that requires auth")
|
||||
def test_install_model():
|
||||
"""
|
||||
Test queuing a model installation.
|
||||
Skipped to avoid modifying state and requires authentication.
|
||||
This feature is also scheduled for deprecation.
|
||||
"""
|
||||
pass
|
||||
213
tests-api/test_queue_api.py
Normal file
213
tests-api/test_queue_api.py
Normal file
@ -0,0 +1,213 @@
|
||||
"""
|
||||
Tests for queue management endpoints.
|
||||
"""
|
||||
import pytest
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Callable, Dict, Tuple
|
||||
|
||||
from utils.validation import validate_response
|
||||
|
||||
|
||||
def test_get_queue_status(
|
||||
api_request: Callable,
|
||||
openapi_spec: Dict
|
||||
):
|
||||
"""
|
||||
Test the endpoint for getting queue status.
|
||||
"""
|
||||
# Make the API request
|
||||
path = "/manager/queue/status"
|
||||
response, json_data = api_request(
|
||||
method="get",
|
||||
path=path,
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Validate response structure against the schema
|
||||
assert json_data is not None
|
||||
validate_response(
|
||||
response_data=json_data,
|
||||
path=path,
|
||||
method="get",
|
||||
spec=openapi_spec,
|
||||
)
|
||||
|
||||
# Verify the response contains the expected fields
|
||||
assert "total_count" in json_data
|
||||
assert "done_count" in json_data
|
||||
assert "in_progress_count" in json_data
|
||||
assert "is_processing" in json_data
|
||||
|
||||
# Type checks
|
||||
assert isinstance(json_data["total_count"], int)
|
||||
assert isinstance(json_data["done_count"], int)
|
||||
assert isinstance(json_data["in_progress_count"], int)
|
||||
assert isinstance(json_data["is_processing"], bool)
|
||||
|
||||
|
||||
def test_reset_queue(
|
||||
api_request: Callable
|
||||
):
|
||||
"""
|
||||
Test the endpoint for resetting the queue.
|
||||
"""
|
||||
# Make the API request
|
||||
path = "/manager/queue/reset"
|
||||
response, _ = api_request(
|
||||
method="get",
|
||||
path=path,
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Now check the queue status to verify it was reset
|
||||
response2, json_data = api_request(
|
||||
method="get",
|
||||
path="/manager/queue/status",
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Queue should be empty after reset
|
||||
assert json_data["total_count"] == json_data["done_count"] + json_data["in_progress_count"]
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="State-modifying operation that requires auth")
|
||||
def test_queue_install_node():
|
||||
"""
|
||||
Test queuing a node installation.
|
||||
Skipped to avoid modifying state and requires authentication.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="State-modifying operation that requires auth")
|
||||
def test_queue_update_node():
|
||||
"""
|
||||
Test queuing a node update.
|
||||
Skipped to avoid modifying state and requires authentication.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="State-modifying operation that requires auth")
|
||||
def test_queue_uninstall_node():
|
||||
"""
|
||||
Test queuing a node uninstallation.
|
||||
Skipped to avoid modifying state and requires authentication.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="State-modifying operation")
|
||||
def test_queue_start():
|
||||
"""
|
||||
Test starting the queue.
|
||||
Skipped to avoid modifying state.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class TestQueueOperations:
|
||||
"""
|
||||
Test a complete queue workflow.
|
||||
These tests are grouped to ensure proper sequencing but are still skipped
|
||||
to avoid modifying state in automated tests.
|
||||
"""
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def node_data(self) -> Dict:
|
||||
"""
|
||||
Create test data for a node operation.
|
||||
"""
|
||||
# This would be replaced with actual data for a known safe node
|
||||
return {
|
||||
"ui_id": "test_node_1",
|
||||
"id": "comfyui-manager", # Manager itself
|
||||
"version": "latest",
|
||||
"channel": "default",
|
||||
"mode": "local",
|
||||
}
|
||||
|
||||
@pytest.mark.skip(reason="State-modifying operation")
|
||||
def test_queue_operation_sequence(
|
||||
self,
|
||||
api_request: Callable,
|
||||
node_data: Dict
|
||||
):
|
||||
"""
|
||||
Test the queue operation sequence.
|
||||
"""
|
||||
# 1. Reset the queue
|
||||
api_request(
|
||||
method="get",
|
||||
path="/manager/queue/reset",
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# 2. Queue a node operation (we'll use the manager itself)
|
||||
api_request(
|
||||
method="post",
|
||||
path="/manager/queue/update",
|
||||
json_data=node_data,
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# 3. Check queue status - should have one operation
|
||||
response, json_data = api_request(
|
||||
method="get",
|
||||
path="/manager/queue/status",
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
assert json_data["total_count"] > 0
|
||||
assert not json_data["is_processing"] # Queue hasn't started yet
|
||||
|
||||
# 4. Start the queue
|
||||
api_request(
|
||||
method="get",
|
||||
path="/manager/queue/start",
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# 5. Check queue status again - should be processing
|
||||
response, json_data = api_request(
|
||||
method="get",
|
||||
path="/manager/queue/status",
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Queue should be processing or already done
|
||||
assert json_data["is_processing"] or json_data["done_count"] == json_data["total_count"]
|
||||
|
||||
# 6. Wait for queue to complete (with timeout)
|
||||
max_wait_time = 60 # seconds
|
||||
start_time = time.time()
|
||||
completed = False
|
||||
|
||||
while time.time() - start_time < max_wait_time:
|
||||
response, json_data = api_request(
|
||||
method="get",
|
||||
path="/manager/queue/status",
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
if json_data["done_count"] == json_data["total_count"] and not json_data["is_processing"]:
|
||||
completed = True
|
||||
break
|
||||
|
||||
time.sleep(2) # Wait before checking again
|
||||
|
||||
assert completed, "Queue did not complete within timeout period"
|
||||
|
||||
@pytest.mark.skip(reason="State-modifying operation")
|
||||
def test_concurrent_queue_operations(
|
||||
self,
|
||||
api_request: Callable,
|
||||
node_data: Dict
|
||||
):
|
||||
"""
|
||||
Test concurrent queue operations.
|
||||
"""
|
||||
# This would test adding multiple operations to the queue
|
||||
# and verifying they all complete correctly
|
||||
pass
|
||||
198
tests-api/test_snapshot_api.py
Normal file
198
tests-api/test_snapshot_api.py
Normal file
@ -0,0 +1,198 @@
|
||||
"""
|
||||
Tests for snapshot management endpoints.
|
||||
"""
|
||||
import pytest
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Callable, Dict, List, Optional
|
||||
|
||||
from utils.validation import validate_response
|
||||
|
||||
|
||||
def test_get_snapshot_list(
|
||||
api_request: Callable,
|
||||
openapi_spec: Dict
|
||||
):
|
||||
"""
|
||||
Test the endpoint for listing snapshots.
|
||||
"""
|
||||
# Make the API request
|
||||
path = "/snapshot/getlist"
|
||||
response, json_data = api_request(
|
||||
method="get",
|
||||
path=path,
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Validate response structure against the schema
|
||||
assert json_data is not None
|
||||
validate_response(
|
||||
response_data=json_data,
|
||||
path=path,
|
||||
method="get",
|
||||
spec=openapi_spec,
|
||||
)
|
||||
|
||||
# Verify the response contains the expected fields
|
||||
assert "items" in json_data
|
||||
assert isinstance(json_data["items"], list)
|
||||
|
||||
|
||||
def test_get_current_snapshot(
|
||||
api_request: Callable,
|
||||
openapi_spec: Dict
|
||||
):
|
||||
"""
|
||||
Test the endpoint for getting the current snapshot.
|
||||
"""
|
||||
# Make the API request
|
||||
path = "/snapshot/get_current"
|
||||
response, json_data = api_request(
|
||||
method="get",
|
||||
path=path,
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Validate response structure against the schema
|
||||
assert json_data is not None
|
||||
validate_response(
|
||||
response_data=json_data,
|
||||
path=path,
|
||||
method="get",
|
||||
spec=openapi_spec,
|
||||
)
|
||||
|
||||
# Check for basic snapshot structure
|
||||
assert "snapshot_date" in json_data
|
||||
assert "custom_nodes" in json_data
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="This test creates a snapshot which is a state-modifying operation")
|
||||
def test_save_snapshot(
|
||||
api_request: Callable
|
||||
):
|
||||
"""
|
||||
Test the endpoint for saving a new snapshot.
|
||||
Skipped to avoid modifying state in tests.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="This test removes a snapshot which is a destructive operation")
|
||||
def test_remove_snapshot(
|
||||
api_request: Callable
|
||||
):
|
||||
"""
|
||||
Test the endpoint for removing a snapshot.
|
||||
Skipped to avoid modifying state in tests.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="This test restores a snapshot which is a state-modifying operation")
|
||||
def test_restore_snapshot(
|
||||
api_request: Callable
|
||||
):
|
||||
"""
|
||||
Test the endpoint for restoring a snapshot.
|
||||
Skipped to avoid modifying state in tests.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class TestSnapshotWorkflow:
|
||||
"""
|
||||
Test the complete snapshot workflow (create, list, get, remove).
|
||||
These tests are grouped to ensure proper sequencing but are still skipped
|
||||
to avoid modifying state in automated tests.
|
||||
"""
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def snapshot_name(self) -> str:
|
||||
"""
|
||||
Generate a unique snapshot name for testing.
|
||||
"""
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
return f"test_snapshot_{timestamp}"
|
||||
|
||||
@pytest.mark.skip(reason="State-modifying test")
|
||||
def test_create_snapshot(
|
||||
self,
|
||||
api_request: Callable,
|
||||
snapshot_name: str
|
||||
):
|
||||
"""
|
||||
Test creating a snapshot.
|
||||
"""
|
||||
# Make the API request to save a snapshot
|
||||
response, _ = api_request(
|
||||
method="get",
|
||||
path="/snapshot/save",
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Verify a snapshot was created (would need to check the snapshot list)
|
||||
response2, json_data = api_request(
|
||||
method="get",
|
||||
path="/snapshot/getlist",
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# The most recently created snapshot should be first in the list
|
||||
assert json_data["items"]
|
||||
|
||||
# Store the snapshot name for later tests
|
||||
self.actual_snapshot_name = json_data["items"][0]
|
||||
|
||||
@pytest.mark.skip(reason="State-modifying test")
|
||||
def test_get_snapshot_details(
|
||||
self,
|
||||
api_request: Callable,
|
||||
openapi_spec: Dict
|
||||
):
|
||||
"""
|
||||
Test getting details of the created snapshot.
|
||||
"""
|
||||
# This would check the current snapshot, not a specific one
|
||||
# since there's no direct API to get a specific snapshot
|
||||
response, json_data = api_request(
|
||||
method="get",
|
||||
path="/snapshot/get_current",
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Validate the snapshot data
|
||||
assert json_data is not None
|
||||
validate_response(
|
||||
response_data=json_data,
|
||||
path="/snapshot/get_current",
|
||||
method="get",
|
||||
spec=openapi_spec,
|
||||
)
|
||||
|
||||
@pytest.mark.skip(reason="State-modifying test")
|
||||
def test_remove_test_snapshot(
|
||||
self,
|
||||
api_request: Callable
|
||||
):
|
||||
"""
|
||||
Test removing the test snapshot.
|
||||
"""
|
||||
# Make the API request to remove the snapshot
|
||||
response, _ = api_request(
|
||||
method="get",
|
||||
path="/snapshot/remove",
|
||||
params={"target": self.actual_snapshot_name},
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# Verify the snapshot was removed
|
||||
response2, json_data = api_request(
|
||||
method="get",
|
||||
path="/snapshot/getlist",
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
# The snapshot should no longer be in the list
|
||||
assert self.actual_snapshot_name not in json_data["items"]
|
||||
150
tests-api/test_spec_validation.py
Normal file
150
tests-api/test_spec_validation.py
Normal file
@ -0,0 +1,150 @@
|
||||
"""
|
||||
Tests for validating the OpenAPI specification.
|
||||
"""
|
||||
import json
|
||||
import pytest
|
||||
import yaml
|
||||
from typing import Dict, Any, List, Tuple
|
||||
from pathlib import Path
|
||||
from openapi_spec_validator import validate_spec
|
||||
from utils.validation import load_openapi_spec
|
||||
from utils.schema_utils import (
|
||||
get_all_paths,
|
||||
get_methods_for_path,
|
||||
find_paths_with_security,
|
||||
get_required_parameters
|
||||
)
|
||||
|
||||
|
||||
def test_spec_is_valid():
|
||||
"""
|
||||
Test that the OpenAPI specification is valid according to the spec validator.
|
||||
"""
|
||||
spec = load_openapi_spec()
|
||||
validate_spec(spec)
|
||||
|
||||
|
||||
def test_spec_has_info():
|
||||
"""
|
||||
Test that the OpenAPI specification has basic info.
|
||||
"""
|
||||
spec = load_openapi_spec()
|
||||
|
||||
assert "info" in spec
|
||||
assert "title" in spec["info"]
|
||||
assert "version" in spec["info"]
|
||||
assert spec["info"]["title"] == "ComfyUI-Manager API"
|
||||
|
||||
|
||||
def test_spec_has_paths():
|
||||
"""
|
||||
Test that the OpenAPI specification has paths defined.
|
||||
"""
|
||||
spec = load_openapi_spec()
|
||||
|
||||
assert "paths" in spec
|
||||
assert len(spec["paths"]) > 0
|
||||
|
||||
|
||||
def test_paths_have_responses():
|
||||
"""
|
||||
Test that all paths have responses defined.
|
||||
"""
|
||||
spec = load_openapi_spec()
|
||||
|
||||
for path, path_item in spec["paths"].items():
|
||||
for method, operation in path_item.items():
|
||||
if method.lower() not in {"get", "post", "put", "delete", "patch", "options", "head"}:
|
||||
continue
|
||||
|
||||
assert "responses" in operation, f"Path {path} method {method} has no responses"
|
||||
assert len(operation["responses"]) > 0, f"Path {path} method {method} has empty responses"
|
||||
|
||||
|
||||
def test_responses_have_schemas():
|
||||
"""
|
||||
Test that responses with application/json content type have schemas.
|
||||
"""
|
||||
spec = load_openapi_spec()
|
||||
|
||||
for path, path_item in spec["paths"].items():
|
||||
for method, operation in path_item.items():
|
||||
if method.lower() not in {"get", "post", "put", "delete", "patch", "options", "head"}:
|
||||
continue
|
||||
|
||||
for status, response in operation["responses"].items():
|
||||
if "content" not in response:
|
||||
continue
|
||||
|
||||
if "application/json" in response["content"]:
|
||||
assert "schema" in response["content"]["application/json"], (
|
||||
f"Path {path} method {method} status {status} "
|
||||
f"application/json content has no schema"
|
||||
)
|
||||
|
||||
|
||||
def test_required_parameters_have_schemas():
|
||||
"""
|
||||
Test that all required parameters have schemas.
|
||||
"""
|
||||
spec = load_openapi_spec()
|
||||
|
||||
for path, path_item in spec["paths"].items():
|
||||
for method, operation in path_item.items():
|
||||
if method.lower() not in {"get", "post", "put", "delete", "patch", "options", "head"}:
|
||||
continue
|
||||
|
||||
if "parameters" not in operation:
|
||||
continue
|
||||
|
||||
for param in operation["parameters"]:
|
||||
if param.get("required", False):
|
||||
assert "schema" in param, (
|
||||
f"Path {path} method {method} required parameter {param.get('name')} has no schema"
|
||||
)
|
||||
|
||||
|
||||
def test_security_schemes_defined():
|
||||
"""
|
||||
Test that security schemes are properly defined.
|
||||
"""
|
||||
spec = load_openapi_spec()
|
||||
|
||||
# Get paths requiring security
|
||||
secure_paths = find_paths_with_security(spec)
|
||||
|
||||
if secure_paths:
|
||||
assert "components" in spec, "Spec has secure paths but no components"
|
||||
assert "securitySchemes" in spec["components"], "Spec has secure paths but no securitySchemes"
|
||||
|
||||
# Check each security reference is defined
|
||||
for path, method in secure_paths:
|
||||
operation = spec["paths"][path][method]
|
||||
for security_req in operation["security"]:
|
||||
for scheme_name in security_req:
|
||||
assert scheme_name in spec["components"]["securitySchemes"], (
|
||||
f"Security scheme {scheme_name} used by {method.upper()} {path} "
|
||||
f"is not defined in components.securitySchemes"
|
||||
)
|
||||
|
||||
|
||||
def test_common_endpoint_groups_present():
|
||||
"""
|
||||
Test that the spec includes the main endpoint groups.
|
||||
"""
|
||||
spec = load_openapi_spec()
|
||||
paths = get_all_paths(spec)
|
||||
|
||||
# Define the expected endpoint prefixes
|
||||
expected_prefixes = [
|
||||
"/customnode/",
|
||||
"/externalmodel/",
|
||||
"/manager/",
|
||||
"/snapshot/",
|
||||
"/comfyui_manager/",
|
||||
]
|
||||
|
||||
# Check that at least one path exists for each expected prefix
|
||||
for prefix in expected_prefixes:
|
||||
matching_paths = [p for p in paths if p.startswith(prefix)]
|
||||
assert matching_paths, f"No endpoints found with prefix {prefix}"
|
||||
1
tests-api/utils/__init__.py
Normal file
1
tests-api/utils/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Make utils directory a proper package
|
||||
174
tests-api/utils/schema_utils.py
Normal file
174
tests-api/utils/schema_utils.py
Normal file
@ -0,0 +1,174 @@
|
||||
"""
|
||||
Schema utilities for extracting and manipulating OpenAPI schemas.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
from .validation import load_openapi_spec
|
||||
|
||||
|
||||
def get_all_paths(spec: Dict[str, Any]) -> List[str]:
|
||||
"""
|
||||
Get all paths defined in the OpenAPI specification.
|
||||
|
||||
Args:
|
||||
spec: The OpenAPI specification
|
||||
|
||||
Returns:
|
||||
List of all paths
|
||||
"""
|
||||
return list(spec.get("paths", {}).keys())
|
||||
|
||||
|
||||
def get_grouped_paths(spec: Dict[str, Any]) -> Dict[str, List[str]]:
|
||||
"""
|
||||
Group paths by their top-level segment.
|
||||
|
||||
Args:
|
||||
spec: The OpenAPI specification
|
||||
|
||||
Returns:
|
||||
Dictionary mapping top-level segments to lists of paths
|
||||
"""
|
||||
result = {}
|
||||
|
||||
for path in get_all_paths(spec):
|
||||
segments = path.strip("/").split("/")
|
||||
if not segments:
|
||||
continue
|
||||
|
||||
top_segment = segments[0]
|
||||
if top_segment not in result:
|
||||
result[top_segment] = []
|
||||
|
||||
result[top_segment].append(path)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_methods_for_path(spec: Dict[str, Any], path: str) -> List[str]:
|
||||
"""
|
||||
Get all HTTP methods defined for a path.
|
||||
|
||||
Args:
|
||||
spec: The OpenAPI specification
|
||||
path: The API path
|
||||
|
||||
Returns:
|
||||
List of HTTP methods (lowercase)
|
||||
"""
|
||||
if path not in spec.get("paths", {}):
|
||||
return []
|
||||
|
||||
return [
|
||||
method.lower()
|
||||
for method in spec["paths"][path].keys()
|
||||
if method.lower() in {"get", "post", "put", "delete", "patch", "options", "head"}
|
||||
]
|
||||
|
||||
|
||||
def find_paths_with_security(
|
||||
spec: Dict[str, Any],
|
||||
security_scheme: Optional[str] = None
|
||||
) -> List[Tuple[str, str]]:
|
||||
"""
|
||||
Find all paths that require security.
|
||||
|
||||
Args:
|
||||
spec: The OpenAPI specification
|
||||
security_scheme: Optional specific security scheme to filter by
|
||||
|
||||
Returns:
|
||||
List of (path, method) tuples that require security
|
||||
"""
|
||||
result = []
|
||||
|
||||
for path, path_item in spec.get("paths", {}).items():
|
||||
for method, operation in path_item.items():
|
||||
if method.lower() not in {"get", "post", "put", "delete", "patch", "options", "head"}:
|
||||
continue
|
||||
|
||||
if "security" in operation:
|
||||
if security_scheme is None:
|
||||
result.append((path, method.lower()))
|
||||
else:
|
||||
# Check if this security scheme is required
|
||||
for security_req in operation["security"]:
|
||||
if security_scheme in security_req:
|
||||
result.append((path, method.lower()))
|
||||
break
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_content_types_for_response(
|
||||
spec: Dict[str, Any],
|
||||
path: str,
|
||||
method: str,
|
||||
status_code: str = "200"
|
||||
) -> List[str]:
|
||||
"""
|
||||
Get content types defined for a response.
|
||||
|
||||
Args:
|
||||
spec: The OpenAPI specification
|
||||
path: The API path
|
||||
method: The HTTP method
|
||||
status_code: The HTTP status code
|
||||
|
||||
Returns:
|
||||
List of content types
|
||||
"""
|
||||
method = method.lower()
|
||||
|
||||
if path not in spec["paths"]:
|
||||
return []
|
||||
|
||||
if method not in spec["paths"][path]:
|
||||
return []
|
||||
|
||||
if "responses" not in spec["paths"][path][method]:
|
||||
return []
|
||||
|
||||
if status_code not in spec["paths"][path][method]["responses"]:
|
||||
return []
|
||||
|
||||
response_def = spec["paths"][path][method]["responses"][status_code]
|
||||
|
||||
if "content" not in response_def:
|
||||
return []
|
||||
|
||||
return list(response_def["content"].keys())
|
||||
|
||||
|
||||
def get_required_parameters(
|
||||
spec: Dict[str, Any],
|
||||
path: str,
|
||||
method: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get all required parameters for a path/method.
|
||||
|
||||
Args:
|
||||
spec: The OpenAPI specification
|
||||
path: The API path
|
||||
method: The HTTP method
|
||||
|
||||
Returns:
|
||||
List of parameter objects that are required
|
||||
"""
|
||||
method = method.lower()
|
||||
|
||||
if path not in spec["paths"]:
|
||||
return []
|
||||
|
||||
if method not in spec["paths"][path]:
|
||||
return []
|
||||
|
||||
if "parameters" not in spec["paths"][path][method]:
|
||||
return []
|
||||
|
||||
return [
|
||||
param for param in spec["paths"][path][method]["parameters"]
|
||||
if param.get("required", False)
|
||||
]
|
||||
155
tests-api/utils/validation.py
Normal file
155
tests-api/utils/validation.py
Normal file
@ -0,0 +1,155 @@
|
||||
"""
|
||||
Validation utilities for API tests.
|
||||
"""
|
||||
import json
|
||||
import jsonschema
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Union
|
||||
|
||||
|
||||
def load_openapi_spec(spec_path: Union[str, Path] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Load the OpenAPI specification document.
|
||||
|
||||
Args:
|
||||
spec_path: Path to the OpenAPI specification file
|
||||
|
||||
Returns:
|
||||
The OpenAPI specification as a dictionary
|
||||
"""
|
||||
if spec_path is None:
|
||||
# Default to the root openapi.yaml file
|
||||
spec_path = Path(__file__).parents[2] / "openapi.yaml"
|
||||
|
||||
with open(spec_path, "r") as f:
|
||||
if str(spec_path).endswith(".yaml") or str(spec_path).endswith(".yml"):
|
||||
return yaml.safe_load(f)
|
||||
else:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def get_schema_for_path(
|
||||
spec: Dict[str, Any],
|
||||
path: str,
|
||||
method: str,
|
||||
status_code: str = "200",
|
||||
content_type: str = "application/json"
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Extract the response schema for a specific path, method, and status code.
|
||||
|
||||
Args:
|
||||
spec: The OpenAPI specification
|
||||
path: The API path (e.g., "/customnode/getlist")
|
||||
method: The HTTP method (e.g., "get", "post")
|
||||
status_code: The HTTP status code (default: "200")
|
||||
content_type: The response content type (default: "application/json")
|
||||
|
||||
Returns:
|
||||
The schema for the specified path and method, or None if not found
|
||||
"""
|
||||
method = method.lower()
|
||||
|
||||
if path not in spec["paths"]:
|
||||
return None
|
||||
|
||||
if method not in spec["paths"][path]:
|
||||
return None
|
||||
|
||||
if "responses" not in spec["paths"][path][method]:
|
||||
return None
|
||||
|
||||
if status_code not in spec["paths"][path][method]["responses"]:
|
||||
return None
|
||||
|
||||
response_def = spec["paths"][path][method]["responses"][status_code]
|
||||
|
||||
if "content" not in response_def:
|
||||
return None
|
||||
|
||||
if content_type not in response_def["content"]:
|
||||
return None
|
||||
|
||||
if "schema" not in response_def["content"][content_type]:
|
||||
return None
|
||||
|
||||
return response_def["content"][content_type]["schema"]
|
||||
|
||||
|
||||
def validate_response_schema(
|
||||
response_data: Any,
|
||||
schema: Dict[str, Any],
|
||||
spec: Dict[str, Any] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Validate a response against a schema from the OpenAPI specification.
|
||||
|
||||
Args:
|
||||
response_data: The response data to validate
|
||||
schema: The schema to validate against
|
||||
spec: The complete OpenAPI specification (for resolving references)
|
||||
|
||||
Returns:
|
||||
True if validation succeeds, raises an exception otherwise
|
||||
"""
|
||||
if spec is None:
|
||||
spec = load_openapi_spec()
|
||||
|
||||
# Create a resolver for references within the schema
|
||||
resolver = jsonschema.RefResolver.from_schema(spec)
|
||||
|
||||
# Validate the response against the schema
|
||||
jsonschema.validate(
|
||||
instance=response_data,
|
||||
schema=schema,
|
||||
resolver=resolver
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def validate_response(
|
||||
response_data: Any,
|
||||
path: str,
|
||||
method: str,
|
||||
status_code: str = "200",
|
||||
content_type: str = "application/json",
|
||||
spec: Dict[str, Any] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Validate a response against the schema defined in the OpenAPI specification.
|
||||
|
||||
Args:
|
||||
response_data: The response data to validate
|
||||
path: The API path
|
||||
method: The HTTP method
|
||||
status_code: The HTTP status code (default: "200")
|
||||
content_type: The response content type (default: "application/json")
|
||||
spec: The OpenAPI specification (loaded from default location if None)
|
||||
|
||||
Returns:
|
||||
True if validation succeeds, raises an exception otherwise
|
||||
"""
|
||||
if spec is None:
|
||||
spec = load_openapi_spec()
|
||||
|
||||
schema = get_schema_for_path(
|
||||
spec=spec,
|
||||
path=path,
|
||||
method=method,
|
||||
status_code=status_code,
|
||||
content_type=content_type
|
||||
)
|
||||
|
||||
if schema is None:
|
||||
raise ValueError(
|
||||
f"No schema found for {method.upper()} {path} "
|
||||
f"with status {status_code} and content type {content_type}"
|
||||
)
|
||||
|
||||
return validate_response_schema(
|
||||
response_data=response_data,
|
||||
schema=schema,
|
||||
spec=spec
|
||||
)
|
||||
Loading…
Reference in New Issue
Block a user