Files

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