mirror of
https://github.com/Comfy-Org/ComfyUI.git
synced 2026-03-02 22:49:07 +00:00
test(isolation): isolation integration + policy tests
This commit is contained in:
122
tests/isolation/test_client_snapshot.py
Normal file
122
tests/isolation/test_client_snapshot.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""Tests for pyisolate._internal.client import-time snapshot handling."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Paths needed for subprocess
|
||||
PYISOLATE_ROOT = str(Path(__file__).parent.parent)
|
||||
COMFYUI_ROOT = os.environ.get("COMFYUI_ROOT") or str(Path.home() / "ComfyUI")
|
||||
|
||||
SCRIPT = """
|
||||
import json, sys
|
||||
import pyisolate._internal.client # noqa: F401 # triggers snapshot logic
|
||||
print(json.dumps(sys.path[:6]))
|
||||
"""
|
||||
|
||||
|
||||
def _run_client_process(env):
|
||||
# Ensure subprocess can find pyisolate and ComfyUI
|
||||
pythonpath_parts = [PYISOLATE_ROOT, COMFYUI_ROOT]
|
||||
existing = env.get("PYTHONPATH", "")
|
||||
if existing:
|
||||
pythonpath_parts.append(existing)
|
||||
env["PYTHONPATH"] = ":".join(pythonpath_parts)
|
||||
|
||||
result = subprocess.run( # noqa: S603
|
||||
[sys.executable, "-c", SCRIPT],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
check=True,
|
||||
)
|
||||
stdout = result.stdout.strip().splitlines()[-1]
|
||||
return json.loads(stdout)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def comfy_module_path(tmp_path):
|
||||
comfy_root = tmp_path / "ComfyUI"
|
||||
module_path = comfy_root / "custom_nodes" / "TestNode"
|
||||
module_path.mkdir(parents=True)
|
||||
return comfy_root, module_path
|
||||
|
||||
|
||||
def test_snapshot_applied_and_comfy_root_prepend(tmp_path, comfy_module_path):
|
||||
comfy_root, module_path = comfy_module_path
|
||||
# Must include real ComfyUI path for utils validation to pass
|
||||
host_paths = [COMFYUI_ROOT, "/host/lib1", "/host/lib2"]
|
||||
snapshot = {
|
||||
"sys_path": host_paths,
|
||||
"sys_executable": sys.executable,
|
||||
"sys_prefix": sys.prefix,
|
||||
"environment": {},
|
||||
}
|
||||
snapshot_path = tmp_path / "snapshot.json"
|
||||
snapshot_path.write_text(json.dumps(snapshot), encoding="utf-8")
|
||||
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"PYISOLATE_CHILD": "1",
|
||||
"PYISOLATE_HOST_SNAPSHOT": str(snapshot_path),
|
||||
"PYISOLATE_MODULE_PATH": str(module_path),
|
||||
}
|
||||
)
|
||||
|
||||
path_prefix = _run_client_process(env)
|
||||
|
||||
# Current client behavior preserves the runtime bootstrap path order and
|
||||
# keeps the resolved ComfyUI root available for imports.
|
||||
assert COMFYUI_ROOT in path_prefix
|
||||
# Module path should not override runtime root selection.
|
||||
assert str(comfy_root) not in path_prefix
|
||||
|
||||
|
||||
def test_missing_snapshot_file_does_not_crash(tmp_path, comfy_module_path):
|
||||
_, module_path = comfy_module_path
|
||||
missing_snapshot = tmp_path / "missing.json"
|
||||
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"PYISOLATE_CHILD": "1",
|
||||
"PYISOLATE_HOST_SNAPSHOT": str(missing_snapshot),
|
||||
"PYISOLATE_MODULE_PATH": str(module_path),
|
||||
}
|
||||
)
|
||||
|
||||
# Should not raise even though snapshot path is missing
|
||||
paths = _run_client_process(env)
|
||||
assert len(paths) > 0
|
||||
|
||||
|
||||
def test_no_comfy_root_when_module_path_absent(tmp_path):
|
||||
# Must include real ComfyUI path for utils validation to pass
|
||||
host_paths = [COMFYUI_ROOT, "/alpha", "/beta"]
|
||||
snapshot = {
|
||||
"sys_path": host_paths,
|
||||
"sys_executable": sys.executable,
|
||||
"sys_prefix": sys.prefix,
|
||||
"environment": {},
|
||||
}
|
||||
snapshot_path = tmp_path / "snapshot.json"
|
||||
snapshot_path.write_text(json.dumps(snapshot), encoding="utf-8")
|
||||
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"PYISOLATE_CHILD": "1",
|
||||
"PYISOLATE_HOST_SNAPSHOT": str(snapshot_path),
|
||||
}
|
||||
)
|
||||
|
||||
paths = _run_client_process(env)
|
||||
# Runtime path bootstrap keeps ComfyUI importability regardless of host
|
||||
# snapshot extras.
|
||||
assert COMFYUI_ROOT in paths
|
||||
assert "/alpha" not in paths and "/beta" not in paths
|
||||
111
tests/isolation/test_folder_paths_proxy.py
Normal file
111
tests/isolation/test_folder_paths_proxy.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""Unit tests for FolderPathsProxy."""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
from comfy.isolation.proxies.folder_paths_proxy import FolderPathsProxy
|
||||
|
||||
|
||||
class TestFolderPathsProxy:
|
||||
"""Test FolderPathsProxy methods."""
|
||||
|
||||
@pytest.fixture
|
||||
def proxy(self):
|
||||
"""Create a FolderPathsProxy instance for testing."""
|
||||
return FolderPathsProxy()
|
||||
|
||||
def test_get_temp_directory_returns_string(self, proxy):
|
||||
"""Verify get_temp_directory returns a non-empty string."""
|
||||
result = proxy.get_temp_directory()
|
||||
assert isinstance(result, str), f"Expected str, got {type(result)}"
|
||||
assert len(result) > 0, "Temp directory path is empty"
|
||||
|
||||
def test_get_temp_directory_returns_absolute_path(self, proxy):
|
||||
"""Verify get_temp_directory returns an absolute path."""
|
||||
result = proxy.get_temp_directory()
|
||||
path = Path(result)
|
||||
assert path.is_absolute(), f"Path is not absolute: {result}"
|
||||
|
||||
def test_get_input_directory_returns_string(self, proxy):
|
||||
"""Verify get_input_directory returns a non-empty string."""
|
||||
result = proxy.get_input_directory()
|
||||
assert isinstance(result, str), f"Expected str, got {type(result)}"
|
||||
assert len(result) > 0, "Input directory path is empty"
|
||||
|
||||
def test_get_input_directory_returns_absolute_path(self, proxy):
|
||||
"""Verify get_input_directory returns an absolute path."""
|
||||
result = proxy.get_input_directory()
|
||||
path = Path(result)
|
||||
assert path.is_absolute(), f"Path is not absolute: {result}"
|
||||
|
||||
def test_get_annotated_filepath_plain_name(self, proxy):
|
||||
"""Verify get_annotated_filepath works with plain filename."""
|
||||
result = proxy.get_annotated_filepath("test.png")
|
||||
assert isinstance(result, str), f"Expected str, got {type(result)}"
|
||||
assert "test.png" in result, f"Filename not in result: {result}"
|
||||
|
||||
def test_get_annotated_filepath_with_output_annotation(self, proxy):
|
||||
"""Verify get_annotated_filepath handles [output] annotation."""
|
||||
result = proxy.get_annotated_filepath("test.png[output]")
|
||||
assert isinstance(result, str), f"Expected str, got {type(result)}"
|
||||
assert "test.pn" in result, f"Filename base not in result: {result}"
|
||||
# Should resolve to output directory
|
||||
assert "output" in result.lower() or Path(result).parent.name == "output"
|
||||
|
||||
def test_get_annotated_filepath_with_input_annotation(self, proxy):
|
||||
"""Verify get_annotated_filepath handles [input] annotation."""
|
||||
result = proxy.get_annotated_filepath("test.png[input]")
|
||||
assert isinstance(result, str), f"Expected str, got {type(result)}"
|
||||
assert "test.pn" in result, f"Filename base not in result: {result}"
|
||||
|
||||
def test_get_annotated_filepath_with_temp_annotation(self, proxy):
|
||||
"""Verify get_annotated_filepath handles [temp] annotation."""
|
||||
result = proxy.get_annotated_filepath("test.png[temp]")
|
||||
assert isinstance(result, str), f"Expected str, got {type(result)}"
|
||||
assert "test.pn" in result, f"Filename base not in result: {result}"
|
||||
|
||||
def test_exists_annotated_filepath_returns_bool(self, proxy):
|
||||
"""Verify exists_annotated_filepath returns a boolean."""
|
||||
result = proxy.exists_annotated_filepath("nonexistent.png")
|
||||
assert isinstance(result, bool), f"Expected bool, got {type(result)}"
|
||||
|
||||
def test_exists_annotated_filepath_nonexistent_file(self, proxy):
|
||||
"""Verify exists_annotated_filepath returns False for nonexistent file."""
|
||||
result = proxy.exists_annotated_filepath("definitely_does_not_exist_12345.png")
|
||||
assert result is False, "Expected False for nonexistent file"
|
||||
|
||||
def test_exists_annotated_filepath_with_annotation(self, proxy):
|
||||
"""Verify exists_annotated_filepath works with annotation suffix."""
|
||||
# Even for nonexistent files, should return bool without error
|
||||
result = proxy.exists_annotated_filepath("test.png[output]")
|
||||
assert isinstance(result, bool), f"Expected bool, got {type(result)}"
|
||||
|
||||
def test_models_dir_property_returns_string(self, proxy):
|
||||
"""Verify models_dir property returns valid path string."""
|
||||
result = proxy.models_dir
|
||||
assert isinstance(result, str), f"Expected str, got {type(result)}"
|
||||
assert len(result) > 0, "Models directory path is empty"
|
||||
|
||||
def test_models_dir_is_absolute_path(self, proxy):
|
||||
"""Verify models_dir returns an absolute path."""
|
||||
result = proxy.models_dir
|
||||
path = Path(result)
|
||||
assert path.is_absolute(), f"Path is not absolute: {result}"
|
||||
|
||||
def test_add_model_folder_path_runs_without_error(self, proxy):
|
||||
"""Verify add_model_folder_path executes without raising."""
|
||||
test_path = "/tmp/test_models_florence2"
|
||||
# Should not raise
|
||||
proxy.add_model_folder_path("TEST_FLORENCE2", test_path)
|
||||
|
||||
def test_get_folder_paths_returns_list(self, proxy):
|
||||
"""Verify get_folder_paths returns a list."""
|
||||
# Use known folder type that should exist
|
||||
result = proxy.get_folder_paths("checkpoints")
|
||||
assert isinstance(result, list), f"Expected list, got {type(result)}"
|
||||
|
||||
def test_get_folder_paths_checkpoints_not_empty(self, proxy):
|
||||
"""Verify checkpoints folder paths list is not empty."""
|
||||
result = proxy.get_folder_paths("checkpoints")
|
||||
# Should have at least one checkpoint path registered
|
||||
assert len(result) > 0, "Checkpoints folder paths is empty"
|
||||
72
tests/isolation/test_host_policy.py
Normal file
72
tests/isolation/test_host_policy.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _write_pyproject(path: Path, content: str) -> None:
|
||||
path.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
def test_load_host_policy_defaults_when_pyproject_missing(tmp_path):
|
||||
from comfy.isolation.host_policy import DEFAULT_POLICY, load_host_policy
|
||||
|
||||
policy = load_host_policy(tmp_path)
|
||||
|
||||
assert policy["allow_network"] == DEFAULT_POLICY["allow_network"]
|
||||
assert policy["writable_paths"] == DEFAULT_POLICY["writable_paths"]
|
||||
assert policy["readonly_paths"] == DEFAULT_POLICY["readonly_paths"]
|
||||
assert policy["whitelist"] == DEFAULT_POLICY["whitelist"]
|
||||
|
||||
|
||||
def test_load_host_policy_defaults_when_section_missing(tmp_path):
|
||||
from comfy.isolation.host_policy import DEFAULT_POLICY, load_host_policy
|
||||
|
||||
_write_pyproject(
|
||||
tmp_path / "pyproject.toml",
|
||||
"""
|
||||
[project]
|
||||
name = "ComfyUI"
|
||||
""".strip(),
|
||||
)
|
||||
|
||||
policy = load_host_policy(tmp_path)
|
||||
assert policy["allow_network"] == DEFAULT_POLICY["allow_network"]
|
||||
assert policy["whitelist"] == {}
|
||||
|
||||
|
||||
def test_load_host_policy_reads_values(tmp_path):
|
||||
from comfy.isolation.host_policy import load_host_policy
|
||||
|
||||
_write_pyproject(
|
||||
tmp_path / "pyproject.toml",
|
||||
"""
|
||||
[tool.comfy.host]
|
||||
allow_network = true
|
||||
writable_paths = ["/tmp/a", "/tmp/b"]
|
||||
readonly_paths = ["/opt/readonly"]
|
||||
|
||||
[tool.comfy.host.whitelist]
|
||||
ExampleNode = "*"
|
||||
""".strip(),
|
||||
)
|
||||
|
||||
policy = load_host_policy(tmp_path)
|
||||
assert policy["allow_network"] is True
|
||||
assert policy["writable_paths"] == ["/tmp/a", "/tmp/b"]
|
||||
assert policy["readonly_paths"] == ["/opt/readonly"]
|
||||
assert policy["whitelist"] == {"ExampleNode": "*"}
|
||||
|
||||
|
||||
def test_load_host_policy_ignores_invalid_whitelist_type(tmp_path):
|
||||
from comfy.isolation.host_policy import DEFAULT_POLICY, load_host_policy
|
||||
|
||||
_write_pyproject(
|
||||
tmp_path / "pyproject.toml",
|
||||
"""
|
||||
[tool.comfy.host]
|
||||
allow_network = true
|
||||
whitelist = ["bad"]
|
||||
""".strip(),
|
||||
)
|
||||
|
||||
policy = load_host_policy(tmp_path)
|
||||
assert policy["allow_network"] is True
|
||||
assert policy["whitelist"] == DEFAULT_POLICY["whitelist"]
|
||||
56
tests/isolation/test_init.py
Normal file
56
tests/isolation/test_init.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Unit tests for PyIsolate isolation system initialization."""
|
||||
|
||||
|
||||
|
||||
def test_log_prefix():
|
||||
"""Verify LOG_PREFIX constant is correctly defined."""
|
||||
from comfy.isolation import LOG_PREFIX
|
||||
assert LOG_PREFIX == "]["
|
||||
assert isinstance(LOG_PREFIX, str)
|
||||
|
||||
|
||||
def test_module_initialization():
|
||||
"""Verify module initializes without errors."""
|
||||
import comfy.isolation
|
||||
assert hasattr(comfy.isolation, 'LOG_PREFIX')
|
||||
assert hasattr(comfy.isolation, 'initialize_proxies')
|
||||
|
||||
|
||||
class TestInitializeProxies:
|
||||
def test_initialize_proxies_runs_without_error(self):
|
||||
from comfy.isolation import initialize_proxies
|
||||
initialize_proxies()
|
||||
|
||||
def test_initialize_proxies_registers_folder_paths_proxy(self):
|
||||
from comfy.isolation import initialize_proxies
|
||||
from comfy.isolation.proxies.folder_paths_proxy import FolderPathsProxy
|
||||
initialize_proxies()
|
||||
proxy = FolderPathsProxy()
|
||||
assert proxy is not None
|
||||
assert hasattr(proxy, "get_temp_directory")
|
||||
|
||||
def test_initialize_proxies_registers_model_management_proxy(self):
|
||||
from comfy.isolation import initialize_proxies
|
||||
from comfy.isolation.proxies.model_management_proxy import ModelManagementProxy
|
||||
initialize_proxies()
|
||||
proxy = ModelManagementProxy()
|
||||
assert proxy is not None
|
||||
assert hasattr(proxy, "get_torch_device")
|
||||
|
||||
def test_initialize_proxies_can_be_called_multiple_times(self):
|
||||
from comfy.isolation import initialize_proxies
|
||||
initialize_proxies()
|
||||
initialize_proxies()
|
||||
initialize_proxies()
|
||||
|
||||
def test_dev_proxies_accessible_when_dev_mode(self, monkeypatch):
|
||||
"""Verify dev mode does not break core proxy initialization."""
|
||||
monkeypatch.setenv("PYISOLATE_DEV", "1")
|
||||
from comfy.isolation import initialize_proxies
|
||||
from comfy.isolation.proxies.folder_paths_proxy import FolderPathsProxy
|
||||
from comfy.isolation.proxies.utils_proxy import UtilsProxy
|
||||
initialize_proxies()
|
||||
folder_proxy = FolderPathsProxy()
|
||||
utils_proxy = UtilsProxy()
|
||||
assert folder_proxy is not None
|
||||
assert utils_proxy is not None
|
||||
434
tests/isolation/test_manifest_loader_cache.py
Normal file
434
tests/isolation/test_manifest_loader_cache.py
Normal file
@@ -0,0 +1,434 @@
|
||||
"""
|
||||
Unit tests for manifest_loader.py cache functions.
|
||||
|
||||
Phase 1 tests verify:
|
||||
1. Cache miss on first run (no cache exists)
|
||||
2. Cache hit when nothing changes
|
||||
3. Invalidation on .py file touch
|
||||
4. Invalidation on manifest change
|
||||
5. Cache location correctness (in venv_root, NOT in custom_nodes)
|
||||
6. Corrupt cache handling (graceful failure)
|
||||
|
||||
These tests verify the cache implementation is correct BEFORE it's activated
|
||||
in extension_loader.py (Phase 2).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
|
||||
class TestComputeCacheKey:
|
||||
"""Tests for compute_cache_key() function."""
|
||||
|
||||
def test_key_includes_manifest_content(self, tmp_path: Path) -> None:
|
||||
"""Cache key changes when manifest content changes."""
|
||||
from comfy.isolation.manifest_loader import compute_cache_key
|
||||
|
||||
node_dir = tmp_path / "test_node"
|
||||
node_dir.mkdir()
|
||||
manifest = node_dir / "pyisolate.yaml"
|
||||
|
||||
# Initial manifest
|
||||
manifest.write_text("isolated: true\ndependencies: []\n")
|
||||
key1 = compute_cache_key(node_dir, manifest)
|
||||
|
||||
# Modified manifest
|
||||
manifest.write_text("isolated: true\ndependencies: [numpy]\n")
|
||||
key2 = compute_cache_key(node_dir, manifest)
|
||||
|
||||
assert key1 != key2, "Key should change when manifest content changes"
|
||||
|
||||
def test_key_includes_py_file_mtime(self, tmp_path: Path) -> None:
|
||||
"""Cache key changes when any .py file is touched."""
|
||||
from comfy.isolation.manifest_loader import compute_cache_key
|
||||
|
||||
node_dir = tmp_path / "test_node"
|
||||
node_dir.mkdir()
|
||||
manifest = node_dir / "pyisolate.yaml"
|
||||
manifest.write_text("isolated: true\n")
|
||||
|
||||
py_file = node_dir / "nodes.py"
|
||||
py_file.write_text("# test code")
|
||||
|
||||
key1 = compute_cache_key(node_dir, manifest)
|
||||
|
||||
# Wait a moment to ensure mtime changes
|
||||
time.sleep(0.01)
|
||||
py_file.write_text("# modified code")
|
||||
|
||||
key2 = compute_cache_key(node_dir, manifest)
|
||||
|
||||
assert key1 != key2, "Key should change when .py file mtime changes"
|
||||
|
||||
def test_key_includes_python_version(self, tmp_path: Path) -> None:
|
||||
"""Cache key changes when Python version changes."""
|
||||
from comfy.isolation.manifest_loader import compute_cache_key
|
||||
|
||||
node_dir = tmp_path / "test_node"
|
||||
node_dir.mkdir()
|
||||
manifest = node_dir / "pyisolate.yaml"
|
||||
manifest.write_text("isolated: true\n")
|
||||
|
||||
key1 = compute_cache_key(node_dir, manifest)
|
||||
|
||||
# Mock different Python version
|
||||
with mock.patch.object(sys, "version", "3.99.0 (fake)"):
|
||||
key2 = compute_cache_key(node_dir, manifest)
|
||||
|
||||
assert key1 != key2, "Key should change when Python version changes"
|
||||
|
||||
def test_key_includes_pyisolate_version(self, tmp_path: Path) -> None:
|
||||
"""Cache key changes when PyIsolate version changes."""
|
||||
from comfy.isolation.manifest_loader import compute_cache_key
|
||||
|
||||
node_dir = tmp_path / "test_node"
|
||||
node_dir.mkdir()
|
||||
manifest = node_dir / "pyisolate.yaml"
|
||||
manifest.write_text("isolated: true\n")
|
||||
|
||||
key1 = compute_cache_key(node_dir, manifest)
|
||||
|
||||
# Mock different pyisolate version
|
||||
with mock.patch.dict(sys.modules, {"pyisolate": mock.MagicMock(__version__="99.99.99")}):
|
||||
# Need to reimport to pick up the mock
|
||||
import importlib
|
||||
from comfy.isolation import manifest_loader
|
||||
importlib.reload(manifest_loader)
|
||||
key2 = manifest_loader.compute_cache_key(node_dir, manifest)
|
||||
|
||||
# Keys should be different (though the mock approach is tricky)
|
||||
# At minimum, verify key is a valid hex string
|
||||
assert len(key1) == 16, "Key should be 16 hex characters"
|
||||
assert all(c in "0123456789abcdef" for c in key1), "Key should be hex"
|
||||
assert len(key2) == 16, "Key should be 16 hex characters"
|
||||
assert all(c in "0123456789abcdef" for c in key2), "Key should be hex"
|
||||
|
||||
def test_key_excludes_pycache(self, tmp_path: Path) -> None:
|
||||
"""Cache key ignores __pycache__ directory changes."""
|
||||
from comfy.isolation.manifest_loader import compute_cache_key
|
||||
|
||||
node_dir = tmp_path / "test_node"
|
||||
node_dir.mkdir()
|
||||
manifest = node_dir / "pyisolate.yaml"
|
||||
manifest.write_text("isolated: true\n")
|
||||
|
||||
py_file = node_dir / "nodes.py"
|
||||
py_file.write_text("# test code")
|
||||
|
||||
key1 = compute_cache_key(node_dir, manifest)
|
||||
|
||||
# Add __pycache__ file
|
||||
pycache = node_dir / "__pycache__"
|
||||
pycache.mkdir()
|
||||
(pycache / "nodes.cpython-310.pyc").write_bytes(b"compiled")
|
||||
|
||||
key2 = compute_cache_key(node_dir, manifest)
|
||||
|
||||
assert key1 == key2, "Key should NOT change when __pycache__ modified"
|
||||
|
||||
def test_key_is_deterministic(self, tmp_path: Path) -> None:
|
||||
"""Same inputs produce same key."""
|
||||
from comfy.isolation.manifest_loader import compute_cache_key
|
||||
|
||||
node_dir = tmp_path / "test_node"
|
||||
node_dir.mkdir()
|
||||
manifest = node_dir / "pyisolate.yaml"
|
||||
manifest.write_text("isolated: true\n")
|
||||
(node_dir / "nodes.py").write_text("# code")
|
||||
|
||||
key1 = compute_cache_key(node_dir, manifest)
|
||||
key2 = compute_cache_key(node_dir, manifest)
|
||||
|
||||
assert key1 == key2, "Key should be deterministic"
|
||||
|
||||
|
||||
class TestGetCachePath:
|
||||
"""Tests for get_cache_path() function."""
|
||||
|
||||
def test_returns_correct_paths(self, tmp_path: Path) -> None:
|
||||
"""Cache paths are in venv_root, not in node_dir."""
|
||||
from comfy.isolation.manifest_loader import get_cache_path
|
||||
|
||||
node_dir = tmp_path / "custom_nodes" / "MyNode"
|
||||
venv_root = tmp_path / ".pyisolate_venvs"
|
||||
|
||||
key_file, data_file = get_cache_path(node_dir, venv_root)
|
||||
|
||||
assert key_file == venv_root / "MyNode" / "cache" / "cache_key"
|
||||
assert data_file == venv_root / "MyNode" / "cache" / "node_info.json"
|
||||
|
||||
def test_cache_not_in_custom_nodes(self, tmp_path: Path) -> None:
|
||||
"""Verify cache is NOT stored in custom_nodes directory."""
|
||||
from comfy.isolation.manifest_loader import get_cache_path
|
||||
|
||||
node_dir = tmp_path / "custom_nodes" / "MyNode"
|
||||
venv_root = tmp_path / ".pyisolate_venvs"
|
||||
|
||||
key_file, data_file = get_cache_path(node_dir, venv_root)
|
||||
|
||||
# Neither path should be under node_dir
|
||||
assert not str(key_file).startswith(str(node_dir))
|
||||
assert not str(data_file).startswith(str(node_dir))
|
||||
|
||||
|
||||
class TestIsCacheValid:
|
||||
"""Tests for is_cache_valid() function."""
|
||||
|
||||
def test_false_when_no_cache_exists(self, tmp_path: Path) -> None:
|
||||
"""Returns False when cache files don't exist."""
|
||||
from comfy.isolation.manifest_loader import is_cache_valid
|
||||
|
||||
node_dir = tmp_path / "test_node"
|
||||
node_dir.mkdir()
|
||||
manifest = node_dir / "pyisolate.yaml"
|
||||
manifest.write_text("isolated: true\n")
|
||||
venv_root = tmp_path / ".pyisolate_venvs"
|
||||
|
||||
assert is_cache_valid(node_dir, manifest, venv_root) is False
|
||||
|
||||
def test_true_when_cache_matches(self, tmp_path: Path) -> None:
|
||||
"""Returns True when cache key matches current state."""
|
||||
from comfy.isolation.manifest_loader import (
|
||||
compute_cache_key,
|
||||
get_cache_path,
|
||||
is_cache_valid,
|
||||
)
|
||||
|
||||
node_dir = tmp_path / "test_node"
|
||||
node_dir.mkdir()
|
||||
manifest = node_dir / "pyisolate.yaml"
|
||||
manifest.write_text("isolated: true\n")
|
||||
(node_dir / "nodes.py").write_text("# code")
|
||||
venv_root = tmp_path / ".pyisolate_venvs"
|
||||
|
||||
# Create valid cache
|
||||
cache_key = compute_cache_key(node_dir, manifest)
|
||||
key_file, data_file = get_cache_path(node_dir, venv_root)
|
||||
key_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
key_file.write_text(cache_key)
|
||||
data_file.write_text("{}")
|
||||
|
||||
assert is_cache_valid(node_dir, manifest, venv_root) is True
|
||||
|
||||
def test_false_when_key_mismatch(self, tmp_path: Path) -> None:
|
||||
"""Returns False when stored key doesn't match current state."""
|
||||
from comfy.isolation.manifest_loader import get_cache_path, is_cache_valid
|
||||
|
||||
node_dir = tmp_path / "test_node"
|
||||
node_dir.mkdir()
|
||||
manifest = node_dir / "pyisolate.yaml"
|
||||
manifest.write_text("isolated: true\n")
|
||||
venv_root = tmp_path / ".pyisolate_venvs"
|
||||
|
||||
# Create cache with wrong key
|
||||
key_file, data_file = get_cache_path(node_dir, venv_root)
|
||||
key_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
key_file.write_text("wrong_key_12345")
|
||||
data_file.write_text("{}")
|
||||
|
||||
assert is_cache_valid(node_dir, manifest, venv_root) is False
|
||||
|
||||
def test_false_when_data_file_missing(self, tmp_path: Path) -> None:
|
||||
"""Returns False when node_info.json is missing."""
|
||||
from comfy.isolation.manifest_loader import (
|
||||
compute_cache_key,
|
||||
get_cache_path,
|
||||
is_cache_valid,
|
||||
)
|
||||
|
||||
node_dir = tmp_path / "test_node"
|
||||
node_dir.mkdir()
|
||||
manifest = node_dir / "pyisolate.yaml"
|
||||
manifest.write_text("isolated: true\n")
|
||||
venv_root = tmp_path / ".pyisolate_venvs"
|
||||
|
||||
# Create only key file, not data file
|
||||
cache_key = compute_cache_key(node_dir, manifest)
|
||||
key_file, _ = get_cache_path(node_dir, venv_root)
|
||||
key_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
key_file.write_text(cache_key)
|
||||
|
||||
assert is_cache_valid(node_dir, manifest, venv_root) is False
|
||||
|
||||
def test_invalidation_on_py_change(self, tmp_path: Path) -> None:
|
||||
"""Cache invalidates when .py file is modified."""
|
||||
from comfy.isolation.manifest_loader import (
|
||||
compute_cache_key,
|
||||
get_cache_path,
|
||||
is_cache_valid,
|
||||
)
|
||||
|
||||
node_dir = tmp_path / "test_node"
|
||||
node_dir.mkdir()
|
||||
manifest = node_dir / "pyisolate.yaml"
|
||||
manifest.write_text("isolated: true\n")
|
||||
py_file = node_dir / "nodes.py"
|
||||
py_file.write_text("# original")
|
||||
venv_root = tmp_path / ".pyisolate_venvs"
|
||||
|
||||
# Create valid cache
|
||||
cache_key = compute_cache_key(node_dir, manifest)
|
||||
key_file, data_file = get_cache_path(node_dir, venv_root)
|
||||
key_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
key_file.write_text(cache_key)
|
||||
data_file.write_text("{}")
|
||||
|
||||
# Verify cache is valid initially
|
||||
assert is_cache_valid(node_dir, manifest, venv_root) is True
|
||||
|
||||
# Modify .py file
|
||||
time.sleep(0.01) # Ensure mtime changes
|
||||
py_file.write_text("# modified")
|
||||
|
||||
# Cache should now be invalid
|
||||
assert is_cache_valid(node_dir, manifest, venv_root) is False
|
||||
|
||||
|
||||
class TestLoadFromCache:
|
||||
"""Tests for load_from_cache() function."""
|
||||
|
||||
def test_returns_none_when_no_cache(self, tmp_path: Path) -> None:
|
||||
"""Returns None when cache doesn't exist."""
|
||||
from comfy.isolation.manifest_loader import load_from_cache
|
||||
|
||||
node_dir = tmp_path / "test_node"
|
||||
venv_root = tmp_path / ".pyisolate_venvs"
|
||||
|
||||
assert load_from_cache(node_dir, venv_root) is None
|
||||
|
||||
def test_returns_data_when_valid(self, tmp_path: Path) -> None:
|
||||
"""Returns cached data when file exists and is valid JSON."""
|
||||
from comfy.isolation.manifest_loader import get_cache_path, load_from_cache
|
||||
|
||||
node_dir = tmp_path / "test_node"
|
||||
venv_root = tmp_path / ".pyisolate_venvs"
|
||||
|
||||
test_data = {"TestNode": {"inputs": [], "outputs": []}}
|
||||
|
||||
_, data_file = get_cache_path(node_dir, venv_root)
|
||||
data_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
data_file.write_text(json.dumps(test_data))
|
||||
|
||||
result = load_from_cache(node_dir, venv_root)
|
||||
assert result == test_data
|
||||
|
||||
def test_returns_none_on_corrupt_json(self, tmp_path: Path) -> None:
|
||||
"""Returns None when JSON is corrupt."""
|
||||
from comfy.isolation.manifest_loader import get_cache_path, load_from_cache
|
||||
|
||||
node_dir = tmp_path / "test_node"
|
||||
venv_root = tmp_path / ".pyisolate_venvs"
|
||||
|
||||
_, data_file = get_cache_path(node_dir, venv_root)
|
||||
data_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
data_file.write_text("{ corrupt json }")
|
||||
|
||||
assert load_from_cache(node_dir, venv_root) is None
|
||||
|
||||
def test_returns_none_on_invalid_structure(self, tmp_path: Path) -> None:
|
||||
"""Returns None when data is not a dict."""
|
||||
from comfy.isolation.manifest_loader import get_cache_path, load_from_cache
|
||||
|
||||
node_dir = tmp_path / "test_node"
|
||||
venv_root = tmp_path / ".pyisolate_venvs"
|
||||
|
||||
_, data_file = get_cache_path(node_dir, venv_root)
|
||||
data_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
data_file.write_text("[1, 2, 3]") # Array, not dict
|
||||
|
||||
assert load_from_cache(node_dir, venv_root) is None
|
||||
|
||||
|
||||
class TestSaveToCache:
|
||||
"""Tests for save_to_cache() function."""
|
||||
|
||||
def test_creates_cache_directory(self, tmp_path: Path) -> None:
|
||||
"""Creates cache directory if it doesn't exist."""
|
||||
from comfy.isolation.manifest_loader import get_cache_path, save_to_cache
|
||||
|
||||
node_dir = tmp_path / "test_node"
|
||||
node_dir.mkdir()
|
||||
manifest = node_dir / "pyisolate.yaml"
|
||||
manifest.write_text("isolated: true\n")
|
||||
venv_root = tmp_path / ".pyisolate_venvs"
|
||||
|
||||
save_to_cache(node_dir, venv_root, {"TestNode": {}}, manifest)
|
||||
|
||||
key_file, data_file = get_cache_path(node_dir, venv_root)
|
||||
assert key_file.parent.exists()
|
||||
|
||||
def test_writes_both_files(self, tmp_path: Path) -> None:
|
||||
"""Writes both cache_key and node_info.json."""
|
||||
from comfy.isolation.manifest_loader import get_cache_path, save_to_cache
|
||||
|
||||
node_dir = tmp_path / "test_node"
|
||||
node_dir.mkdir()
|
||||
manifest = node_dir / "pyisolate.yaml"
|
||||
manifest.write_text("isolated: true\n")
|
||||
venv_root = tmp_path / ".pyisolate_venvs"
|
||||
|
||||
save_to_cache(node_dir, venv_root, {"TestNode": {"key": "value"}}, manifest)
|
||||
|
||||
key_file, data_file = get_cache_path(node_dir, venv_root)
|
||||
assert key_file.exists()
|
||||
assert data_file.exists()
|
||||
|
||||
def test_data_is_valid_json(self, tmp_path: Path) -> None:
|
||||
"""Written data can be parsed as JSON."""
|
||||
from comfy.isolation.manifest_loader import get_cache_path, save_to_cache
|
||||
|
||||
node_dir = tmp_path / "test_node"
|
||||
node_dir.mkdir()
|
||||
manifest = node_dir / "pyisolate.yaml"
|
||||
manifest.write_text("isolated: true\n")
|
||||
venv_root = tmp_path / ".pyisolate_venvs"
|
||||
|
||||
test_data = {"TestNode": {"inputs": ["IMAGE"], "outputs": ["IMAGE"]}}
|
||||
save_to_cache(node_dir, venv_root, test_data, manifest)
|
||||
|
||||
_, data_file = get_cache_path(node_dir, venv_root)
|
||||
loaded = json.loads(data_file.read_text())
|
||||
assert loaded == test_data
|
||||
|
||||
def test_roundtrip_with_validation(self, tmp_path: Path) -> None:
|
||||
"""Saved cache is immediately valid."""
|
||||
from comfy.isolation.manifest_loader import (
|
||||
is_cache_valid,
|
||||
load_from_cache,
|
||||
save_to_cache,
|
||||
)
|
||||
|
||||
node_dir = tmp_path / "test_node"
|
||||
node_dir.mkdir()
|
||||
manifest = node_dir / "pyisolate.yaml"
|
||||
manifest.write_text("isolated: true\n")
|
||||
(node_dir / "nodes.py").write_text("# code")
|
||||
venv_root = tmp_path / ".pyisolate_venvs"
|
||||
|
||||
test_data = {"TestNode": {"foo": "bar"}}
|
||||
save_to_cache(node_dir, venv_root, test_data, manifest)
|
||||
|
||||
assert is_cache_valid(node_dir, manifest, venv_root) is True
|
||||
assert load_from_cache(node_dir, venv_root) == test_data
|
||||
|
||||
def test_cache_not_in_custom_nodes(self, tmp_path: Path) -> None:
|
||||
"""Verify no files written to custom_nodes directory."""
|
||||
from comfy.isolation.manifest_loader import save_to_cache
|
||||
|
||||
node_dir = tmp_path / "custom_nodes" / "MyNode"
|
||||
node_dir.mkdir(parents=True)
|
||||
manifest = node_dir / "pyisolate.yaml"
|
||||
manifest.write_text("isolated: true\n")
|
||||
venv_root = tmp_path / ".pyisolate_venvs"
|
||||
|
||||
save_to_cache(node_dir, venv_root, {"TestNode": {}}, manifest)
|
||||
|
||||
# Check nothing was created under node_dir
|
||||
for item in node_dir.iterdir():
|
||||
assert item.name == "pyisolate.yaml", f"Unexpected file in node_dir: {item}"
|
||||
50
tests/isolation/test_model_management_proxy.py
Normal file
50
tests/isolation/test_model_management_proxy.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Unit tests for ModelManagementProxy."""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from comfy.isolation.proxies.model_management_proxy import ModelManagementProxy
|
||||
|
||||
|
||||
class TestModelManagementProxy:
|
||||
"""Test ModelManagementProxy methods."""
|
||||
|
||||
@pytest.fixture
|
||||
def proxy(self):
|
||||
"""Create a ModelManagementProxy instance for testing."""
|
||||
return ModelManagementProxy()
|
||||
|
||||
def test_get_torch_device_returns_device(self, proxy):
|
||||
"""Verify get_torch_device returns a torch.device object."""
|
||||
result = proxy.get_torch_device()
|
||||
assert isinstance(result, torch.device), f"Expected torch.device, got {type(result)}"
|
||||
|
||||
def test_get_torch_device_is_valid(self, proxy):
|
||||
"""Verify get_torch_device returns a valid device (cpu or cuda)."""
|
||||
result = proxy.get_torch_device()
|
||||
assert result.type in ("cpu", "cuda"), f"Unexpected device type: {result.type}"
|
||||
|
||||
def test_get_torch_device_name_returns_string(self, proxy):
|
||||
"""Verify get_torch_device_name returns a non-empty string."""
|
||||
device = proxy.get_torch_device()
|
||||
result = proxy.get_torch_device_name(device)
|
||||
assert isinstance(result, str), f"Expected str, got {type(result)}"
|
||||
assert len(result) > 0, "Device name is empty"
|
||||
|
||||
def test_get_torch_device_name_with_cpu(self, proxy):
|
||||
"""Verify get_torch_device_name works with CPU device."""
|
||||
cpu_device = torch.device("cpu")
|
||||
result = proxy.get_torch_device_name(cpu_device)
|
||||
assert isinstance(result, str), f"Expected str, got {type(result)}"
|
||||
assert "cpu" in result.lower(), f"Expected 'cpu' in device name, got: {result}"
|
||||
|
||||
def test_get_torch_device_name_with_cuda_if_available(self, proxy):
|
||||
"""Verify get_torch_device_name works with CUDA device if available."""
|
||||
if not torch.cuda.is_available():
|
||||
pytest.skip("CUDA not available")
|
||||
|
||||
cuda_device = torch.device("cuda:0")
|
||||
result = proxy.get_torch_device_name(cuda_device)
|
||||
assert isinstance(result, str), f"Expected str, got {type(result)}"
|
||||
# Should contain device identifier
|
||||
assert len(result) > 0, "CUDA device name is empty"
|
||||
93
tests/isolation/test_path_helpers.py
Normal file
93
tests/isolation/test_path_helpers.py
Normal file
@@ -0,0 +1,93 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from pyisolate.path_helpers import build_child_sys_path, serialize_host_snapshot
|
||||
|
||||
|
||||
def test_serialize_host_snapshot_includes_expected_keys(tmp_path: Path, monkeypatch) -> None:
|
||||
output = tmp_path / "snapshot.json"
|
||||
monkeypatch.setenv("EXTRA_FLAG", "1")
|
||||
snapshot = serialize_host_snapshot(output_path=output, extra_env_keys=["EXTRA_FLAG"])
|
||||
|
||||
assert "sys_path" in snapshot
|
||||
assert "sys_executable" in snapshot
|
||||
assert "sys_prefix" in snapshot
|
||||
assert "environment" in snapshot
|
||||
assert output.exists()
|
||||
assert snapshot["environment"].get("EXTRA_FLAG") == "1"
|
||||
|
||||
persisted = json.loads(output.read_text(encoding="utf-8"))
|
||||
assert persisted["sys_path"] == snapshot["sys_path"]
|
||||
|
||||
|
||||
def test_build_child_sys_path_preserves_host_order() -> None:
|
||||
host_paths = ["/host/root", "/host/site-packages"]
|
||||
extra_paths = ["/node/.venv/lib/python3.12/site-packages"]
|
||||
result = build_child_sys_path(host_paths, extra_paths, preferred_root=None)
|
||||
assert result == host_paths + extra_paths
|
||||
|
||||
|
||||
def test_build_child_sys_path_inserts_comfy_root_when_missing() -> None:
|
||||
host_paths = ["/host/site-packages"]
|
||||
comfy_root = os.environ.get("COMFYUI_ROOT") or str(Path.home() / "ComfyUI")
|
||||
extra_paths: list[str] = []
|
||||
result = build_child_sys_path(host_paths, extra_paths, preferred_root=comfy_root)
|
||||
assert result[0] == comfy_root
|
||||
assert result[1:] == host_paths
|
||||
|
||||
|
||||
def test_build_child_sys_path_deduplicates_entries(tmp_path: Path) -> None:
|
||||
path_a = str(tmp_path / "a")
|
||||
path_b = str(tmp_path / "b")
|
||||
host_paths = [path_a, path_b]
|
||||
extra_paths = [path_a, path_b, str(tmp_path / "c")]
|
||||
result = build_child_sys_path(host_paths, extra_paths)
|
||||
assert result == [path_a, path_b, str(tmp_path / "c")]
|
||||
|
||||
|
||||
def test_build_child_sys_path_skips_duplicate_comfy_root() -> None:
|
||||
comfy_root = os.environ.get("COMFYUI_ROOT") or str(Path.home() / "ComfyUI")
|
||||
host_paths = [comfy_root, "/host/other"]
|
||||
result = build_child_sys_path(host_paths, extra_paths=[], preferred_root=comfy_root)
|
||||
assert result == host_paths
|
||||
|
||||
|
||||
def test_child_import_succeeds_after_path_unification(tmp_path: Path, monkeypatch) -> None:
|
||||
host_root = tmp_path / "host"
|
||||
utils_pkg = host_root / "utils"
|
||||
app_pkg = host_root / "app"
|
||||
utils_pkg.mkdir(parents=True)
|
||||
app_pkg.mkdir(parents=True)
|
||||
|
||||
(utils_pkg / "__init__.py").write_text("from . import install_util\n", encoding="utf-8")
|
||||
(utils_pkg / "install_util.py").write_text("VALUE = 'hello'\n", encoding="utf-8")
|
||||
(app_pkg / "__init__.py").write_text("", encoding="utf-8")
|
||||
(app_pkg / "frontend_management.py").write_text(
|
||||
"from utils import install_util\nVALUE = install_util.VALUE\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
child_only = tmp_path / "child_only"
|
||||
child_only.mkdir()
|
||||
|
||||
target_module = "app.frontend_management"
|
||||
for name in [n for n in list(sys.modules) if n.startswith("app") or n.startswith("utils")]:
|
||||
sys.modules.pop(name)
|
||||
|
||||
monkeypatch.setattr(sys, "path", [str(child_only)])
|
||||
with pytest.raises(ModuleNotFoundError):
|
||||
__import__(target_module)
|
||||
|
||||
for name in [n for n in list(sys.modules) if n.startswith("app") or n.startswith("utils")]:
|
||||
sys.modules.pop(name)
|
||||
|
||||
unified = build_child_sys_path([], [], preferred_root=str(host_root))
|
||||
monkeypatch.setattr(sys, "path", unified)
|
||||
module = __import__(target_module, fromlist=["VALUE"])
|
||||
assert module.VALUE == "hello"
|
||||
51
tests/test_adapter.py
Normal file
51
tests/test_adapter.py
Normal file
@@ -0,0 +1,51 @@
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
repo_root = Path(__file__).resolve().parents[1]
|
||||
pyisolate_root = repo_root.parent / "pyisolate"
|
||||
if pyisolate_root.exists():
|
||||
sys.path.insert(0, str(pyisolate_root))
|
||||
|
||||
from comfy.isolation.adapter import ComfyUIAdapter
|
||||
from pyisolate._internal.serialization_registry import SerializerRegistry
|
||||
|
||||
|
||||
def test_identifier():
|
||||
adapter = ComfyUIAdapter()
|
||||
assert adapter.identifier == "comfyui"
|
||||
|
||||
|
||||
def test_get_path_config_valid():
|
||||
adapter = ComfyUIAdapter()
|
||||
path = os.path.join("/opt", "ComfyUI", "custom_nodes", "demo")
|
||||
cfg = adapter.get_path_config(path)
|
||||
assert cfg is not None
|
||||
assert cfg["preferred_root"].endswith("ComfyUI")
|
||||
assert "custom_nodes" in cfg["additional_paths"][0]
|
||||
|
||||
|
||||
def test_get_path_config_invalid():
|
||||
adapter = ComfyUIAdapter()
|
||||
assert adapter.get_path_config("/random/path") is None
|
||||
|
||||
|
||||
def test_provide_rpc_services():
|
||||
adapter = ComfyUIAdapter()
|
||||
services = adapter.provide_rpc_services()
|
||||
names = {s.__name__ for s in services}
|
||||
assert "PromptServerService" in names
|
||||
assert "FolderPathsProxy" in names
|
||||
|
||||
|
||||
def test_register_serializers():
|
||||
adapter = ComfyUIAdapter()
|
||||
registry = SerializerRegistry.get_instance()
|
||||
registry.clear()
|
||||
|
||||
adapter.register_serializers(registry)
|
||||
assert registry.has_handler("ModelPatcher")
|
||||
assert registry.has_handler("CLIP")
|
||||
assert registry.has_handler("VAE")
|
||||
|
||||
registry.clear()
|
||||
Reference in New Issue
Block a user