mirror of
https://github.com/comfyanonymous/ComfyUI.git
synced 2026-10-28 15:57:23 +03:00
* Turn the assets system on by default; add --disable-assets Assets are on unless ComfyUI is started with --disable-assets; --enable-assets is still accepted, does nothing and logs one deprecation line. Startup failures that already stopped ComfyUI with assets on now log "ASSETS_STARTUP_FAILED: <kind>" and a message naming the database file and the fix (another ComfyUI or program holding it, corrupt, written by a newer ComfyUI, not writable, a file in the way, an unsupported --database-url), each ending with --disable-assets. Missing database packages stop startup with the install command instead of quietly turning assets off. A read-only database that needs an upgrade is reported before it's opened or backed up, so no read-only copies are left behind. A % in the install or database path works, and the default database URL is built by SQLAlchemy. * Read the stored revision through the WAL after an unclean shutdown; point a bad --database-url back to the default * Build the suggested --database-url with SQLAlchemy; mention programs holding the file; log the full error at DEBUG The suggested URL is quoted the same way as the default one, so a %20 or ? in the path opens the intended file. A file held open by another program (SQLite reports it as unable to open) gets that in the advice. An unrecognised startup failure logs its traceback at DEBUG, as its message says, except for an unsupported --database-url, whose error repeats the URL. * Read through the WAL only when its -shm is there too, so the check never creates one * Quote only what SQLAlchemy reads as URL syntax; report a corrupt read-only database as corrupt A suggested --database-url keeps a plain path as typed, so pasting it into a .bat file can't expand a quoted ':' or '\' as a batch argument. With SQLAlchemy 2.1+, % ? and # are still quoted so the URL opens that file. The pre-upgrade revision read lets a corrupt database raise, so it is reported as corrupt rather than as not writable. * Stub the revision read in the legacy-copy lock test, whose copied file isn't a real database * Drop the absolute --database-url example; build the default URL with SQLAlchemy again The failure messages go back to the generic --database-url example, so the helper and the version-dependent quoting that kept its pasted form plain are gone; the default database URL is built by URL.create again. Also tests a read-only -wal left by a run that didn't shut down. * Remove the pre-upgrade read-only check A read-only database that needs an upgrade fails in the upgrade again, with the not-writable message. The check and its tests move to a follow-up.
173 lines
5.7 KiB
Python
173 lines
5.7 KiB
Python
from contextlib import contextmanager
|
|
import json
|
|
from pathlib import Path
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from typing import Iterator
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
import pytest
|
|
|
|
|
|
COMFYUI_ROOT = Path(__file__).parents[2]
|
|
|
|
|
|
def _unused_port() -> int:
|
|
with socket.socket() as sock:
|
|
sock.bind(("127.0.0.1", 0))
|
|
return sock.getsockname()[1]
|
|
|
|
|
|
def _get_json(url: str) -> dict:
|
|
with urllib.request.urlopen(url, timeout=5) as response:
|
|
return json.load(response)
|
|
|
|
|
|
def _post_prompt(base_url: str, prompt: dict) -> tuple[int, dict]:
|
|
request = urllib.request.Request(
|
|
f"{base_url}/prompt",
|
|
data=json.dumps({"prompt": prompt}).encode(),
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=15) as response:
|
|
return response.status, json.load(response)
|
|
except urllib.error.HTTPError as error:
|
|
return error.code, json.load(error)
|
|
|
|
|
|
def _wait_for_server(process: subprocess.Popen, base_url: str, log_path: Path) -> None:
|
|
deadline = time.monotonic() + 120
|
|
while time.monotonic() < deadline:
|
|
if process.poll() is not None:
|
|
pytest.fail(f"server exited during startup:\n{log_path.read_text(encoding='utf-8')}")
|
|
try:
|
|
_get_json(f"{base_url}/system_stats")
|
|
return
|
|
except urllib.error.URLError:
|
|
time.sleep(0.25)
|
|
pytest.fail(f"server readiness timed out:\n{log_path.read_text(encoding='utf-8')}")
|
|
|
|
|
|
def _wait_for_history(base_url: str, prompt_id: str) -> dict:
|
|
deadline = time.monotonic() + 30
|
|
while time.monotonic() < deadline:
|
|
history = _get_json(f"{base_url}/history/{prompt_id}")
|
|
if prompt_id in history:
|
|
return history[prompt_id]
|
|
time.sleep(0.1)
|
|
raise AssertionError(f"prompt {prompt_id} did not finish")
|
|
|
|
|
|
@contextmanager
|
|
def _running_server(tmp_path: Path, disabled_node: str, testing_nodes: bool = False) -> Iterator[str]:
|
|
config_path = tmp_path / "disabled_nodes.yaml"
|
|
config_path.write_text(f"disabled_nodes:\n - {disabled_node}\n", encoding="utf-8")
|
|
log_path = tmp_path / "server.log"
|
|
port = _unused_port()
|
|
base_url = f"http://127.0.0.1:{port}"
|
|
command = [
|
|
sys.executable,
|
|
"main.py",
|
|
"--listen",
|
|
"127.0.0.1",
|
|
"--port",
|
|
str(port),
|
|
"--cpu",
|
|
"--disable-assets",
|
|
"--cache-none",
|
|
"--disable-api-nodes",
|
|
"--output-directory",
|
|
str(tmp_path / "output"),
|
|
"--temp-directory",
|
|
str(tmp_path),
|
|
"--disabled-nodes-config",
|
|
str(config_path),
|
|
]
|
|
if testing_nodes:
|
|
command += ["--extra-model-paths-config", str(Path(__file__).parent / "extra_model_paths.yaml")]
|
|
else:
|
|
command.append("--disable-all-custom-nodes")
|
|
with log_path.open("w", encoding="utf-8") as log:
|
|
process = subprocess.Popen(command, cwd=COMFYUI_ROOT, stdout=log, stderr=subprocess.STDOUT, text=True)
|
|
try:
|
|
_wait_for_server(process, base_url, log_path)
|
|
yield base_url
|
|
finally:
|
|
process.terminate()
|
|
try:
|
|
process.wait(timeout=10)
|
|
except subprocess.TimeoutExpired:
|
|
process.kill()
|
|
process.wait(timeout=10)
|
|
|
|
|
|
@pytest.mark.execution
|
|
def test_disabled_old_id_forwards_and_executes_allowed_target(tmp_path: Path) -> None:
|
|
# Given
|
|
prompt = {
|
|
"source-a": {
|
|
"class_type": "EmptyImage",
|
|
"inputs": {"width": 16, "height": 16, "batch_size": 1, "color": 0},
|
|
},
|
|
"source-b": {
|
|
"class_type": "EmptyImage",
|
|
"inputs": {"width": 16, "height": 16, "batch_size": 1, "color": 0},
|
|
},
|
|
"legacy": {
|
|
"class_type": "ImageBatch",
|
|
"inputs": {"image1": ["source-a", 0], "image2": ["source-b", 0]},
|
|
},
|
|
"output": {"class_type": "PreviewImage", "inputs": {"images": ["legacy", 0]}},
|
|
}
|
|
|
|
# When
|
|
with _running_server(tmp_path, "ImageBatch") as base_url:
|
|
object_info = _get_json(f"{base_url}/object_info")
|
|
status, response = _post_prompt(base_url, prompt)
|
|
history = _wait_for_history(base_url, response["prompt_id"])
|
|
|
|
# Then
|
|
assert "ImageBatch" not in object_info
|
|
assert status == 200
|
|
assert history["status"]["status_str"] == "success"
|
|
assert history["prompt"][2]["legacy"]["class_type"] == "BatchImagesNode"
|
|
|
|
|
|
@pytest.mark.execution
|
|
def test_replacement_pointing_to_disabled_target_is_refused(tmp_path: Path) -> None:
|
|
# Given
|
|
prompt = {"1": {"class_type": "ConditioningAverage ", "inputs": {}}}
|
|
|
|
# When
|
|
with _running_server(tmp_path, "ConditioningAverage") as base_url:
|
|
object_info = _get_json(f"{base_url}/object_info")
|
|
status, response = _post_prompt(base_url, prompt)
|
|
|
|
# Then
|
|
assert "ConditioningAverage" not in object_info
|
|
assert status == 400
|
|
assert response["error"]["type"] == "missing_node_type"
|
|
assert response["error"]["extra_info"]["class_type"] == "ConditioningAverage "
|
|
|
|
|
|
@pytest.mark.execution
|
|
def test_disabled_node_cannot_be_reached_through_expansion(tmp_path: Path) -> None:
|
|
# Given
|
|
prompt = {"1": {"class_type": "TestExpandsToDisabledNode", "inputs": {}}}
|
|
|
|
# When
|
|
with _running_server(tmp_path, "TestDisabledNode", testing_nodes=True) as base_url:
|
|
object_info = _get_json(f"{base_url}/object_info")
|
|
status, response = _post_prompt(base_url, prompt)
|
|
history = _wait_for_history(base_url, response["prompt_id"])
|
|
|
|
# Then
|
|
assert "TestExpandsToDisabledNode" in object_info
|
|
assert "TestDisabledNode" not in object_info
|
|
assert status == 200
|
|
assert history["status"]["status_str"] == "error"
|