Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tw9fjV3UWsHHjJiAXix29B
699 lines
30 KiB
Python
699 lines
30 KiB
Python
"""Keeping the install alive, over HTTP.
|
|
|
|
Every one of these routes ends by relaunching ComfyUI. The point of the tests is
|
|
that the ANSWER is written first and the restart is a scheduled callback -- so
|
|
they replace it, and a suite that did not would relaunch the test runner.
|
|
"""
|
|
|
|
import json
|
|
import socket
|
|
import threading
|
|
import time
|
|
|
|
import pytest
|
|
|
|
pytest.importorskip("aiohttp")
|
|
|
|
from core import routes, update as update_mod # noqa: E402
|
|
|
|
|
|
@pytest.fixture
|
|
def server(comfyui, monkeypatch):
|
|
from aiohttp import web as aioweb
|
|
|
|
restarts = []
|
|
from core import restart as restart_mod
|
|
monkeypatch.setattr(restart_mod, "restart", lambda: restarts.append(1))
|
|
# `_pending_restart` is module state, not per-request -- a test that blocks a
|
|
# restart and never finishes it would otherwise leak "an update is waiting"
|
|
# into every test that runs after it in the same process.
|
|
monkeypatch.setattr(routes, "_pending_restart", False)
|
|
|
|
app = aioweb.Application()
|
|
table = aioweb.RouteTableDef()
|
|
routes.register(table, prefix="/fun")
|
|
app.add_routes(table)
|
|
|
|
holder = {"ready": threading.Event(), "restarts": restarts}
|
|
|
|
def run():
|
|
import asyncio
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
runner = aioweb.AppRunner(app)
|
|
loop.run_until_complete(runner.setup())
|
|
site = aioweb.TCPSite(runner, "127.0.0.1", 0)
|
|
loop.run_until_complete(site.start())
|
|
holder["port"] = site._server.sockets[0].getsockname()[1]
|
|
holder["loop"] = loop
|
|
holder["ready"].set()
|
|
loop.run_forever()
|
|
|
|
thread = threading.Thread(target=run, daemon=True)
|
|
thread.start()
|
|
assert holder["ready"].wait(20), "the test server did not start"
|
|
yield holder
|
|
holder["loop"].call_soon_threadsafe(holder["loop"].stop)
|
|
|
|
|
|
def _request(port, method, path, body=None):
|
|
payload = b"" if body is None else json.dumps(body).encode()
|
|
head = (f"{method} {path} HTTP/1.1\r\nHost: localhost\r\n"
|
|
f"Content-Type: application/json\r\n"
|
|
f"Content-Length: {len(payload)}\r\nConnection: close\r\n\r\n").encode()
|
|
with socket.create_connection(("127.0.0.1", port), timeout=60) as sock:
|
|
sock.sendall(head + payload)
|
|
chunks = []
|
|
while True:
|
|
got = sock.recv(65536)
|
|
if not got:
|
|
break
|
|
chunks.append(got)
|
|
raw = b"".join(chunks)
|
|
header, _, rest = raw.partition(b"\r\n\r\n")
|
|
status = int(header.split()[1])
|
|
try:
|
|
return status, json.loads(rest.decode() or "{}")
|
|
except ValueError:
|
|
return status, {"raw": rest.decode(errors="replace")}
|
|
|
|
|
|
def test_the_app_can_see_which_branch_it_is_on(server):
|
|
"""The one question every update starts from."""
|
|
status, body = _request(server["port"], "GET", "/fun/api/git/status")
|
|
assert status == 200
|
|
assert body["ok"] is True, body
|
|
assert body["branch"], "no branch reported"
|
|
assert isinstance(body["branches"], list) and body["branches"]
|
|
assert body["repo"] == str(update_mod.REPO_ROOT)
|
|
assert server["restarts"] == [], "reading the status restarted ComfyUI"
|
|
|
|
|
|
def test_system_info_describes_the_host_comfyui_runs_on(server):
|
|
"""The About panel's hardware facts -- best-effort, but never a 500. This
|
|
machine has no CUDA device, so gpus is empty and mps says whether that is
|
|
because there is genuinely nothing (mps False too) or an Apple GPU instead."""
|
|
status, body = _request(server["port"], "GET", "/fun/api/system")
|
|
assert status == 200
|
|
assert isinstance(body["cpu"]["threads"], int) and body["cpu"]["threads"] > 0
|
|
assert isinstance(body["gpus"], list)
|
|
assert body["python"]
|
|
assert body["memory"]["total_gb"] or body["memory"]["total_gb"] == 0
|
|
|
|
|
|
def test_switching_to_a_branch_that_is_not_there_is_refused_by_name(server):
|
|
"""A refusal has to say which branch and that it exists nowhere -- otherwise
|
|
the user cannot tell it from a typo in their own request."""
|
|
status, body = _request(server["port"], "POST", "/fun/api/git/checkout",
|
|
{"branch": "no-such-branch-here"})
|
|
assert status == 400
|
|
assert "no-such-branch-here" in body["detail"]
|
|
assert server["restarts"] == [], "a refused switch still restarted ComfyUI"
|
|
|
|
|
|
def test_switching_to_nothing_is_refused_before_git_is_touched(server):
|
|
status, body = _request(server["port"], "POST", "/fun/api/git/checkout", {})
|
|
assert status == 400
|
|
assert "branch" in body["detail"].lower()
|
|
assert server["restarts"] == []
|
|
|
|
|
|
def test_a_refusal_from_git_is_reported_as_a_reason_not_a_crash(server, monkeypatch):
|
|
"""Dirty tree, no git on PATH, not a checkout: the user has to be told which."""
|
|
def refuse(*_a, **_k):
|
|
raise update_mod.GitUpdateError("Working tree has local changes.")
|
|
monkeypatch.setattr(update_mod, "pull", refuse)
|
|
|
|
status, body = _request(server["port"], "POST", "/fun/api/git/update", {})
|
|
assert status == 400
|
|
assert "local changes" in body["detail"]
|
|
assert server["restarts"] == []
|
|
|
|
|
|
def test_an_update_that_worked_answers_before_it_restarts(server, monkeypatch):
|
|
"""The answer has to be written first: the process is about to go."""
|
|
monkeypatch.setattr(update_mod, "pull",
|
|
lambda **_k: {"updated": True, "before": "a1", "after": "b2"})
|
|
|
|
status, body = _request(server["port"], "POST", "/fun/api/git/update",
|
|
{"branch": "dev"})
|
|
assert status == 200, body
|
|
assert body["restarting"] is True
|
|
assert body["after"] == "b2"
|
|
|
|
|
|
def test_an_unexpected_failure_is_a_500_with_the_reason_not_a_hang(server, monkeypatch):
|
|
def explode(*_a, **_k):
|
|
raise OSError("disk went away")
|
|
monkeypatch.setattr(update_mod, "rollback", explode)
|
|
|
|
status, body = _request(server["port"], "POST", "/fun/api/git/rollback")
|
|
assert status == 500
|
|
assert "disk went away" in body["detail"]
|
|
assert server["restarts"] == [], "a failed rollback restarted ComfyUI anyway"
|
|
|
|
|
|
def test_the_status_can_be_had_without_touching_the_network(server):
|
|
"""The fetch is the only part that can hang. On a machine that cannot reach
|
|
the remote it must still be possible to see which branch you are on."""
|
|
status, body = _request(server["port"], "GET", "/fun/api/git/status?remote=0")
|
|
assert status == 200
|
|
assert body["ok"] is True
|
|
assert body["branch"]
|
|
assert body["checked_remote"] is False
|
|
assert body["behind"] == 0, "behind was reported without asking the remote"
|
|
|
|
|
|
def test_a_remote_that_cannot_be_reached_is_said_rather_than_guessed(server, monkeypatch):
|
|
def no_network(*args, **kwargs):
|
|
class Failed:
|
|
returncode = 1
|
|
stdout = ""
|
|
stderr = "could not resolve host"
|
|
return Failed() if args and args[0] == "fetch" else _real(*args, **kwargs)
|
|
_real = update_mod._run_git
|
|
monkeypatch.setattr(update_mod, "_run_git", no_network)
|
|
|
|
status, body = _request(server["port"], "GET", "/fun/api/git/status")
|
|
assert status == 200
|
|
assert body["fetch_ok"] is False
|
|
assert body["checked_remote"] is True
|
|
assert body["behind"] == 0, "a failed fetch was read as 'up to date'"
|
|
|
|
|
|
# --- node packs --------------------------------------------------------------
|
|
|
|
def test_the_pack_list_says_where_it_is_looking(server):
|
|
status, body = _request(server["port"], "GET", "/fun/api/packs")
|
|
assert status == 200, body
|
|
assert body["root"].endswith("custom_nodes"), body["root"]
|
|
assert isinstance(body["nodes"], list)
|
|
|
|
|
|
def test_removing_something_that_is_not_a_pack_name_is_refused(server):
|
|
"""The delete is the whole risk here: one segment, resolved, inside
|
|
custom_nodes, and never FunUI itself."""
|
|
for name in ["..", "../..", "a/b", ".git", ""]:
|
|
status, body = _request(server["port"], "POST", "/fun/api/packs/remove",
|
|
{"name": name})
|
|
assert status == 400, (name, status, body)
|
|
assert body["detail"], name
|
|
|
|
|
|
def test_removing_funui_itself_is_refused_by_name(server):
|
|
from core import nodes_manager
|
|
me = nodes_manager.FUNUI_ROOT.name
|
|
status, body = _request(server["port"], "POST", "/fun/api/packs/remove", {"name": me})
|
|
assert status == 400
|
|
# It has to say WHICH thing it refused to delete, or the user tries again.
|
|
assert me in body["detail"]
|
|
|
|
|
|
def test_a_pack_that_is_not_installed_is_refused_rather_than_created(server):
|
|
status, body = _request(server["port"], "POST", "/fun/api/packs/update",
|
|
{"name": "ComfyUI-DoesNotExist"})
|
|
assert status == 400
|
|
assert "ComfyUI-DoesNotExist" in body["detail"]
|
|
|
|
|
|
def test_installing_from_nothing_is_refused(server):
|
|
status, body = _request(server["port"], "POST", "/fun/api/packs/install", {"url": ""})
|
|
assert status == 400
|
|
assert body["detail"]
|
|
|
|
|
|
def test_an_install_outside_comfyui_says_it_cannot_tell_where_packs_live(server, monkeypatch):
|
|
"""A development checkout is not inside custom_nodes. v4 could assume it was,
|
|
because v4 only ever ran from an install; here the assumption is checked and
|
|
the answer is a refusal rather than somebody's home directory."""
|
|
from core import nodes_manager
|
|
import sys as _sys
|
|
monkeypatch.setitem(_sys.modules, "folder_paths", None)
|
|
monkeypatch.setattr(nodes_manager, "FUNUI_ROOT",
|
|
nodes_manager.Path("/tmp/somewhere/FunUI-v5"))
|
|
|
|
status, body = _request(server["port"], "GET", "/fun/api/packs")
|
|
assert status == 400, body
|
|
assert "custom_nodes" in body["detail"]
|
|
assert "/tmp/somewhere" in body["detail"], "it did not say where it thinks it is"
|
|
|
|
|
|
def test_an_update_that_changed_nothing_does_not_restart(server, monkeypatch):
|
|
"""Pressing Update while already up to date is a normal thing to do -- there
|
|
is no way to know until it has been asked. Restarting for it costs a boot,
|
|
and a generation if one is running."""
|
|
monkeypatch.setattr(update_mod, "pull",
|
|
lambda **_k: {"updated": False, "before": "a1", "after": "a1"})
|
|
|
|
status, body = _request(server["port"], "POST", "/fun/api/git/update", {})
|
|
assert status == 200, body
|
|
assert body["restarting"] is False
|
|
assert server["restarts"] == [], "restarted for a checkout that had not moved"
|
|
|
|
|
|
def test_a_branch_switch_restarts_even_when_the_commit_is_the_same(server, monkeypatch):
|
|
"""Two branches can point at the same commit and still be different code
|
|
once anything is committed to either."""
|
|
monkeypatch.setattr(update_mod, "checkout",
|
|
lambda **_k: {"updated": False, "before": "a1", "after": "a1",
|
|
"before_branch": "v5", "branch": "dev"})
|
|
|
|
status, body = _request(server["port"], "POST", "/fun/api/git/checkout",
|
|
{"branch": "dev"})
|
|
assert status == 200, body
|
|
assert body["restarting"] is True
|
|
# Scheduled 0.7s AFTER the answer is written, which is the whole point: the
|
|
# process is about to go and the client needs the reply first.
|
|
time.sleep(1.0)
|
|
assert server["restarts"] == [1]
|
|
|
|
|
|
def test_a_generation_started_during_the_07s_delay_still_stops_the_restart(server, monkeypatch):
|
|
"""`_generation_running()` was only ever checked once, at schedule time --
|
|
the 0.7s gap before the process actually goes (so the response reaches the
|
|
client first) was a window nothing re-checked. A run queued in that window
|
|
used to be killed anyway, which is exactly what this whole mechanism
|
|
exists to prevent."""
|
|
monkeypatch.setattr(update_mod, "pull",
|
|
lambda **_k: {"updated": True, "before": "a1", "after": "b2"})
|
|
running = False
|
|
monkeypatch.setattr(routes, "_generation_running", lambda: running)
|
|
|
|
status, body = _request(server["port"], "POST", "/fun/api/git/update", {})
|
|
assert status == 200, body
|
|
assert body["restarting"] is True, "scheduled optimistically, before the delay"
|
|
|
|
# A generation starts in the gap between the response and the actual
|
|
# restart -- the moment the real bug happened.
|
|
running = True
|
|
time.sleep(1.0)
|
|
|
|
assert server["restarts"] == [], "restarted anyway despite the generation"
|
|
|
|
status, body = _request(server["port"], "GET", "/fun/api/git/status")
|
|
assert body["restart_pending"] is True, "the deferred restart is not visible anywhere"
|
|
|
|
|
|
def test_a_moved_checkout_does_not_restart_while_a_generation_is_running(server, monkeypatch):
|
|
"""The client disables the buttons while a run is in flight, but that check
|
|
goes stale if the dialog was left open across one starting -- this is the
|
|
guard that actually stops the restart, not just the appearance of one."""
|
|
monkeypatch.setattr(update_mod, "pull",
|
|
lambda **_k: {"updated": True, "before": "a1", "after": "b2"})
|
|
monkeypatch.setattr(routes, "_generation_running", lambda: True)
|
|
|
|
status, body = _request(server["port"], "POST", "/fun/api/git/update",
|
|
{"branch": "dev"})
|
|
assert status == 200, body
|
|
assert body["restarting"] is False
|
|
assert body["blocked"], "did not say why it refused to restart"
|
|
assert body["after"] == "b2", "the git operation itself should still have run"
|
|
time.sleep(1.0)
|
|
assert server["restarts"] == [], "restarted ComfyUI while a generation was running"
|
|
|
|
|
|
def test_a_blocked_restart_is_actually_finishable_once_the_run_ends(server, monkeypatch):
|
|
"""A blocked update used to be a dead end: HEAD had already moved, so the
|
|
same button's next press saw nothing to update and never restarted --
|
|
ComfyUI kept running stale code with no way back short of the terminal."""
|
|
monkeypatch.setattr(update_mod, "pull",
|
|
lambda **_k: {"updated": True, "before": "a1", "after": "b2"})
|
|
monkeypatch.setattr(routes, "_generation_running", lambda: True)
|
|
|
|
status, body = _request(server["port"], "POST", "/fun/api/git/update", {})
|
|
assert status == 200, body
|
|
assert body["restarting"] is False
|
|
|
|
status, body = _request(server["port"], "GET", "/fun/api/git/status")
|
|
assert status == 200, body
|
|
assert body["restart_pending"] is True, "the owed restart is not visible anywhere"
|
|
|
|
# Nothing else here works while a restart is owed -- running another git
|
|
# action on top of an unrestarted change is not a retry, it is a second
|
|
# change stacked on the first.
|
|
status, body = _request(server["port"], "POST", "/fun/api/git/update", {})
|
|
assert status == 409, body
|
|
|
|
# Still running: the dedicated restart route refuses too, same as the guard.
|
|
status, body = _request(server["port"], "POST", "/fun/api/git/restart")
|
|
assert status == 200, body
|
|
assert body["restarting"] is False
|
|
assert server["restarts"] == []
|
|
|
|
# The run ends. The SAME deferred restart finishes -- `pull` is not called
|
|
# again, so if it were, this would fail on Mock's default None return.
|
|
monkeypatch.setattr(routes, "_generation_running", lambda: False)
|
|
status, body = _request(server["port"], "POST", "/fun/api/git/restart")
|
|
assert status == 200, body
|
|
assert body["restarting"] is True
|
|
time.sleep(1.0)
|
|
assert server["restarts"] == [1]
|
|
|
|
status, body = _request(server["port"], "GET", "/fun/api/git/status")
|
|
assert body["restart_pending"] is False, "stayed pending after actually restarting"
|
|
|
|
|
|
def test_restarting_with_nothing_pending_just_restarts(server, monkeypatch):
|
|
"""The route now doubles as a plain "Restart ComfyUI" button: nothing owed
|
|
is not a reason to refuse, it is the normal case for someone pressing it
|
|
on purpose."""
|
|
status, body = _request(server["port"], "POST", "/fun/api/git/restart")
|
|
assert status == 200, body
|
|
assert body["restarting"] is True
|
|
time.sleep(1.0)
|
|
assert server["restarts"] == [1]
|
|
|
|
|
|
def test_restarting_with_nothing_pending_still_respects_a_running_generation(server, monkeypatch):
|
|
monkeypatch.setattr(routes, "_generation_running", lambda: True)
|
|
status, body = _request(server["port"], "POST", "/fun/api/git/restart")
|
|
assert status == 200, body
|
|
assert body["restarting"] is False
|
|
assert body["blocked"]
|
|
assert server["restarts"] == []
|
|
|
|
|
|
def test_a_rollback_pressed_twice_while_blocked_does_not_roll_back_twice(server, monkeypatch):
|
|
"""The dangerous version of the dead end: HEAD already moved once, so
|
|
re-running `rollback` on a second press would target the commit BEFORE the
|
|
blocked rollback and silently undo it instead of finishing the restart."""
|
|
calls = []
|
|
|
|
def rollback():
|
|
calls.append(1)
|
|
return {"branch": "v5", "before": "b2", "after": "a1"}
|
|
monkeypatch.setattr(update_mod, "rollback", rollback)
|
|
monkeypatch.setattr(routes, "_generation_running", lambda: True)
|
|
|
|
status, body = _request(server["port"], "POST", "/fun/api/git/rollback")
|
|
assert status == 200, body
|
|
assert body["restarting"] is False
|
|
assert calls == [1]
|
|
|
|
# Pressed again while still blocked: refused outright, `rollback` not called
|
|
# a second time.
|
|
status, body = _request(server["port"], "POST", "/fun/api/git/rollback")
|
|
assert status == 409, body
|
|
assert calls == [1], "ran rollback a second time instead of refusing"
|
|
|
|
|
|
def test_two_git_actions_at_once_do_not_touch_the_checkout_at_the_same_time(server, monkeypatch):
|
|
"""Two tabs, or a double-click before the button disables -- both requests
|
|
used to be able to pass the pending-restart check and then run two real git
|
|
subprocesses against the same working tree at once."""
|
|
events = []
|
|
|
|
def slow_pull(**_k):
|
|
events.append(("pull", "enter"))
|
|
time.sleep(0.3)
|
|
events.append(("pull", "exit"))
|
|
return {"updated": False, "before": "a1", "after": "a1"}
|
|
|
|
def slow_checkout(**_k):
|
|
events.append(("checkout", "enter"))
|
|
time.sleep(0.3)
|
|
events.append(("checkout", "exit"))
|
|
return {"updated": False, "before": "a1", "after": "a1",
|
|
"before_branch": "v5", "branch": "v5"}
|
|
|
|
monkeypatch.setattr(update_mod, "pull", slow_pull)
|
|
monkeypatch.setattr(update_mod, "checkout", slow_checkout)
|
|
|
|
results = {}
|
|
|
|
def fire(name, path, body):
|
|
results[name] = _request(server["port"], "POST", path, body)
|
|
|
|
t1 = threading.Thread(target=fire, args=("update", "/fun/api/git/update", {}))
|
|
t2 = threading.Thread(target=fire, args=("checkout", "/fun/api/git/checkout", {"branch": "v5"}))
|
|
t1.start()
|
|
time.sleep(0.05) # let the first one actually reach the lock first
|
|
t2.start()
|
|
t1.join(5)
|
|
t2.join(5)
|
|
|
|
assert results["update"][0] == 200, results["update"]
|
|
assert results["checkout"][0] == 200, results["checkout"]
|
|
# Whichever ran first, it must have fully exited before the other entered --
|
|
# never interleaved (enter, enter, exit, exit) and never reordered so an
|
|
# exit is missing before the next enter.
|
|
assert events in (
|
|
[("pull", "enter"), ("pull", "exit"), ("checkout", "enter"), ("checkout", "exit")],
|
|
[("checkout", "enter"), ("checkout", "exit"), ("pull", "enter"), ("pull", "exit")],
|
|
), events
|
|
|
|
|
|
def test_status_with_a_remote_fetch_does_not_race_a_git_action_either(server, monkeypatch):
|
|
"""`GET /api/git/status?remote=1` runs its own `git fetch` -- a real
|
|
mutation of the checkout's refs, same as pull/checkout/rollback -- and the
|
|
Updates window fires it on every open. Without sharing the lock, opening
|
|
that window mid-update raced two fetches against the same repo."""
|
|
events = []
|
|
|
|
def slow_status(*, remote=True):
|
|
events.append(("status", "enter"))
|
|
time.sleep(0.3)
|
|
events.append(("status", "exit"))
|
|
return {"ok": True, "branch": "v5", "branches": ["v5"], "dirty": False,
|
|
"ahead": 0, "behind": 0, "fetch_ok": True, "repo": "/x"}
|
|
|
|
def slow_pull(**_k):
|
|
events.append(("pull", "enter"))
|
|
time.sleep(0.3)
|
|
events.append(("pull", "exit"))
|
|
return {"updated": False, "before": "a1", "after": "a1"}
|
|
|
|
monkeypatch.setattr(update_mod, "status", slow_status)
|
|
monkeypatch.setattr(update_mod, "pull", slow_pull)
|
|
|
|
results = {}
|
|
|
|
def fire(name, method, path, body=None):
|
|
results[name] = _request(server["port"], method, path, body)
|
|
|
|
t1 = threading.Thread(target=fire, args=("update", "POST", "/fun/api/git/update", {}))
|
|
t2 = threading.Thread(target=fire, args=("status", "GET", "/fun/api/git/status"))
|
|
t1.start()
|
|
time.sleep(0.05)
|
|
t2.start()
|
|
t1.join(5)
|
|
t2.join(5)
|
|
|
|
assert results["update"][0] == 200, results["update"]
|
|
assert results["status"][0] == 200, results["status"]
|
|
assert events in (
|
|
[("pull", "enter"), ("pull", "exit"), ("status", "enter"), ("status", "exit")],
|
|
[("status", "enter"), ("status", "exit"), ("pull", "enter"), ("pull", "exit")],
|
|
), events
|
|
|
|
|
|
def test_a_rollback_restarts_because_the_commit_moved(server, monkeypatch):
|
|
monkeypatch.setattr(update_mod, "rollback",
|
|
lambda: {"branch": "v5", "before": "b2", "after": "a1"})
|
|
|
|
status, body = _request(server["port"], "POST", "/fun/api/git/rollback")
|
|
assert status == 200, body
|
|
assert body["restarting"] is True
|
|
time.sleep(1.0)
|
|
assert server["restarts"] == [1]
|
|
|
|
|
|
def test_the_pack_check_answers_under_the_key_the_app_reads(server):
|
|
"""The app reads `checked`. It read `nodes` for a while: the request
|
|
succeeded, the JSON was valid, and every pack reported nothing to update
|
|
however far behind it was. Neither side's tests could see it alone."""
|
|
from core import nodes_manager
|
|
shape = nodes_manager.check_updates.__doc__
|
|
assert shape, "check_updates lost its docstring"
|
|
|
|
status, body = _request(server["port"], "POST", "/fun/api/packs/check")
|
|
assert status == 200, body
|
|
assert "checked" in body, f"the key the app reads is not in {sorted(body)}"
|
|
assert isinstance(body["checked"], dict)
|
|
|
|
|
|
# --- the backend log ---------------------------------------------------------
|
|
|
|
def test_the_log_comes_back_with_where_it_was_read_from(server):
|
|
"""A log panel showing lines from an unknown file is a panel nobody can
|
|
check against the terminal they also have open."""
|
|
status, body = _request(server["port"], "GET", "/fun/api/log?limit=5")
|
|
assert status == 200, body
|
|
assert isinstance(body["lines"], list)
|
|
assert len(body["lines"]) <= 5
|
|
# This machine's ComfyUI does write one; either way the answer names itself.
|
|
assert body["path"] or body["detail"]
|
|
|
|
|
|
def test_no_log_file_is_an_answer_with_a_reason(server, monkeypatch):
|
|
"""An empty list looks exactly like a quiet log. The difference matters when
|
|
the reason somebody opened the panel is that something already broke."""
|
|
from core import backend_log
|
|
monkeypatch.setattr(backend_log, "_memory", lambda: None)
|
|
monkeypatch.setattr(backend_log, "log_file", lambda: None)
|
|
|
|
status, body = _request(server["port"], "GET", "/fun/api/log")
|
|
assert status == 200
|
|
assert body["lines"] == []
|
|
assert "terminal" in body["detail"], body["detail"]
|
|
|
|
|
|
def test_a_log_that_cannot_be_read_says_so_rather_than_raising(server, monkeypatch, tmp_path):
|
|
from core import backend_log
|
|
missing = tmp_path / "gone.log"
|
|
missing.write_text("x\n")
|
|
monkeypatch.setattr(backend_log, "_memory", lambda: None)
|
|
monkeypatch.setattr(backend_log, "log_file", lambda: missing)
|
|
missing.unlink()
|
|
|
|
status, body = _request(server["port"], "GET", "/fun/api/log")
|
|
assert status == 200, body
|
|
assert body["lines"] == []
|
|
assert "gone.log" in body["detail"]
|
|
|
|
|
|
def test_comfyuis_own_output_is_read_when_it_writes_no_file(server, monkeypatch):
|
|
"""Plain ComfyUI keeps its output in memory and writes no file; that copy is the log.
|
|
A progress bar's carriage returns keep only its last state."""
|
|
from collections import deque
|
|
from core import backend_log
|
|
writes = deque([{"m": "boot\n"}, {"m": " 5%|#\r 100%|##########"}, {"m": "\n"}, {"m": "\x1b[33mPrompt executed\x1b[0m\n"}])
|
|
monkeypatch.setattr(backend_log, "_memory", lambda: writes)
|
|
monkeypatch.setattr(backend_log, "log_file", lambda: None)
|
|
|
|
status, body = _request(server["port"], "GET", "/fun/api/log")
|
|
assert status == 200, body
|
|
assert body["lines"] == ["boot", " 100%|##########", "Prompt executed"]
|
|
assert body["detail"] == ""
|
|
|
|
|
|
def test_a_silly_limit_does_not_read_a_whole_session_into_memory(server):
|
|
for limit in ("999999", "-4", "0", "not-a-number"):
|
|
status, body = _request(server["port"], "GET", f"/fun/api/log?limit={limit}")
|
|
assert status == 200, (limit, body)
|
|
assert len(body["lines"]) <= 2000, limit
|
|
|
|
|
|
def test_funuis_own_log_answers_at_its_own_path_not_the_backend_ones(server):
|
|
"""Both logs were once registered on the same path, one shadowing the
|
|
other -- FunUI's severity/source log never answered, and its consumer
|
|
crashed on `data.levels`. They live at different paths now."""
|
|
status, body = _request(server["port"], "GET", "/fun/api/log/fun")
|
|
assert status == 200, body
|
|
assert "levels" in body and "records" in body
|
|
assert isinstance(body["levels"], list)
|
|
|
|
|
|
# --- temp files --------------------------------------------------------------
|
|
|
|
def test_temp_files_come_back_newest_first_with_where_they_are(server, monkeypatch, tmp_path):
|
|
"""Newest first, because the file somebody is hunting for is the one just
|
|
written."""
|
|
import os
|
|
from core import temp_files
|
|
(tmp_path / "sub").mkdir()
|
|
old = tmp_path / "old.png"
|
|
new = tmp_path / "sub" / "new.mp4"
|
|
old.write_bytes(b"x")
|
|
new.write_bytes(b"yy")
|
|
os.utime(old, (1, 1))
|
|
os.utime(new, (2000, 2000))
|
|
monkeypatch.setattr(temp_files, "temp_dir", lambda: str(tmp_path))
|
|
|
|
status, body = _request(server["port"], "GET", "/fun/api/temp")
|
|
assert status == 200, body
|
|
assert [f["filename"] for f in body["files"]] == ["new.mp4", "old.png"]
|
|
assert body["files"][0]["subfolder"] == "sub"
|
|
assert body["files"][0]["kind"] == "video"
|
|
assert body["files"][1]["subfolder"] == "", "a file at the root grew a subfolder"
|
|
assert body["path"] == str(tmp_path)
|
|
|
|
|
|
def test_only_media_is_listed(server, monkeypatch, tmp_path):
|
|
"""A temp directory fills with whatever any node felt like writing, and a
|
|
browser listing 400 .pt files is not a media bin."""
|
|
from core import temp_files
|
|
for name in ("keep.png", "keep.wav", "weights.pt", "notes.txt", "no_extension"):
|
|
(tmp_path / name).write_bytes(b"x")
|
|
monkeypatch.setattr(temp_files, "temp_dir", lambda: str(tmp_path))
|
|
|
|
status, body = _request(server["port"], "GET", "/fun/api/temp")
|
|
assert sorted(f["filename"] for f in body["files"]) == ["keep.png", "keep.wav"]
|
|
|
|
|
|
def test_an_empty_temp_directory_says_why_it_is_empty(server, monkeypatch, tmp_path):
|
|
from core import temp_files
|
|
monkeypatch.setattr(temp_files, "temp_dir", lambda: str(tmp_path))
|
|
status, body = _request(server["port"], "GET", "/fun/api/temp")
|
|
assert body["files"] == []
|
|
assert "wiped" in body["detail"]
|
|
|
|
|
|
def test_no_comfyui_is_a_different_answer_from_an_empty_directory(server, monkeypatch):
|
|
from core import temp_files
|
|
monkeypatch.setattr(temp_files, "temp_dir", lambda: None)
|
|
status, body = _request(server["port"], "GET", "/fun/api/temp")
|
|
assert body["files"] == []
|
|
assert "ComfyUI is not here" in body["detail"]
|
|
assert body["path"] is None
|
|
|
|
|
|
def test_the_listing_is_bounded(server, monkeypatch, tmp_path):
|
|
from core import temp_files
|
|
for i in range(30):
|
|
(tmp_path / f"f{i}.png").write_bytes(b"x")
|
|
monkeypatch.setattr(temp_files, "temp_dir", lambda: str(tmp_path))
|
|
|
|
status, body = _request(server["port"], "GET", "/fun/api/temp?limit=10")
|
|
assert len(body["files"]) == 10
|
|
status, body = _request(server["port"], "GET", "/fun/api/temp?limit=999999")
|
|
assert len(body["files"]) <= temp_files.MAX_FILES
|
|
|
|
|
|
def test_requirements_route_runs_the_check_and_says_what_it_did(server, monkeypatch):
|
|
monkeypatch.setattr(routes, "_generation_running", lambda: False)
|
|
monkeypatch.setattr(update_mod, "install_requirements",
|
|
lambda: {"ran": False, "ok": True, "detail": "Nothing to install — every requirement is already present."})
|
|
status, body = _request(server["port"], "POST", "/fun/api/requirements")
|
|
assert status == 200 and body["ran"] is False and "already present" in body["detail"]
|
|
|
|
|
|
def test_requirements_route_refuses_during_a_generation(server, monkeypatch):
|
|
monkeypatch.setattr(routes, "_generation_running", lambda: True)
|
|
monkeypatch.setattr(update_mod, "install_requirements",
|
|
lambda: (_ for _ in ()).throw(AssertionError("must not install during a generation")))
|
|
status, body = _request(server["port"], "POST", "/fun/api/requirements")
|
|
assert status == 409 and "generation" in body["detail"]
|
|
|
|
|
|
# --- the gallery: saved files, and deleting them -------------------------------
|
|
|
|
def test_saved_files_are_listed_from_the_output_folder(server, monkeypatch, tmp_path):
|
|
from core import temp_files
|
|
(tmp_path / "FunUI_00001_.mp4").write_bytes(b"x")
|
|
monkeypatch.setattr(temp_files, "output_dir", lambda: str(tmp_path))
|
|
monkeypatch.setattr(temp_files, "temp_dir", lambda: None)
|
|
status, body = _request(server["port"], "GET", "/fun/api/temp?where=output")
|
|
assert [f["filename"] for f in body["files"]] == ["FunUI_00001_.mp4"]
|
|
status, body = _request(server["port"], "GET", "/fun/api/temp")
|
|
assert body["files"] == [], "temp and saved were mixed up"
|
|
|
|
|
|
def test_delete_removes_a_media_file_and_nothing_else(server, monkeypatch, tmp_path):
|
|
from core import temp_files
|
|
out = tmp_path / "out"
|
|
(out / "sub").mkdir(parents=True)
|
|
pic, weights, outside = out / "sub" / "a.png", out / "model.safetensors", tmp_path / "secret.png"
|
|
for f in (pic, weights, outside):
|
|
f.write_bytes(b"x")
|
|
monkeypatch.setattr(temp_files, "output_dir", lambda: str(out))
|
|
port = server["port"]
|
|
ask = lambda **kw: _request(port, "POST", "/fun/api/temp/delete", {"where": "output", **kw})
|
|
|
|
assert ask(filename="a.png", subfolder="sub")[0] == 200 and not pic.exists()
|
|
assert ask(filename="model.safetensors")[0] == 400 and weights.exists(), "a non-media file was deleted"
|
|
assert ask(filename="secret.png", subfolder="..")[0] == 400 and outside.exists(), "left the folder"
|
|
assert ask(filename="../secret.png")[0] == 400 and outside.exists()
|
|
assert ask(filename="a.png", subfolder="sub")[0] == 400, "a second delete should say it is gone"
|
|
assert _request(port, "POST", "/fun/api/temp/delete", {"where": "input", "filename": "a.png"})[0] == 400
|