"""Tests for the core API module (py/api/__init__.py): helpers and base routes.""" import json import os import shutil import sys import tempfile import types import unittest from pathlib import Path from unittest.mock import MagicMock _mock_server = MagicMock() _mock_server.PromptServer.instance.routes = MagicMock() sys.modules["server"] = _mock_server sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from aiohttp import web # noqa: E402 from aiohttp.test_utils import AioHTTPTestCase # noqa: E402 import py.api as api_module # noqa: E402 from database.operations import PromptDatabase # noqa: E402 from py.api import PromptManagerAPI, _public_error, _public_path # noqa: E402 from py.config import GalleryConfig # noqa: E402 EXTRA_ROOTS_ENV = "PROMPT_MANAGER_EXTRA_GALLERY_ROOTS" class FolderPathsFixture(unittest.TestCase): """Temp ComfyUI tree exposed through a stub folder_paths module.""" def setUp(self): self.tmpdir = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, self.tmpdir, True) self.comfy_dir = Path(self.tmpdir) / "ComfyUI" self.output_dir = self.comfy_dir / "output" self.output_dir.mkdir(parents=True) self._orig_folder_paths = sys.modules.get("folder_paths") self.addCleanup(self._restore_folder_paths) self._orig_extra = os.environ.pop(EXTRA_ROOTS_ENV, None) self.addCleanup(self._restore_extra_env) self.install_folder_paths(base_path=str(self.comfy_dir)) def install_folder_paths(self, **attrs): attrs.setdefault("get_output_directory", lambda: str(self.output_dir)) sys.modules["folder_paths"] = types.SimpleNamespace(**attrs) def _restore_folder_paths(self): if self._orig_folder_paths is None: sys.modules.pop("folder_paths", None) else: sys.modules["folder_paths"] = self._orig_folder_paths def _restore_extra_env(self): if self._orig_extra is None: os.environ.pop(EXTRA_ROOTS_ENV, None) else: os.environ[EXTRA_ROOTS_ENV] = self._orig_extra class TestPublicPath(FolderPathsFixture): def test_path_under_base_is_relative_posix(self): target = self.output_dir / "renders" / "a.png" self.assertEqual(_public_path(str(target)), "output/renders/a.png") def test_base_itself_is_dot(self): self.assertEqual(_public_path(str(self.comfy_dir)), ".") def test_path_outside_base_is_basename_only(self): secret = Path(self.tmpdir) / "secret" / "passwords.txt" secret.parent.mkdir() secret.write_text("x") self.assertEqual(_public_path(str(secret)), "passwords.txt") self.assertNotIn(self.tmpdir, _public_path(str(secret))) def test_path_object_accepted(self): self.assertEqual(_public_path(self.output_dir), "output") def test_falls_back_to_output_parent_without_base_path(self): self.install_folder_paths() # no base_path attribute self.assertEqual(_public_path(str(self.output_dir / "x.png")), "output/x.png") def test_env_extra_root_keeps_its_own_name(self): extra = Path(self.tmpdir) / "nas" / "gallery" (extra / "sub").mkdir(parents=True) os.environ[EXTRA_ROOTS_ENV] = str(extra) self.assertEqual(_public_path(str(extra / "sub")), "gallery/sub") def test_without_folder_paths_returns_basename(self): sys.modules.pop("folder_paths", None) self.assertEqual(_public_path(str(self.output_dir / "img.png")), "img.png") def test_empty_or_none_gives_empty_string(self): self.assertEqual(_public_path(""), "") self.assertEqual(_public_path(None), "") class TestPublicError(FolderPathsFixture): def test_oserror_uses_strerror_and_basename(self): err = FileNotFoundError( 2, "No such file or directory", str(self.tmpdir) + "/a/b.png" ) text = _public_error(err) self.assertIn("No such file or directory", text) self.assertIn("b.png", text) self.assertNotIn(self.tmpdir, text) def test_oserror_without_filename(self): err = PermissionError(13, "Permission denied") self.assertEqual(_public_error(err), "Permission denied") def test_oserror_without_strerror_uses_type_name(self): err = OSError() self.assertEqual(_public_error(err), "OSError") def test_non_oserror_is_str(self): self.assertEqual(_public_error(ValueError("bad value")), "bad value") def test_non_oserror_with_empty_message_uses_type_name(self): self.assertEqual(_public_error(RuntimeError()), "RuntimeError") # ── gzip middleware ─────────────────────────────────────────────────── class TestGzipMiddleware(AioHTTPTestCase): async def get_application(self): big = json.dumps({"data": "x" * 4000}) async def big_json(request): return web.json_response(text=big) async def small_json(request): return web.json_response({"ok": True}) async def already_encoded(request): return web.Response( text=big, content_type="text/plain", headers={"Content-Encoding": "br"} ) async def binary(request): return web.Response(body=b"\x89PNG" * 1000, content_type="image/png") async def incompressible(request): return web.Response(body=os.urandom(4096), content_type="text/plain") async def streamed(request): resp = web.StreamResponse() await resp.prepare(request) await resp.write(b"x" * 4000) await resp.write_eof() return resp app = web.Application(middlewares=[api_module._gzip_middleware]) app.router.add_get("/prompt_manager/big", big_json) app.router.add_get("/prompt_manager/small", small_json) app.router.add_get("/prompt_manager/encoded", already_encoded) app.router.add_get("/prompt_manager/binary", binary) app.router.add_get("/prompt_manager/random", incompressible) app.router.add_get("/prompt_manager/stream", streamed) app.router.add_get("/other/big", big_json) return app async def _get(self, path, accept="gzip"): headers = {"Accept-Encoding": accept} if accept else {} return await self.client.request( "GET", path, headers=headers, auto_decompress=False, skip_auto_headers=["Accept-Encoding"], ) async def test_large_json_is_gzipped(self): resp = await self._get("/prompt_manager/big") self.assertEqual(resp.headers.get("Content-Encoding"), "gzip") self.assertEqual(resp.headers.get("Vary"), "Accept-Encoding") import gzip self.assertIn(b'"data"', gzip.decompress(await resp.read())) async def test_small_body_not_compressed(self): resp = await self._get("/prompt_manager/small") self.assertIsNone(resp.headers.get("Content-Encoding")) async def test_other_paths_untouched(self): resp = await self._get("/other/big") self.assertIsNone(resp.headers.get("Content-Encoding")) async def test_without_accept_encoding(self): resp = await self._get("/prompt_manager/big", accept=None) self.assertIsNone(resp.headers.get("Content-Encoding")) async def test_already_encoded_untouched(self): resp = await self._get("/prompt_manager/encoded") self.assertEqual(resp.headers.get("Content-Encoding"), "br") async def test_binary_type_untouched(self): resp = await self._get("/prompt_manager/binary") self.assertIsNone(resp.headers.get("Content-Encoding")) async def test_incompressible_body_untouched(self): resp = await self._get("/prompt_manager/random") self.assertIsNone(resp.headers.get("Content-Encoding")) async def test_stream_response_untouched(self): resp = await self._get("/prompt_manager/stream") self.assertIsNone(resp.headers.get("Content-Encoding")) self.assertEqual(len(await resp.read()), 4000) # ── UI and static routes ────────────────────────────────────────────── class CoreRoutesTestCase(AioHTTPTestCase): async def get_application(self): self.tmpdir = tempfile.mkdtemp() app = web.Application() routes = web.RouteTableDef() self.api = PromptManagerAPI() self.api.db = PromptDatabase(os.path.join(self.tmpdir, "prompts.db")) self.api.add_routes(routes) app.router.add_routes(routes) return app async def tearDownAsync(self): shutil.rmtree(self.tmpdir, ignore_errors=True) def _fake_root(self): """Point _get_project_root at the temp dir and return its web/ dir.""" web_dir = Path(self.tmpdir) / "web" (web_dir / "js").mkdir(parents=True) (web_dir / "lib").mkdir() orig_root = api_module._get_project_root api_module._get_project_root = lambda: self.tmpdir self.addCleanup(setattr, api_module, "_get_project_root", orig_root) return web_dir UI_PATHS = ( "/prompt_manager/web", "/prompt_manager/gallery.html", "/prompt_manager/admin", "/prompt_manager/gallery", ) class TestUiRoutes(CoreRoutesTestCase): async def test_pages_are_served(self): for path in UI_PATHS: resp = await self.client.request("GET", path) self.assertEqual(resp.status, 200, path) self.assertIn("text/html", resp.headers["Content-Type"]) self.assertIn("<", await resp.text()) async def test_pages_are_cached_after_first_read(self): await self.client.request("GET", "/prompt_manager/admin") self.assertTrue(any(p.endswith("admin.html") for p in self.api._html_cache)) resp = await self.client.request("GET", "/prompt_manager/admin") self.assertEqual(resp.status, 200) async def test_missing_pages_are_404(self): self._fake_root() for path in UI_PATHS: resp = await self.client.request("GET", path) self.assertEqual(resp.status, 404, path) async def test_unreadable_pages_are_500(self): web_dir = self._fake_root() for name in ("index.html", "metadata.html", "admin.html", "gallery.html"): (web_dir / name).mkdir() # a directory cannot be read as a file for path in UI_PATHS: resp = await self.client.request("GET", path) self.assertEqual(resp.status, 500, path) self.assertNotIn(self.tmpdir, await resp.text()) async def test_test_route(self): resp = await self.client.request("GET", "/prompt_manager/test") self.assertTrue((await resp.json())["success"]) class TestStaticRoutes(CoreRoutesTestCase): async def test_js_served_with_mime(self): resp = await self.client.request("GET", "/prompt_manager/js/admin.js") self.assertEqual(resp.status, 200) self.assertIn("application/javascript", resp.headers["Content-Type"]) async def test_lib_served(self): web_dir = self._fake_root() (web_dir / "lib" / "x.css").write_text("body{}") (web_dir / "lib" / "x.bin").write_bytes(b"\x00\x01") resp = await self.client.request("GET", "/prompt_manager/lib/x.css") self.assertEqual(resp.status, 200) self.assertIn("text/css", resp.headers["Content-Type"]) resp = await self.client.request("GET", "/prompt_manager/lib/x.bin") self.assertIn("application/octet-stream", resp.headers["Content-Type"]) async def test_traversal_is_403(self): for prefix in ("lib", "js"): resp = await self.client.request( "GET", f"/prompt_manager/{prefix}/..%2F..%2Fpyproject.toml" ) self.assertEqual(resp.status, 403, prefix) async def test_missing_is_404(self): for prefix in ("lib", "js"): resp = await self.client.request("GET", f"/prompt_manager/{prefix}/nope.js") self.assertEqual(resp.status, 404, prefix) async def test_directory_is_404(self): resp = await self.client.request("GET", "/prompt_manager/lib/tailwind") self.assertEqual(resp.status, 404) # ── instance helpers ────────────────────────────────────────────────── class TestInstanceHelpers(FolderPathsFixture): def setUp(self): super().setUp() self.api = PromptManagerAPI() self.api.db = PromptDatabase(os.path.join(self.tmpdir, "prompts.db")) orig_dirs = list(GalleryConfig.MONITORING_DIRECTORIES) self.addCleanup(setattr, GalleryConfig, "MONITORING_DIRECTORIES", orig_dirs) def _run(self, coro): import asyncio return asyncio.run(coro) def test_run_in_executor_with_kwargs(self): def add(a, b=0): return a + b self.assertEqual(self._run(self.api._run_in_executor(add, 1, b=2)), 3) self.assertEqual(self._run(self.api._run_in_executor(add, 1, 2)), 3) def test_invalidate_gallery_cache(self): self.api._gallery_cache = {"x": 1} self.api.invalidate_gallery_cache() self.assertEqual(self.api._gallery_cache, {}) def test_enrich_images_adds_urls_and_thumbnails(self): GalleryConfig.MONITORING_DIRECTORIES = [str(self.output_dir)] img = self.output_dir / "sub" / "a.png" img.parent.mkdir() img.write_bytes(b"x") thumb = self.output_dir / "thumbnails" / "sub" / "a_thumb.png" thumb.parent.mkdir(parents=True) thumb.write_bytes(b"t") outside = Path(self.tmpdir) / "elsewhere.png" outside.write_bytes(b"o") images = [ {"id": 7, "image_path": str(img)}, {"id": 8, "image_path": str(outside)}, {"id": 9, "image_path": ""}, {"image_path": str(self.output_dir / "missing.png")}, ] result = self.api._enrich_images(images) self.assertEqual(result[0]["url"], "/prompt_manager/images/serve/sub/a.png") self.assertEqual( result[0]["thumbnail_url"], "/prompt_manager/images/serve/thumbnails/sub/a_thumb.png", ) self.assertEqual(result[0]["relative_path"], os.path.join("sub", "a.png")) self.assertEqual(result[1]["url"], "/prompt_manager/images/8/file") self.assertNotIn("relative_path", result[1]) self.assertNotIn("url", result[2]) self.assertEqual(result[3]["url"], "/prompt_manager/images/serve/missing.png") self.assertNotIn("thumbnail_url", result[3]) def test_enrich_prompt_images_walks_prompts(self): GalleryConfig.MONITORING_DIRECTORIES = [str(self.output_dir)] prompts = [ {"images": [{"id": 1, "image_path": str(self.output_dir / "p.png")}]}, {}, ] result = self.api._enrich_prompt_images(prompts) self.assertEqual( result[0]["images"][0]["url"], "/prompt_manager/images/serve/p.png" ) def test_clean_nan_recursive(self): nan = float("nan") cleaned = self.api._clean_nan_recursive( {"a": nan, "b": [nan, 1.5, {"c": nan}], "d": "x"} ) self.assertEqual(cleaned, {"a": None, "b": [None, 1.5, {"c": None}], "d": "x"}) def test_find_output_dir_uses_configured_and_caches(self): GalleryConfig.MONITORING_DIRECTORIES = [str(self.output_dir)] self.api._cached_output_dir = None first = self.api._find_comfyui_output_dir() self.assertEqual(first, str(self.output_dir.resolve())) GalleryConfig.MONITORING_DIRECTORIES = [] self.assertEqual(self.api._find_comfyui_output_dir(), first) def test_find_output_dir_skips_missing_configured(self): GalleryConfig.MONITORING_DIRECTORIES = [os.path.join(self.tmpdir, "nope")] self.api._cached_output_dir = None result = self.api._find_comfyui_output_dir() self.assertNotEqual(result, os.path.join(self.tmpdir, "nope")) # ── metadata parsing ────────────────────────────────────────────────── class TestMetadataParsing(unittest.TestCase): def setUp(self): self.api = PromptManagerAPI() self.tmpdir = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, self.tmpdir, True) def test_extract_metadata_from_png_and_garbage(self): from PIL import Image, PngImagePlugin path = os.path.join(self.tmpdir, "m.png") info = PngImagePlugin.PngInfo() info.add_text("prompt", "{}") Image.new("RGB", (1, 1)).save(path, pnginfo=info) self.assertEqual(self.api._extract_comfyui_metadata(path), {"prompt": "{}"}) garbage = os.path.join(self.tmpdir, "g.png") with open(garbage, "wb") as f: f.write(b"nope") self.assertEqual(self.api._extract_comfyui_metadata(garbage), {}) def test_parse_a1111_parameters(self): meta = {"parameters": "a cat\nNegative prompt: dog\nSteps: 20"} parsed = self.api._parse_comfyui_prompt(meta) self.assertEqual(parsed["positive_prompt"], "a cat") self.assertEqual(parsed["negative_prompt"], "dog") self.assertEqual(parsed["parameters"]["parameters"], meta["parameters"]) self.assertEqual(self.api._extract_readable_prompt(parsed), "a cat") def test_parse_comfyui_fields(self): meta = { "prompt": json.dumps({"1": {"class_type": "X", "inputs": {}}}), "workflow": "not json", "steps": "20", "cfg": "not-json-either", } parsed = self.api._parse_comfyui_prompt(meta) self.assertIsInstance(parsed["prompt"], dict) self.assertEqual(parsed["workflow"], "not json") self.assertEqual(parsed["parameters"]["steps"], 20) self.assertEqual(parsed["parameters"]["cfg"], "not-json-either") parsed = self.api._parse_comfyui_prompt( {"prompt": "plain text", "workflow": json.dumps({"nodes": []})} ) self.assertEqual(parsed["prompt"], "plain text") self.assertEqual(parsed["workflow"], {"nodes": []}) def test_extract_readable_prompt_variants(self): extract = self.api._extract_readable_prompt self.assertEqual(extract({"prompt": "plain"}), "plain") self.assertEqual(extract({"prompt": ["a", "", "b"]}), "a b") self.assertEqual(extract({"prompt": 42}), "42") self.assertEqual( extract({"prompt": None, "parameters": {"positive": ["x", "y"]}}), "x y" ) self.assertIsNone(extract({"prompt": {}, "parameters": {}})) graph = { "1": {"class_type": "CLIPTextEncode", "inputs": {"text": "pos"}}, "2": {"class_type": "CLIPTextEncode", "inputs": {"text": "neg"}}, "3": { "class_type": "KSampler", "inputs": {"positive": ["1", 0], "negative": ["2", 0]}, }, } self.assertEqual(extract({"prompt": graph}), "pos") self.assertEqual(extract({"prompt": {"x": 1}, "workflow": graph}), "pos") def test_node_inputs_and_text_lookup(self): self.assertEqual(self.api._get_node_inputs("nope"), {}) self.assertEqual(self.api._get_node_inputs({"inputs": 5}), {}) self.assertEqual(self.api._get_node_inputs({"inputs": {"a": 1}}), {"a": 1}) listed = self.api._get_node_inputs( {"inputs": [{"name": "text", "link": None}, {"nope": 1}, "junk"]} ) self.assertEqual(list(listed), ["text"]) self.assertIsNone(self.api._find_text_in_node(None)) self.assertEqual(self.api._find_text_in_node({"inputs": {"text": "hi"}}), "hi") self.assertEqual( self.api._find_text_in_node( {"type": "CLIPTextEncode", "widgets_values": ["w"]} ), "w", ) self.assertIsNone( self.api._find_text_in_node( {"type": "CLIPTextEncode", "widgets_values": [" "]} ) ) self.assertIsNone(self.api._find_text_in_node({"type": "KSampler"})) def test_positive_prompt_from_nodes_array_and_fallbacks(self): extract = self.api._extract_positive_prompt_from_comfyui_data self.assertIsNone(extract("x")) self.assertIsNone(extract({})) self.assertIsNone(extract({"nodes": []})) workflow = { "nodes": [ {"id": 1, "type": "CLIPTextEncode", "widgets_values": ["pos"]}, { "id": 2, "type": "CLIPTextEncode", "widgets_values": ["neg"], "title": "Negative", }, {"id": 3, "type": "KSampler", "inputs": [{"name": "seed", "link": 4}]}, "junk", ] } self.assertEqual(extract(workflow), "pos") only_negative = { "nodes": [ { "id": 2, "type": "CLIPTextEncode", "widgets_values": ["neg"], "title": "neg", }, ] } self.assertEqual(extract(only_negative), "neg") bad_link = { "1": {"class_type": "CLIPTextEncode", "inputs": {"text": "pos"}}, "3": { "class_type": "KSampler", "inputs": {"positive": "oops", "negative": []}, }, "4": { "class_type": "KSampler", "inputs": {"positive": ["zz", 0], "negative": []}, }, } self.assertEqual(extract(bad_link), "pos") if __name__ == "__main__": unittest.main()