mirror of
https://github.com/Comfy-Org/ComfyUI-Manager.git
synced 2026-06-20 23:09:20 +08:00
Some checks failed
Python Linting / Run Ruff (push) Has been cancelled
* feat(security): dedicated install flags decouple git_url/pip from security_level
Install via git URL and pip install are no longer gated by
security_level. Each surface gets a dedicated config.ini flag —
allow_git_url_install / allow_pip_install (both default false, secure
by default) — that fully REPLACES the security-level term for these two
features. The network-position invariant is retained: a non-local
listener stays denied regardless of the flags unless
network_mode = personal_cloud.
- New pure predicate is_dedicated_install_allowed() in
common/manager_security (no config access; callers resolve config)
- Legacy endpoints /v2/customnode/install/git_url and .../pip switch
from is_allowed_security_level('high+') to the flag gate; batch
installs of unknown git URLs likewise (middle+ entry gate unchanged,
unknown-pip 'block' stays unconditional; response shapes preserved)
- Config readers/writers (glob + legacy) parse and persist the flags;
denial logs and frontend 403 messages name the responsible flag and
note the non-local-listener requirement (network_mode=personal_cloud)
- No auto-seed from security_level — users previously on weak/normal-
must opt in explicitly (see CHANGELOG migration notes; README
documents the new contract)
- Update the pre-existing permissive E2E harness
(start_comfyui_permissive.sh + test_e2e_legacy_real_ops.py) to the
new contract: it now also sets allow_git_url_install /
allow_pip_install = true, since security_level = normal- alone no
longer opens the git_url/pip endpoints
Tests: predicate truth table proving security_level independence in
both directions, dual-reader config contract, security-level-matrix
freeze guards, legacy gate regression guards (121 unit), plus 22
real-server E2E tests incl. URL-form pip install with self-clean.
* test(e2e): fix fresh-env failures in customnode_info and git_clone harnesses
Two pre-existing harness defects that fail deterministically on a fresh
E2E environment (unrelated to the dedicated-install-flags change):
- test_e2e_customnode_info: TestInstalledPacks asserted the seed pack
ComfyUI_SigmoidOffsetScheduler is installed, but nothing seeded it —
the installing module (test_e2e_endpoint) runs alphabetically later
and uninstalls it at the end. Add a module-scoped autouse fixture
that installs the pack via cm-cli BEFORE the server starts (the
imported-mode test asserts against the startup-frozen snapshot, so
API-based seeding after boot cannot work) and removes it on teardown
only if the fixture installed it.
- test_e2e_git_clone: _ensure_cache ran cm-cli update-cache with a
120s timeout; the full DB download routinely exceeds that on slow
links, erroring the whole module at setup. Raise to 600s.
Verified from a fresh state (seed pack absent): both modules pass
(13 tests, incl. previously-failing TestInstalledPacks 2 and
TestNightlyInstallCycle 3).
445 lines
18 KiB
Python
445 lines
18 KiB
Python
"""E2E tests for ComfyUI Manager custom node information endpoints.
|
|
|
|
Tests the custom node information and mapping endpoints:
|
|
- GET /v2/customnode/getmappings — node-to-package mappings
|
|
- GET /v2/customnode/fetch_updates — update check (deprecated, 410)
|
|
- GET /v2/customnode/installed — installed packages dict
|
|
- POST /v2/customnode/import_fail_info — single node failure info
|
|
- POST /v2/customnode/import_fail_info_bulk — bulk node failure info
|
|
|
|
Requires a pre-built E2E environment (from setup_e2e_env.sh).
|
|
Set E2E_ROOT env var to point at it, or the tests will be skipped.
|
|
|
|
Usage:
|
|
E2E_ROOT=/tmp/e2e_full_test pytest tests/e2e/test_e2e_customnode_info.py -v
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
|
|
import pytest
|
|
import requests
|
|
|
|
E2E_ROOT = os.environ.get("E2E_ROOT", "")
|
|
COMFYUI_PATH = os.path.join(E2E_ROOT, "comfyui") if E2E_ROOT else ""
|
|
CUSTOM_NODES = os.path.join(COMFYUI_PATH, "custom_nodes") if COMFYUI_PATH else ""
|
|
SCRIPTS_DIR = os.path.join(
|
|
os.path.dirname(os.path.abspath(__file__)), "scripts"
|
|
)
|
|
|
|
PORT = 8199
|
|
BASE_URL = f"http://127.0.0.1:{PORT}"
|
|
|
|
# Seed pack for the `installed` tests — same CNR test package used by
|
|
# test_e2e_endpoint.py / test_e2e_task_operations.py. Installed by the
|
|
# `seed_pack_on_disk` autouse fixture below (this module runs
|
|
# alphabetically BEFORE test_e2e_endpoint.py, so it cannot rely on that
|
|
# module having installed the pack — on a fresh E2E env nothing else
|
|
# seeds it).
|
|
PACK_ID = "ComfyUI_SigmoidOffsetScheduler"
|
|
PACK_DIR_NAME = "ComfyUI_SigmoidOffsetScheduler"
|
|
PACK_VERSION = "1.0.1"
|
|
|
|
pytestmark = pytest.mark.skipif(
|
|
not E2E_ROOT
|
|
or not os.path.isfile(os.path.join(E2E_ROOT, ".e2e_setup_complete")),
|
|
reason="E2E_ROOT not set or E2E environment not ready (run setup_e2e_env.sh first)",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _start_comfyui() -> int:
|
|
"""Start ComfyUI and return its PID."""
|
|
env = {**os.environ, "E2E_ROOT": E2E_ROOT, "PORT": str(PORT)}
|
|
r = subprocess.run(
|
|
["bash", os.path.join(SCRIPTS_DIR, "start_comfyui.sh")],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=180,
|
|
env=env,
|
|
)
|
|
if r.returncode != 0:
|
|
raise RuntimeError(f"Failed to start ComfyUI:\n{r.stderr}")
|
|
for part in r.stdout.strip().split():
|
|
if part.startswith("COMFYUI_PID="):
|
|
return int(part.split("=")[1])
|
|
raise RuntimeError(f"Could not parse PID from start_comfyui output:\n{r.stdout}")
|
|
|
|
|
|
def _stop_comfyui():
|
|
"""Stop ComfyUI."""
|
|
env = {**os.environ, "E2E_ROOT": E2E_ROOT, "PORT": str(PORT)}
|
|
subprocess.run(
|
|
["bash", os.path.join(SCRIPTS_DIR, "stop_comfyui.sh")],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
env=env,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.fixture(scope="module")
|
|
def comfyui():
|
|
"""Start ComfyUI once for the module, stop after all tests."""
|
|
pid = _start_comfyui()
|
|
yield pid
|
|
_stop_comfyui()
|
|
|
|
|
|
def _pack_exists() -> bool:
|
|
return os.path.isdir(os.path.join(CUSTOM_NODES, PACK_DIR_NAME))
|
|
|
|
|
|
def _cm_cli_path() -> str:
|
|
if sys.platform == "win32":
|
|
return os.path.join(E2E_ROOT, "venv", "Scripts", "cm-cli.exe")
|
|
return os.path.join(E2E_ROOT, "venv", "bin", "cm-cli")
|
|
|
|
|
|
@pytest.fixture(scope="module", autouse=True)
|
|
def seed_pack_on_disk():
|
|
"""Ensure the seed pack is on disk BEFORE the server starts.
|
|
|
|
The `installed?mode=imported` test asserts against the startup
|
|
snapshot, which is frozen when the server boots — so the pack must be
|
|
installed before the module's `comfyui` fixture launches it. Autouse +
|
|
module scope guarantees this fixture is instantiated ahead of the
|
|
non-autouse `comfyui` fixture. Installs via cm-cli (no server needed,
|
|
creates the same CNR `.tracking` layout) and removes the pack on
|
|
teardown only if this fixture installed it, leaving the environment
|
|
as found.
|
|
"""
|
|
installed_by_fixture = False
|
|
if not _pack_exists():
|
|
env = {**os.environ, "COMFYUI_PATH": COMFYUI_PATH}
|
|
r = subprocess.run(
|
|
[_cm_cli_path(), "install", f"{PACK_ID}@{PACK_VERSION}"],
|
|
capture_output=True, text=True, timeout=300, env=env,
|
|
)
|
|
assert r.returncode == 0 and _pack_exists(), (
|
|
f"seed fixture failed: cm-cli install {PACK_ID}@{PACK_VERSION} "
|
|
f"(exit {r.returncode})\nSTDOUT: {r.stdout[-500:]}\n"
|
|
f"STDERR: {r.stderr[-500:]}"
|
|
)
|
|
installed_by_fixture = True
|
|
|
|
yield PACK_ID
|
|
|
|
if installed_by_fixture:
|
|
shutil.rmtree(
|
|
os.path.join(CUSTOM_NODES, PACK_DIR_NAME), ignore_errors=True
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests — getmappings
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestCustomNodeMappings:
|
|
"""Test GET /v2/customnode/getmappings."""
|
|
|
|
def test_getmappings_returns_dict(self, comfyui):
|
|
"""GET /v2/customnode/getmappings?mode=local returns non-empty mapping with valid per-entry schema.
|
|
|
|
WI-M strengthening: previously only dict-type check. Now verifies
|
|
content-level invariants: non-empty DB (the manager ships with the
|
|
full custom-node mappings baked in), and every entry conforms to
|
|
the documented `[node_list: list, metadata: dict]` shape on a
|
|
random sample. Defeats a regression where the DB loader returns
|
|
an empty `{}` (dict type PASS, zero-utility content).
|
|
"""
|
|
resp = requests.get(
|
|
f"{BASE_URL}/v2/customnode/getmappings",
|
|
params={"mode": "local"},
|
|
timeout=30,
|
|
)
|
|
assert resp.status_code == 200, (
|
|
f"Expected 200, got {resp.status_code}: {resp.text[:200]}"
|
|
)
|
|
data = resp.json()
|
|
assert isinstance(data, dict), (
|
|
f"Expected dict response, got {type(data).__name__}"
|
|
)
|
|
# Content: at least 1 entry (E2E env ships the stock DB with thousands
|
|
# of mappings; anything < 100 suggests DB load regression).
|
|
assert len(data) >= 100, (
|
|
f"getmappings returned only {len(data)} entries — DB load regression?"
|
|
)
|
|
# Structural sample: first 5 entries must conform to [node_list, metadata].
|
|
for i, (key, entry) in enumerate(list(data.items())[:5]):
|
|
assert isinstance(entry, list) and len(entry) >= 2, (
|
|
f"Entry {i} ({key!r}) not [node_list, metadata]: {entry!r}"
|
|
)
|
|
assert isinstance(entry[0], list), (
|
|
f"Entry {i} node_list is not a list: {type(entry[0]).__name__}"
|
|
)
|
|
assert isinstance(entry[1], dict), (
|
|
f"Entry {i} metadata is not a dict: {type(entry[1]).__name__}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests — fetch_updates (deprecated)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestFetchUpdates:
|
|
"""Test GET /v2/customnode/fetch_updates (deprecated endpoint)."""
|
|
|
|
def test_fetch_updates_returns_deprecated(self, comfyui):
|
|
"""GET /v2/customnode/fetch_updates returns 410 Gone with deprecation notice."""
|
|
resp = requests.get(
|
|
f"{BASE_URL}/v2/customnode/fetch_updates",
|
|
params={"mode": "local"},
|
|
timeout=10,
|
|
)
|
|
assert resp.status_code == 410, (
|
|
f"Expected 410 (Gone) for deprecated endpoint, got {resp.status_code}"
|
|
)
|
|
data = resp.json()
|
|
assert data.get("deprecated") is True, (
|
|
"Response should include 'deprecated: true'"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests — installed
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestInstalledPacks:
|
|
"""Test GET /v2/customnode/installed."""
|
|
|
|
def test_installed_returns_dict(self, comfyui):
|
|
"""GET /v2/customnode/installed returns dict containing seeded E2E pack with valid per-entry schema.
|
|
|
|
WI-M strengthening: previously only dict-type check. The module's
|
|
`seed_pack_on_disk` autouse fixture installs
|
|
`ComfyUI_SigmoidOffsetScheduler` (the test package used across
|
|
task_operations/endpoint tests) before the server starts.
|
|
We now assert it's in the installed dict AND
|
|
that its entry has the documented InstalledPack fields
|
|
(cnr_id/ver/enabled). Defeats a regression where `installed` returns
|
|
an empty dict despite packs existing on disk.
|
|
"""
|
|
resp = requests.get(
|
|
f"{BASE_URL}/v2/customnode/installed", timeout=10
|
|
)
|
|
assert resp.status_code == 200, (
|
|
f"Expected 200, got {resp.status_code}: {resp.text[:200]}"
|
|
)
|
|
data = resp.json()
|
|
assert isinstance(data, dict), (
|
|
f"Expected dict response, got {type(data).__name__}"
|
|
)
|
|
# Content: E2E seed pack must be present.
|
|
seed_pack = "ComfyUI_SigmoidOffsetScheduler"
|
|
assert seed_pack in data, (
|
|
f"Seeded E2E pack {seed_pack!r} missing from installed dict. "
|
|
f"Keys: {list(data.keys())}"
|
|
)
|
|
# Schema: the seed pack's entry must carry the documented fields.
|
|
entry = data[seed_pack]
|
|
assert isinstance(entry, dict), (
|
|
f"{seed_pack} entry should be a dict, got {type(entry).__name__}"
|
|
)
|
|
for required_key in ("cnr_id", "ver", "enabled"):
|
|
assert required_key in entry, (
|
|
f"{seed_pack} entry missing required key {required_key!r}: {entry!r}"
|
|
)
|
|
|
|
def test_installed_imported_mode(self, comfyui):
|
|
"""GET ?mode=imported returns the frozen startup snapshot with schema.
|
|
|
|
WI-T Cluster G target 4 (research-cluster-g.md Strategy A):
|
|
(a) status 200 + dict body (contract)
|
|
(b) E2E seed pack `ComfyUI_SigmoidOffsetScheduler` is in the snapshot
|
|
(c) each entry carries the documented InstalledPack schema —
|
|
cnr_id / ver / enabled (aux_id is Optional)
|
|
(d) frozen-at-startup invariant (cheap form) — no install has run
|
|
since server start, so imported keys == default keys.
|
|
|
|
Design intent (glob/manager_server.py:1510-1520): `imported` returns
|
|
the module-level `startup_time_installed_node_packs` captured once at
|
|
import; `default` re-scans the filesystem. At test time they must
|
|
agree on keys. Divergence post-install is covered by the
|
|
[E2E-DEBT] companion below.
|
|
"""
|
|
# (a) Frozen snapshot
|
|
resp_imp = requests.get(
|
|
f"{BASE_URL}/v2/customnode/installed",
|
|
params={"mode": "imported"},
|
|
timeout=10,
|
|
)
|
|
assert resp_imp.status_code == 200, (
|
|
f"Expected 200 for imported mode, got {resp_imp.status_code}"
|
|
)
|
|
imported = resp_imp.json()
|
|
assert isinstance(imported, dict), (
|
|
f"Expected dict response, got {type(imported).__name__}"
|
|
)
|
|
|
|
# (b) E2E seed pack must appear in the startup snapshot
|
|
seed = "ComfyUI_SigmoidOffsetScheduler"
|
|
assert seed in imported, (
|
|
f"seed pack {seed!r} missing from imported snapshot; "
|
|
f"keys={list(imported)}"
|
|
)
|
|
|
|
# (c) Schema: each entry carries cnr_id / ver / enabled
|
|
entry = imported[seed]
|
|
assert isinstance(entry, dict), (
|
|
f"{seed} entry should be dict, got {type(entry).__name__}: {entry!r}"
|
|
)
|
|
for required in ("cnr_id", "ver", "enabled"):
|
|
assert required in entry, (
|
|
f"{seed} entry missing required field {required!r}: {entry!r}"
|
|
)
|
|
|
|
# (d) Frozen invariant (cheap form): no install has run since startup,
|
|
# so imported keys must equal default keys at this point.
|
|
resp_def = requests.get(
|
|
f"{BASE_URL}/v2/customnode/installed", timeout=10,
|
|
)
|
|
assert resp_def.status_code == 200
|
|
default = resp_def.json()
|
|
assert set(imported.keys()) == set(default.keys()), (
|
|
f"imported != default at startup (no install has run): "
|
|
f"only-imported={set(imported) - set(default)}, "
|
|
f"only-default={set(default) - set(imported)}"
|
|
)
|
|
|
|
# WI-OO Item 4 (bloat reviewer:ci-013 B7 stale-skip): removed
|
|
# `test_imported_mode_is_frozen_after_install` — the body was a TODO stub
|
|
# masked by a skip marker. With no install trigger between the two
|
|
# imported-mode GETs, `snap_before == snap_after` held trivially; the test
|
|
# could not prove the frozen-invariant it claimed. The E2E-DEBT for a true
|
|
# mid-session install (Strategy B) remains — when revisited, add a fresh
|
|
# test that actually exercises /v2/customnode/install or FS manipulation
|
|
# between the two snapshots. Strategy A (cheap equality at startup) is
|
|
# already covered by `test_installed_imported_mode` above.
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests — import_fail_info
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestImportFailInfo:
|
|
"""Test POST /v2/customnode/import_fail_info."""
|
|
|
|
def test_unknown_cnr_id_returns_400(self, comfyui):
|
|
"""POST with unknown cnr_id returns 400 (no failure info available)."""
|
|
resp = requests.post(
|
|
f"{BASE_URL}/v2/customnode/import_fail_info",
|
|
json={"cnr_id": "nonexistent_pack_that_does_not_exist_12345"},
|
|
timeout=10,
|
|
)
|
|
assert resp.status_code == 400, (
|
|
f"Expected 400 for unknown cnr_id, got {resp.status_code}"
|
|
)
|
|
|
|
def test_missing_fields_returns_400(self, comfyui):
|
|
"""POST without cnr_id or url returns 400."""
|
|
resp = requests.post(
|
|
f"{BASE_URL}/v2/customnode/import_fail_info",
|
|
json={"invalid_field": "value"},
|
|
timeout=10,
|
|
)
|
|
assert resp.status_code == 400, (
|
|
f"Expected 400 for missing fields, got {resp.status_code}"
|
|
)
|
|
|
|
def test_invalid_body_returns_error(self, comfyui):
|
|
"""POST with non-dict body returns 400."""
|
|
resp = requests.post(
|
|
f"{BASE_URL}/v2/customnode/import_fail_info",
|
|
json="not-a-dict",
|
|
timeout=10,
|
|
)
|
|
assert resp.status_code == 400, (
|
|
f"Expected 400 for non-dict body, got {resp.status_code}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests — import_fail_info_bulk
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestImportFailInfoBulk:
|
|
"""Test POST /v2/customnode/import_fail_info_bulk."""
|
|
|
|
def test_bulk_with_cnr_ids_returns_dict(self, comfyui):
|
|
"""POST with cnr_ids list returns 200 with results dict."""
|
|
resp = requests.post(
|
|
f"{BASE_URL}/v2/customnode/import_fail_info_bulk",
|
|
json={"cnr_ids": ["nonexistent_pack_12345"]},
|
|
timeout=30,
|
|
)
|
|
assert resp.status_code == 200, (
|
|
f"Expected 200, got {resp.status_code}: {resp.text[:200]}"
|
|
)
|
|
data = resp.json()
|
|
assert isinstance(data, dict), (
|
|
f"Expected dict response, got {type(data).__name__}"
|
|
)
|
|
# Unknown pack should have null value (no error info)
|
|
assert "nonexistent_pack_12345" in data, (
|
|
"Response should contain entry for requested cnr_id"
|
|
)
|
|
assert data["nonexistent_pack_12345"] is None, (
|
|
"Unknown pack should map to null (no import failure info)"
|
|
)
|
|
|
|
def test_bulk_empty_lists_returns_400(self, comfyui):
|
|
"""POST with empty cnr_ids and no urls returns 400."""
|
|
resp = requests.post(
|
|
f"{BASE_URL}/v2/customnode/import_fail_info_bulk",
|
|
json={"cnr_ids": [], "urls": []},
|
|
timeout=10,
|
|
)
|
|
assert resp.status_code == 400, (
|
|
f"Expected 400 for empty lists, got {resp.status_code}"
|
|
)
|
|
|
|
def test_bulk_with_urls_returns_dict(self, comfyui):
|
|
"""POST with urls list returns 200 + per-url result of None (unknown) or dict (found).
|
|
|
|
WI-M strengthening: previously only dict-type check. Now verifies
|
|
per-url result correctness: each requested URL MUST appear as a key,
|
|
and the value is either `None` (unknown URL — expected for the fake
|
|
URL we send) or a `dict` (populated fail-info). Anything else
|
|
(e.g. a bare string, a list, or missing-key) is a schema violation.
|
|
"""
|
|
fake_url = "https://github.com/nonexistent/nonexistent-node-pack"
|
|
resp = requests.post(
|
|
f"{BASE_URL}/v2/customnode/import_fail_info_bulk",
|
|
json={"urls": [fake_url]},
|
|
timeout=30,
|
|
)
|
|
assert resp.status_code == 200, (
|
|
f"Expected 200, got {resp.status_code}: {resp.text[:200]}"
|
|
)
|
|
data = resp.json()
|
|
assert isinstance(data, dict), (
|
|
f"Expected dict response, got {type(data).__name__}"
|
|
)
|
|
# Content: the URL we queried must be a key in the response.
|
|
assert fake_url in data, (
|
|
f"Requested URL missing from bulk response. Expected key {fake_url!r}, "
|
|
f"got keys: {list(data.keys())}"
|
|
)
|
|
# Per-URL value must be None (unknown, expected here) or dict (populated).
|
|
result = data[fake_url]
|
|
assert result is None or isinstance(result, dict), (
|
|
f"bulk[{fake_url!r}] must be None or dict, got {type(result).__name__}: {result!r}"
|
|
)
|