* perf(images): refresh the listing when the monitor sees a new output (#348, PR 2) The image monitor now reports every settled output (linked or not, also when linking raised) with its resolved path through set_output_listener. The API's notify_output_settled hops from the monitor's worker thread onto the server's loop (call_soon_threadsafe) and does nothing before the loop is known or after cleanup. mark_root_dirty matches the path against each walk's resolved_root (normcase(realpath), computed in the executor by walk_root, now its fourth value) and marks the root dirty. A dirty root gets one background walk, no sooner than rewalk_interval = min(15, max(5, 5 x last walk's duration)) after the previous walk started (last_started_at / last_duration are recorded on every walk). _schedule_walk is shared with PR 1's delayed stale refresh: one deferred walk per root, never armed while a walk runs (the walk's end re-checks dirty: the trailing walk), at least 0.5 s away, the earlier deadline wins, and every walk start cancels it. A failed walk clears dirty. Rescan semantics are unchanged. * fix(images): keep removed roots and cancelled walks from walking again (#348, PR 2) Review findings on the monitor-triggered walks: - reset_gallery_roots_cache now retires every listing record: its pending timer is cancelled, dirty is cleared, and no mark, timer or trailing walk starts a walk of it until a reader or the warm-up asks for that root again. Before, a timer or trailing walk pending when the roots changed stored a listing for the removed root, and the monitor (which watches the old roots until restart) kept it walking on every new output. - A walk task cancelled outside cleanup_gallery_listings no longer starts a trailing walk while the loop shuts down. - root_contains ignores a Windows extended-length prefix (\\?\ and \\?\UNC\) on either side, which realpath can keep on a file past MAX_PATH. - The REWALK comment states the ~20% GIL share only for walks up to 3 s. * test(images): prove a cancelled walk starts no trailing walk (#348, PR 2) The previous assertions held even with the trailing walk, because that walk finished before they ran; assert the walk number and the scan count. * perf(images): add a settled output to the listing without a walk (#348, PR 2) Live test: each landing in a 20,000-file folder cost a full walk (~0.4 s of GIL) plus a full workspace rebuild, so Images reads during a run took up to 2.1 s. The monitor's report now patches the stored listing instead. - output_settled (the monitor hop's target) slices the file's part below the root in its own case (relative_part; Windows compares folded), and _run_adds places one root's pending files in one executor batch: _settled_entry applies the walk's filters (suffix, thumbnails, depth, symlink by lstat; macOS on-disk spelling), insert_entry puts the (Path, mtime) entry where the newest-first sort does, add_folder copies the folder sets. Stored only when no walk started and no drop happened meanwhile; walked_at is untouched, so the 30 s refresh still walks. - Anything inexact marks the root dirty and walks as before: a running walk, an unsliceable path, a failed lstat, a tie in mtime, the cap, an unexpected error. Rescan, the refresh, the warm-up and invalidation stay full walks; a walk start forgets the pending adds. - The workspace caches follow a patched listing by entry identity (listing_delta): only added values are built, the newest order places them by bisection, the favorites lookup is copied and extended. Synthetic 20,000 files: per landing 252 ms (walk + full build) -> 18 ms (3 ms add + 15 ms warm build). * fix(images): review fixes for adds without a walk (#348, PR 2) - _newest_step steps the cached newest order only when the per-file cache still holds exactly the values this call built. A stale page read racing a newer warm build (both run in executor threads) stored an order labelled with older values than it held, and the next read inserted the new file a second time. It also sorts in full when a roots reset dropped the per-file values meanwhile. - The stored spelling is checked on Windows too (nt._getfinalpathname: realpath keeps the given case when it cannot open the file), and before the name filters, which compare the walk's stored spelling. macOS opens non-blocking, so a FIFO named like an image never holds the executor. - An output in a folder the walk cannot list (no read permission) is not added, as the walk skips it. - Windows-portable unit tests: POSIX paths in the insert tests, and a str.lower fold for the length guard (Windows' normcase keeps lengths). * test(images): reproduce the newest-order race and fix a Windows clock flake (#348, PR 2) - The race test now has the newer build step from the values the cached order was built from, which is the interleaving that inserted a file twice; it fails without the parts check in _newest_step. - test_a_failed_walk_records_its_timing_and_clears_dirty compared two monotonic readings that Windows' ~15.6 ms clock can make equal; the previous start is moved back instead. - TESTING.md: os.access ignores ACLs on Windows.
4232 lines
172 KiB
Python
4232 lines
172 KiB
Python
"""
|
|
Route tests for the image API (py/api/images.py).
|
|
|
|
Uses aiohttp's test client against a real PromptManagerAPI wired to a
|
|
temporary SQLite database and a temporary output directory.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import unittest
|
|
from contextlib import ExitStack
|
|
from pathlib import Path, PurePath
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
# ComfyUI-only modules are stubbed so the API can be imported standalone.
|
|
try:
|
|
from host_stubs import install_folder_paths_stub
|
|
except ImportError: # imported as tests.test_api_images
|
|
from tests.host_stubs import install_folder_paths_stub
|
|
|
|
install_folder_paths_stub()
|
|
sys.modules.setdefault("server", MagicMock())
|
|
|
|
from aiohttp import web # noqa: E402
|
|
from aiohttp.test_utils import AioHTTPTestCase # noqa: E402
|
|
from PIL import Image # noqa: E402
|
|
|
|
from database.operations import PromptDatabase # noqa: E402
|
|
import py.api.images as images_module
|
|
from py.api import gallery_listing # noqa: E402
|
|
from py.api.thumbnail_settings import ThumbnailSettings # noqa: E402
|
|
from py.api import PromptManagerAPI # noqa: E402
|
|
from py.api.prompts import MAX_PAGE_LIMIT # noqa: E402
|
|
from py.config import GalleryConfig # noqa: E402
|
|
from utils.hashing import generate_prompt_hash # noqa: E402
|
|
|
|
|
|
def make_png(path, size=(4, 4), color=(255, 0, 0)):
|
|
"""Write a tiny but valid PNG to *path* and return it as a Path."""
|
|
path = Path(path)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with Image.new("RGB", size, color) as img:
|
|
img.save(path, "PNG")
|
|
return path
|
|
|
|
|
|
class ImageAPITestCase(AioHTTPTestCase):
|
|
"""App with PromptManager routes, a temp DB and a temp output directory."""
|
|
|
|
async def get_application(self):
|
|
self._temp_db = tempfile.NamedTemporaryFile(delete=False, suffix=".db")
|
|
self._temp_db.close()
|
|
|
|
self._output_tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
|
# A fake ComfyUI tree: <tmp>/output is the gallery root and <tmp> is the
|
|
# anchor that public (response) paths are rendered relative to.
|
|
self.comfy_dir = Path(os.path.realpath(self._output_tmp.name))
|
|
self.output_dir = self.comfy_dir / "output"
|
|
self.output_dir.mkdir()
|
|
anchors = [os.path.normcase(str(self.comfy_dir))]
|
|
self._anchor_patch = patch.object(
|
|
GalleryConfig, "path_anchors", classmethod(lambda cls: list(anchors))
|
|
)
|
|
self._anchor_patch.start()
|
|
|
|
app = web.Application()
|
|
routes = web.RouteTableDef()
|
|
|
|
self.api = PromptManagerAPI(db=PromptDatabase(self._temp_db.name))
|
|
self.api._get_all_output_dirs = lambda: [self.output_dir]
|
|
self.api._find_comfyui_output_dir = lambda: str(self.output_dir)
|
|
self.api.add_routes(routes)
|
|
app.router.add_routes(routes)
|
|
return app
|
|
|
|
async def tearDownAsync(self):
|
|
await super().tearDownAsync() # closes the aiohttp test client
|
|
# The automatic preview worker writes into the temp tree: stop it first.
|
|
self.api.close_auto_thumbnails()
|
|
self._anchor_patch.stop()
|
|
# Windows refuses to unlink a database that still has open handles.
|
|
self.api.db.close_all()
|
|
for path in (
|
|
self._temp_db.name,
|
|
self._temp_db.name + "-wal",
|
|
self._temp_db.name + "-shm",
|
|
):
|
|
if os.path.exists(path):
|
|
os.unlink(path)
|
|
self._output_tmp.cleanup()
|
|
|
|
# -- helpers ---------------------------------------------------------
|
|
|
|
def _save_prompt(self, text="Test prompt", **kwargs):
|
|
return self.api.db.save_prompt(
|
|
text=text, prompt_hash=generate_prompt_hash(text), **kwargs
|
|
)
|
|
|
|
def _link_image(self, image_path, text="Linked prompt"):
|
|
"""Create a prompt and link *image_path* to it; return the image id."""
|
|
prompt_id = self._save_prompt(text)
|
|
image_id = self.api.db.link_image_to_prompt(prompt_id, str(image_path))
|
|
self.assertGreater(image_id, 0, "fixture: image link failed")
|
|
return image_id
|
|
|
|
def _make_symlink(self, target, link):
|
|
"""Create a symlink or skip the test when the platform refuses."""
|
|
try:
|
|
os.symlink(str(target), str(link))
|
|
except (OSError, NotImplementedError, AttributeError) as exc:
|
|
self.skipTest(f"symlinks unavailable: {exc}")
|
|
|
|
|
|
class TestServeOutputImage(ImageAPITestCase):
|
|
"""GET /prompt_manager/images/serve/{filepath}"""
|
|
|
|
async def test_serves_png_inside_output_dir(self):
|
|
make_png(self.output_dir / "sub" / "pic.png")
|
|
|
|
resp = await self.client.request(
|
|
"GET", "/prompt_manager/images/serve/sub/pic.png"
|
|
)
|
|
|
|
self.assertEqual(resp.status, 200)
|
|
self.assertEqual(resp.headers["Content-Type"], "image/png")
|
|
body = await resp.read()
|
|
self.assertTrue(body.startswith(b"\x89PNG"))
|
|
|
|
async def test_non_media_extensions_are_forbidden(self):
|
|
for name in ("secret.py", "prompts.db", "notes.txt"):
|
|
(self.output_dir / name).write_text("not an image")
|
|
resp = await self.client.request(
|
|
"GET", f"/prompt_manager/images/serve/{name}"
|
|
)
|
|
self.assertEqual(resp.status, 403, name)
|
|
data = await resp.json()
|
|
self.assertFalse(data["success"])
|
|
|
|
async def test_extension_check_is_case_insensitive(self):
|
|
make_png(self.output_dir / "UPPER.PNG")
|
|
|
|
resp = await self.client.request(
|
|
"GET", "/prompt_manager/images/serve/UPPER.PNG"
|
|
)
|
|
|
|
self.assertEqual(resp.status, 200)
|
|
|
|
async def test_empty_allowed_dirs_fails_closed(self):
|
|
make_png(self.output_dir / "pic.png")
|
|
self.api._get_all_output_dirs = lambda: []
|
|
|
|
resp = await self.client.request("GET", "/prompt_manager/images/serve/pic.png")
|
|
|
|
self.assertEqual(resp.status, 403)
|
|
data = await resp.json()
|
|
self.assertFalse(data["success"])
|
|
|
|
async def test_dot_dot_traversal_is_forbidden(self):
|
|
outside = Path(self._output_tmp.name).parent / "pm_outside_probe.png"
|
|
make_png(outside)
|
|
self.addCleanup(lambda: outside.exists() and outside.unlink())
|
|
|
|
# "..%2f" survives URL normalisation and reaches the handler as "../".
|
|
resp = await self.client.request(
|
|
"GET", "/prompt_manager/images/serve/..%2fpm_outside_probe.png"
|
|
)
|
|
|
|
self.assertEqual(resp.status, 403)
|
|
|
|
async def test_absolute_path_is_forbidden(self):
|
|
outside = Path(self._output_tmp.name).parent / "pm_abs_probe.png"
|
|
make_png(outside)
|
|
self.addCleanup(lambda: outside.exists() and outside.unlink())
|
|
|
|
encoded = str(outside).replace(os.sep, "%2f").replace("/", "%2f")
|
|
resp = await self.client.request(
|
|
"GET", f"/prompt_manager/images/serve/{encoded}"
|
|
)
|
|
|
|
self.assertEqual(resp.status, 403)
|
|
|
|
async def test_symlink_escaping_output_dir_is_forbidden(self):
|
|
with tempfile.TemporaryDirectory() as other:
|
|
target = make_png(Path(other) / "elsewhere.png")
|
|
self._make_symlink(target, self.output_dir / "escape.png")
|
|
|
|
resp = await self.client.request(
|
|
"GET", "/prompt_manager/images/serve/escape.png"
|
|
)
|
|
|
|
self.assertEqual(resp.status, 403)
|
|
|
|
async def test_every_gallery_video_format_is_served(self):
|
|
# The gallery lists these as playable cards; opening them must not be a 403.
|
|
for ext in sorted(images_module.VIDEO_EXTENSIONS):
|
|
(self.output_dir / f"clip{ext}").write_bytes(b"\x00\x00\x00\x18ftyp")
|
|
|
|
for ext in sorted(images_module.VIDEO_EXTENSIONS):
|
|
resp = await self.client.request(
|
|
"GET", f"/prompt_manager/images/serve/clip{ext}"
|
|
)
|
|
await resp.read()
|
|
self.assertEqual(resp.status, 200, ext)
|
|
|
|
async def test_missing_file_is_404(self):
|
|
resp = await self.client.request("GET", "/prompt_manager/images/serve/nope.png")
|
|
|
|
self.assertEqual(resp.status, 404)
|
|
data = await resp.json()
|
|
self.assertFalse(data["success"])
|
|
|
|
async def test_root_index_selects_single_root(self):
|
|
with tempfile.TemporaryDirectory() as second:
|
|
second_dir = Path(os.path.realpath(second))
|
|
make_png(second_dir / "only_here.png")
|
|
self.api._get_all_output_dirs = lambda: [self.output_dir, second_dir]
|
|
|
|
hit = await self.client.request(
|
|
"GET", "/prompt_manager/images/serve/only_here.png?root=1"
|
|
)
|
|
miss = await self.client.request(
|
|
"GET", "/prompt_manager/images/serve/only_here.png?root=0"
|
|
)
|
|
bad_index = await self.client.request(
|
|
"GET", "/prompt_manager/images/serve/only_here.png?root=abc"
|
|
)
|
|
|
|
self.assertEqual(hit.status, 200)
|
|
self.assertEqual(miss.status, 404)
|
|
self.assertEqual(bad_index.status, 403)
|
|
for resp in (hit, miss, bad_index):
|
|
await resp.read() # release the served file before the directory goes
|
|
|
|
|
|
class TestServeImageById(ImageAPITestCase):
|
|
"""GET /prompt_manager/images/{image_id}/file"""
|
|
|
|
async def test_serves_linked_png(self):
|
|
png = make_png(self.output_dir / "linked.png")
|
|
image_id = self._link_image(png)
|
|
|
|
resp = await self.client.request(
|
|
"GET", f"/prompt_manager/images/{image_id}/file"
|
|
)
|
|
|
|
self.assertEqual(resp.status, 200)
|
|
self.assertEqual(resp.headers["Content-Type"], "image/png")
|
|
|
|
async def test_non_media_record_is_forbidden(self):
|
|
txt = self.output_dir / "linked.txt"
|
|
txt.write_text("plain")
|
|
image_id = self._link_image(txt)
|
|
|
|
resp = await self.client.request(
|
|
"GET", f"/prompt_manager/images/{image_id}/file"
|
|
)
|
|
|
|
self.assertEqual(resp.status, 403)
|
|
|
|
async def test_empty_allowed_dirs_fails_closed(self):
|
|
png = make_png(self.output_dir / "linked.png")
|
|
image_id = self._link_image(png)
|
|
self.api._get_all_output_dirs = lambda: []
|
|
|
|
resp = await self.client.request(
|
|
"GET", f"/prompt_manager/images/{image_id}/file"
|
|
)
|
|
|
|
self.assertEqual(resp.status, 403)
|
|
|
|
async def test_record_outside_allowed_dirs_is_forbidden(self):
|
|
with tempfile.TemporaryDirectory() as other:
|
|
png = make_png(Path(other) / "outside.png")
|
|
image_id = self._link_image(png)
|
|
|
|
resp = await self.client.request(
|
|
"GET", f"/prompt_manager/images/{image_id}/file"
|
|
)
|
|
|
|
self.assertEqual(resp.status, 403)
|
|
|
|
async def test_symlink_record_escaping_is_forbidden(self):
|
|
with tempfile.TemporaryDirectory() as other:
|
|
target = make_png(Path(other) / "elsewhere.png")
|
|
link = self.output_dir / "escape.png"
|
|
self._make_symlink(target, link)
|
|
image_id = self._link_image(link)
|
|
|
|
resp = await self.client.request(
|
|
"GET", f"/prompt_manager/images/{image_id}/file"
|
|
)
|
|
|
|
self.assertEqual(resp.status, 403)
|
|
|
|
async def test_missing_file_is_404(self):
|
|
image_id = self._link_image(self.output_dir / "gone.png")
|
|
|
|
resp = await self.client.request(
|
|
"GET", f"/prompt_manager/images/{image_id}/file"
|
|
)
|
|
|
|
self.assertEqual(resp.status, 404)
|
|
|
|
async def test_unknown_id_is_404(self):
|
|
resp = await self.client.request("GET", "/prompt_manager/images/424242/file")
|
|
|
|
self.assertEqual(resp.status, 404)
|
|
|
|
async def test_non_numeric_id_is_400(self):
|
|
resp = await self.client.request("GET", "/prompt_manager/images/abc/file")
|
|
|
|
self.assertEqual(resp.status, 400)
|
|
|
|
|
|
class TestRecentImagesBounds(ImageAPITestCase):
|
|
"""GET /prompt_manager/images/recent clamps limit/offset and rejects junk."""
|
|
|
|
async def test_limit_is_clamped_to_max_page_limit(self):
|
|
resp = await self.client.request(
|
|
"GET", "/prompt_manager/images/recent?limit=999999"
|
|
)
|
|
|
|
self.assertEqual(resp.status, 200)
|
|
data = await resp.json()
|
|
self.assertTrue(data["success"])
|
|
self.assertLessEqual(len(data["images"]), MAX_PAGE_LIMIT)
|
|
self.assertEqual(data["pagination"]["limit"], MAX_PAGE_LIMIT)
|
|
|
|
async def test_non_integer_limit_is_400(self):
|
|
resp = await self.client.request(
|
|
"GET", "/prompt_manager/images/recent?limit=abc"
|
|
)
|
|
|
|
self.assertEqual(resp.status, 400)
|
|
data = await resp.json()
|
|
self.assertFalse(data["success"])
|
|
self.assertIn("error", data)
|
|
|
|
async def test_negative_offset_is_clamped_to_zero(self):
|
|
# Negative offsets are clamped rather than rejected so that a
|
|
# client paging backwards past the first page still gets page one.
|
|
resp = await self.client.request(
|
|
"GET", "/prompt_manager/images/recent?offset=-5"
|
|
)
|
|
|
|
self.assertEqual(resp.status, 200)
|
|
data = await resp.json()
|
|
self.assertEqual(data["pagination"]["offset"], 0)
|
|
|
|
async def test_offset_skips_rows(self):
|
|
for name in ("a.png", "b.png", "c.png"):
|
|
self._link_image(make_png(self.output_dir / name), text=f"p-{name}")
|
|
|
|
page = await self.client.request(
|
|
"GET", "/prompt_manager/images/recent?limit=2&offset=2"
|
|
)
|
|
|
|
data = await page.json()
|
|
self.assertEqual(len(data["images"]), 1)
|
|
self.assertEqual(data["pagination"]["offset"], 2)
|
|
|
|
|
|
def parse_sse(text):
|
|
"""Turn an SSE body into a list of (event, data_dict) tuples."""
|
|
events = []
|
|
for block in text.strip().split("\n\n"):
|
|
event, data = None, None
|
|
for line in block.splitlines():
|
|
if line.startswith("event:"):
|
|
event = line[len("event:") :].strip()
|
|
elif line.startswith("data:"):
|
|
data = json.loads(line[len("data:") :].strip())
|
|
if event is not None:
|
|
events.append((event, data))
|
|
return events
|
|
|
|
|
|
class ThumbnailTestCase(ImageAPITestCase):
|
|
"""Shared helpers for the thumbnail routes."""
|
|
|
|
PROGRESS_URL = "/prompt_manager/images/generate-thumbnails/progress"
|
|
|
|
def _spy_executor(self):
|
|
"""Record the __name__ of every callable sent to _run_in_executor."""
|
|
original = self.api._run_in_executor
|
|
calls = []
|
|
|
|
async def spy(func, *args, **kwargs):
|
|
calls.append(getattr(func, "__name__", repr(func)))
|
|
return await original(func, *args, **kwargs)
|
|
|
|
self.api._run_in_executor = spy
|
|
return calls
|
|
|
|
def _thumb_path(self, name, suffix=".webp"):
|
|
return self.output_dir / "thumbnails" / f"{name}_thumb{suffix}"
|
|
|
|
|
|
class TestThumbnailProgressStream(ThumbnailTestCase):
|
|
"""POST /prompt_manager/images/generate-thumbnails/progress (SSE)."""
|
|
|
|
def _record_threads(self):
|
|
"""Make every thumbnail write record its thread and take a moment."""
|
|
import threading
|
|
import time
|
|
|
|
from py.api import images as images_module
|
|
|
|
seen = set()
|
|
lock = threading.Lock()
|
|
original = images_module._write_image_thumbnail
|
|
|
|
def recording(src, dst, size, *args):
|
|
with lock:
|
|
seen.add(threading.current_thread().name)
|
|
time.sleep(0.05)
|
|
return original(src, dst, size, *args)
|
|
|
|
images_module._write_image_thumbnail = recording
|
|
self.addCleanup(setattr, images_module, "_write_image_thumbnail", original)
|
|
return seen
|
|
|
|
async def test_thumbnails_are_generated_on_several_threads(self):
|
|
from py.config import PromptManagerConfig
|
|
|
|
for i in range(8):
|
|
make_png(self.output_dir / f"p{i}.png", size=(64, 64))
|
|
orig = PromptManagerConfig.WORKER_THREADS
|
|
PromptManagerConfig.WORKER_THREADS = 4
|
|
self.addCleanup(setattr, PromptManagerConfig, "WORKER_THREADS", orig)
|
|
seen = self._record_threads()
|
|
|
|
resp = await self.client.request("POST", self.PROGRESS_URL)
|
|
events = parse_sse(await resp.text())
|
|
|
|
names = [e for e, _ in events]
|
|
self.assertEqual(names.count("progress"), 8)
|
|
self.assertEqual(dict(events)["complete"]["count"], 8)
|
|
self.assertGreaterEqual(len(seen), 2)
|
|
for i in range(8):
|
|
self.assertTrue(self._thumb_path(f"p{i}").is_file())
|
|
|
|
async def test_blocking_endpoint_also_uses_the_worker_threads(self):
|
|
from py.config import PromptManagerConfig
|
|
|
|
for i in range(8):
|
|
make_png(self.output_dir / f"p{i}.png", size=(64, 64))
|
|
orig = PromptManagerConfig.WORKER_THREADS
|
|
PromptManagerConfig.WORKER_THREADS = 4
|
|
self.addCleanup(setattr, PromptManagerConfig, "WORKER_THREADS", orig)
|
|
seen = self._record_threads()
|
|
|
|
resp = await self.client.request(
|
|
"POST", "/prompt_manager/images/generate-thumbnails", json={}
|
|
)
|
|
|
|
self.assertEqual((await resp.json())["count"], 8)
|
|
self.assertGreaterEqual(len(seen), 2)
|
|
|
|
async def test_get_is_method_not_allowed(self):
|
|
# The stream writes thumbnails, so a plain GET (prefetch, link
|
|
# preview, <img src>) must not be able to trigger it.
|
|
make_png(self.output_dir / "a.png")
|
|
|
|
resp = await self.client.request("GET", self.PROGRESS_URL)
|
|
|
|
self.assertEqual(resp.status, 405)
|
|
self.assertFalse(self._thumb_path("a").exists())
|
|
|
|
async def test_three_pngs_yield_three_thumbnails_and_events(self):
|
|
for name in ("a", "b", "c"):
|
|
make_png(self.output_dir / f"{name}.png", size=(64, 64))
|
|
calls = self._spy_executor()
|
|
|
|
resp = await self.client.request("POST", f"{self.PROGRESS_URL}?quality=low")
|
|
|
|
self.assertEqual(resp.status, 200)
|
|
self.assertEqual(resp.headers["Content-Type"], "text/event-stream")
|
|
events = parse_sse(await resp.text())
|
|
names = [e for e, _ in events]
|
|
self.assertEqual(names.count("progress"), 3)
|
|
self.assertEqual(names.count("complete"), 1)
|
|
complete = dict(events)["complete"]
|
|
self.assertEqual(complete["count"], 3)
|
|
for name in ("a", "b", "c"):
|
|
self.assertTrue(self._thumb_path(name).is_file(), name)
|
|
with Image.open(self._thumb_path("a")) as thumb:
|
|
self.assertLessEqual(max(thumb.size), 150)
|
|
# PIL work ran in the executor in batches (never on the loop), plus the scan.
|
|
self.assertGreaterEqual(calls.count("_generate_batch"), 1)
|
|
self.assertNotIn("_generate_one", calls)
|
|
self.assertIn("_plan_gallery_thumbnails", calls)
|
|
|
|
async def test_a_run_past_the_batch_cap_goes_on_to_the_end(self):
|
|
# The cap bounds one batch; the run plans again and finishes (#317).
|
|
for name in ("a", "b", "c"):
|
|
make_png(self.output_dir / f"{name}.png", size=(64, 64))
|
|
|
|
with patch.object(images_module, "MAX_THUMBNAIL_FILES", 2):
|
|
first = parse_sse(
|
|
await (await self.client.request("POST", self.PROGRESS_URL)).text()
|
|
)
|
|
second = parse_sse(
|
|
await (await self.client.request("POST", self.PROGRESS_URL)).text()
|
|
)
|
|
|
|
names = [e for e, _ in first]
|
|
self.assertEqual(names.count("progress"), 3) # only pending files are work
|
|
self.assertEqual(dict(first)["start"]["total_images"], 3)
|
|
self.assertEqual(
|
|
[d["total_images"] for e, d in first if e == "progress"], [3] * 3
|
|
)
|
|
done = dict(first)["complete"]
|
|
self.assertEqual(done["count"], 3)
|
|
self.assertEqual(done["remaining"], 0)
|
|
self.assertEqual(done["total_images"], 3)
|
|
self.assertNotIn("more", done["message"])
|
|
done = dict(second)["complete"]
|
|
self.assertEqual(done["count"], 0)
|
|
self.assertEqual(done["skipped"], 3)
|
|
|
|
async def test_blocking_endpoint_reports_remaining(self):
|
|
for name in ("a", "b", "c"):
|
|
make_png(self.output_dir / f"{name}.png", size=(64, 64))
|
|
|
|
with patch.object(images_module, "MAX_THUMBNAIL_FILES", 2):
|
|
resp = await self.client.request(
|
|
"POST", "/prompt_manager/images/generate-thumbnails", json={}
|
|
)
|
|
data = await resp.json()
|
|
|
|
self.assertEqual(data["count"], 2)
|
|
self.assertEqual(data["remaining"], 1)
|
|
self.assertEqual(data["total_images"], 3)
|
|
|
|
async def test_corrupt_png_is_reported_and_loop_continues(self):
|
|
make_png(self.output_dir / "good1.png")
|
|
(self.output_dir / "bad.png").write_bytes(b"\x89PNG definitely not a png")
|
|
make_png(self.output_dir / "good2.png")
|
|
|
|
resp = await self.client.request("POST", self.PROGRESS_URL)
|
|
|
|
events = parse_sse(await resp.text())
|
|
file_errors = [d for e, d in events if e == "file_error"]
|
|
self.assertEqual(len(file_errors), 1)
|
|
self.assertEqual(file_errors[0]["file"], "bad.png")
|
|
complete = dict(events)["complete"]
|
|
self.assertEqual(complete["count"], 2)
|
|
self.assertEqual(complete["error_count"], 1)
|
|
self.assertTrue(self._thumb_path("good1").is_file())
|
|
self.assertTrue(self._thumb_path("good2").is_file())
|
|
self.assertFalse(self._thumb_path("bad").exists())
|
|
|
|
async def test_response_has_no_wildcard_cors_header(self):
|
|
make_png(self.output_dir / "one.png")
|
|
|
|
resp = await self.client.request("POST", self.PROGRESS_URL)
|
|
await resp.text()
|
|
|
|
self.assertNotIn("Access-Control-Allow-Origin", resp.headers)
|
|
|
|
async def test_existing_newer_thumbnail_is_skipped(self):
|
|
make_png(self.output_dir / "one.png")
|
|
first = await self.client.request("POST", self.PROGRESS_URL)
|
|
await first.text()
|
|
|
|
second = await self.client.request("POST", self.PROGRESS_URL)
|
|
|
|
complete = dict(parse_sse(await second.text()))["complete"]
|
|
self.assertEqual(complete["skipped"], 1)
|
|
self.assertEqual(complete["count"], 0)
|
|
|
|
async def test_empty_output_dir_completes_with_zero(self):
|
|
resp = await self.client.request("POST", self.PROGRESS_URL)
|
|
|
|
events = parse_sse(await resp.text())
|
|
self.assertEqual(events[-1][0], "complete")
|
|
self.assertEqual(events[-1][1]["total_images"], 0)
|
|
|
|
async def test_missing_output_dir_sends_error_event(self):
|
|
self.api._get_all_output_dirs = lambda: []
|
|
|
|
resp = await self.client.request("POST", self.PROGRESS_URL)
|
|
|
|
events = parse_sse(await resp.text())
|
|
self.assertEqual(events[-1][0], "error")
|
|
|
|
async def test_any_quality_preset_uses_the_server_size_limit(self):
|
|
# #317 R3: the server's thumbnail settings size every preview.
|
|
make_png(self.output_dir / "one.png", size=(1000, 1000))
|
|
|
|
resp = await self.client.request("POST", f"{self.PROGRESS_URL}?quality=huge")
|
|
await resp.text()
|
|
|
|
with Image.open(self._thumb_path("one")) as thumb:
|
|
self.assertEqual(max(thumb.size), 512)
|
|
|
|
|
|
class TestGenerateThumbnailsPost(ThumbnailTestCase):
|
|
"""POST /prompt_manager/images/generate-thumbnails (blocking variant)."""
|
|
|
|
async def test_generates_then_skips(self):
|
|
make_png(self.output_dir / "sub" / "x.png")
|
|
make_png(self.output_dir / "y.png")
|
|
|
|
first = await self.client.request(
|
|
"POST",
|
|
"/prompt_manager/images/generate-thumbnails",
|
|
json={"quality": "high"},
|
|
)
|
|
second = await self.client.request(
|
|
"POST", "/prompt_manager/images/generate-thumbnails", json={}
|
|
)
|
|
|
|
self.assertEqual(first.status, 200)
|
|
data = await first.json()
|
|
self.assertTrue(data["success"])
|
|
self.assertEqual(data["count"], 2)
|
|
self.assertTrue(self._thumb_path("sub/x").is_file())
|
|
data2 = await second.json()
|
|
self.assertEqual(data2["skipped"], 2)
|
|
self.assertEqual(data2["count"], 0)
|
|
|
|
async def test_missing_output_dir_is_404(self):
|
|
self.api._get_all_output_dirs = lambda: []
|
|
|
|
resp = await self.client.request(
|
|
"POST", "/prompt_manager/images/generate-thumbnails", json={}
|
|
)
|
|
|
|
self.assertEqual(resp.status, 404)
|
|
|
|
async def test_no_media_reports_zero(self):
|
|
resp = await self.client.request(
|
|
"POST", "/prompt_manager/images/generate-thumbnails", json={}
|
|
)
|
|
|
|
data = await resp.json()
|
|
self.assertTrue(data["success"])
|
|
self.assertEqual(data["total_images"], 0)
|
|
|
|
|
|
class TestThumbnailRoutesHoldTheRegistry(ThumbnailTestCase):
|
|
"""Thumbnail work is ``guarded``: never beside a record-changing job."""
|
|
|
|
def _run_scan(self):
|
|
gate = asyncio.Event()
|
|
|
|
async def runner(job):
|
|
await gate.wait()
|
|
|
|
job = self.api._jobs.start(
|
|
"scan", "Scan for metadata", admission="records", scope={}, runner=runner
|
|
)
|
|
|
|
async def release():
|
|
gate.set()
|
|
await asyncio.wait_for(job.task, 5)
|
|
|
|
self.addAsyncCleanup(release)
|
|
return job
|
|
|
|
async def test_thumbnail_generate_refused_while_scan_runs(self):
|
|
make_png(self.output_dir / "a.png")
|
|
self._run_scan()
|
|
|
|
resp = await self.client.request(
|
|
"POST", "/prompt_manager/images/generate-thumbnails", json={}
|
|
)
|
|
self.assertEqual(resp.status, 409)
|
|
self.assertEqual(
|
|
await resp.json(),
|
|
{"success": False, "error": "Scan for metadata already running"},
|
|
)
|
|
|
|
resp = await self.client.request("POST", self.PROGRESS_URL)
|
|
text = await resp.text()
|
|
self.assertEqual(resp.status, 200) # the gallery reads an SSE stream
|
|
self.assertEqual(
|
|
text,
|
|
"event: error\ndata: "
|
|
+ json.dumps(
|
|
{
|
|
"error": "Scan for metadata already running",
|
|
"message": "Scan for metadata already running",
|
|
}
|
|
)
|
|
+ "\n\n",
|
|
)
|
|
|
|
thumbs = self.output_dir / "thumbnails"
|
|
make_png(thumbs / "b_thumb.png")
|
|
resp = await self.client.request(
|
|
"POST", "/prompt_manager/images/clear-thumbnails"
|
|
)
|
|
self.assertEqual(resp.status, 409)
|
|
self.assertFalse(self._thumb_path("a").exists())
|
|
self.assertTrue((thumbs / "b_thumb.png").exists())
|
|
|
|
async def test_scan_refused_while_thumbnails_generate(self):
|
|
make_png(self.output_dir / "a.png")
|
|
entered = threading.Event()
|
|
release = threading.Event()
|
|
real = self.api._generate_gallery_thumbnails_sync
|
|
|
|
def gated(*args):
|
|
entered.set()
|
|
release.wait(10)
|
|
return real(*args)
|
|
|
|
self.api._generate_gallery_thumbnails_sync = gated
|
|
loop = asyncio.get_running_loop()
|
|
generate = asyncio.ensure_future(
|
|
self.client.request(
|
|
"POST", "/prompt_manager/images/generate-thumbnails", json={}
|
|
)
|
|
)
|
|
self.assertTrue(await loop.run_in_executor(None, entered.wait, 10))
|
|
resp = await self.client.request("POST", "/prompt_manager/jobs/scan")
|
|
body = await resp.json()
|
|
self.assertEqual((resp.status, body["code"]), (409, "busy"))
|
|
self.assertEqual(
|
|
body["running"]["title"], "Thumbnail generation (gallery page)"
|
|
)
|
|
release.set()
|
|
self.assertEqual((await generate).status, 200)
|
|
self.assertTrue(self._thumb_path("a").exists())
|
|
|
|
|
|
class TestRecentImagesCaps(ImageAPITestCase):
|
|
"""Every path through /images/recent hands the DB a bounded window."""
|
|
|
|
def _spy_db(self):
|
|
seen = []
|
|
original = self.api.db.get_recent_images
|
|
|
|
def spy(limit, offset=0):
|
|
seen.append((limit, offset))
|
|
return original(limit, offset)
|
|
|
|
self.api.db.get_recent_images = spy
|
|
return seen
|
|
|
|
async def test_absent_params_use_bounded_default(self):
|
|
seen = self._spy_db()
|
|
|
|
resp = await self.client.request("GET", "/prompt_manager/images/recent")
|
|
|
|
data = await resp.json()
|
|
self.assertEqual(data["pagination"]["limit"], 50)
|
|
self.assertEqual(seen, [(50, 0)])
|
|
|
|
async def test_huge_offset_is_clamped_before_reaching_db(self):
|
|
seen = self._spy_db()
|
|
|
|
resp = await self.client.request(
|
|
"GET", "/prompt_manager/images/recent?limit=10&offset=999999999999"
|
|
)
|
|
|
|
self.assertEqual(resp.status, 200)
|
|
data = await resp.json()
|
|
# The offset is paged in SQL, so the DB sees the clamped value itself.
|
|
self.assertEqual(seen[0], (10, data["pagination"]["offset"]))
|
|
self.assertLess(data["pagination"]["offset"], 999999999999)
|
|
|
|
async def test_float_string_limit_is_400(self):
|
|
resp = await self.client.request(
|
|
"GET", "/prompt_manager/images/recent?limit=1e9"
|
|
)
|
|
|
|
self.assertEqual(resp.status, 400)
|
|
|
|
async def test_zero_limit_becomes_one(self):
|
|
seen = self._spy_db()
|
|
|
|
await self.client.request("GET", "/prompt_manager/images/recent?limit=0")
|
|
|
|
self.assertEqual(seen, [(1, 0)])
|
|
|
|
|
|
class TestAllImagesCaps(ImageAPITestCase):
|
|
"""/images/all never asks the DB for an unbounded result."""
|
|
|
|
def _spy_db(self):
|
|
seen = []
|
|
original = self.api.db.get_all_images
|
|
|
|
def spy(limit=0, offset=0):
|
|
seen.append((limit, offset))
|
|
return original(limit=limit, offset=offset)
|
|
|
|
self.api.db.get_all_images = spy
|
|
return seen
|
|
|
|
async def test_absent_params_use_bulk_ceiling(self):
|
|
seen = self._spy_db()
|
|
|
|
resp = await self.client.request("GET", "/prompt_manager/images/all")
|
|
|
|
data = await resp.json()
|
|
self.assertTrue(data["success"])
|
|
self.assertEqual(data["pagination"]["limit"], images_module.MAX_BULK_LIMIT)
|
|
self.assertEqual(seen, [(images_module.MAX_BULK_LIMIT, 0)])
|
|
|
|
async def test_limit_above_bulk_ceiling_is_clamped(self):
|
|
seen = self._spy_db()
|
|
|
|
await self.client.request("GET", "/prompt_manager/images/all?limit=99999999")
|
|
|
|
self.assertEqual(seen[0][0], images_module.MAX_BULK_LIMIT)
|
|
|
|
async def test_negative_offset_is_clamped(self):
|
|
seen = self._spy_db()
|
|
|
|
await self.client.request("GET", "/prompt_manager/images/all?offset=-9")
|
|
|
|
self.assertEqual(seen[0][1], 0)
|
|
|
|
async def test_non_integer_limit_is_400(self):
|
|
resp = await self.client.request("GET", "/prompt_manager/images/all?limit=abc")
|
|
|
|
self.assertEqual(resp.status, 400)
|
|
|
|
async def test_returns_linked_images(self):
|
|
self._link_image(make_png(self.output_dir / "one.png"))
|
|
|
|
resp = await self.client.request("GET", "/prompt_manager/images/all?limit=1")
|
|
|
|
data = await resp.json()
|
|
self.assertEqual(data["count"], 1)
|
|
self.assertEqual(data["pagination"]["count"], 1)
|
|
|
|
|
|
class TestOutputImagesCaps(ImageAPITestCase):
|
|
"""GET /prompt_manager/images/output clamps paging on every path."""
|
|
|
|
URL = "/prompt_manager/images/output"
|
|
|
|
async def test_absent_params_use_default_limit(self):
|
|
resp = await self.client.request("GET", self.URL)
|
|
|
|
data = await resp.json()
|
|
self.assertTrue(data["success"])
|
|
self.assertEqual(data["limit"], 100)
|
|
self.assertEqual(data["offset"], 0)
|
|
|
|
async def test_limit_is_clamped_to_max_page_limit(self):
|
|
resp = await self.client.request("GET", f"{self.URL}?limit=999999")
|
|
|
|
data = await resp.json()
|
|
self.assertEqual(data["limit"], MAX_PAGE_LIMIT)
|
|
|
|
async def test_zero_limit_becomes_one(self):
|
|
resp = await self.client.request("GET", f"{self.URL}?limit=0")
|
|
|
|
data = await resp.json()
|
|
self.assertEqual(data["limit"], 1)
|
|
|
|
async def test_float_string_limit_is_400(self):
|
|
resp = await self.client.request("GET", f"{self.URL}?limit=1e9")
|
|
|
|
self.assertEqual(resp.status, 400)
|
|
data = await resp.json()
|
|
self.assertFalse(data["success"])
|
|
|
|
async def test_negative_offset_is_clamped(self):
|
|
resp = await self.client.request("GET", f"{self.URL}?offset=-5")
|
|
|
|
data = await resp.json()
|
|
self.assertEqual(data["offset"], 0)
|
|
|
|
async def test_pages_and_filters_by_subfolder(self):
|
|
make_png(self.output_dir / "sub" / "a.png")
|
|
make_png(self.output_dir / "sub" / "b.png")
|
|
make_png(self.output_dir / "top.png")
|
|
make_png(self.output_dir / "thumbnails" / "top_thumb.png")
|
|
|
|
page = await self.client.request("GET", f"{self.URL}?subfolder=sub&limit=1")
|
|
rest = await self.client.request("GET", f"{self.URL}?limit=10")
|
|
|
|
data = await page.json()
|
|
self.assertEqual(data["total"], 2)
|
|
self.assertEqual(len(data["images"]), 1)
|
|
self.assertTrue(data["has_more"])
|
|
self.assertEqual(data["images"][0]["root_index"], 0)
|
|
all_data = await rest.json()
|
|
names = sorted(i["filename"] for i in all_data["images"])
|
|
self.assertEqual(names, ["a.png", "b.png", "top.png"])
|
|
top = next(i for i in all_data["images"] if i["filename"] == "top.png")
|
|
self.assertIn("top_thumb.png", top["thumbnail_url"])
|
|
|
|
async def test_no_output_dirs_reports_failure(self):
|
|
self.api._get_all_output_dirs = lambda: []
|
|
|
|
resp = await self.client.request("GET", self.URL)
|
|
|
|
data = await resp.json()
|
|
self.assertFalse(data["success"])
|
|
|
|
|
|
class TestThumbnailFilesAreNeverSeenHalfWritten(ThumbnailTestCase):
|
|
"""A browser that lists the gallery mid-run must never fetch a partial file,
|
|
and a regenerated thumbnail must never be served from a stale cache entry."""
|
|
|
|
def test_image_thumbnail_write_is_atomic(self):
|
|
import os
|
|
|
|
make_png(self.output_dir / "a.png", size=(64, 64))
|
|
thumbs = self.output_dir / "thumbnails"
|
|
thumbs.mkdir()
|
|
dst = thumbs / "a_thumb.webp"
|
|
settings = ThumbnailSettings("webp", 80, 32)
|
|
|
|
def crash_mid_write(self_img, fp, *args, **kwargs):
|
|
# Simulates an interrupted encoder: bytes on disk, then failure.
|
|
pathlib.Path(fp).write_bytes(b"\x89PNG partial")
|
|
raise OSError("disk full")
|
|
|
|
with patch.object(Image.Image, "save", crash_mid_write):
|
|
with self.assertRaises(OSError):
|
|
images_module._write_image_thumbnail(
|
|
self.output_dir / "a.png", dst, settings
|
|
)
|
|
self.assertFalse(dst.exists(), "a half-written thumbnail was left in place")
|
|
self.assertEqual(os.listdir(thumbs), [])
|
|
|
|
images_module._write_image_thumbnail(self.output_dir / "a.png", dst, settings)
|
|
self.assertTrue(dst.is_file())
|
|
self.assertEqual(os.listdir(thumbs), ["a_thumb.webp"])
|
|
|
|
def test_thumbnail_url_is_versioned_by_its_mtime(self):
|
|
import os
|
|
import re
|
|
|
|
make_png(self.output_dir / "a.png", size=(64, 64))
|
|
self.api._generate_thumbnails_sync(
|
|
self.output_dir,
|
|
self.output_dir / "thumbnails",
|
|
ThumbnailSettings("webp", 80, 32),
|
|
)
|
|
thumb = self._thumb_path("a")
|
|
|
|
entry = images_module._output_image_entry(
|
|
self.output_dir / "a.png", self.output_dir, 0
|
|
)
|
|
self.assertRegex(
|
|
entry["thumbnail_url"],
|
|
r"^/prompt_manager/images/serve/thumbnails/a_thumb\.webp\?v=\d+&root=0$",
|
|
)
|
|
first = int(re.search(r"v=(\d+)", entry["thumbnail_url"]).group(1))
|
|
self.assertEqual(first, int(thumb.stat().st_mtime))
|
|
|
|
later = thumb.stat().st_mtime + 10
|
|
os.utime(thumb, (later, later))
|
|
entry = images_module._output_image_entry(
|
|
self.output_dir / "a.png", self.output_dir, 0
|
|
)
|
|
second = int(re.search(r"v=(\d+)", entry["thumbnail_url"]).group(1))
|
|
self.assertEqual(second, first + 10)
|
|
|
|
async def test_versioned_thumbnail_url_is_served(self):
|
|
make_png(self.output_dir / "a.png", size=(64, 64))
|
|
self.api._generate_thumbnails_sync(
|
|
self.output_dir,
|
|
self.output_dir / "thumbnails",
|
|
ThumbnailSettings("webp", 80, 32),
|
|
)
|
|
entry = images_module._output_image_entry(
|
|
self.output_dir / "a.png", self.output_dir, 0
|
|
)
|
|
|
|
resp = await self.client.request("GET", entry["thumbnail_url"])
|
|
|
|
self.assertEqual(resp.status, 200)
|
|
self.assertEqual(resp.headers["Content-Type"], "image/webp")
|
|
|
|
async def test_missing_image_404_is_not_cacheable(self):
|
|
resp = await self.client.request(
|
|
"GET", "/prompt_manager/images/serve/thumbnails/nope_thumb.png"
|
|
)
|
|
self.assertEqual(resp.status, 404)
|
|
self.assertEqual(resp.headers.get("Cache-Control"), "no-store")
|
|
|
|
|
|
class TestMediaScanCaps(ImageAPITestCase):
|
|
"""Request-triggered filesystem walks are depth- and count-capped."""
|
|
|
|
def test_gallery_scan_honours_file_cap(self):
|
|
for name in ("a", "b", "c"):
|
|
make_png(self.output_dir / f"{name}.png")
|
|
|
|
with patch.object(images_module, "MAX_GALLERY_FILES", 2):
|
|
found = self.api._scan_gallery_files_sync(self.output_dir)
|
|
|
|
self.assertEqual(len(found), 2)
|
|
|
|
def test_gallery_scan_honours_depth_cap(self):
|
|
make_png(self.output_dir / "a" / "shallow.png")
|
|
make_png(self.output_dir / "a" / "b" / "deep.png")
|
|
|
|
with patch.object(images_module, "MAX_SCAN_DEPTH", 1):
|
|
found = [
|
|
p.name for p, _ in self.api._scan_gallery_files_sync(self.output_dir)
|
|
]
|
|
|
|
self.assertEqual(found, ["shallow.png"])
|
|
|
|
def test_gallery_scan_skips_symlinked_directories(self):
|
|
with tempfile.TemporaryDirectory() as other:
|
|
make_png(Path(other) / "outside.png")
|
|
self._make_symlink(other, self.output_dir / "link")
|
|
|
|
found = self.api._scan_gallery_files_sync(self.output_dir)
|
|
|
|
self.assertEqual(found, [])
|
|
|
|
def test_gallery_scan_skips_thumbnails_and_non_media(self):
|
|
make_png(self.output_dir / "keep.png")
|
|
make_png(self.output_dir / "thumbnails" / "keep_thumb.png")
|
|
(self.output_dir / "notes.txt").write_text("x")
|
|
|
|
found = [p.name for p, _ in self.api._scan_gallery_files_sync(self.output_dir)]
|
|
|
|
self.assertEqual(found, ["keep.png"])
|
|
|
|
def test_thumbnail_plan_skips_fresh_thumbnails_before_applying_the_cap(self):
|
|
for name in ("a", "b", "c"):
|
|
make_png(self.output_dir / f"{name}.png")
|
|
self.api._generate_thumbnails_sync(
|
|
self.output_dir,
|
|
self.output_dir / "thumbnails",
|
|
ThumbnailSettings("webp", 80, 64),
|
|
)
|
|
for name in ("d", "e", "f"):
|
|
make_png(self.output_dir / f"{name}.png")
|
|
|
|
with patch.object(images_module, "MAX_THUMBNAIL_FILES", 2):
|
|
plan = self.api._plan_thumbnails(
|
|
self.output_dir, self.output_dir / "thumbnails"
|
|
)
|
|
|
|
self.assertEqual(plan["skipped"], 3)
|
|
self.assertEqual([t[0].name for t in plan["targets"]], ["d.png", "e.png"])
|
|
self.assertEqual(plan["remaining"], 1)
|
|
|
|
def test_thumbnail_walk_is_bounded_like_the_gallery_walk(self):
|
|
for name in ("a", "b", "c"):
|
|
make_png(self.output_dir / f"{name}.png")
|
|
|
|
with patch.object(images_module, "MAX_GALLERY_FILES", 2):
|
|
plan = self.api._plan_thumbnails(
|
|
self.output_dir, self.output_dir / "thumbnails"
|
|
)
|
|
|
|
self.assertEqual(len(plan["targets"]), 2)
|
|
self.assertEqual(plan["remaining"], 0)
|
|
|
|
def test_thumbnail_targets_honour_file_cap(self):
|
|
for name in ("a", "b", "c"):
|
|
make_png(self.output_dir / f"{name}.png")
|
|
|
|
with patch.object(images_module, "MAX_THUMBNAIL_FILES", 2):
|
|
targets = self.api._thumbnail_targets(
|
|
self.output_dir, self.output_dir / "thumbnails"
|
|
)
|
|
|
|
self.assertEqual(len(targets), 2)
|
|
|
|
def test_thumbnail_targets_honour_depth_cap(self):
|
|
make_png(self.output_dir / "a" / "shallow.png")
|
|
make_png(self.output_dir / "a" / "b" / "deep.png")
|
|
|
|
with patch.object(images_module, "MAX_SCAN_DEPTH", 1):
|
|
targets = self.api._thumbnail_targets(
|
|
self.output_dir, self.output_dir / "thumbnails"
|
|
)
|
|
|
|
self.assertEqual([src.name for src, _, _ in targets], ["shallow.png"])
|
|
|
|
def test_thumbnail_targets_skip_symlinked_directories(self):
|
|
with tempfile.TemporaryDirectory() as other:
|
|
make_png(Path(other) / "outside.png")
|
|
self._make_symlink(other, self.output_dir / "link")
|
|
|
|
targets = self.api._thumbnail_targets(
|
|
self.output_dir, self.output_dir / "thumbnails"
|
|
)
|
|
|
|
self.assertEqual(targets, [])
|
|
|
|
|
|
class TestJsonBodyCaps(ImageAPITestCase):
|
|
"""JSON bodies are read in chunks and rejected past MAX_JSON_BODY_BYTES."""
|
|
|
|
LINK_URL = "/prompt_manager/images/link"
|
|
THUMBS_URL = "/prompt_manager/images/generate-thumbnails"
|
|
|
|
def _oversized(self, cap):
|
|
return b'{"image_path": "' + b"a" * (cap + 16) + b'"}'
|
|
|
|
async def test_oversized_content_length_is_413(self):
|
|
with patch.object(images_module, "MAX_JSON_BODY_BYTES", 512):
|
|
resp = await self.client.request(
|
|
"POST",
|
|
self.LINK_URL,
|
|
data=self._oversized(512),
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
|
|
self.assertEqual(resp.status, 413)
|
|
data = await resp.json()
|
|
self.assertFalse(data["success"])
|
|
|
|
async def test_oversized_chunked_body_is_413(self):
|
|
with patch.object(images_module, "MAX_JSON_BODY_BYTES", 512):
|
|
resp = await self.client.request(
|
|
"POST",
|
|
self.THUMBS_URL,
|
|
data=self._oversized(512),
|
|
chunked=True,
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
|
|
self.assertEqual(resp.status, 413)
|
|
|
|
async def test_invalid_json_is_400(self):
|
|
resp = await self.client.request(
|
|
"POST",
|
|
self.LINK_URL,
|
|
data=b"{not json",
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
|
|
self.assertEqual(resp.status, 400)
|
|
data = await resp.json()
|
|
self.assertFalse(data["success"])
|
|
|
|
async def test_non_object_json_is_400(self):
|
|
resp = await self.client.request("POST", self.THUMBS_URL, json=[1, 2, 3])
|
|
|
|
self.assertEqual(resp.status, 400)
|
|
|
|
|
|
class TestLinkImage(ImageAPITestCase):
|
|
"""POST /prompt_manager/images/link only links media inside allowed dirs."""
|
|
|
|
URL = "/prompt_manager/images/link"
|
|
|
|
async def test_links_png_inside_output_dir(self):
|
|
prompt_id = self._save_prompt("to link")
|
|
png = make_png(self.output_dir / "gen.png")
|
|
|
|
resp = await self.client.request(
|
|
"POST", self.URL, json={"prompt_id": prompt_id, "image_path": str(png)}
|
|
)
|
|
|
|
self.assertEqual(resp.status, 200)
|
|
data = await resp.json()
|
|
self.assertTrue(data["success"])
|
|
self.assertGreater(data["image_id"], 0)
|
|
|
|
async def test_missing_fields_is_400(self):
|
|
resp = await self.client.request("POST", self.URL, json={"prompt_id": 1})
|
|
|
|
self.assertEqual(resp.status, 400)
|
|
|
|
async def test_path_outside_allowed_dirs_is_403(self):
|
|
prompt_id = self._save_prompt("to link")
|
|
with tempfile.TemporaryDirectory() as other:
|
|
png = make_png(Path(other) / "outside.png")
|
|
|
|
resp = await self.client.request(
|
|
"POST", self.URL, json={"prompt_id": prompt_id, "image_path": str(png)}
|
|
)
|
|
|
|
self.assertEqual(resp.status, 403)
|
|
|
|
async def test_non_media_path_is_403(self):
|
|
prompt_id = self._save_prompt("to link")
|
|
txt = self.output_dir / "secret.txt"
|
|
txt.write_text("x")
|
|
|
|
resp = await self.client.request(
|
|
"POST", self.URL, json={"prompt_id": prompt_id, "image_path": str(txt)}
|
|
)
|
|
|
|
self.assertEqual(resp.status, 403)
|
|
|
|
async def test_missing_file_is_404(self):
|
|
prompt_id = self._save_prompt("to link")
|
|
|
|
resp = await self.client.request(
|
|
"POST",
|
|
self.URL,
|
|
json={
|
|
"prompt_id": prompt_id,
|
|
"image_path": str(self.output_dir / "no.png"),
|
|
},
|
|
)
|
|
|
|
self.assertEqual(resp.status, 404)
|
|
|
|
|
|
def _raise(*_args, **_kwargs):
|
|
raise RuntimeError("boom")
|
|
|
|
|
|
class ImageRouteCoverageCase(ImageAPITestCase):
|
|
"""Helpers for driving success and failure branches of each route."""
|
|
|
|
def _break_db(self, db_method):
|
|
setattr(self.api.db, db_method, _raise)
|
|
|
|
async def _json(self, method, path, **kwargs):
|
|
resp = await self.client.request(method, path, **kwargs)
|
|
return resp.status, await resp.json()
|
|
|
|
|
|
class TestPathHelpers(unittest.TestCase):
|
|
|
|
def test_has_traversal(self):
|
|
self.assertTrue(images_module._has_traversal(""))
|
|
self.assertTrue(images_module._has_traversal("a/../b.png"))
|
|
self.assertTrue(images_module._has_traversal("\\\\srv\\share\\x.png"))
|
|
self.assertTrue(images_module._has_traversal(os.path.abspath(os.sep)))
|
|
self.assertFalse(images_module._has_traversal("sub/x.png"))
|
|
|
|
|
|
class TestPromptImagesAndSearch(ImageRouteCoverageCase):
|
|
|
|
async def test_prompt_images_success_and_nan_cleanup(self):
|
|
png = make_png(self.output_dir / "p.png")
|
|
self._link_image(png, text="owner")
|
|
prompt_id = self.api.db.get_prompt_by_hash(generate_prompt_hash("owner"))["id"]
|
|
|
|
status, data = await self._json(
|
|
"GET", f"/prompt_manager/prompts/{prompt_id}/images"
|
|
)
|
|
|
|
self.assertEqual(status, 200)
|
|
self.assertEqual(len(data["images"]), 1)
|
|
self.assertTrue(data["images"][0]["url"].endswith("p.png"))
|
|
|
|
self.api.db.get_prompt_images = lambda _pid: [
|
|
{"id": 1, "image_path": str(png), "width": float("nan")}
|
|
]
|
|
status, data = await self._json("GET", "/prompt_manager/prompts/1/images")
|
|
self.assertEqual(status, 200)
|
|
self.assertIsNone(data["images"][0]["width"])
|
|
|
|
async def test_prompt_images_db_failure(self):
|
|
self._break_db("get_prompt_images")
|
|
status, data = await self._json("GET", "/prompt_manager/prompts/1/images")
|
|
self.assertEqual(status, 500)
|
|
self.assertFalse(data["success"])
|
|
|
|
async def test_search_images_paths(self):
|
|
self._link_image(make_png(self.output_dir / "s.png"), text="sunset beach")
|
|
missing, _ = await self._json("GET", "/prompt_manager/images/search")
|
|
ok, data = await self._json("GET", "/prompt_manager/images/search?q=sunset")
|
|
self._break_db("search_images_by_prompt")
|
|
err, _ = await self._json("GET", "/prompt_manager/images/search?q=x")
|
|
self.assertEqual((missing, ok, err), (400, 200, 500))
|
|
self.assertEqual(data["query"], "sunset")
|
|
self.assertEqual(len(data["images"]), 1)
|
|
|
|
async def test_list_routes_db_failures(self):
|
|
self._break_db("get_recent_images")
|
|
self._break_db("get_all_images")
|
|
recent, _ = await self._json("GET", "/prompt_manager/images/recent")
|
|
every, _ = await self._json("GET", "/prompt_manager/images/all")
|
|
self.assertEqual((recent, every), (500, 500))
|
|
|
|
|
|
class TestServeErrorBranches(ImageRouteCoverageCase):
|
|
|
|
async def test_serve_by_id_db_failure(self):
|
|
self._break_db("get_image_by_id")
|
|
status, _ = await self._json("GET", "/prompt_manager/images/1/file")
|
|
self.assertEqual(status, 500)
|
|
|
|
async def test_serve_output_lookup_failure(self):
|
|
self.api._get_all_output_dirs = _raise
|
|
status, _ = await self._json("GET", "/prompt_manager/images/serve/x.png")
|
|
self.assertEqual(status, 500)
|
|
|
|
async def test_out_of_range_root_index_is_rejected(self):
|
|
make_png(self.output_dir / "r.png")
|
|
resp = await self.client.request(
|
|
"GET", "/prompt_manager/images/serve/r.png?root=7"
|
|
)
|
|
self.assertEqual(resp.status, 403)
|
|
|
|
async def test_directory_named_like_media_is_404(self):
|
|
(self.output_dir / "dir.png").mkdir()
|
|
status, _ = await self._json("GET", "/prompt_manager/images/serve/dir.png")
|
|
self.assertEqual(status, 404)
|
|
|
|
async def test_lora_dirs_extend_allowed_roots(self):
|
|
from py.config import IntegrationConfig
|
|
|
|
with (
|
|
tempfile.TemporaryDirectory() as lora,
|
|
tempfile.TemporaryDirectory() as cache,
|
|
):
|
|
png = make_png(Path(lora) / "preview.png")
|
|
image_id = self._link_image(png)
|
|
# An empty configured path means auto-detection; a developer's
|
|
# own config.json may hold a real LoraManager path, so pin it.
|
|
with (
|
|
patch.object(IntegrationConfig, "LORA_MANAGER_ENABLED", True),
|
|
patch.object(IntegrationConfig, "LORA_MANAGER_PATH", ""),
|
|
patch("py.lora_utils.find_lora_directories", return_value=[lora]),
|
|
patch("py.lora_utils.get_lora_image_cache_dir", return_value=cache),
|
|
):
|
|
resp = await self.client.request(
|
|
"GET", f"/prompt_manager/images/{image_id}/file"
|
|
)
|
|
self.assertEqual(resp.status, 200)
|
|
|
|
with (
|
|
patch.object(IntegrationConfig, "LORA_MANAGER_ENABLED", True),
|
|
patch.object(IntegrationConfig, "LORA_MANAGER_PATH", ""),
|
|
patch("py.lora_utils.find_lora_directories", side_effect=RuntimeError),
|
|
):
|
|
resp = await self.client.request(
|
|
"GET", f"/prompt_manager/images/{image_id}/file"
|
|
)
|
|
self.assertEqual(resp.status, 403)
|
|
|
|
|
|
class TestLoraManagerPathAllowList(ImageRouteCoverageCase):
|
|
"""A hand-edited LoraManager path must not widen what serve_image reads."""
|
|
|
|
def _enter_lora(self, stack, lora, cache, path, resolved):
|
|
from py.config import IntegrationConfig
|
|
|
|
stack.enter_context(
|
|
patch.object(IntegrationConfig, "LORA_MANAGER_ENABLED", True)
|
|
)
|
|
stack.enter_context(patch.object(IntegrationConfig, "LORA_MANAGER_PATH", path))
|
|
stack.enter_context(
|
|
patch("py.lora_utils.find_lora_directories", return_value=[lora])
|
|
)
|
|
stack.enter_context(
|
|
patch("py.lora_utils.get_lora_image_cache_dir", return_value=cache)
|
|
)
|
|
return stack.enter_context(
|
|
patch(
|
|
"py.lora_utils.resolve_lora_manager_path",
|
|
return_value=resolved,
|
|
create=True,
|
|
)
|
|
)
|
|
|
|
async def test_configured_path_outside_custom_nodes_is_ignored(self):
|
|
with ExitStack() as stack:
|
|
lora = stack.enter_context(tempfile.TemporaryDirectory())
|
|
cache = stack.enter_context(tempfile.TemporaryDirectory())
|
|
image_id = self._link_image(make_png(Path(lora) / "preview.png"))
|
|
self._enter_lora(stack, lora, cache, lora, None)
|
|
|
|
self.assertEqual(self.api._lora_image_dirs(), [])
|
|
resp = await self.client.request(
|
|
"GET", f"/prompt_manager/images/{image_id}/file"
|
|
)
|
|
self.assertEqual(resp.status, 403)
|
|
|
|
async def test_configured_custom_nodes_child_is_served(self):
|
|
configured = "custom_nodes/ComfyUI-Lora-Manager"
|
|
with ExitStack() as stack:
|
|
lora = stack.enter_context(tempfile.TemporaryDirectory())
|
|
cache = stack.enter_context(tempfile.TemporaryDirectory())
|
|
image_id = self._link_image(make_png(Path(lora) / "preview.png"))
|
|
resolve = self._enter_lora(stack, lora, cache, configured, lora)
|
|
|
|
resp = await self.client.request(
|
|
"GET", f"/prompt_manager/images/{image_id}/file"
|
|
)
|
|
self.assertEqual(resp.status, 200)
|
|
await resp.read() # release the served file before the directory goes
|
|
resolve.assert_called_once_with(configured)
|
|
|
|
|
|
class TestLinkImageRelativePaths(ImageAPITestCase):
|
|
"""POST /images/link accepts the path forms the API hands out."""
|
|
|
|
URL = "/prompt_manager/images/link"
|
|
|
|
async def setUpAsync(self):
|
|
await super().setUpAsync()
|
|
self.png = make_png(self.output_dir / "sub" / "gen.png")
|
|
|
|
async def _link(self, image_path):
|
|
prompt_id = self._save_prompt(f"link {image_path}")
|
|
resp = await self.client.request(
|
|
"POST", self.URL, json={"prompt_id": prompt_id, "image_path": image_path}
|
|
)
|
|
return resp.status, await resp.json()
|
|
|
|
async def test_root_relative_and_base_relative_paths_link(self):
|
|
for form in ("sub/gen.png", "output/sub/gen.png"):
|
|
status, data = await self._link(form)
|
|
|
|
self.assertEqual(status, 200, form)
|
|
image = self.api.db.get_image_by_id(data["image_id"])
|
|
self.assertEqual(Path(image["image_path"]), self.png, form)
|
|
|
|
async def test_relative_traversal_is_forbidden(self):
|
|
make_png(self.comfy_dir / "secret.png") # removed with the temp tree
|
|
|
|
status, _ = await self._link("../secret.png")
|
|
|
|
self.assertEqual(status, 403)
|
|
|
|
async def test_unknown_relative_path_is_404(self):
|
|
status, _ = await self._link("sub/missing.png")
|
|
|
|
self.assertEqual(status, 404)
|
|
|
|
|
|
class TestGalleryListing(ImageRouteCoverageCase):
|
|
|
|
async def test_subfolders_with_and_without_ancestors(self):
|
|
make_png(self.output_dir / "a" / "b" / "x.png")
|
|
make_png(self.output_dir / "c" / "y.png")
|
|
plain, data = await self._json("GET", "/prompt_manager/gallery/subfolders")
|
|
_, with_anc = await self._json(
|
|
"GET", "/prompt_manager/gallery/subfolders?include_ancestors=true"
|
|
)
|
|
self.assertEqual(plain, 200)
|
|
self.assertEqual(data["subfolders"], [os.path.join("a", "b"), "c"])
|
|
self.assertIn("a", with_anc["subfolders"])
|
|
|
|
async def test_subfolders_and_output_lookup_failures(self):
|
|
self.api._get_all_output_dirs = _raise
|
|
sub, _ = await self._json("GET", "/prompt_manager/gallery/subfolders")
|
|
out, _ = await self._json("GET", "/prompt_manager/images/output")
|
|
self.assertEqual((sub, out), (500, 500))
|
|
|
|
async def test_vanished_file_is_dropped_from_page(self):
|
|
png = make_png(self.output_dir / "gone.png")
|
|
await self._json("GET", "/prompt_manager/images/output")
|
|
png.unlink()
|
|
status, data = await self._json("GET", "/prompt_manager/images/output")
|
|
self.assertEqual(status, 200)
|
|
self.assertEqual(data["total"], 1)
|
|
self.assertEqual(data["images"], [])
|
|
|
|
def test_gallery_scan_skips_unreadable_entries(self):
|
|
make_png(self.output_dir / "real.png")
|
|
listed = list(images_module._walk_media(self.output_dir, 10))
|
|
ghost = MagicMock(path=str(self.output_dir / "ghost.png"))
|
|
ghost.stat.side_effect = FileNotFoundError("gone")
|
|
with patch.object(
|
|
images_module, "_walk_media", return_value=iter([ghost, *listed])
|
|
):
|
|
found = self.api._scan_gallery_files_sync(self.output_dir)
|
|
self.assertEqual([path.name for path, _mtime in found], ["real.png"])
|
|
|
|
|
|
class TestThumbnailUnits(ImageRouteCoverageCase):
|
|
|
|
SIZE = ThumbnailSettings("webp", 80, 64)
|
|
|
|
def test_generate_one_reports_vanished_source(self):
|
|
result = self.api._generate_one(
|
|
self.output_dir / "nope.png", self._dst("nope"), self.SIZE, False
|
|
)
|
|
self.assertEqual(result["action"], "error")
|
|
self.assertIn("no longer exists", result["error"])
|
|
|
|
def test_generate_one_video_success_and_failure(self):
|
|
src = self.output_dir / "clip.mp4"
|
|
src.write_bytes(b"\x00")
|
|
with patch.object(self.api, "_generate_video_thumbnail", return_value=True):
|
|
ok = self.api._generate_one(src, self._dst("clip"), self.SIZE, True)
|
|
with patch.object(self.api, "_generate_video_thumbnail", return_value=False):
|
|
bad = self.api._generate_one(src, self._dst("clip"), self.SIZE, True)
|
|
self.assertEqual((ok["action"], ok["type"]), ("generated", "video"))
|
|
self.assertEqual(bad["action"], "error")
|
|
|
|
def test_generate_one_unexpected_error_is_safe(self):
|
|
src = make_png(self.output_dir / "boom.png")
|
|
with patch.object(
|
|
images_module, "_write_image_thumbnail", side_effect=RuntimeError("bad")
|
|
):
|
|
result = self.api._generate_one(src, self._dst("boom"), self.SIZE, False)
|
|
self.assertEqual(result["action"], "error")
|
|
self.assertEqual(result["error"], "bad")
|
|
|
|
def test_rgba_and_jpeg_sources(self):
|
|
with Image.new("RGBA", (32, 32), (0, 255, 0, 128)) as img:
|
|
img.save(self.output_dir / "alpha.png")
|
|
with Image.new("RGB", (32, 32), (0, 0, 255)) as img:
|
|
img.save(self.output_dir / "photo.jpg", "JPEG")
|
|
result = self.api._generate_thumbnails_sync(
|
|
self.output_dir, self.output_dir / "thumbnails", self.SIZE
|
|
)
|
|
self.assertEqual(result["count"], 2)
|
|
self.assertTrue(self._dst("alpha").is_file())
|
|
self.assertTrue(self._dst("photo").is_file())
|
|
|
|
async def test_emit_sse_swallows_write_errors(self):
|
|
response = MagicMock()
|
|
response.write = AsyncMock(side_effect=ConnectionResetError("gone"))
|
|
await self.api._emit_sse(response, "progress", {"n": 1})
|
|
response.write.assert_awaited_once()
|
|
|
|
async def test_progress_stream_reports_internal_failure(self):
|
|
self.api._plan_thumbnails = _raise
|
|
resp = await self.client.request(
|
|
"POST", "/prompt_manager/images/generate-thumbnails/progress"
|
|
)
|
|
events = parse_sse(await resp.text())
|
|
self.assertEqual(events[-1][0], "error")
|
|
|
|
async def test_post_generate_reports_internal_failure(self):
|
|
self.api._get_all_output_dirs = _raise
|
|
status, _ = await self._json(
|
|
"POST", "/prompt_manager/images/generate-thumbnails", json={}
|
|
)
|
|
self.assertEqual(status, 500)
|
|
|
|
def _dst(self, name, suffix=".webp"):
|
|
return self.output_dir / "thumbnails" / f"{name}_thumb{suffix}"
|
|
|
|
|
|
class TestVideoThumbnailFallbacks(ImageRouteCoverageCase):
|
|
"""_generate_video_thumbnail degrades: cv2 -> ffmpeg -> placeholder."""
|
|
|
|
# JPEG keeps these checks format-specific; #317 settings carry the edge.
|
|
SIZE = ThumbnailSettings("jpeg", 85, 48)
|
|
|
|
@property
|
|
def video(self):
|
|
# output_dir only exists once the app is built, so create lazily.
|
|
path = self.output_dir / "clip.mp4"
|
|
if not path.exists():
|
|
path.write_bytes(b"\x00\x00")
|
|
return path
|
|
|
|
@property
|
|
def thumb(self):
|
|
return self.output_dir / "clip_thumb.jpg"
|
|
|
|
def _fake_cv2(self, opened=True, read_ok=True):
|
|
numpy = self._numpy()
|
|
cv2 = MagicMock()
|
|
cap = cv2.VideoCapture.return_value
|
|
cap.isOpened.return_value = opened
|
|
cap.get.return_value = 30
|
|
frame = numpy.zeros((8, 8, 3), dtype=numpy.uint8)
|
|
cap.read.return_value = (read_ok, frame if read_ok else None)
|
|
cv2.cvtColor.side_effect = lambda f, _code: f
|
|
return cv2
|
|
|
|
def _numpy(self):
|
|
try:
|
|
import numpy
|
|
except ImportError:
|
|
self.skipTest("numpy not installed")
|
|
return numpy
|
|
|
|
def test_cv2_success(self):
|
|
with patch.dict(sys.modules, {"cv2": self._fake_cv2()}):
|
|
ok = self.api._generate_video_thumbnail(self.video, self.thumb, self.SIZE)
|
|
self.assertTrue(ok)
|
|
with Image.open(self.thumb) as img:
|
|
self.assertEqual(img.format, "JPEG")
|
|
|
|
def test_cv2_cannot_open_or_read_falls_through_to_ffmpeg_then_placeholder(self):
|
|
# #249 I1: an OpenCV that cannot decode the clip (AV1) no longer ends
|
|
# the run; ffmpeg and then the placeholder are tried.
|
|
for fake in (self._fake_cv2(opened=False), self._fake_cv2(read_ok=False)):
|
|
self.thumb.unlink(missing_ok=True)
|
|
with (
|
|
patch.dict(sys.modules, {"cv2": fake}),
|
|
patch("subprocess.Popen", side_effect=FileNotFoundError("ffmpeg")),
|
|
):
|
|
ok = self.api._generate_video_thumbnail(
|
|
self.video, self.thumb, self.SIZE
|
|
)
|
|
self.assertTrue(ok)
|
|
with Image.open(self.thumb) as img:
|
|
self.assertEqual((img.format, img.size), ("JPEG", self.SIZE.size))
|
|
|
|
def test_cv2_crash_is_contained(self):
|
|
cv2 = MagicMock()
|
|
cv2.VideoCapture.side_effect = RuntimeError("driver crash")
|
|
with (
|
|
patch.dict(sys.modules, {"cv2": cv2}),
|
|
patch("subprocess.Popen", side_effect=FileNotFoundError("ffmpeg")),
|
|
):
|
|
ok = self.api._generate_video_thumbnail(self.video, self.thumb, self.SIZE)
|
|
self.assertTrue(ok, "the placeholder is written")
|
|
|
|
def test_cv2_failure_keeps_an_existing_preview_on_rebuild(self):
|
|
make_png(self.thumb)
|
|
before = self.thumb.read_bytes()
|
|
with (
|
|
patch.dict(sys.modules, {"cv2": self._fake_cv2(opened=False)}),
|
|
patch("subprocess.Popen", side_effect=FileNotFoundError("ffmpeg")),
|
|
):
|
|
ok = self.api._generate_video_thumbnail(
|
|
self.video, self.thumb, self.SIZE, keep_existing=True
|
|
)
|
|
self.assertFalse(ok)
|
|
self.assertEqual(self.thumb.read_bytes(), before)
|
|
|
|
def test_ffmpeg_absent_yields_placeholder(self):
|
|
with (
|
|
patch.dict(sys.modules, {"cv2": None}),
|
|
patch("subprocess.Popen", side_effect=FileNotFoundError("ffmpeg")),
|
|
):
|
|
ok = self.api._generate_video_thumbnail(self.video, self.thumb, self.SIZE)
|
|
self.assertTrue(ok)
|
|
with Image.open(self.thumb) as img:
|
|
self.assertEqual(img.size, self.SIZE.size)
|
|
|
|
@staticmethod
|
|
def _process(returncode, stderr=b"", stdout=b""):
|
|
process = MagicMock(returncode=returncode)
|
|
process.communicate.return_value = (stdout, stderr)
|
|
return process
|
|
|
|
@staticmethod
|
|
def _png_frame(size=(96, 64)):
|
|
import io
|
|
|
|
buffer = io.BytesIO()
|
|
Image.new("RGB", size, (200, 0, 0)).save(buffer, "PNG")
|
|
return buffer.getvalue()
|
|
|
|
def test_ffmpeg_failure_yields_placeholder(self):
|
|
with (
|
|
patch.dict(sys.modules, {"cv2": None}),
|
|
patch("subprocess.Popen", return_value=self._process(1, b"no stream")),
|
|
):
|
|
ok = self.api._generate_video_thumbnail(self.video, self.thumb, self.SIZE)
|
|
self.assertTrue(ok)
|
|
self.assertTrue(self.thumb.is_file())
|
|
|
|
def test_ffmpeg_success(self):
|
|
with (
|
|
patch.dict(sys.modules, {"cv2": None}),
|
|
patch(
|
|
"subprocess.Popen",
|
|
return_value=self._process(0, stdout=self._png_frame()),
|
|
) as run,
|
|
):
|
|
ok = self.api._generate_video_thumbnail(self.video, self.thumb, self.SIZE)
|
|
self.assertTrue(ok)
|
|
self.assertEqual(run.call_args.args[0][0], "ffmpeg")
|
|
with Image.open(self.thumb) as img:
|
|
# ffmpeg's frame (a PNG on stdout) fitted inside the edge (#317).
|
|
self.assertEqual((img.format, img.size), ("JPEG", (48, 32)))
|
|
|
|
def test_ffmpeg_is_tracked_and_a_hold_before_the_rename_writes_nothing(self):
|
|
tracked = []
|
|
|
|
def writing(cmd, **kwargs):
|
|
return self._process(0, stdout=self._png_frame())
|
|
|
|
with (
|
|
patch.dict(sys.modules, {"cv2": None}),
|
|
patch("subprocess.Popen", side_effect=writing),
|
|
):
|
|
with self.assertRaises(images_module.PreviewWriteHeld):
|
|
self.api._generate_video_thumbnail(
|
|
self.video,
|
|
self.thumb,
|
|
self.SIZE,
|
|
guard=lambda: False,
|
|
track=tracked.append,
|
|
)
|
|
self.assertEqual(len(tracked), 1)
|
|
self.assertFalse(self.thumb.exists())
|
|
self.assertEqual(
|
|
[p.name for p in self.thumb.parent.iterdir() if "part" in p.name], []
|
|
)
|
|
|
|
def test_placeholder_failure_returns_false(self):
|
|
with (
|
|
patch.dict(sys.modules, {"cv2": None}),
|
|
patch("subprocess.Popen", side_effect=FileNotFoundError),
|
|
patch.object(
|
|
images_module.Image, "new", side_effect=RuntimeError("no PIL")
|
|
),
|
|
):
|
|
ok = self.api._generate_video_thumbnail(self.video, self.thumb, self.SIZE)
|
|
self.assertFalse(ok)
|
|
|
|
|
|
class TestClearThumbnails(ImageRouteCoverageCase):
|
|
|
|
URL = "/prompt_manager/images/clear-thumbnails"
|
|
|
|
async def test_missing_output_dir_is_404(self):
|
|
self.api._get_all_output_dirs = lambda: []
|
|
status, _ = await self._json("POST", self.URL)
|
|
self.assertEqual(status, 404)
|
|
|
|
async def test_no_thumbnails_dir_is_noop(self):
|
|
status, data = await self._json("POST", self.URL)
|
|
self.assertEqual(status, 200)
|
|
self.assertEqual(data["cleared_files"], 0)
|
|
|
|
async def test_clears_only_generated_thumbnails(self):
|
|
thumbs = self.output_dir / "thumbnails"
|
|
make_png(thumbs / "sub" / "a_thumb.png")
|
|
make_png(thumbs / "b_thumb.png")
|
|
(thumbs / "readme.txt").write_text("keep")
|
|
make_png(self.output_dir / "original.png")
|
|
|
|
status, data = await self._json("POST", self.URL)
|
|
|
|
self.assertEqual(status, 200)
|
|
self.assertEqual(data["cleared_files"], 2)
|
|
self.assertIn("cleared_size_formatted", data)
|
|
self.assertFalse((thumbs / "sub").exists())
|
|
self.assertTrue((thumbs / "readme.txt").is_file())
|
|
self.assertTrue((self.output_dir / "original.png").is_file())
|
|
|
|
async def test_unlink_failure_is_logged_not_raised(self):
|
|
make_png(self.output_dir / "thumbnails" / "a_thumb.png")
|
|
with patch.object(Path, "unlink", side_effect=PermissionError("locked")):
|
|
status, data = await self._json("POST", self.URL)
|
|
self.assertEqual(status, 200)
|
|
self.assertEqual(data["cleared_files"], 0)
|
|
|
|
async def test_internal_failure_is_500(self):
|
|
self.api._get_all_output_dirs = _raise
|
|
status, _ = await self._json("POST", self.URL)
|
|
self.assertEqual(status, 500)
|
|
|
|
async def test_sibling_prefix_directory_is_refused(self):
|
|
# "<base>/out2/thumbnails" starts with "<base>/out" as a string but is
|
|
# not inside it; a symlinked thumbnails dir pointing there must be
|
|
# refused rather than emptied.
|
|
out = self.comfy_dir / "out"
|
|
out.mkdir()
|
|
sibling = make_png(self.comfy_dir / "out2" / "thumbnails" / "a_thumb.png")
|
|
self._make_symlink(sibling.parent, out / "thumbnails")
|
|
self.api._get_all_output_dirs = lambda: [out]
|
|
|
|
status, data = await self._json("POST", self.URL)
|
|
|
|
self.assertEqual(status, 400)
|
|
self.assertFalse(data["success"])
|
|
self.assertTrue(sibling.is_file())
|
|
|
|
async def test_clears_all_roots_and_combines_counts_without_touching_originals(
|
|
self,
|
|
):
|
|
second = self.comfy_dir / "extra-output"
|
|
roots = [self.output_dir, second]
|
|
self.api._get_all_output_dirs = lambda: roots
|
|
expected_size = 0
|
|
for root in roots:
|
|
make_png(root / "original_thumb.png")
|
|
thumb = make_png(root / "thumbnails" / "sub" / "generated_thumb.png")
|
|
expected_size += thumb.stat().st_size
|
|
(root / "thumbnails" / "notes.txt").write_text("keep")
|
|
status, data = await self._json("POST", self.URL)
|
|
self.assertEqual(status, 200)
|
|
self.assertEqual(data["cleared_files"], 2)
|
|
self.assertEqual(data["cleared_size"], expected_size)
|
|
self.assertNotIn("{format_size", data["message"])
|
|
for root in roots:
|
|
self.assertTrue((root / "original_thumb.png").is_file())
|
|
self.assertTrue((root / "thumbnails" / "notes.txt").is_file())
|
|
self.assertFalse((root / "thumbnails" / "sub").exists())
|
|
|
|
async def test_missing_thumbnails_in_first_root_does_not_skip_later_roots(self):
|
|
second = self.comfy_dir / "extra-output"
|
|
thumb = make_png(second / "thumbnails" / "a_thumb.png")
|
|
self.api._get_all_output_dirs = lambda: [self.output_dir, second]
|
|
status, data = await self._json("POST", self.URL)
|
|
self.assertEqual(status, 200)
|
|
self.assertEqual(data["cleared_files"], 1)
|
|
self.assertFalse(thumb.exists())
|
|
|
|
async def test_invalid_later_root_is_rejected_before_deleting_any_files(self):
|
|
first_thumb = make_png(self.output_dir / "thumbnails" / "a_thumb.png")
|
|
second = self.comfy_dir / "extra-output"
|
|
second.mkdir()
|
|
outside = make_png(self.comfy_dir / "elsewhere" / "a_thumb.png")
|
|
self._make_symlink(outside.parent, second / "thumbnails")
|
|
self.api._get_all_output_dirs = lambda: [self.output_dir, second]
|
|
status, data = await self._json("POST", self.URL)
|
|
self.assertEqual(status, 400)
|
|
self.assertFalse(data["success"])
|
|
self.assertTrue(first_thumb.is_file())
|
|
self.assertTrue(outside.is_file())
|
|
|
|
async def test_thumbnail_symlink_back_to_root_cannot_delete_originals(self):
|
|
original = make_png(self.output_dir / "original_thumb.png")
|
|
self._make_symlink(self.output_dir, self.output_dir / "thumbnails")
|
|
status, data = await self._json("POST", self.URL)
|
|
self.assertEqual(status, 400)
|
|
self.assertFalse(data["success"])
|
|
self.assertTrue(original.is_file())
|
|
|
|
async def test_cleanup_runs_off_loop_and_keeps_the_listing(self):
|
|
import threading
|
|
|
|
make_png(self.output_dir / "thumbnails" / "a_thumb.png")
|
|
make_png(self.output_dir / "a.png")
|
|
await self._json("GET", "/prompt_manager/images/output")
|
|
listing = self.api._root_listings[str(self.output_dir)].files
|
|
loop_thread = threading.get_ident()
|
|
deletion_threads = []
|
|
original_unlink = Path.unlink
|
|
|
|
def unlink(path, *args, **kwargs):
|
|
deletion_threads.append(threading.get_ident())
|
|
return original_unlink(path, *args, **kwargs)
|
|
|
|
with patch.object(Path, "unlink", unlink):
|
|
status, data = await self._json("POST", self.URL)
|
|
self.assertEqual(status, 200)
|
|
self.assertEqual(data["cleared_files"], 1)
|
|
self.assertTrue(deletion_threads)
|
|
self.assertNotIn(loop_thread, deletion_threads)
|
|
# Previews are not part of the listing (#348): it stays as it was.
|
|
self.assertIs(self.api._root_listings[str(self.output_dir)].files, listing)
|
|
|
|
|
|
class TestImagePromptLookup(ImageRouteCoverageCase):
|
|
|
|
URL = "/prompt_manager/images/prompt/"
|
|
|
|
async def test_relative_path_resolves_against_output_dir(self):
|
|
self._link_image(make_png(self.output_dir / "gen.png"), text="the prompt")
|
|
status, data = await self._json("GET", f"{self.URL}gen.png")
|
|
self.assertEqual(status, 200)
|
|
self.assertTrue(data["success"])
|
|
self.assertEqual(data["prompt"]["text"], "the prompt")
|
|
self.assertEqual(data["prompt"]["image_path"], "output/gen.png")
|
|
|
|
async def test_unknown_image_reports_no_prompt(self):
|
|
status, data = await self._json("GET", f"{self.URL}unknown.png")
|
|
self.assertEqual(status, 200)
|
|
self.assertFalse(data["success"])
|
|
self.assertIn("image_path", data)
|
|
|
|
async def test_empty_path_is_400(self):
|
|
status, _ = await self._json("GET", self.URL)
|
|
self.assertEqual(status, 400)
|
|
|
|
async def test_db_failure_is_500_without_details(self):
|
|
self._break_db("get_image_prompt_info")
|
|
status, data = await self._json("GET", f"{self.URL}x.png")
|
|
self.assertEqual(status, 500)
|
|
self.assertEqual(data["error"], "Database error occurred")
|
|
|
|
async def test_output_dir_lookup_failure_is_500(self):
|
|
self.api._find_comfyui_output_dir = _raise
|
|
status, _ = await self._json("GET", f"{self.URL}x.png")
|
|
self.assertEqual(status, 500)
|
|
|
|
|
|
class TestPublicPathsInResponses(ImageAPITestCase):
|
|
"""Image routes describe files relative to the ComfyUI tree, never absolutely."""
|
|
|
|
async def setUpAsync(self):
|
|
await super().setUpAsync()
|
|
self.png = make_png(self.output_dir / "sub" / "gen.png")
|
|
self.prompt_id = self._save_prompt("public paths")
|
|
self.assertGreater(
|
|
self.api.db.link_image_to_prompt(self.prompt_id, str(self.png)), 0
|
|
)
|
|
|
|
async def _body(self, method, path, **kwargs):
|
|
resp = await self.client.request(method, path, **kwargs)
|
|
text = await resp.text()
|
|
self.assertNotIn(str(self.comfy_dir), text, path)
|
|
return resp.status, json.loads(text)
|
|
|
|
async def test_output_listing_paths_are_root_relative(self):
|
|
status, data = await self._body("GET", "/prompt_manager/images/output")
|
|
|
|
self.assertEqual(status, 200)
|
|
entry = data["images"][0]
|
|
self.assertEqual(entry["path"], "sub/gen.png")
|
|
self.assertEqual(entry["root_dir"], "output")
|
|
self.assertEqual(entry["root_index"], 0)
|
|
|
|
async def test_image_lists_publish_image_path_and_drop_file_path(self):
|
|
routes = (
|
|
f"/prompt_manager/prompts/{self.prompt_id}/images",
|
|
"/prompt_manager/images/recent",
|
|
"/prompt_manager/images/all",
|
|
"/prompt_manager/images/search?q=public",
|
|
)
|
|
for route in routes:
|
|
status, data = await self._body("GET", route)
|
|
|
|
self.assertEqual(status, 200, route)
|
|
image = data["images"][0]
|
|
self.assertEqual(image["image_path"], "output/sub/gen.png", route)
|
|
self.assertNotIn("file_path", image, route)
|
|
self.assertTrue(image["url"].endswith("sub/gen.png"), route)
|
|
|
|
async def test_image_prompt_lookup_publishes_path(self):
|
|
_, found = await self._body("GET", "/prompt_manager/images/prompt/sub/gen.png")
|
|
_, missing = await self._body("GET", "/prompt_manager/images/prompt/sub/no.png")
|
|
|
|
self.assertEqual(found["prompt"]["image_path"], "output/sub/gen.png")
|
|
self.assertEqual(missing["image_path"], "output/sub/no.png")
|
|
|
|
async def test_thumbnails_path_is_public(self):
|
|
status, data = await self._body(
|
|
"POST", "/prompt_manager/images/generate-thumbnails", json={}
|
|
)
|
|
|
|
self.assertEqual(status, 200)
|
|
self.assertEqual(data["thumbnails_path"], "output/thumbnails")
|
|
|
|
async def test_oserror_bodies_keep_only_the_basename(self):
|
|
secret = str(self.comfy_dir / "hidden.png")
|
|
|
|
def boom(*_args, **_kwargs):
|
|
raise FileNotFoundError(2, "No such file", secret)
|
|
|
|
self.api._get_all_output_dirs = boom
|
|
self.api._find_comfyui_output_dir = boom
|
|
self.api.db.get_prompt_images = boom
|
|
self.api.db.delete_image = boom
|
|
routes = (
|
|
("GET", "/prompt_manager/gallery/subfolders"),
|
|
("GET", "/prompt_manager/prompts/1/images"),
|
|
("DELETE", "/prompt_manager/images/1"),
|
|
("POST", "/prompt_manager/images/clear-thumbnails"),
|
|
("GET", "/prompt_manager/images/prompt/x.png"),
|
|
)
|
|
for method, route in routes:
|
|
status, data = await self._body(method, route)
|
|
|
|
self.assertEqual(status, 500, route)
|
|
self.assertIn("hidden.png", data["error"], route)
|
|
|
|
|
|
class TestDeleteAndLinkErrors(ImageRouteCoverageCase):
|
|
|
|
async def test_delete_image_paths(self):
|
|
image_id = self._link_image(make_png(self.output_dir / "d.png"))
|
|
ok, _ = await self._json("DELETE", f"/prompt_manager/images/{image_id}")
|
|
gone, _ = await self._json("DELETE", f"/prompt_manager/images/{image_id}")
|
|
bad, _ = await self._json("DELETE", "/prompt_manager/images/abc")
|
|
self._break_db("delete_image")
|
|
err, _ = await self._json("DELETE", "/prompt_manager/images/1")
|
|
self.assertEqual((ok, gone, bad, err), (200, 404, 400, 500))
|
|
|
|
async def test_link_db_failure_is_500(self):
|
|
prompt_id = self._save_prompt("to link")
|
|
png = make_png(self.output_dir / "l.png")
|
|
self._break_db("link_image_to_prompt")
|
|
status, _ = await self._json(
|
|
"POST",
|
|
"/prompt_manager/images/link",
|
|
json={"prompt_id": prompt_id, "image_path": str(png)},
|
|
)
|
|
self.assertEqual(status, 500)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|
|
|
|
|
|
class TestGalleryRescan(ImageAPITestCase):
|
|
"""POST /prompt_manager/gallery/rescan drops the listing cache and rewalks."""
|
|
|
|
URL = "/prompt_manager/images/output"
|
|
|
|
async def _json(self, method, path, **kwargs):
|
|
resp = await self.client.request(method, path, **kwargs)
|
|
return resp.status, await resp.json()
|
|
|
|
async def test_rescan_makes_new_files_visible_before_the_cache_expires(self):
|
|
# The listing alone would hide the new file for an hour.
|
|
refresh = patch.object(gallery_listing, "REFRESH_AFTER", 3600)
|
|
refresh.start()
|
|
self.addCleanup(refresh.stop)
|
|
make_png(self.output_dir / "a.png")
|
|
_, first = await self._json("GET", self.URL)
|
|
self.assertEqual(first["total"], 1)
|
|
|
|
make_png(self.output_dir / "b.png")
|
|
_, stale = await self._json("GET", self.URL)
|
|
self.assertEqual(stale["total"], 1)
|
|
|
|
status, data = await self._json("POST", "/prompt_manager/gallery/rescan")
|
|
_, fresh = await self._json("GET", self.URL)
|
|
|
|
self.assertEqual(status, 200)
|
|
self.assertTrue(data["success"])
|
|
self.assertEqual(data["total"], 2)
|
|
self.assertEqual(data["roots"], 1)
|
|
self.assertEqual(fresh["total"], 2)
|
|
|
|
async def test_rescan_is_post_only(self):
|
|
resp = await self.client.request("GET", "/prompt_manager/gallery/rescan")
|
|
self.assertEqual(resp.status, 405)
|
|
|
|
async def test_rescan_reports_internal_failure(self):
|
|
self.api._scan_gallery_files_sync = _raise
|
|
status, data = await self._json("POST", "/prompt_manager/gallery/rescan")
|
|
self.assertEqual(status, 500)
|
|
self.assertFalse(data["success"])
|
|
self.assertNotIn("boom", json.dumps(data))
|
|
|
|
|
|
class TestOutputScanHelpers(ImageAPITestCase):
|
|
"""Stable ids for filesystem entries and no symlink reads.
|
|
|
|
The thumbnail stream no longer stops when its client goes away: see
|
|
``tests/test_thumbnail_jobs.py`` (the work is a job that outlives it).
|
|
"""
|
|
|
|
def test_output_entry_id_is_a_stable_digest_of_the_path(self):
|
|
import hashlib
|
|
|
|
output = Path(self.output_dir)
|
|
media = output / "stable.png"
|
|
make_png(media)
|
|
entry = images_module._output_image_entry(media, output, 0)
|
|
expected = hashlib.sha1(str(media).encode("utf-8")).hexdigest()[:16]
|
|
self.assertEqual(entry["id"], expected)
|
|
|
|
def test_media_walk_matches_the_os_walk_reference(self):
|
|
# The scandir walk (#348) must list exactly what the os.walk version
|
|
# listed, in the same order: ties in mtime keep this order.
|
|
output = Path(self.output_dir)
|
|
for rel in (
|
|
"z.png",
|
|
"a.PNG",
|
|
"notes.txt",
|
|
"b/2.png",
|
|
"b/1.webp",
|
|
"b/c/deep.mp4",
|
|
"b/c/d/too_deep.png",
|
|
"thumbnails/t.png",
|
|
"b/thumbnails/u.png",
|
|
"e/.hidden.png",
|
|
"e/f.jpeg",
|
|
"e/..png",
|
|
"e/...webp",
|
|
"e/g.",
|
|
):
|
|
make_png(output / rel)
|
|
try:
|
|
os.symlink(output / "z.png", output / "b" / "link.png")
|
|
os.symlink(output / "b", output / "dirlink", target_is_directory=True)
|
|
except (OSError, NotImplementedError):
|
|
pass # the rest of the tree still checks order and filters
|
|
with patch.object(images_module, "MAX_SCAN_DEPTH", 2):
|
|
listed = list(images_module._iter_media_files(output, 100))
|
|
expected = list(_reference_iter_media_files(output, 100))
|
|
capped = list(images_module._iter_media_files(output, 3))
|
|
self.assertEqual(listed, expected)
|
|
self.assertEqual(capped, expected[:3])
|
|
# Path.suffix of Python 3.10-3.13, which CI and hosts run (3.14 changed
|
|
# it): "..png" has the suffix ".png", ".png" and "g." have none.
|
|
names = {path.name for path in listed}
|
|
self.assertTrue({"..png", "...webp", ".hidden.png"} <= names, names)
|
|
self.assertFalse({"g.", "notes.txt"} & names, names)
|
|
self.assertTrue(all(isinstance(path, Path) for path in listed))
|
|
|
|
def test_gallery_scan_reads_each_file_type_and_mtime_from_scandir(self):
|
|
# Path.stat and Path.is_symlink per file were the other half of the
|
|
# live walk's cost (#348); the scandir entries already carry both.
|
|
output = Path(self.output_dir)
|
|
make_png(output / "a.png")
|
|
make_png(output / "sub" / "b.png")
|
|
with (
|
|
patch.object(Path, "stat", side_effect=AssertionError),
|
|
patch.object(Path, "is_symlink", side_effect=AssertionError),
|
|
):
|
|
found = self.api._scan_gallery_files_sync(output)
|
|
self.assertEqual(
|
|
sorted(path.name for path, _mtime in found), ["a.png", "b.png"]
|
|
)
|
|
for path, mtime in found:
|
|
self.assertEqual(mtime, os.stat(path).st_mtime)
|
|
|
|
def test_favorite_listing_slices_the_path_strings(self):
|
|
# relative_to per file was the largest cold cost of the first open.
|
|
output = Path(self.output_dir)
|
|
files = [
|
|
(output / "a.png", 1.0),
|
|
(output / "Sub" / "B.png", 2.0),
|
|
(output / "sub" / "deep" / "c.webp", 3.0),
|
|
]
|
|
expected = {}
|
|
for path, _mtime in files:
|
|
posix = path.relative_to(output).as_posix()
|
|
expected.setdefault(images_module._favorite_relative_key(posix), posix)
|
|
with patch.object(PurePath, "relative_to", side_effect=AssertionError):
|
|
listing = self.api._favorite_listing(output, files)
|
|
self.assertEqual(listing, expected)
|
|
|
|
def test_iter_media_files_skips_symlinked_files(self):
|
|
output = Path(self.output_dir)
|
|
make_png(output / "real.png")
|
|
with tempfile.TemporaryDirectory() as other:
|
|
outside = Path(other) / "outside.png"
|
|
make_png(outside)
|
|
try:
|
|
os.symlink(outside, output / "link.png")
|
|
except (OSError, NotImplementedError):
|
|
self.skipTest("symlinks not available")
|
|
names = sorted(p.name for p in images_module._iter_media_files(output, 100))
|
|
self.assertEqual(names, ["real.png"])
|
|
|
|
|
|
class TestThumbnailStartupStatus(ThumbnailTestCase):
|
|
"""Startup counts and generation cover the same configured collection."""
|
|
|
|
STATUS_URL = "/prompt_manager/images/thumbnails/status"
|
|
|
|
async def test_status_reports_exact_pending_count_without_generating(self):
|
|
for name in ("ready", "missing", "outdated"):
|
|
make_png(self.output_dir / f"{name}.png")
|
|
thumbs = self.output_dir / "thumbnails"
|
|
ready = make_png(thumbs / "ready_thumb.png")
|
|
outdated = make_png(thumbs / "outdated_thumb.png")
|
|
source_time = (self.output_dir / "ready.png").stat().st_mtime
|
|
os.utime(ready, (source_time + 10, source_time + 10))
|
|
os.utime(outdated, (source_time - 10, source_time - 10))
|
|
calls = self._spy_executor()
|
|
|
|
response = await self.client.get(self.STATUS_URL)
|
|
|
|
self.assertEqual(response.status, 200)
|
|
data = await response.json()
|
|
self.assertTrue(data["success"])
|
|
self.assertEqual(data["total_images"], 3)
|
|
self.assertEqual(data["pending"], 2)
|
|
self.assertEqual(data["ready"], 1)
|
|
self.assertTrue(data["collection_id"])
|
|
self.assertIn("_plan_gallery_thumbnails", calls)
|
|
self.assertFalse((thumbs / "missing_thumb.png").exists())
|
|
|
|
async def test_both_generation_routes_cover_all_gallery_roots(self):
|
|
second = self.comfy_dir / "another-output"
|
|
make_png(self.output_dir / "a.png")
|
|
make_png(second / "b.png")
|
|
self.api._get_all_output_dirs = lambda: [self.output_dir, second]
|
|
for route in (
|
|
"/prompt_manager/images/generate-thumbnails",
|
|
self.PROGRESS_URL,
|
|
):
|
|
with self.subTest(route=route):
|
|
for root in (self.output_dir, second):
|
|
for thumb in (root / "thumbnails").glob("*_thumb.webp"):
|
|
thumb.unlink()
|
|
response = await self.client.post(route, json={})
|
|
if route == self.PROGRESS_URL:
|
|
data = dict(parse_sse(await response.text()))["complete"]
|
|
else:
|
|
data = await response.json()
|
|
self.assertEqual(data["count"], 2)
|
|
self.assertTrue((second / "thumbnails" / "b_thumb.webp").exists())
|
|
status = await (await self.client.get(self.STATUS_URL)).json()
|
|
self.assertEqual(status["pending"], 0)
|
|
self.assertEqual(status["total_images"], 2)
|
|
|
|
async def test_pending_count_includes_files_beyond_the_generation_cap(self):
|
|
for name in ("a", "b", "c"):
|
|
make_png(self.output_dir / f"{name}.png")
|
|
with patch.object(images_module, "MAX_THUMBNAIL_FILES", 1):
|
|
response = await self.client.get(self.STATUS_URL)
|
|
self.assertEqual(response.status, 200)
|
|
data = await response.json()
|
|
self.assertEqual(data["pending"], 3)
|
|
|
|
async def test_generation_cap_is_shared_across_roots(self):
|
|
second = self.comfy_dir / "another-output"
|
|
make_png(self.output_dir / "a.png")
|
|
make_png(second / "b.png")
|
|
self.api._get_all_output_dirs = lambda: [self.output_dir, second]
|
|
with patch.object(images_module, "MAX_THUMBNAIL_FILES", 1):
|
|
plan = self.api._plan_gallery_thumbnails([self.output_dir, second])
|
|
response = await self.client.post(self.PROGRESS_URL)
|
|
data = dict(parse_sse(await response.text()))["complete"]
|
|
# One batch holds one file across both roots; the run goes on (#317).
|
|
self.assertEqual((len(plan["targets"]), plan["remaining"]), (1, 1))
|
|
self.assertEqual(data["count"], 2)
|
|
self.assertEqual(data["remaining"], 0)
|
|
self.assertEqual(data["total_images"], 2)
|
|
|
|
async def test_equal_timestamps_do_not_regenerate_existing_thumbnails(self):
|
|
source = make_png(self.output_dir / "ready.png")
|
|
thumb = make_png(self.output_dir / "thumbnails" / "ready_thumb.png")
|
|
timestamp = int(source.stat().st_mtime)
|
|
os.utime(source, (timestamp, timestamp))
|
|
os.utime(thumb, (timestamp, timestamp))
|
|
response = await self.client.post(self.PROGRESS_URL)
|
|
data = dict(parse_sse(await response.text()))["complete"]
|
|
self.assertEqual(data["count"], 0)
|
|
self.assertEqual(data["skipped"], 1)
|
|
|
|
|
|
class TestGalleryRootIdentity(ThumbnailTestCase):
|
|
async def test_colliding_original_and_thumbnail_urls_serve_their_own_roots(self):
|
|
roots = [self.output_dir, self.comfy_dir / "second-output"]
|
|
for index, root in enumerate(roots):
|
|
make_png(root / "same.png", color=(index * 255, 0, 255 - index * 255))
|
|
self.api._get_all_output_dirs = lambda: roots
|
|
await self.client.post("/prompt_manager/images/generate-thumbnails", json={})
|
|
data = await (await self.client.get("/prompt_manager/images/output")).json()
|
|
self.assertEqual(len({image["url"] for image in data["images"]}), 2)
|
|
self.assertEqual(len({image["thumbnail_url"] for image in data["images"]}), 2)
|
|
for image in data["images"]:
|
|
root = roots[image["root_index"]]
|
|
for key, path in (
|
|
("url", root / "same.png"),
|
|
("thumbnail_url", root / "thumbnails" / "same_thumb.webp"),
|
|
):
|
|
response = await self.client.get(image[key])
|
|
self.assertEqual(response.status, 200)
|
|
self.assertEqual(await response.read(), path.read_bytes())
|
|
|
|
async def test_root_scoped_prompt_lookup_uses_the_matching_database_image(self):
|
|
roots = [self.output_dir, self.comfy_dir / "second-output"]
|
|
for index, root in enumerate(roots):
|
|
self._link_image(make_png(root / "same.png"), text=f"root {index} prompt")
|
|
self.api._get_all_output_dirs = lambda: roots
|
|
for index in range(2):
|
|
response = await self.client.get(
|
|
f"/prompt_manager/images/prompt/same.png?root={index}"
|
|
)
|
|
self.assertEqual(response.status, 200)
|
|
data = await response.json()
|
|
self.assertEqual(data["prompt"]["text"], f"root {index} prompt")
|
|
|
|
async def test_special_characters_in_paths_survive_serving_and_prompt_lookup(self):
|
|
from urllib.parse import quote
|
|
|
|
filenames = ["space #%20 café.png"]
|
|
if os.name != "nt":
|
|
filenames.append("space #?%20 café.png")
|
|
for filename in filenames:
|
|
self._link_image(make_png(self.output_dir / filename), text="special path")
|
|
for filename in filenames:
|
|
with self.subTest(filename=filename):
|
|
original = self.output_dir / filename
|
|
data = await (
|
|
await self.client.get("/prompt_manager/images/output")
|
|
).json()
|
|
entry = next(
|
|
image for image in data["images"] if image["filename"] == filename
|
|
)
|
|
response = await self.client.get(entry["url"])
|
|
self.assertEqual(response.status, 200)
|
|
self.assertEqual(await response.read(), original.read_bytes())
|
|
response = await self.client.get(
|
|
f"/prompt_manager/images/prompt/{quote(filename, safe='')}?root=0"
|
|
)
|
|
data = await response.json()
|
|
self.assertEqual(data["prompt"]["text"], "special path")
|
|
|
|
async def test_invalid_explicit_roots_never_fall_back_to_another_image(self):
|
|
self._link_image(make_png(self.output_dir / "same.png"), text="first root")
|
|
for root in ("bad", "-1", "7", ""):
|
|
for route in ("serve", "prompt"):
|
|
with self.subTest(root=root, route=route):
|
|
response = await self.client.get(
|
|
f"/prompt_manager/images/{route}/same.png?root={root}"
|
|
)
|
|
self.assertEqual(response.status, 403)
|
|
|
|
async def test_explicit_root_prompt_lookup_rejects_paths_outside_that_root(self):
|
|
from urllib.parse import quote
|
|
|
|
outside = make_png(self.comfy_dir / "outside.png")
|
|
self._link_image(outside, text="outside")
|
|
response = await self.client.get(
|
|
f"/prompt_manager/images/prompt/{quote(str(outside), safe='')}?root=0"
|
|
)
|
|
self.assertEqual(response.status, 403)
|
|
|
|
async def test_unlinked_image_does_not_inherit_a_prompt_from_another_root(self):
|
|
second = self.comfy_dir / "second-output"
|
|
self._link_image(make_png(self.output_dir / "same.png"), text="first root")
|
|
make_png(second / "same.png")
|
|
self.api._get_all_output_dirs = lambda: [self.output_dir, second]
|
|
response = await self.client.get(
|
|
"/prompt_manager/images/prompt/same.png?root=1"
|
|
)
|
|
self.assertEqual(response.status, 200)
|
|
self.assertFalse((await response.json())["success"])
|
|
|
|
|
|
class TestRootScopeServing(ImageAPITestCase):
|
|
async def scoped_request(self, path="image.png", root="0", scope="0" * 64):
|
|
params = {"root_scope": scope}
|
|
if root is not None:
|
|
params["root"] = root
|
|
return await self.client.get(
|
|
f"/prompt_manager/images/serve/{path}", params=params
|
|
)
|
|
|
|
async def assert_no_access(self, path, root="0", scope="0" * 64, status=409):
|
|
original_stat = Path.stat
|
|
|
|
def stat(candidate, *args, **kwargs):
|
|
if candidate.name in ("image.png", "image_thumb.png"):
|
|
raise AssertionError("candidate stat before scope guard")
|
|
return original_stat(candidate, *args, **kwargs)
|
|
|
|
with (
|
|
patch.object(Path, "stat", stat),
|
|
patch.object(
|
|
self.api, "_file_response", side_effect=AssertionError("file response")
|
|
),
|
|
patch.object(
|
|
self.api.db,
|
|
"get_image_prompt_info",
|
|
side_effect=AssertionError("saved lookup"),
|
|
),
|
|
patch(
|
|
"py.api.inspection_service.read_inspection_file",
|
|
side_effect=AssertionError("metadata read"),
|
|
),
|
|
patch(
|
|
"utils.prompt_inspector.subprocess.Popen",
|
|
side_effect=AssertionError("probe"),
|
|
),
|
|
):
|
|
response = await self.scoped_request(path, root, scope)
|
|
self.assertEqual(response.status, status)
|
|
result = await response.json()
|
|
self.assertFalse(result["success"])
|
|
if status == 409:
|
|
self.assertEqual(result["code"], "source-changed")
|
|
self.assertEqual(response.headers.get("Cache-Control"), "no-store")
|
|
self.assertNotIn(str(self.comfy_dir), json.dumps(result))
|
|
|
|
async def test_root_scope_stale_original_and_thumbnail_fail_before_access(self):
|
|
make_png(self.output_dir / "image.png")
|
|
make_png(self.output_dir / "thumbnails" / "image_thumb.png")
|
|
for path in ("image.png", "thumbnails/image_thumb.png"):
|
|
await self.assert_no_access(path)
|
|
|
|
async def test_root_scope_malformed_tokens_are_bad_requests(self):
|
|
make_png(self.output_dir / "image.png")
|
|
for scope in ("", "g" * 64, "a" * 63, "a" * 65, "a" * 4097, "A" * 64):
|
|
await self.assert_no_access("image.png", scope=scope, status=400)
|
|
for root in (None, "", "x", "-1", "0.0", "+0"):
|
|
await self.assert_no_access("image.png", root=root, status=400)
|
|
|
|
async def test_root_scope_rotated_key_and_removed_index_require_refresh(self):
|
|
make_png(self.output_dir / "image.png")
|
|
listing = await self.client.get("/prompt_manager/images/output?mode=workspace")
|
|
descriptor = (await listing.json())["images"][0]
|
|
self.assertRegex(descriptor.get("root_scope", ""), r"^[0-9a-f]{64}$")
|
|
with patch.object(
|
|
images_module, "_WORKSPACE_CURSOR_SECRET", b"different-process-key"
|
|
):
|
|
await self.assert_no_access("image.png", scope=descriptor["root_scope"])
|
|
self.api._get_all_output_dirs = lambda: []
|
|
await self.assert_no_access("image.png", scope=descriptor["root_scope"])
|
|
|
|
async def test_root_scope_keeps_containment_and_unscoped_access(self):
|
|
make_png(self.output_dir / "image.png")
|
|
listing = await self.client.get("/prompt_manager/images/output?mode=workspace")
|
|
descriptor = (await listing.json())["images"][0]
|
|
self.assertRegex(descriptor.get("root_scope", ""), r"^[0-9a-f]{64}$")
|
|
scope = descriptor["root_scope"]
|
|
self.assertEqual((await self.scoped_request(scope=scope)).status, 200)
|
|
outside = make_png(self.comfy_dir / "outside.png")
|
|
self._make_symlink(outside, self.output_dir / "escape.png")
|
|
for path in ("..%2Foutside.png", "escape.png"):
|
|
self.assertEqual((await self.scoped_request(path, scope=scope)).status, 403)
|
|
self.assertEqual(
|
|
(await self.client.get("/prompt_manager/images/serve/image.png")).status,
|
|
200,
|
|
)
|
|
|
|
async def test_root_scope_equal_label_reorder_replacement_and_symlink_are_stale(
|
|
self,
|
|
):
|
|
roots = [self.comfy_dir / name / "output" for name in ("a", "b", "c")]
|
|
for index, root in enumerate(roots):
|
|
make_png(root / "image.png", color=(index, 0, 0))
|
|
make_png(root / "thumbnails" / "image_thumb.png", color=(index, 0, 0))
|
|
# Both roots have the same display label under disposable public anchors.
|
|
with patch.object(
|
|
GalleryConfig,
|
|
"path_anchors",
|
|
classmethod(lambda cls: [str(root.parent) for root in roots]),
|
|
):
|
|
self.api._get_all_output_dirs = lambda: roots[:2]
|
|
listing = await self.client.get(
|
|
"/prompt_manager/images/output?mode=workspace"
|
|
)
|
|
descriptors = (await listing.json())["images"]
|
|
self.assertEqual({entry["root_dir"] for entry in descriptors}, {"output"})
|
|
token = next(
|
|
entry for entry in descriptors if entry["root_index"] == 0
|
|
).get("root_scope", "0" * 64)
|
|
for changed in ([roots[1], roots[0]], [roots[2], roots[1]]):
|
|
self.api._get_all_output_dirs = lambda: changed
|
|
for path in ("image.png", "thumbnails/image_thumb.png"):
|
|
await self.assert_no_access(path, scope=token)
|
|
link = self.comfy_dir / "linked-output"
|
|
self._make_symlink(roots[0], link)
|
|
self.api._get_all_output_dirs = lambda: [link]
|
|
listing = await self.client.get(
|
|
"/prompt_manager/images/output?mode=workspace"
|
|
)
|
|
token = (await listing.json())["images"][0]["root_scope"]
|
|
link.unlink()
|
|
self._make_symlink(roots[1], link)
|
|
for path in ("image.png", "thumbnails/image_thumb.png"):
|
|
await self.assert_no_access(path, scope=token)
|
|
|
|
|
|
# Seconds any single wait in the shared-listing tests may take.
|
|
_HANG_LIMIT = 30
|
|
|
|
|
|
def _reference_iter_media_files(output_path, max_files):
|
|
"""``_iter_media_files`` as it was before the scandir walk (#348)."""
|
|
output_path = Path(output_path)
|
|
root_depth = len(output_path.parts)
|
|
produced = 0
|
|
for root, dirs, files in os.walk(output_path):
|
|
root_path = Path(root)
|
|
rel_parts = root_path.parts[root_depth:]
|
|
if "thumbnails" in rel_parts:
|
|
dirs[:] = []
|
|
continue
|
|
if len(rel_parts) >= images_module.MAX_SCAN_DEPTH:
|
|
dirs[:] = []
|
|
for name in sorted(files):
|
|
# Path.suffix as Python 3.10-3.13 computed it (3.14 changed it).
|
|
dot = name.rfind(".")
|
|
suffix = name[dot:] if 0 < dot < len(name) - 1 else ""
|
|
if suffix.lower() not in images_module.MEDIA_EXTENSIONS:
|
|
continue
|
|
if (root_path / name).is_symlink():
|
|
continue
|
|
if produced >= max_files:
|
|
return
|
|
produced += 1
|
|
yield root_path / name
|
|
|
|
|
|
class _GatedScan:
|
|
"""Wraps the real scan, counts calls and can hold one call after it listed.
|
|
|
|
``hold_next()`` makes the next call list the files, then wait on the
|
|
returned gate, so a test can change the disk or the cache while a walk
|
|
is in flight and decide when it ends.
|
|
"""
|
|
|
|
def __init__(self, real):
|
|
self.real = real
|
|
self.calls = 0
|
|
self._gate = None
|
|
self.entered = threading.Event()
|
|
self._gates = []
|
|
|
|
def hold_next(self):
|
|
self._gate = threading.Event()
|
|
self._gates.append(self._gate)
|
|
self.entered.clear()
|
|
return self._gate
|
|
|
|
def release_all(self):
|
|
for gate in self._gates:
|
|
gate.set()
|
|
|
|
def __call__(self, root):
|
|
self.calls += 1
|
|
files = self.real(root)
|
|
gate, self._gate = self._gate, None
|
|
if gate is not None:
|
|
self.entered.set()
|
|
gate.wait(30)
|
|
return files
|
|
|
|
async def wait_entered(self):
|
|
loop = asyncio.get_running_loop()
|
|
entered = await loop.run_in_executor(None, self.entered.wait, 30)
|
|
if not entered:
|
|
raise AssertionError("the walk never reached the gate")
|
|
|
|
|
|
class TestSharedListing(ImageAPITestCase):
|
|
"""One listing per output root, shared and refreshed in the background (#348)."""
|
|
|
|
URL = "/prompt_manager/images/output"
|
|
|
|
async def asyncSetUp(self):
|
|
await super().asyncSetUp()
|
|
self.scan = _GatedScan(self.api._scan_gallery_files_sync)
|
|
self.api._scan_gallery_files_sync = self.scan
|
|
self.addCleanup(self.scan.release_all)
|
|
# The stale refresh waits REFRESH_DELAY after the read (#348); these
|
|
# tests start it on the next loop turn and _settle waits for it.
|
|
delay = patch.object(gallery_listing, "REFRESH_DELAY", 0)
|
|
delay.start()
|
|
self.addCleanup(delay.stop)
|
|
|
|
async def _json(self, method, path, **kwargs):
|
|
# Bounded, like every wait in this class: a stuck walk fails the test
|
|
# with a traceback instead of hanging the whole run.
|
|
async def call():
|
|
resp = await self.client.request(method, path, **kwargs)
|
|
return resp.status, await resp.json()
|
|
|
|
return await asyncio.wait_for(call(), _HANG_LIMIT)
|
|
|
|
def _record(self):
|
|
return self.api._root_listings[str(self.output_dir)]
|
|
|
|
def _pending(self):
|
|
return any(
|
|
record.rewalk_handle is not None
|
|
for record in self.api._root_listings.values()
|
|
)
|
|
|
|
def _warming(self):
|
|
task = self.api._workspace_warm_task
|
|
return task is not None and not task.done()
|
|
|
|
def _age(self, seconds=None):
|
|
from py.api import gallery_listing
|
|
|
|
record = self._record()
|
|
shift = (seconds or gallery_listing.REFRESH_AFTER) + 1
|
|
record.walked_at -= shift
|
|
# An old listing's walk also started long ago (rewalk_interval).
|
|
record.last_started_at -= shift
|
|
|
|
async def _settle(self):
|
|
"""Wait for every background walk this API started."""
|
|
|
|
async def settle():
|
|
while self.api._listing_tasks or self._warming() or self._pending():
|
|
if self._pending():
|
|
await asyncio.sleep(0.01) # a delayed refresh not started yet
|
|
await asyncio.gather(*list(self.api._listing_tasks))
|
|
# A finished walk leaves the set in a done callback, which needs
|
|
# a loop turn; gather over finished tasks does not yield one.
|
|
await asyncio.sleep(0)
|
|
if self._warming():
|
|
await asyncio.wait([self.api._workspace_warm_task])
|
|
|
|
await asyncio.wait_for(settle(), _HANG_LIMIT)
|
|
|
|
async def test_concurrent_cold_readers_share_one_walk(self):
|
|
make_png(self.output_dir / "sub" / "a.png")
|
|
results = await asyncio.gather(
|
|
self._json("GET", self.URL),
|
|
self._json("GET", self.URL + "?limit=1"),
|
|
self._json("GET", self.URL + "?mode=workspace&limit=1"),
|
|
self._json("GET", "/prompt_manager/gallery/subfolders"),
|
|
)
|
|
self.assertEqual([status for status, _ in results], [200] * 4)
|
|
self.assertEqual(self.scan.calls, 1)
|
|
self.assertEqual(results[0][1]["total"], 1)
|
|
self.assertEqual(results[3][1]["subfolders"], ["sub"])
|
|
|
|
async def test_a_stale_listing_is_served_at_once_and_refreshed_behind(self):
|
|
make_png(self.output_dir / "a.png")
|
|
_, first = await self._json("GET", self.URL)
|
|
make_png(self.output_dir / "b.png")
|
|
self._age()
|
|
gate = self.scan.hold_next()
|
|
_, stale = await self._json("GET", self.URL)
|
|
# Served from the old listing while the walk is still held.
|
|
self.assertEqual((first["total"], stale["total"]), (1, 1))
|
|
await self.scan.wait_entered()
|
|
# A second stale read joins the running walk instead of starting one.
|
|
_, again = await self._json("GET", self.URL)
|
|
self.assertEqual(again["total"], 1)
|
|
gate.set()
|
|
await self._settle()
|
|
_, fresh = await self._json("GET", self.URL)
|
|
self.assertEqual(fresh["total"], 2)
|
|
self.assertEqual(self.scan.calls, 2)
|
|
|
|
async def test_a_fresh_listing_is_not_walked_again(self):
|
|
make_png(self.output_dir / "a.png")
|
|
for _ in range(3):
|
|
await self._json("GET", self.URL)
|
|
await self._json("GET", "/prompt_manager/gallery/subfolders")
|
|
self.assertEqual(self.scan.calls, 1)
|
|
self.assertEqual(self.api._listing_tasks, set())
|
|
|
|
async def test_rescan_waits_for_a_walk_started_after_the_click(self):
|
|
make_png(self.output_dir / "a.png")
|
|
await self._json("GET", self.URL)
|
|
self._age()
|
|
gate = self.scan.hold_next()
|
|
await self._json("GET", self.URL) # starts a background walk
|
|
await self.scan.wait_entered() # it has listed: it cannot see c.png
|
|
make_png(self.output_dir / "c.png")
|
|
rescan = asyncio.ensure_future(
|
|
self._json("POST", "/prompt_manager/gallery/rescan")
|
|
)
|
|
await asyncio.sleep(0)
|
|
self.assertFalse(rescan.done())
|
|
gate.set()
|
|
status, data = await rescan
|
|
self.assertEqual(status, 200)
|
|
self.assertEqual((data["total"], data["roots"]), (2, 1))
|
|
self.assertEqual(self.scan.calls, 3)
|
|
|
|
async def test_rescans_clicked_during_a_walk_share_the_next_one(self):
|
|
make_png(self.output_dir / "a.png")
|
|
await self._json("GET", self.URL)
|
|
original = self.api._listing
|
|
rescans = []
|
|
clicked = asyncio.Event()
|
|
|
|
async def listing(root, *, fresh=False):
|
|
if fresh:
|
|
rescans.append(root)
|
|
if len(rescans) == 3:
|
|
clicked.set()
|
|
# Runs on in this step up to its first wait, so the Rescan's
|
|
# walk number is taken before the test resumes.
|
|
return await original(root, fresh=fresh)
|
|
|
|
self.api._listing = listing
|
|
gate = self.scan.hold_next()
|
|
first = asyncio.ensure_future(
|
|
self._json("POST", "/prompt_manager/gallery/rescan")
|
|
)
|
|
await self.scan.wait_entered()
|
|
# Clicked while the first Rescan's walk runs: neither may use it,
|
|
# and both wait for the same next walk.
|
|
later = [
|
|
asyncio.ensure_future(self._json("POST", "/prompt_manager/gallery/rescan"))
|
|
for _ in range(2)
|
|
]
|
|
await asyncio.wait_for(clicked.wait(), 30)
|
|
gate.set()
|
|
results = await asyncio.gather(first, *later)
|
|
self.assertEqual([status for status, _ in results], [200] * 3)
|
|
self.assertEqual(self.scan.calls, 3)
|
|
|
|
async def test_rescan_failure_with_a_previous_listing_is_500(self):
|
|
make_png(self.output_dir / "a.png")
|
|
await self._json("GET", self.URL)
|
|
self.api._scan_gallery_files_sync = _raise
|
|
with self.assertLogs(self.api.logger, "WARNING"):
|
|
status, data = await self._json("POST", "/prompt_manager/gallery/rescan")
|
|
self.assertEqual(status, 500)
|
|
self.assertFalse(data["success"])
|
|
self.assertNotIn("boom", json.dumps(data))
|
|
# Plain readers keep the previous listing, and do not retry at once.
|
|
status, listed = await self._json("GET", self.URL)
|
|
self.assertEqual((status, listed["total"]), (200, 1))
|
|
self.assertEqual(self.api._listing_tasks, set())
|
|
|
|
async def test_a_cold_read_whose_walk_fails_is_500(self):
|
|
self.api._scan_gallery_files_sync = _raise
|
|
with self.assertLogs(self.api.logger, "WARNING"):
|
|
status, data = await self._json("GET", self.URL)
|
|
self.assertEqual(status, 500)
|
|
self.assertFalse(data["success"])
|
|
|
|
async def test_a_deleted_root_keeps_the_previous_listing_and_warns_once(self):
|
|
import shutil
|
|
|
|
make_png(self.output_dir / "a.png")
|
|
await self._json("GET", self.URL)
|
|
shutil.rmtree(self.output_dir)
|
|
with self.assertLogs(self.api.logger, "WARNING") as logs:
|
|
for _ in range(2):
|
|
self._age()
|
|
status, data = await self._json("GET", self.URL)
|
|
self.assertEqual((status, data["total"]), (200, 1))
|
|
await self._settle()
|
|
warnings = [r for r in logs.records if r.levelname == "WARNING"]
|
|
self.assertEqual(len(warnings), 1, [r.getMessage() for r in warnings])
|
|
self.assertEqual(self.scan.calls, 1) # the probe failed before the scan
|
|
|
|
async def test_a_root_that_recovers_warns_again_on_its_next_failure(self):
|
|
make_png(self.output_dir / "a.png")
|
|
await self._json("GET", self.URL)
|
|
real = self.api._scan_gallery_files_sync
|
|
with self.assertLogs(self.api.logger, "WARNING") as logs:
|
|
for scan in (_raise, real, _raise):
|
|
self.api._scan_gallery_files_sync = scan
|
|
self._age()
|
|
await self._json("GET", self.URL)
|
|
await self._settle()
|
|
warnings = [r for r in logs.records if r.levelname == "WARNING"]
|
|
self.assertEqual(len(warnings), 2)
|
|
|
|
async def test_an_invalidation_during_a_walk_discards_it(self):
|
|
make_png(self.output_dir / "a.png")
|
|
gate = self.scan.hold_next()
|
|
reader = asyncio.ensure_future(self._json("GET", self.URL))
|
|
await self.scan.wait_entered()
|
|
make_png(self.output_dir / "b.png")
|
|
self.api.invalidate_gallery_cache()
|
|
gate.set()
|
|
status, data = await reader
|
|
self.assertEqual((status, data["total"]), (200, 2))
|
|
self.assertEqual(self.scan.calls, 2)
|
|
|
|
async def test_an_invalidation_from_a_worker_thread_drops_on_the_loop(self):
|
|
make_png(self.output_dir / "a.png")
|
|
await self._json("GET", self.URL)
|
|
record = self._record()
|
|
loop_thread = threading.get_ident()
|
|
threads = []
|
|
original = record.drop
|
|
|
|
def drop():
|
|
threads.append(threading.get_ident())
|
|
original()
|
|
|
|
record.drop = drop
|
|
loop = asyncio.get_running_loop()
|
|
await loop.run_in_executor(None, self.api.invalidate_gallery_cache)
|
|
# Dropped on the loop, before invalidate_gallery_cache returned.
|
|
self.assertEqual(threads, [loop_thread])
|
|
self.assertIsNone(record.files)
|
|
|
|
async def test_three_discarded_walks_are_a_500(self):
|
|
loop = asyncio.get_running_loop()
|
|
real = self.scan.real
|
|
|
|
async def invalidate():
|
|
self.api.invalidate_gallery_cache()
|
|
|
|
def scan(root):
|
|
asyncio.run_coroutine_threadsafe(invalidate(), loop).result(30)
|
|
return real(root)
|
|
|
|
self.scan.real = scan
|
|
status, data = await self._json("GET", self.URL)
|
|
self.assertEqual(status, 500)
|
|
self.assertFalse(data["success"])
|
|
self.assertEqual(self.scan.calls, 3)
|
|
|
|
async def test_a_disconnected_waiter_does_not_cancel_the_walk(self):
|
|
make_png(self.output_dir / "a.png")
|
|
gate = self.scan.hold_next()
|
|
waiter = asyncio.ensure_future(self.api._listing(self.output_dir))
|
|
await self.scan.wait_entered()
|
|
waiter.cancel()
|
|
with self.assertRaises(asyncio.CancelledError):
|
|
await waiter
|
|
flight = self._record().flight
|
|
self.assertIsNotNone(flight)
|
|
gate.set()
|
|
await asyncio.shield(flight)
|
|
self.assertEqual(len(self._record().files), 1)
|
|
self.assertIsNone(self._record().flight)
|
|
|
|
async def test_a_duplicate_delete_is_gone_on_the_very_next_read(self):
|
|
make_png(self.output_dir / "keep.png")
|
|
make_png(self.output_dir / "dupe.png")
|
|
_, before = await self._json("GET", self.URL)
|
|
self.assertEqual(before["total"], 2)
|
|
status, data = await self._json(
|
|
"POST",
|
|
"/prompt_manager/delete_duplicate_images",
|
|
json={"image_paths": ["dupe.png"]},
|
|
)
|
|
self.assertEqual((status, data["deleted_count"]), (200, 1))
|
|
_, after = await self._json("GET", self.URL)
|
|
self.assertEqual([image["filename"] for image in after["images"]], ["keep.png"])
|
|
|
|
async def test_response_keys_are_unchanged(self):
|
|
make_png(self.output_dir / "sub" / "a.png")
|
|
_, legacy = await self._json("GET", self.URL)
|
|
self.assertEqual(
|
|
set(legacy),
|
|
{"success", "images", "total", "offset", "limit", "has_more"},
|
|
)
|
|
_, workspace = await self._json("GET", self.URL + "?mode=workspace")
|
|
self.assertEqual(
|
|
set(workspace),
|
|
{
|
|
"success",
|
|
"images",
|
|
"total",
|
|
"positive_count",
|
|
"limit",
|
|
"has_more",
|
|
"next_cursor",
|
|
},
|
|
)
|
|
_, folders = await self._json("GET", "/prompt_manager/gallery/subfolders")
|
|
self.assertEqual(set(folders), {"success", "subfolders"})
|
|
_, scoped = await self._json(
|
|
"GET", "/prompt_manager/gallery/subfolders?mode=workspace"
|
|
)
|
|
self.assertEqual(set(scoped), {"success", "subfolders", "roots"})
|
|
self.assertEqual(set(scoped["roots"][0]), {"index", "label", "subfolders"})
|
|
_, rescan = await self._json("POST", "/prompt_manager/gallery/rescan")
|
|
self.assertEqual(set(rescan), {"success", "total", "roots"})
|
|
|
|
async def test_subfolders_with_ancestors_come_from_the_listing(self):
|
|
make_png(self.output_dir / "a" / "b" / "c.png")
|
|
_, plain = await self._json("GET", "/prompt_manager/gallery/subfolders")
|
|
_, nested = await self._json(
|
|
"GET", "/prompt_manager/gallery/subfolders?include_ancestors=true"
|
|
)
|
|
self.assertEqual(plain["subfolders"], [str(Path("a") / "b")])
|
|
self.assertEqual(
|
|
sorted(nested["subfolders"]), sorted(["a", str(Path("a") / "b")])
|
|
)
|
|
self.assertEqual(self.scan.calls, 1)
|
|
|
|
async def test_warm_up_walks_every_root_before_the_first_read(self):
|
|
second = self.comfy_dir / "second"
|
|
make_png(second / "x.png")
|
|
make_png(self.output_dir / "a.png")
|
|
self.api._get_all_output_dirs = lambda: [self.output_dir, second]
|
|
await self.api.warm_gallery_listings(web.Application())
|
|
self.assertEqual(len(self.api._listing_tasks), 2)
|
|
await self._settle()
|
|
self.assertEqual(self.scan.calls, 2)
|
|
_, data = await self._json("GET", self.URL)
|
|
self.assertEqual(data["total"], 2)
|
|
self.assertEqual(self.scan.calls, 2)
|
|
|
|
async def test_warm_up_never_fails_startup(self):
|
|
def broken():
|
|
raise RuntimeError("no roots")
|
|
|
|
self.api._get_all_output_dirs = broken
|
|
with self.assertLogs(self.api.logger, "WARNING"):
|
|
await self.api.warm_gallery_listings(web.Application())
|
|
self.assertEqual(self.api._listing_tasks, set())
|
|
|
|
async def test_warm_up_failure_is_retried_by_the_first_read(self):
|
|
make_png(self.output_dir / "a.png")
|
|
self.api._scan_gallery_files_sync = _raise
|
|
with self.assertLogs(self.api.logger, "WARNING"):
|
|
await self.api.warm_gallery_listings(web.Application())
|
|
await self._settle()
|
|
self.api._scan_gallery_files_sync = self.scan
|
|
status, data = await self._json("GET", self.URL)
|
|
self.assertEqual((status, data["total"]), (200, 1))
|
|
|
|
async def test_cleanup_cancels_running_walks(self):
|
|
make_png(self.output_dir / "a.png")
|
|
gate = self.scan.hold_next()
|
|
await self.api.warm_gallery_listings(web.Application())
|
|
await self.scan.wait_entered()
|
|
tasks = set(self.api._listing_tasks)
|
|
flight = self._record().flight
|
|
await self.api.cleanup_gallery_listings(web.Application())
|
|
gate.set()
|
|
self.assertTrue(all(task.cancelled() for task in tasks))
|
|
self.assertEqual(self.api._listing_tasks, set())
|
|
self.assertTrue(flight.done())
|
|
self.assertIsNone(self._record().flight)
|
|
self.assertIsNone(self._record().files)
|
|
|
|
async def test_cleanup_resolves_a_walk_cancelled_before_it_ran(self):
|
|
await self.api.warm_gallery_listings(web.Application())
|
|
flight = self._record().flight
|
|
waiter = asyncio.ensure_future(asyncio.shield(flight))
|
|
# No await between the warm-up and the cleanup: the task never ran.
|
|
await self.api.cleanup_gallery_listings(web.Application())
|
|
outcome = await waiter
|
|
self.assertEqual(outcome.kind, gallery_listing.FAILED)
|
|
self.assertIsNone(self._record().flight)
|
|
self.assertEqual(self.scan.calls, 0)
|
|
|
|
async def test_a_stale_read_starts_its_refresh_after_a_delay(self):
|
|
# The read is served from the old listing; the walk starts
|
|
# REFRESH_DELAY later so it does not share the GIL with the page's
|
|
# own requests (#348 live test: 2.2 s reads during the walk).
|
|
make_png(self.output_dir / "a.png")
|
|
await self._json("GET", self.URL)
|
|
self._age()
|
|
with patch.object(gallery_listing, "REFRESH_DELAY", 30):
|
|
status, data = await self._json("GET", self.URL)
|
|
await self._json("GET", self.URL) # still one pending refresh
|
|
record = self._record()
|
|
handle = record.rewalk_handle
|
|
self.assertEqual((status, data["total"]), (200, 1))
|
|
self.assertIsNotNone(handle)
|
|
self.assertIsNone(record.flight)
|
|
self.assertEqual(self.scan.calls, 1)
|
|
self.assertAlmostEqual(
|
|
handle.when() - asyncio.get_running_loop().time(), 30, delta=2
|
|
)
|
|
handle.cancel()
|
|
record.rewalk_handle = None
|
|
|
|
async def test_the_delayed_refresh_walks_once_when_it_fires(self):
|
|
make_png(self.output_dir / "a.png")
|
|
await self._json("GET", self.URL)
|
|
make_png(self.output_dir / "b.png")
|
|
self._age()
|
|
await self._json("GET", self.URL)
|
|
await self._json("GET", self.URL)
|
|
await self._settle()
|
|
self.assertEqual(self.scan.calls, 2)
|
|
self.assertIsNone(self._record().rewalk_handle)
|
|
_, data = await self._json("GET", self.URL)
|
|
self.assertEqual(data["total"], 2)
|
|
|
|
async def test_a_walk_started_meanwhile_cancels_the_delayed_refresh(self):
|
|
make_png(self.output_dir / "a.png")
|
|
await self._json("GET", self.URL)
|
|
self._age()
|
|
with patch.object(gallery_listing, "REFRESH_DELAY", 30):
|
|
await self._json("GET", self.URL)
|
|
handle = self._record().rewalk_handle
|
|
status, _ = await self._json("POST", "/prompt_manager/gallery/rescan")
|
|
self.assertEqual(status, 200)
|
|
self.assertTrue(handle.cancelled())
|
|
self.assertIsNone(self._record().rewalk_handle)
|
|
self.assertEqual(self.scan.calls, 2)
|
|
|
|
async def test_cleanup_cancels_a_delayed_refresh(self):
|
|
make_png(self.output_dir / "a.png")
|
|
await self._json("GET", self.URL)
|
|
self._age()
|
|
with patch.object(gallery_listing, "REFRESH_DELAY", 30):
|
|
await self._json("GET", self.URL)
|
|
handle = self._record().rewalk_handle
|
|
await self.api.cleanup_gallery_listings(web.Application())
|
|
self.assertTrue(handle.cancelled())
|
|
self.assertIsNone(self._record().rewalk_handle)
|
|
self.assertEqual(self.scan.calls, 1)
|
|
|
|
WORKSPACE_URL = "/prompt_manager/images/output?mode=workspace&limit=1"
|
|
|
|
async def _warmed(self):
|
|
await self.api.warm_gallery_listings(web.Application())
|
|
await self._settle()
|
|
|
|
def _workspace_ready(self):
|
|
"""True when a first workspace page needs no per-file rebuild."""
|
|
files = self._record().files
|
|
favorites = self.api._favorite_listing_cache.get(str(self.output_dir))
|
|
listed = self.api._workspace_listed_cache.get((0, str(self.output_dir)))
|
|
return (
|
|
favorites is not None
|
|
and favorites[0] is files
|
|
and listed is not None
|
|
and listed[0] is files
|
|
and bool(self.api._workspace_order_cache)
|
|
)
|
|
|
|
async def test_warm_up_also_builds_the_workspace_caches(self):
|
|
# The first open after a restart paid ~3 s building these (#348 live).
|
|
make_png(self.output_dir / "a.png")
|
|
make_png(self.output_dir / "sub" / "b.png")
|
|
await self._warmed()
|
|
self.assertTrue(self._workspace_ready())
|
|
with patch.object(images_module, "_listed", side_effect=AssertionError):
|
|
status, data = await self._json("GET", self.WORKSPACE_URL)
|
|
self.assertEqual((status, data["total"]), (200, 2))
|
|
|
|
async def test_a_changed_refresh_rebuilds_the_workspace_caches_behind(self):
|
|
make_png(self.output_dir / "a.png")
|
|
await self._warmed()
|
|
make_png(self.output_dir / "b.png")
|
|
self._age()
|
|
await self._json("GET", self.URL) # stale: one background walk
|
|
await self._settle()
|
|
self.assertEqual(len(self._record().files), 2)
|
|
self.assertTrue(self._workspace_ready())
|
|
|
|
async def test_an_unchanged_refresh_does_not_rebuild_them(self):
|
|
make_png(self.output_dir / "a.png")
|
|
await self._warmed()
|
|
with patch.object(
|
|
self.api, "_build_workspace_caches", side_effect=AssertionError
|
|
):
|
|
self._age()
|
|
await self._json("GET", self.URL)
|
|
await self._settle()
|
|
self.assertTrue(self._workspace_ready())
|
|
|
|
async def test_without_the_warm_up_reads_start_no_workspace_build(self):
|
|
make_png(self.output_dir / "a.png")
|
|
await self._json("GET", self.URL)
|
|
await self._settle()
|
|
self.assertIsNone(self.api._workspace_warm_task)
|
|
self.assertEqual(self.api._favorite_listing_cache, {})
|
|
|
|
async def test_a_failed_workspace_build_warns_and_reads_still_work(self):
|
|
make_png(self.output_dir / "a.png")
|
|
with (
|
|
patch.object(
|
|
self.api, "_select_workspace_page", side_effect=RuntimeError("boom")
|
|
),
|
|
self.assertLogs(self.api.logger, "WARNING") as logs,
|
|
):
|
|
await self._warmed()
|
|
self.assertTrue(any("Images page" in line for line in logs.output))
|
|
status, data = await self._json("GET", self.URL)
|
|
self.assertEqual((status, data["total"]), (200, 1))
|
|
|
|
async def test_walks_during_a_workspace_build_coalesce_into_one_more(self):
|
|
make_png(self.output_dir / "a.png")
|
|
release, entered = threading.Event(), threading.Event()
|
|
built = []
|
|
real = self.api._build_workspace_caches
|
|
|
|
def build(listings):
|
|
built.append(len(listings[0][2]))
|
|
if len(built) == 1:
|
|
entered.set()
|
|
release.wait(_HANG_LIMIT)
|
|
real(listings)
|
|
|
|
self.addCleanup(release.set)
|
|
with patch.object(self.api, "_build_workspace_caches", side_effect=build):
|
|
await self.api.warm_gallery_listings(web.Application())
|
|
loop = asyncio.get_running_loop()
|
|
await loop.run_in_executor(None, entered.wait, _HANG_LIMIT)
|
|
for name in ("b.png", "c.png"): # two changed walks while it builds
|
|
make_png(self.output_dir / name)
|
|
self._age()
|
|
await self._json("GET", self.URL)
|
|
while self.api._listing_tasks:
|
|
await asyncio.gather(*list(self.api._listing_tasks))
|
|
await asyncio.sleep(0)
|
|
release.set()
|
|
await self._settle()
|
|
self.assertEqual(built, [1, 3])
|
|
self.assertEqual(self.scan.calls, 3) # two stored walks, one more build
|
|
self.assertTrue(self._workspace_ready())
|
|
|
|
async def test_a_build_that_cannot_list_the_roots_only_warns(self):
|
|
make_png(self.output_dir / "a.png")
|
|
await self._warmed()
|
|
|
|
def broken():
|
|
raise RuntimeError("no roots")
|
|
|
|
self.api._get_all_output_dirs = broken
|
|
with self.assertLogs(self.api.logger, "WARNING") as logs:
|
|
self.api._schedule_workspace_warm()
|
|
task = self.api._workspace_warm_task
|
|
await asyncio.wait([task])
|
|
self.assertIsNone(task.exception())
|
|
self.assertTrue(any("Images page" in line for line in logs.output))
|
|
|
|
async def test_cleanup_stops_a_running_workspace_build(self):
|
|
make_png(self.output_dir / "a.png")
|
|
release, entered = threading.Event(), threading.Event()
|
|
|
|
def build(_listings):
|
|
entered.set()
|
|
release.wait(_HANG_LIMIT)
|
|
|
|
self.addCleanup(release.set)
|
|
with patch.object(self.api, "_build_workspace_caches", side_effect=build):
|
|
await self.api.warm_gallery_listings(web.Application())
|
|
loop = asyncio.get_running_loop()
|
|
await loop.run_in_executor(None, entered.wait, _HANG_LIMIT)
|
|
task = self.api._workspace_warm_task
|
|
await self.api.cleanup_gallery_listings(web.Application())
|
|
release.set()
|
|
self.assertTrue(task.done())
|
|
self.assertIsNone(self.api._workspace_warm_task)
|
|
|
|
# -- monitor-triggered walks (#348, PR 2) ---------------------------
|
|
|
|
def _interval(self, seconds):
|
|
"""Patch rewalk_interval() to *seconds*, whatever the walk took."""
|
|
for name in ("REWALK_FLOOR", "REWALK_CAP"):
|
|
patcher = patch.object(gallery_listing, name, seconds)
|
|
patcher.start()
|
|
self.addCleanup(patcher.stop)
|
|
|
|
def _output(self, name):
|
|
"""A new output on disk, spelled as the monitor reports it (resolved)."""
|
|
return os.path.realpath(make_png(self.output_dir / name))
|
|
|
|
async def _listed(self):
|
|
"""A stored listing of the root, built by a read."""
|
|
await self._json("GET", self.URL)
|
|
await self._settle()
|
|
return self._record()
|
|
|
|
def _fire(self, record):
|
|
"""Run the pending deferred walk now, as its timer would."""
|
|
handle = record.rewalk_handle
|
|
self.assertIsNotNone(handle, "no deferred walk is pending")
|
|
handle.cancel()
|
|
self.api._delayed_walk(self.output_dir, record)
|
|
|
|
def _remaining(self, handle):
|
|
return handle.when() - asyncio.get_running_loop().time()
|
|
|
|
async def test_a_settled_output_is_listed_without_a_reload(self):
|
|
make_png(self.output_dir / "a.png")
|
|
record = await self._listed()
|
|
self._interval(0)
|
|
self.api.mark_root_dirty(self._output("b.png"))
|
|
self.assertIsNotNone(record.flight) # the interval allows: walks now
|
|
self.assertFalse(record.dirty)
|
|
await self._settle()
|
|
self.assertEqual(self.scan.calls, 2)
|
|
self.assertEqual(len(record.files), 2)
|
|
_, data = await self._json("GET", self.URL)
|
|
self.assertEqual(data["total"], 2)
|
|
self.assertEqual(self.scan.calls, 2)
|
|
|
|
async def test_the_monitor_thread_hops_onto_the_loop(self):
|
|
make_png(self.output_dir / "a.png")
|
|
record = await self._listed()
|
|
self._interval(0)
|
|
path = self._output("b.png")
|
|
calls = []
|
|
real = self.api.output_settled
|
|
|
|
def settled(seen):
|
|
calls.append(threading.get_ident())
|
|
real(seen)
|
|
|
|
self.api.output_settled = settled
|
|
loop = asyncio.get_running_loop()
|
|
await loop.run_in_executor(None, self.api.notify_output_settled, path)
|
|
# The hop was queued before the executor's result: one loop turn runs it.
|
|
await asyncio.sleep(0)
|
|
self.assertEqual(calls, [threading.get_ident()])
|
|
await self._settle()
|
|
self.assertEqual(len(record.files), 2)
|
|
self.assertEqual(self.scan.calls, 1) # added, not walked
|
|
|
|
async def test_notify_before_the_loop_is_known_does_nothing(self):
|
|
self.assertIsNone(self.api._listing_loop)
|
|
with patch.object(self.api, "output_settled") as mark:
|
|
self.api.notify_output_settled(self._output("b.png"))
|
|
await asyncio.sleep(0)
|
|
mark.assert_not_called()
|
|
|
|
async def test_notify_after_cleanup_does_nothing(self):
|
|
make_png(self.output_dir / "a.png")
|
|
record = await self._listed()
|
|
await self.api.cleanup_gallery_listings(web.Application())
|
|
self._interval(0)
|
|
with patch.object(self.api, "output_settled") as mark:
|
|
self.api.notify_output_settled(self._output("b.png"))
|
|
await asyncio.sleep(0)
|
|
mark.assert_not_called()
|
|
# A hop queued before the cleanup is dropped when it runs.
|
|
self.api.output_settled(self._output("c.png"))
|
|
self.api.mark_root_dirty(self._output("d.png"))
|
|
self.assertEqual(self.api._listing_tasks, set())
|
|
self.assertEqual(record.pending_adds, [])
|
|
self.assertFalse(record.dirty)
|
|
self.assertIsNone(record.flight)
|
|
self.assertIsNone(record.rewalk_handle)
|
|
|
|
async def test_notify_on_a_closed_loop_does_nothing(self):
|
|
loop = asyncio.new_event_loop()
|
|
loop.close()
|
|
self.api._listing_loop = loop
|
|
self.api.notify_output_settled(self._output("b.png")) # no exception
|
|
|
|
async def test_notify_racing_the_loop_closing_does_nothing(self):
|
|
class ClosingLoop:
|
|
def is_running(self):
|
|
return True
|
|
|
|
def call_soon_threadsafe(self, *_args):
|
|
raise RuntimeError("Event loop is closed")
|
|
|
|
self.api._listing_loop = ClosingLoop()
|
|
self.api.notify_output_settled(self._output("b.png")) # no exception
|
|
|
|
async def test_an_output_outside_every_root_is_ignored(self):
|
|
make_png(self.output_dir / "a.png")
|
|
record = await self._listed()
|
|
self._interval(0)
|
|
elsewhere = make_png(self.comfy_dir / "output2" / "x.png")
|
|
self.api.mark_root_dirty(os.path.realpath(elsewhere))
|
|
self.assertFalse(record.dirty)
|
|
self.assertIsNone(record.flight)
|
|
self.assertIsNone(record.rewalk_handle)
|
|
|
|
async def test_a_root_never_listed_is_not_walked_by_a_mark(self):
|
|
# No listing, no walk running: the first read walks anyway.
|
|
self.api.mark_root_dirty(self._output("b.png"))
|
|
self.assertEqual(self.api._root_listings, {})
|
|
self.assertEqual(self.scan.calls, 0)
|
|
|
|
async def test_a_dropped_listing_is_left_to_the_next_read(self):
|
|
make_png(self.output_dir / "a.png")
|
|
record = await self._listed()
|
|
self.api.invalidate_gallery_cache()
|
|
self._interval(0)
|
|
self.api.mark_root_dirty(self._output("b.png"))
|
|
self.assertIsNone(record.flight)
|
|
self.assertEqual(self.scan.calls, 1)
|
|
|
|
async def test_the_walk_records_the_resolved_root_and_its_timing(self):
|
|
make_png(self.output_dir / "a.png")
|
|
before = images_module._time.monotonic()
|
|
record = await self._listed()
|
|
self.assertEqual(
|
|
record.resolved_root, os.path.normcase(os.path.realpath(self.output_dir))
|
|
)
|
|
self.assertGreaterEqual(record.last_started_at, before)
|
|
self.assertLessEqual(record.last_started_at, record.walked_at)
|
|
# Measured when the walk ended, after its listing was stored.
|
|
self.assertGreaterEqual(
|
|
record.last_duration, record.walked_at - record.last_started_at
|
|
)
|
|
|
|
async def test_a_failed_walk_records_its_timing_and_clears_dirty(self):
|
|
make_png(self.output_dir / "a.png")
|
|
record = await self._listed()
|
|
self._interval(30)
|
|
self.api.mark_root_dirty(self._output("b.png"))
|
|
self.assertTrue(record.dirty)
|
|
self.api._scan_gallery_files_sync = _raise
|
|
# Moved back, so the failed walk's start is later even on a coarse
|
|
# monotonic clock (Windows ticks every ~15.6 ms).
|
|
record.last_started_at -= 1000
|
|
record.last_duration = None
|
|
started = record.last_started_at
|
|
with self.assertLogs(self.api.logger, "WARNING"):
|
|
self._fire(record)
|
|
await self._settle()
|
|
self.assertGreater(record.last_started_at, started)
|
|
self.assertIsNotNone(record.last_duration)
|
|
self.assertFalse(record.dirty)
|
|
self.assertIsNone(record.rewalk_handle) # no trailing walk after a failure
|
|
self.assertEqual(len(record.files), 1) # the previous listing is kept
|
|
|
|
async def test_a_burst_of_marks_coalesces_into_one_walk(self):
|
|
make_png(self.output_dir / "a.png")
|
|
record = await self._listed()
|
|
self._interval(30)
|
|
for i in range(5):
|
|
self.api.mark_root_dirty(self._output(f"b{i}.png"))
|
|
handle = record.rewalk_handle
|
|
self.assertIsNotNone(handle)
|
|
self.assertIsNone(record.flight)
|
|
self.assertEqual(self.scan.calls, 1)
|
|
# Due one interval after the last walk started, not 30 s from now.
|
|
due = record.last_started_at + 30 - images_module._time.monotonic()
|
|
self.assertAlmostEqual(self._remaining(handle), due, delta=1)
|
|
self._fire(record)
|
|
await self._settle()
|
|
self.assertEqual(self.scan.calls, 2)
|
|
self.assertEqual(len(record.files), 6)
|
|
self.assertFalse(record.dirty)
|
|
self.assertIsNone(record.rewalk_handle)
|
|
|
|
async def test_a_mark_during_a_walk_gives_exactly_one_trailing_walk(self):
|
|
make_png(self.output_dir / "a.png")
|
|
record = await self._listed()
|
|
self._interval(30)
|
|
gate = self.scan.hold_next()
|
|
rescan = asyncio.ensure_future(
|
|
self._json("POST", "/prompt_manager/gallery/rescan")
|
|
)
|
|
await self.scan.wait_entered() # listed before b.png exists
|
|
for name in ("b.png", "c.png"):
|
|
self.api.mark_root_dirty(self._output(name))
|
|
# Never armed while a walk runs: the walk's end re-checks dirty.
|
|
self.assertIsNone(record.rewalk_handle)
|
|
self.assertTrue(record.dirty)
|
|
gate.set()
|
|
status, _ = await rescan
|
|
self.assertEqual(status, 200)
|
|
self.assertEqual(len(record.files), 1)
|
|
handle = record.rewalk_handle
|
|
self.assertIsNotNone(handle) # the trailing walk, deferred by the interval
|
|
self.assertGreaterEqual(self._remaining(handle), 0.5)
|
|
self._fire(record)
|
|
await self._settle()
|
|
self.assertEqual(self.scan.calls, 3)
|
|
self.assertEqual(len(record.files), 3)
|
|
self.assertIsNone(record.rewalk_handle)
|
|
self.assertFalse(record.dirty)
|
|
|
|
async def test_a_walk_slower_than_the_interval_trails_at_once_and_stops(self):
|
|
make_png(self.output_dir / "a.png")
|
|
record = await self._listed()
|
|
self._interval(0) # the walk outlasts its interval
|
|
gate = self.scan.hold_next()
|
|
self.api.mark_root_dirty(self._output("b.png"))
|
|
await self.scan.wait_entered()
|
|
self.api.mark_root_dirty(self._output("c.png"))
|
|
self.assertIsNone(record.rewalk_handle)
|
|
gate.set()
|
|
await self._settle()
|
|
# One walk for the first mark, one trailing walk for the second, then
|
|
# nothing: no mark arrived during the trailing walk.
|
|
self.assertEqual(self.scan.calls, 3)
|
|
self.assertEqual(len(record.files), 3)
|
|
self.assertIsNone(record.rewalk_handle)
|
|
self.assertIsNone(record.flight)
|
|
|
|
async def test_a_read_triggered_walk_cancels_a_pending_rewalk(self):
|
|
make_png(self.output_dir / "a.png")
|
|
record = await self._listed()
|
|
self._interval(30)
|
|
self.api.mark_root_dirty(self._output("b.png"))
|
|
handle = record.rewalk_handle
|
|
status, data = await self._json("POST", "/prompt_manager/gallery/rescan")
|
|
self.assertEqual((status, data["total"]), (200, 2))
|
|
self.assertTrue(handle.cancelled())
|
|
self.assertIsNone(record.rewalk_handle)
|
|
self.assertFalse(record.dirty)
|
|
await self._settle()
|
|
self.assertEqual(self.scan.calls, 2)
|
|
|
|
async def test_after_the_trailing_walk_a_new_mark_arms_again(self):
|
|
make_png(self.output_dir / "a.png")
|
|
record = await self._listed()
|
|
self._interval(30)
|
|
self.api.mark_root_dirty(self._output("b.png"))
|
|
self._fire(record)
|
|
await self._settle()
|
|
self.assertIsNone(record.rewalk_handle)
|
|
self.api.mark_root_dirty(self._output("c.png"))
|
|
self.assertIsNotNone(record.rewalk_handle)
|
|
self._fire(record)
|
|
await self._settle()
|
|
self.assertEqual(self.scan.calls, 3)
|
|
self.assertEqual(len(record.files), 3)
|
|
|
|
async def test_a_deferred_walk_waits_at_least_half_a_second(self):
|
|
make_png(self.output_dir / "a.png")
|
|
record = await self._listed()
|
|
self._interval(30)
|
|
# The interval ends in a few milliseconds: still never a zero delay.
|
|
record.last_started_at = images_module._time.monotonic() - 29.99
|
|
self.api.mark_root_dirty(self._output("b.png"))
|
|
handle = record.rewalk_handle
|
|
self.assertIsNotNone(handle)
|
|
self.assertGreaterEqual(self._remaining(handle), 0.45)
|
|
handle.cancel()
|
|
|
|
async def test_a_mark_moves_a_later_stale_refresh_earlier(self):
|
|
make_png(self.output_dir / "a.png")
|
|
record = await self._listed()
|
|
self._age()
|
|
self._interval(5)
|
|
with patch.object(gallery_listing, "REFRESH_DELAY", 30):
|
|
await self._json("GET", self.URL)
|
|
stale = record.rewalk_handle
|
|
self.assertGreater(self._remaining(stale), 25)
|
|
# The interval has long passed: the mark's walk starts now, and that
|
|
# walk is the refresh the stale read was waiting for.
|
|
self.api.mark_root_dirty(self._output("b.png"))
|
|
self.assertTrue(stale.cancelled())
|
|
self.assertIsNotNone(record.flight)
|
|
self.assertIsNone(record.rewalk_handle)
|
|
await self._settle()
|
|
self.assertEqual(self.scan.calls, 2)
|
|
self.assertEqual(len(record.files), 2)
|
|
|
|
async def test_a_stale_read_keeps_an_earlier_pending_rewalk(self):
|
|
make_png(self.output_dir / "a.png")
|
|
record = await self._listed()
|
|
self._interval(20)
|
|
self.api.mark_root_dirty(self._output("b.png"))
|
|
pending = record.rewalk_handle
|
|
when = pending.when()
|
|
# Stale by its age, while the interval since the last walk still runs.
|
|
record.walked_at -= gallery_listing.REFRESH_AFTER + 1
|
|
with patch.object(gallery_listing, "REFRESH_DELAY", 30):
|
|
await self._json("GET", self.URL)
|
|
self.assertIs(record.rewalk_handle, pending)
|
|
self.assertEqual(pending.when(), when)
|
|
self.assertFalse(pending.cancelled())
|
|
|
|
async def test_a_stale_read_moves_a_later_pending_rewalk_earlier(self):
|
|
make_png(self.output_dir / "a.png")
|
|
record = await self._listed()
|
|
self._interval(15)
|
|
self.api.mark_root_dirty(self._output("b.png"))
|
|
pending = record.rewalk_handle
|
|
self.assertGreater(self._remaining(pending), 10)
|
|
# A listing older than REFRESH_AFTER whose interval is over.
|
|
self._age()
|
|
with patch.object(gallery_listing, "REFRESH_DELAY", 2):
|
|
await self._json("GET", self.URL)
|
|
self.assertTrue(pending.cancelled())
|
|
replaced = record.rewalk_handle
|
|
self.assertIsNotNone(replaced)
|
|
self.assertLess(self._remaining(replaced), 3)
|
|
self.assertEqual(self.scan.calls, 1)
|
|
replaced.cancel()
|
|
|
|
async def test_a_dirty_read_rearms_a_lost_rewalk(self):
|
|
make_png(self.output_dir / "a.png")
|
|
record = await self._listed()
|
|
self._interval(30)
|
|
record.dirty = True # e.g. its timer was cancelled by hand
|
|
await self._json("GET", self.URL)
|
|
self.assertIsNotNone(record.rewalk_handle)
|
|
self.assertEqual(self.scan.calls, 1)
|
|
|
|
async def test_cleanup_cancels_a_pending_rewalk(self):
|
|
make_png(self.output_dir / "a.png")
|
|
record = await self._listed()
|
|
self._interval(30)
|
|
self.api.mark_root_dirty(self._output("b.png"))
|
|
handle = record.rewalk_handle
|
|
await self.api.cleanup_gallery_listings(web.Application())
|
|
self.assertTrue(handle.cancelled())
|
|
self.assertIsNone(record.rewalk_handle)
|
|
self.assertEqual(self.scan.calls, 1)
|
|
|
|
async def test_a_walk_cancelled_by_cleanup_starts_no_trailing_walk(self):
|
|
make_png(self.output_dir / "a.png")
|
|
record = await self._listed()
|
|
self._interval(0)
|
|
gate = self.scan.hold_next()
|
|
self.api.mark_root_dirty(self._output("b.png"))
|
|
await self.scan.wait_entered()
|
|
self.api.mark_root_dirty(self._output("c.png"))
|
|
self.assertTrue(record.dirty)
|
|
await self.api.cleanup_gallery_listings(web.Application())
|
|
gate.set()
|
|
self.assertIsNone(record.flight)
|
|
self.assertIsNone(record.rewalk_handle)
|
|
self.assertEqual(self.api._listing_tasks, set())
|
|
self.assertEqual(self.scan.calls, 2)
|
|
|
|
async def test_a_warm_up_after_cleanup_listens_again(self):
|
|
make_png(self.output_dir / "a.png")
|
|
self._interval(0)
|
|
await self._warmed()
|
|
await self.api.cleanup_gallery_listings(web.Application())
|
|
await self._warmed()
|
|
record = self._record()
|
|
self.api.mark_root_dirty(self._output("b.png"))
|
|
await self._settle()
|
|
self.assertEqual(len(record.files), 2)
|
|
|
|
async def test_a_root_removed_from_the_roots_is_never_walked_again(self):
|
|
make_png(self.output_dir / "a.png")
|
|
record = await self._listed()
|
|
self._interval(30)
|
|
self.api.mark_root_dirty(self._output("b.png"))
|
|
handle = record.rewalk_handle
|
|
self.assertIsNotNone(handle)
|
|
self.api.reset_gallery_roots_cache() # e.g. the gallery root changed
|
|
self.assertTrue(handle.cancelled())
|
|
self.assertIsNone(record.rewalk_handle)
|
|
self.assertFalse(record.dirty)
|
|
self._interval(0)
|
|
# The monitor still watches the old root until ComfyUI restarts.
|
|
self.api.mark_root_dirty(self._output("c.png"))
|
|
self.api._delayed_walk(self.output_dir, record) # a timer that raced
|
|
self.assertIsNone(record.flight)
|
|
self.assertIsNone(record.files)
|
|
self.assertEqual(self.scan.calls, 1)
|
|
|
|
async def test_a_walk_running_when_the_roots_change_gets_no_trailing_walk(self):
|
|
make_png(self.output_dir / "a.png")
|
|
record = await self._listed()
|
|
self._interval(0)
|
|
gate = self.scan.hold_next()
|
|
self.api.mark_root_dirty(self._output("b.png"))
|
|
await self.scan.wait_entered()
|
|
self.api.reset_gallery_roots_cache()
|
|
self.api.mark_root_dirty(self._output("c.png"))
|
|
gate.set()
|
|
await self._settle()
|
|
self.assertEqual(self.scan.calls, 2)
|
|
self.assertIsNone(record.files) # the running walk was discarded
|
|
self.assertFalse(record.dirty)
|
|
|
|
async def test_a_root_read_again_after_the_roots_change_listens_again(self):
|
|
make_png(self.output_dir / "a.png")
|
|
record = await self._listed()
|
|
self.api.reset_gallery_roots_cache()
|
|
record = await self._listed() # still configured: read and walked
|
|
self._interval(0)
|
|
self.api.mark_root_dirty(self._output("b.png"))
|
|
await self._settle()
|
|
self.assertEqual(len(record.files), 2)
|
|
|
|
async def test_a_cancelled_walk_starts_no_trailing_walk(self):
|
|
make_png(self.output_dir / "a.png")
|
|
record = await self._listed()
|
|
self._interval(0)
|
|
gate = self.scan.hold_next()
|
|
self.api.mark_root_dirty(self._output("b.png"))
|
|
await self.scan.wait_entered()
|
|
self.api.mark_root_dirty(self._output("c.png"))
|
|
(task,) = self.api._listing_tasks
|
|
seq = record.walk_seq
|
|
task.cancel() # e.g. the loop shutting down without on_cleanup
|
|
await asyncio.gather(task, return_exceptions=True)
|
|
gate.set()
|
|
await self._settle()
|
|
self.assertTrue(task.cancelled())
|
|
self.assertEqual(record.walk_seq, seq) # no walk started after it
|
|
self.assertEqual(self.scan.calls, 2)
|
|
self.assertIsNone(record.flight)
|
|
self.assertIsNone(record.rewalk_handle)
|
|
self.assertEqual(self.api._listing_tasks, set())
|
|
|
|
# -- settled outputs added without a walk (live test round) -----------
|
|
|
|
def _walked(self):
|
|
"""The listing a full walk of the root gives now (not counted)."""
|
|
return self.scan.real(self.output_dir)
|
|
|
|
def _at(self, name, mtime):
|
|
"""A new output on disk with *mtime*, spelled as the monitor reports it."""
|
|
path = make_png(self.output_dir / name)
|
|
os.utime(path, (mtime, mtime))
|
|
return os.path.realpath(path)
|
|
|
|
def _hold_adds(self):
|
|
"""Hold each add batch in the executor until the returned gate is set."""
|
|
entered, gate, batches = threading.Event(), threading.Event(), []
|
|
real = images_module._patch_listing
|
|
|
|
def held(*args):
|
|
batches.append(list(args[4]))
|
|
entered.set()
|
|
gate.wait(_HANG_LIMIT)
|
|
return real(*args)
|
|
|
|
patcher = patch.object(images_module, "_patch_listing", held)
|
|
patcher.start()
|
|
self.addCleanup(patcher.stop)
|
|
self.addCleanup(gate.set)
|
|
return entered, gate, batches
|
|
|
|
async def _entered(self, event):
|
|
loop = asyncio.get_running_loop()
|
|
if not await loop.run_in_executor(None, event.wait, _HANG_LIMIT):
|
|
raise AssertionError("the add never reached the executor")
|
|
|
|
def _no_walk_pending(self, record):
|
|
self.assertFalse(record.dirty)
|
|
self.assertIsNone(record.flight)
|
|
self.assertIsNone(record.rewalk_handle)
|
|
|
|
async def test_a_settled_output_is_added_without_a_walk(self):
|
|
self._at("a.png", 1000)
|
|
record = await self._listed()
|
|
self._interval(0)
|
|
self.api.output_settled(self._at("b.png", 2000))
|
|
self.assertIsNone(record.flight)
|
|
await self._settle()
|
|
self.assertEqual(self.scan.calls, 1)
|
|
self.assertEqual(record.patched, 1)
|
|
self._no_walk_pending(record)
|
|
self.assertEqual(record.files, self._walked())
|
|
self.assertEqual(str(record.files[0][0]), str(self.output_dir / "b.png"))
|
|
_, data = await self._json("GET", self.URL)
|
|
self.assertEqual(data["total"], 2)
|
|
self.assertEqual(self.scan.calls, 1)
|
|
|
|
async def test_adds_land_where_a_walk_lists_them_and_name_new_folders(self):
|
|
for index, name in enumerate(("x/a.png", "x/y/b.png", "c.png", "z/d.png")):
|
|
self._at(name, 1000 + 100 * index)
|
|
record = await self._listed()
|
|
old_folders = record.folders
|
|
for name, mtime in (
|
|
("x/y/n1.png", 1150),
|
|
("new/deep/n2.png", 1250),
|
|
("n3.png", 50),
|
|
("x/n4.webp", 9000),
|
|
):
|
|
make_png(self.output_dir / name.replace(".webp", ".png"))
|
|
if name.endswith(".webp"):
|
|
os.replace(self.output_dir / "x" / "n4.png", self.output_dir / name)
|
|
os.utime(self.output_dir / name, (mtime, mtime))
|
|
self.api.output_settled(os.path.realpath(self.output_dir / name))
|
|
await self._settle()
|
|
self.assertEqual(self.scan.calls, 1)
|
|
walked = self._walked()
|
|
self.assertEqual(record.files, walked)
|
|
self.assertEqual(
|
|
[str(path) for path, _ in record.files], [str(path) for path, _ in walked]
|
|
)
|
|
expected = gallery_listing.folder_sets(self.output_dir, walked)
|
|
self.assertEqual((record.folders, record.folders_with_ancestors), expected)
|
|
self.assertIsNot(record.folders, old_folders)
|
|
_, data = await self._json("GET", "/prompt_manager/gallery/subfolders")
|
|
self.assertIn(os.path.join("new", "deep"), data["subfolders"])
|
|
|
|
async def test_a_rewritten_output_moves_to_its_new_place(self):
|
|
self._at("a.png", 1000)
|
|
self._at("b.png", 2000)
|
|
record = await self._listed()
|
|
self.api.output_settled(self._at("a.png", 3000))
|
|
await self._settle()
|
|
self.assertEqual(self.scan.calls, 1)
|
|
self.assertEqual(record.files, self._walked())
|
|
self.assertEqual([path.name for path, _ in record.files], ["a.png", "b.png"])
|
|
|
|
async def test_a_repeated_report_changes_nothing(self):
|
|
self._at("a.png", 1000)
|
|
path = self._at("b.png", 2000)
|
|
record = await self._listed()
|
|
self.api.output_settled(path)
|
|
await self._settle()
|
|
files, patched = record.files, record.patched
|
|
with patch.object(self.api, "_schedule_workspace_warm") as warm:
|
|
self.api.output_settled(path)
|
|
await self._settle()
|
|
warm.assert_not_called()
|
|
self.assertIs(record.files, files)
|
|
self.assertEqual((record.patched, self.scan.calls), (patched, 1))
|
|
|
|
async def test_the_next_full_walk_keeps_the_patched_listing(self):
|
|
self._at("a.png", 1000)
|
|
record = await self._listed()
|
|
self.api.output_settled(self._at("sub/b.png", 2000))
|
|
await self._settle()
|
|
patched = record.files
|
|
self._age()
|
|
with patch.object(self.api, "_schedule_workspace_warm") as warm:
|
|
await self._json("GET", self.URL) # the 30 s rule walks behind
|
|
await self._settle()
|
|
self.assertEqual(self.scan.calls, 2)
|
|
self.assertIs(record.files, patched) # equal: the walk kept it
|
|
self.assertEqual(record.patched, 0)
|
|
warm.assert_not_called()
|
|
|
|
async def test_the_walks_filters_apply_to_an_add(self):
|
|
self._at("a.png", 1000)
|
|
record = await self._listed()
|
|
files = record.files
|
|
deep = os.path.join(*[f"d{i}" for i in range(images_module.MAX_SCAN_DEPTH + 1)])
|
|
skipped = [
|
|
self._at(".png", 2000), # no suffix, as Path.suffix on 3.10-3.13
|
|
self._at("notes.txt", 2000),
|
|
self._at(os.path.join("thumbnails", "t.png"), 2000),
|
|
self._at(os.path.join("x", "thumbnails", "t.png"), 2000),
|
|
self._at(os.path.join(deep, "too-deep.png"), 2000),
|
|
]
|
|
for path in skipped:
|
|
self.api.output_settled(path)
|
|
await self._settle()
|
|
self.assertIs(record.files, files)
|
|
self.assertEqual(record.files, self._walked())
|
|
self._no_walk_pending(record)
|
|
self.assertEqual(self.scan.calls, 1)
|
|
# The deepest listed folder and a "..png" name are listed by a walk.
|
|
deepest = os.path.join(*[f"d{i}" for i in range(images_module.MAX_SCAN_DEPTH)])
|
|
for path in (
|
|
self._at("..png", 3000),
|
|
self._at(os.path.join(deepest, "deepest.png"), 4000),
|
|
):
|
|
self.api.output_settled(path)
|
|
await self._settle()
|
|
self.assertEqual(record.files, self._walked())
|
|
self.assertEqual(len(record.files), 3)
|
|
self.assertEqual(self.scan.calls, 1)
|
|
|
|
async def test_a_symlinked_output_is_not_added(self):
|
|
self._at("a.png", 1000)
|
|
record = await self._listed()
|
|
target = make_png(self.comfy_dir / "elsewhere.png")
|
|
link = self.output_dir / "link.png"
|
|
try:
|
|
os.symlink(target, link)
|
|
except (OSError, NotImplementedError):
|
|
self.skipTest("symlinks are not available")
|
|
files = record.files
|
|
self.api.output_settled(str(link)) # as an unresolved report would name it
|
|
await self._settle()
|
|
self.assertIs(record.files, files)
|
|
self.assertEqual(record.files, self._walked())
|
|
self._no_walk_pending(record)
|
|
|
|
async def _assert_walks_instead(self, record, report):
|
|
self._interval(0)
|
|
self.api.output_settled(report)
|
|
await self._settle()
|
|
self.assertEqual(self.scan.calls, 2)
|
|
self.assertEqual(record.patched, 0)
|
|
self.assertEqual(record.files, self._walked())
|
|
|
|
async def test_a_tie_in_mtime_walks_instead(self):
|
|
self._at("a.png", 1000)
|
|
record = await self._listed()
|
|
await self._assert_walks_instead(record, self._at("b.png", 1000))
|
|
|
|
async def test_a_report_that_cannot_be_read_walks_instead(self):
|
|
self._at("a.png", 1000)
|
|
record = await self._listed()
|
|
gone = self._at("gone.png", 2000)
|
|
os.unlink(gone)
|
|
await self._assert_walks_instead(record, gone)
|
|
|
|
async def test_a_listing_at_the_cap_walks_instead(self):
|
|
self._at("a.png", 1000)
|
|
record = await self._listed()
|
|
with patch.object(images_module, "MAX_GALLERY_FILES", 1):
|
|
self._interval(0)
|
|
self.api.output_settled(self._at("b.png", 2000))
|
|
await self._settle()
|
|
self.assertEqual((self.scan.calls, record.patched), (2, 0))
|
|
self.assertEqual(len(record.files), 1) # the walk is capped too
|
|
|
|
async def test_a_path_that_cannot_be_sliced_walks_instead(self):
|
|
self._at("a.png", 1000)
|
|
record = await self._listed()
|
|
self._at("b.png", 2000)
|
|
report = os.path.join(os.path.realpath(self.output_dir), ".", "b.png")
|
|
await self._assert_walks_instead(record, report)
|
|
|
|
async def test_an_on_disk_spelling_mismatch_walks_instead(self):
|
|
self._at("a.png", 1000)
|
|
record = await self._listed()
|
|
report = self._at("b.png", 2000)
|
|
with patch.object(images_module, "_on_disk_path", return_value="/x/B.png"):
|
|
await self._assert_walks_instead(record, report)
|
|
|
|
async def test_an_unexpected_add_failure_walks_instead(self):
|
|
self._at("a.png", 1000)
|
|
record = await self._listed()
|
|
report = self._at("b.png", 2000)
|
|
with patch.object(images_module, "_patch_listing", side_effect=ValueError):
|
|
with self.assertLogs(self.api.logger, "WARNING"):
|
|
await self._assert_walks_instead(record, report)
|
|
self.assertIsNone(record.add_task)
|
|
|
|
async def test_a_report_during_a_walk_gets_the_trailing_walk(self):
|
|
self._at("a.png", 1000)
|
|
record = await self._listed()
|
|
self._interval(0)
|
|
self._age()
|
|
gate = self.scan.hold_next()
|
|
await self._json("GET", self.URL)
|
|
await self.scan.wait_entered() # listed already: misses b.png
|
|
self.api.output_settled(self._at("b.png", 2000))
|
|
self.assertTrue(record.dirty)
|
|
self.assertEqual(record.pending_adds, [])
|
|
gate.set()
|
|
await self._settle()
|
|
self.assertEqual(self.scan.calls, 3)
|
|
self.assertEqual(record.files, self._walked())
|
|
|
|
async def test_a_walk_started_during_an_add_lists_it_and_the_add_is_dropped(self):
|
|
self._at("a.png", 1000)
|
|
record = await self._listed()
|
|
files = record.files
|
|
entered, gate, _ = self._hold_adds()
|
|
self.api.output_settled(self._at("b.png", 2000))
|
|
await self._entered(entered)
|
|
walk = self.scan.hold_next()
|
|
rescan = asyncio.ensure_future(
|
|
self._json("POST", "/prompt_manager/gallery/rescan")
|
|
)
|
|
await self.scan.wait_entered()
|
|
add = record.add_task
|
|
gate.set()
|
|
await asyncio.wait_for(asyncio.shield(add), _HANG_LIMIT)
|
|
# The add ended while the Rescan walk runs: not stored over it.
|
|
self.assertIs(record.files, files)
|
|
self.assertEqual(record.patched, 0)
|
|
walk.set()
|
|
status, data = await rescan
|
|
await self._settle()
|
|
self.assertEqual((status, data["total"]), (200, 2))
|
|
self.assertEqual(self.scan.calls, 2)
|
|
self.assertEqual(record.files, self._walked())
|
|
self._no_walk_pending(record)
|
|
|
|
async def test_a_roots_change_during_an_add_drops_it(self):
|
|
self._at("a.png", 1000)
|
|
record = await self._listed()
|
|
entered, gate, _ = self._hold_adds()
|
|
self.api.output_settled(self._at("b.png", 2000))
|
|
await self._entered(entered)
|
|
self.api.reset_gallery_roots_cache()
|
|
gate.set()
|
|
await self._settle()
|
|
self.assertIsNone(record.files)
|
|
self.assertEqual((self.scan.calls, record.patched), (1, 0))
|
|
self._no_walk_pending(record)
|
|
|
|
async def test_an_invalidation_during_an_add_drops_it(self):
|
|
self._at("a.png", 1000)
|
|
record = await self._listed()
|
|
entered, gate, batches = self._hold_adds()
|
|
self.api.output_settled(self._at("b.png", 2000))
|
|
await self._entered(entered)
|
|
self.api.output_settled(self._at("c.png", 3000)) # waits for the batch
|
|
self.api.invalidate_gallery_cache()
|
|
gate.set()
|
|
await self._settle()
|
|
self.assertEqual(len(batches), 1) # the dropped listing gets no second
|
|
self.assertIsNone(record.files) # the next read walks
|
|
self.assertEqual((self.scan.calls, record.patched), (1, 0))
|
|
self._no_walk_pending(record)
|
|
_, data = await self._json("GET", self.URL)
|
|
self.assertEqual((data["total"], self.scan.calls), (3, 2))
|
|
|
|
async def test_an_add_failing_after_a_drop_walks_nothing(self):
|
|
self._at("a.png", 1000)
|
|
record = await self._listed()
|
|
|
|
def drop_then_fail(*_args):
|
|
self.api.invalidate_gallery_cache() # a settings save, off the loop
|
|
raise ValueError("boom")
|
|
|
|
with patch.object(images_module, "_patch_listing", drop_then_fail):
|
|
with self.assertLogs(self.api.logger, "WARNING"):
|
|
self.api.output_settled(self._at("b.png", 2000))
|
|
await self._settle()
|
|
self.assertIsNone(record.files)
|
|
self.assertEqual(self.scan.calls, 1)
|
|
self._no_walk_pending(record)
|
|
|
|
async def test_cleanup_during_an_add_cancels_it(self):
|
|
self._at("a.png", 1000)
|
|
record = await self._listed()
|
|
files = record.files
|
|
entered, gate, _ = self._hold_adds()
|
|
self.api.output_settled(self._at("b.png", 2000))
|
|
await self._entered(entered)
|
|
self.api.output_settled(self._at("c.png", 3000))
|
|
cleanup = asyncio.ensure_future(
|
|
self.api.cleanup_gallery_listings(web.Application())
|
|
)
|
|
await asyncio.sleep(0)
|
|
gate.set()
|
|
await asyncio.wait_for(cleanup, _HANG_LIMIT)
|
|
self.assertIs(record.files, files)
|
|
self.assertIsNone(record.add_task)
|
|
self.assertEqual(self.api._listing_tasks, set())
|
|
self._no_walk_pending(record)
|
|
|
|
async def test_a_removed_root_gets_no_add(self):
|
|
self._at("a.png", 1000)
|
|
record = await self._listed()
|
|
self.api.reset_gallery_roots_cache()
|
|
self.api.output_settled(self._at("b.png", 2000))
|
|
await self._settle()
|
|
self.assertIsNone(record.files)
|
|
self.assertEqual(self.api._listing_tasks, set())
|
|
self.assertEqual(self.scan.calls, 1)
|
|
|
|
async def test_a_burst_is_added_in_batches_with_one_warm_build_each(self):
|
|
self._at("a.png", 1000)
|
|
await self.api.warm_gallery_listings(web.Application())
|
|
await self._settle()
|
|
record = self._record()
|
|
entered, gate, batches = self._hold_adds()
|
|
built = []
|
|
real = self.api._build_workspace_caches
|
|
|
|
def build(listings):
|
|
built.append(len(listings[0][2]))
|
|
real(listings)
|
|
|
|
with patch.object(self.api, "_build_workspace_caches", side_effect=build):
|
|
self.api.output_settled(self._at("b.png", 2000))
|
|
await self._entered(entered)
|
|
for index in range(4): # land while the first batch is placed
|
|
self.api.output_settled(self._at(f"c{index}.png", 3000 + index))
|
|
gate.set()
|
|
await self._settle()
|
|
self.assertEqual([len(batch) for batch in batches], [1, 4])
|
|
self.assertEqual(self.scan.calls, 1)
|
|
self.assertEqual(record.patched, 2)
|
|
self.assertEqual(record.files, self._walked())
|
|
self.assertEqual(built[-1], 6)
|
|
self.assertLessEqual(len(built), 2)
|
|
self.assertTrue(self._workspace_ready())
|
|
|
|
async def _workspace_pages(self, **params):
|
|
"""Every workspace order's full first page, as file names."""
|
|
pages = {}
|
|
for order in images_module.WORKSPACE_ORDERS:
|
|
query = "&".join(f"{k}={v}" for k, v in {"order": order, **params}.items())
|
|
_, data = await self._json(
|
|
"GET", f"{self.URL}?mode=workspace&limit=100&{query}"
|
|
)
|
|
pages[order] = [image["filename"] for image in data["images"]]
|
|
return pages
|
|
|
|
def _drop_workspace_caches(self):
|
|
self.api._workspace_listed_cache = {}
|
|
self.api._workspace_order_cache = {}
|
|
self.api._workspace_card_cache = {}
|
|
self.api._favorite_listing_cache = {}
|
|
|
|
async def test_workspace_pages_after_adds_match_a_cold_rebuild(self):
|
|
for index, name in enumerate(("a.png", "x/b.png", "x/c.mp4", "d.png")):
|
|
path = self.output_dir / name
|
|
path.parent.mkdir(exist_ok=True)
|
|
path.write_bytes(b"x")
|
|
os.utime(path, (1000 + index, 1000 + index))
|
|
await self._warmed()
|
|
for name in ("x/b.png", "d.png"):
|
|
status, _ = await self._json(
|
|
"PUT",
|
|
"/prompt_manager/images/favorite",
|
|
json={"root_index": 0, "path": name},
|
|
)
|
|
self.assertEqual(status, 200)
|
|
await self._workspace_pages()
|
|
await self._workspace_pages(favorites=1)
|
|
self.api.output_settled(self._at("y/new.png", 5000)) # a new folder
|
|
self.api.output_settled(self._at("x/b.png", 6000)) # rewritten favorite
|
|
await self._settle()
|
|
self.assertEqual(self.scan.calls, 1)
|
|
patched = (
|
|
await self._workspace_pages(),
|
|
await self._workspace_pages(favorites=1),
|
|
)
|
|
self._drop_workspace_caches()
|
|
cold = (await self._workspace_pages(), await self._workspace_pages(favorites=1))
|
|
self.assertEqual(patched, cold)
|
|
self.assertEqual(patched[0]["newest"][:2], ["b.png", "new.png"])
|
|
self.assertEqual(sorted(patched[1]["newest"]), ["b.png", "d.png"])
|
|
|
|
async def test_a_warm_build_after_an_add_builds_only_the_added_values(self):
|
|
for index in range(300):
|
|
path = self.output_dir / f"f{index:03d}.png"
|
|
path.write_bytes(b"x")
|
|
os.utime(path, (1000 + index, 1000 + index))
|
|
await self._warmed()
|
|
record = self._record()
|
|
listed, keys = [], []
|
|
real_listed, real_key = images_module._listed, images_module._newest_key
|
|
|
|
def counted_listed(*args):
|
|
listed.append(args[0].name)
|
|
return real_listed(*args)
|
|
|
|
def counted_key(entry):
|
|
keys.append(entry)
|
|
return real_key(entry)
|
|
|
|
with (
|
|
patch.object(images_module, "_listed", counted_listed),
|
|
patch.object(images_module, "_newest_key", counted_key),
|
|
patch.object(
|
|
images_module,
|
|
"_favorite_relative_key",
|
|
wraps=images_module._favorite_relative_key,
|
|
) as favorite_keys,
|
|
):
|
|
self.api.output_settled(self._at("new.png", 9000))
|
|
await self._settle()
|
|
self.assertEqual(self.scan.calls, 1)
|
|
self.assertTrue(self._workspace_ready())
|
|
self.assertEqual(listed, ["new.png"])
|
|
self.assertLess(len(keys), 20) # bisection, not a sort of 301 files
|
|
self.assertLess(favorite_keys.call_count, 10) # not one per file
|
|
self.assertEqual(
|
|
self.api._workspace_order([(0, self.output_dir, record.files)], "newest")[
|
|
0
|
|
].path.name,
|
|
"new.png",
|
|
)
|
|
|
|
async def test_a_rewritten_output_drops_its_old_card_facts(self):
|
|
self._at("a.png", 1000)
|
|
self._at("b.png", 2000)
|
|
await self._warmed()
|
|
await self._json("GET", f"{self.URL}?mode=workspace&limit=10")
|
|
key = (0, str(self.output_dir))
|
|
before = set(self.api._workspace_card_cache.get(key, {}))
|
|
self.assertIn(Path(self._at("a.png", 1000)).name, {p.name for p, _ in before})
|
|
self.api.output_settled(self._at("a.png", 3000))
|
|
await self._settle()
|
|
after = self.api._workspace_card_cache.get(key, {})
|
|
self.assertNotIn((self.output_dir / "a.png", 1000.0), after)
|
|
self.assertIn((self.output_dir / "b.png", 2000.0), after)
|
|
|
|
def _synthetic(self, root, count, start=1000.0):
|
|
return [(root / f"f{i:04d}.png", start + i) for i in range(count)][::-1]
|
|
|
|
def test_the_newest_order_sorts_again_when_it_missed_a_step(self):
|
|
root = self.output_dir
|
|
files = self._synthetic(root, 50)
|
|
listings = [(0, root, files)]
|
|
self.api._workspace_orders(listings)
|
|
one = gallery_listing.insert_entry(files, (root / "n1.png", 5000.0), 100)
|
|
self.api._workspace_listed(0, root, one) # values follow, the order not
|
|
two = gallery_listing.insert_entry(one, (root / "n2.png", 6000.0), 100)
|
|
newest = self.api._workspace_orders([(0, root, two)])["newest"]
|
|
self.assertEqual([entry.path for entry in newest], [p for p, _m in two])
|
|
|
|
def test_the_newest_order_follows_one_changed_root_of_two(self):
|
|
first, second = self.output_dir, self.comfy_dir / "second"
|
|
files = self._synthetic(first, 30)
|
|
others = self._synthetic(second, 30, start=1000.5)
|
|
self.api._workspace_orders([(0, first, files), (1, second, others)])
|
|
patched = gallery_listing.insert_entry(others, (second / "n.png", 1010.25), 100)
|
|
with patch.object(
|
|
images_module, "_newest_key", wraps=images_module._newest_key
|
|
) as keys:
|
|
newest = self.api._workspace_orders(
|
|
[(0, first, files), (1, second, patched)]
|
|
)["newest"]
|
|
self.assertLess(keys.call_count, 20)
|
|
expected = sorted(
|
|
[(p, m, 0) for p, m in files] + [(p, m, 1) for p, m in patched],
|
|
key=lambda item: -item[1],
|
|
)
|
|
self.assertEqual(
|
|
[entry.path for entry in newest], [path for path, _m, _i in expected]
|
|
)
|
|
|
|
def test_the_on_disk_spelling_is_read_where_names_fold_case(self):
|
|
read = images_module._on_disk_path
|
|
self.assertIsNone(read(__file__, platform="linux"))
|
|
fake_fcntl = MagicMock(F_GETPATH=50)
|
|
fake_fcntl.fcntl.return_value = b"/Out/Sub/A.png" + bytes(10)
|
|
fake_nt = MagicMock()
|
|
fake_nt._getfinalpathname.return_value = "\\\\?\\C:\\Out\\A.png"
|
|
with patch.dict(sys.modules, {"fcntl": fake_fcntl, "nt": fake_nt}):
|
|
self.assertEqual(read(__file__, platform="darwin"), "/Out/Sub/A.png")
|
|
self.assertEqual(read(__file__, platform="win32"), "\\\\?\\C:\\Out\\A.png")
|
|
self.assertEqual(fake_fcntl.fcntl.call_args.args[1], 50)
|
|
fake_nt._getfinalpathname.assert_called_once_with(__file__)
|
|
|
|
def test_a_fifo_named_like_an_image_never_blocks_the_spelling_read(self):
|
|
if not hasattr(os, "mkfifo"):
|
|
self.skipTest("no FIFOs here")
|
|
fifo = self.output_dir / "pipe.png"
|
|
os.mkfifo(fifo)
|
|
fake = MagicMock(F_GETPATH=50)
|
|
fake.fcntl.return_value = os.fsencode(str(fifo))
|
|
done = []
|
|
|
|
def read():
|
|
with patch.dict(sys.modules, {"fcntl": fake}):
|
|
done.append(images_module._on_disk_path(fifo, platform="darwin"))
|
|
|
|
reader = threading.Thread(target=read, daemon=True)
|
|
reader.start()
|
|
reader.join(_HANG_LIMIT)
|
|
if reader.is_alive():
|
|
# Unblock the open so the thread ends, then fail.
|
|
with open(fifo, "wb"):
|
|
pass
|
|
self.fail("opening the FIFO blocked")
|
|
self.assertEqual(done, [str(fifo)])
|
|
|
|
async def test_a_folder_the_walk_cannot_list_gets_no_add(self):
|
|
if os.name == "nt" or not hasattr(os, "geteuid") or os.geteuid() == 0:
|
|
self.skipTest("needs POSIX permissions and a non-root user")
|
|
self._at("a.png", 1000)
|
|
record = await self._listed()
|
|
report = self._at(os.path.join("locked", "b.png"), 2000)
|
|
locked = self.output_dir / "locked"
|
|
os.chmod(locked, 0o300) # enter but not list
|
|
try:
|
|
self.assertEqual(self._walked(), record.files) # the walk skips it
|
|
files = record.files
|
|
self.api.output_settled(report)
|
|
await self._settle()
|
|
finally:
|
|
os.chmod(locked, 0o700)
|
|
self.assertIs(record.files, files)
|
|
self._no_walk_pending(record)
|
|
|
|
async def test_a_folder_named_like_an_image_is_not_added(self):
|
|
self._at("a.png", 1000)
|
|
record = await self._listed()
|
|
folder = self.output_dir / "folder.png"
|
|
folder.mkdir()
|
|
files = record.files
|
|
self.api.output_settled(os.path.realpath(folder))
|
|
await self._settle()
|
|
self.assertIs(record.files, files)
|
|
self._no_walk_pending(record)
|
|
|
|
async def test_the_filters_read_the_stored_spelling(self):
|
|
# Reported as thumbnails/, stored as Thumbnails/: a walk lists it, so
|
|
# the add must not skip it as a preview folder; the spelling differs,
|
|
# so the root is walked.
|
|
self._at("a.png", 1000)
|
|
record = await self._listed()
|
|
report = self._at(os.path.join("thumbnails", "t.png"), 2000)
|
|
stored = os.path.join(str(self.output_dir), "Thumbnails", "t.png")
|
|
with patch.object(images_module, "_on_disk_path", return_value=stored):
|
|
await self._assert_walks_instead(record, report)
|
|
|
|
def test_a_stale_read_racing_a_newer_build_sorts_in_full(self):
|
|
root = self.output_dir
|
|
f0 = self._synthetic(root, 50)
|
|
f1 = gallery_listing.insert_entry(f0, (root / "n1.png", 5000.0), 100)
|
|
f2 = gallery_listing.insert_entry(f1, (root / "n2.png", 6000.0), 100)
|
|
self.api._workspace_orders([(0, root, f0)])
|
|
key = (0, str(root))
|
|
built_from_f0 = self.api._workspace_listed_cache[key]
|
|
real = self.api._workspace_listed
|
|
armed = [True]
|
|
|
|
def listed(index, part_root, files):
|
|
values = real(index, part_root, files)
|
|
if armed[0] and files is f1:
|
|
armed[0] = False
|
|
# Another executor thread read the f0 values before this one
|
|
# stored f1's, and stores its f2 step (from f0) only now.
|
|
self.api._workspace_listed_cache[key] = built_from_f0
|
|
real(index, part_root, f2)
|
|
return values
|
|
|
|
with patch.object(self.api, "_workspace_listed", side_effect=listed):
|
|
stale = self.api._workspace_orders([(0, root, f1)])["newest"]
|
|
self.assertEqual([entry.path for entry in stale], [p for p, _m in f1])
|
|
for files in (f1, f2, f1, f2):
|
|
newest = self.api._workspace_orders([(0, root, files)])["newest"]
|
|
self.assertEqual([entry.path for entry in newest], [p for p, _m in files])
|
|
|
|
def test_a_roots_change_during_an_order_build_sorts_in_full(self):
|
|
root = self.output_dir
|
|
f0 = self._synthetic(root, 20)
|
|
f1 = gallery_listing.insert_entry(f0, (root / "n1.png", 5000.0), 100)
|
|
self.api._workspace_orders([(0, root, f0)])
|
|
real = self.api._workspace_listed
|
|
|
|
def listed(index, part_root, files):
|
|
values = real(index, part_root, files)
|
|
# A roots reset in another thread, between this call's two cache
|
|
# reads: the per-file values are gone, the order not yet.
|
|
self.api._workspace_listed_cache = {}
|
|
return values
|
|
|
|
with patch.object(self.api, "_workspace_listed", side_effect=listed):
|
|
newest = self.api._workspace_orders([(0, root, f1)])["newest"]
|
|
self.assertEqual([entry.path for entry in newest], [p for p, _m in f1])
|
|
|
|
def test_the_favorite_listing_step_rebuilds_when_it_cannot_follow(self):
|
|
root = self.output_dir
|
|
listing = {"a.png": "a.png"}
|
|
step = images_module._favorite_listing_step
|
|
self.assertEqual(
|
|
step(root, listing, [(root / "b.png", 2.0)], []),
|
|
{"a.png": "a.png", "b.png": "b.png"},
|
|
)
|
|
self.assertEqual(listing, {"a.png": "a.png"}) # never changed in place
|
|
# A removed file not added again, or a key another spelling holds.
|
|
self.assertIsNone(step(root, listing, [], [(root / "a.png", 1.0)]))
|
|
with patch.object(
|
|
images_module, "_favorite_relative_key", side_effect=str.lower
|
|
):
|
|
self.assertIsNone(step(root, listing, [(root / "A.png", 2.0)], []))
|
|
|
|
async def test_monitor_walks_during_a_workspace_build_add_one_more_build(self):
|
|
make_png(self.output_dir / "a.png")
|
|
release, entered = threading.Event(), threading.Event()
|
|
built = []
|
|
real = self.api._build_workspace_caches
|
|
|
|
def build(listings):
|
|
built.append(len(listings[0][2]))
|
|
if len(built) == 1:
|
|
entered.set()
|
|
release.wait(_HANG_LIMIT)
|
|
real(listings)
|
|
|
|
self.addCleanup(release.set)
|
|
self._interval(0)
|
|
with patch.object(self.api, "_build_workspace_caches", side_effect=build):
|
|
await self.api.warm_gallery_listings(web.Application())
|
|
loop = asyncio.get_running_loop()
|
|
await loop.run_in_executor(None, entered.wait, _HANG_LIMIT)
|
|
for name in ("b.png", "c.png", "d.png"): # three monitor walks
|
|
self.api.mark_root_dirty(self._output(name))
|
|
while self.api._listing_tasks:
|
|
await asyncio.gather(*list(self.api._listing_tasks))
|
|
await asyncio.sleep(0)
|
|
release.set()
|
|
await self._settle()
|
|
self.assertEqual(built, [1, 4])
|
|
self.assertEqual(self.scan.calls, 4)
|
|
self.assertTrue(self._workspace_ready())
|
|
|
|
|
|
class TestGalleryListingHostHooks(unittest.IsolatedAsyncioTestCase):
|
|
async def test_root_registers_the_warm_up_and_its_cleanup(self):
|
|
try:
|
|
from test_init_package import fake_server_module, load_package
|
|
except ImportError:
|
|
from tests.test_init_package import fake_server_module, load_package
|
|
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
server = fake_server_module()
|
|
server.PromptServer.instance.app = web.Application()
|
|
with load_package(
|
|
"gallery_listing_hooks_pkg",
|
|
str(Path(directory) / "db.sqlite"),
|
|
directory,
|
|
server,
|
|
) as (package, host):
|
|
self.assertIn(package.api.warm_gallery_listings, host.app.on_startup)
|
|
self.assertIn(package.api.cleanup_gallery_listings, host.app.on_cleanup)
|
|
|
|
async def test_root_connects_the_monitor_to_the_listing(self):
|
|
try:
|
|
from test_init_package import fake_server_module, load_package
|
|
except ImportError:
|
|
from tests.test_init_package import fake_server_module, load_package
|
|
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
server = fake_server_module()
|
|
server.PromptServer.instance.app = web.Application()
|
|
with load_package(
|
|
"gallery_listing_monitor_pkg",
|
|
str(Path(directory) / "db.sqlite"),
|
|
directory,
|
|
server,
|
|
) as (package, host):
|
|
monitor = package._global_image_monitor
|
|
self.assertEqual(
|
|
monitor.output_listener, package.api.notify_output_settled
|
|
)
|
|
# The first cleanup hook stops the monitor's hand-offs.
|
|
await host.app.on_cleanup[0](host.app)
|
|
self.assertIsNone(monitor.output_listener)
|