Files
ComfyUI/tests-unit/assets_test/test_preview_url.py
Simon Pinfold 3aba3daef3 Derive asset preview URLs from the file path (#15509)
preview_url was assembled from a /api/view link whose type was chosen by
matching the asset's tags against "input" then "output". Anything written
anywhere else - temp above all, where preview nodes put their images - fell
off the end of that chain and came back with no preview at all. Tags are
user-editable, so removing one also silently destroyed the URL.

Derive the URL from where the file actually sits instead. That covers every
root /api/view serves, temp included, and no longer depends on tags or on a
filename in user_metadata. A file outside those roots, or content no client
can render from its own bytes, gets no preview URL rather than one that
cannot work.

Nominated previews are resolved a page at a time rather than per row, so a
list costs one extra query however long it is. A preview that is soft-deleted
or not visible to the caller drops out of that lookup and is no longer
advertised.

Co-authored-by: guill <jacob.e.segal@gmail.com>
2026-08-18 21:00:28 -07:00

217 lines
7.6 KiB
Python

import contextlib
import json
import re
import uuid
import requests
def test_preview_url_serves_the_asset(
http: requests.Session, api_base: str, asset_factory, make_asset_bytes
):
scope = f"preview-url-{uuid.uuid4().hex[:6]}"
name = f"{scope}.png"
data = make_asset_bytes(name, 2048)
body = asset_factory(name, ["output", "unit-tests", scope], {}, data)
assert re.fullmatch(r"/api/view\?type=output&filename=[^&]+", body["preview_url"]), (
f"unexpected preview URL shape: {body['preview_url']!r}"
)
r = http.get(api_base + body["preview_url"], timeout=120)
assert r.status_code == 200, r.text
assert r.content == data, "the preview URL must serve the asset's own bytes"
def test_preview_url_honours_range_requests(
http: requests.Session, api_base: str, asset_factory, make_asset_bytes
):
scope = f"preview-range-{uuid.uuid4().hex[:6]}"
name = f"{scope}.png"
data = make_asset_bytes(name, 2048)
body = asset_factory(name, ["output", "unit-tests", scope], {}, data)
r = http.get(
api_base + body["preview_url"], headers={"Range": "bytes=10-109"}, timeout=120
)
assert r.status_code == 206, (
f"expected a partial response, got {r.status_code}: native <video>/<audio> "
f"seek by byte range and will not play a source that ignores it"
)
assert r.content == data[10:110], "the served range must be the requested one"
def test_preview_url_needs_no_user_header(
api_base: str, asset_factory, make_asset_bytes
):
scope = f"preview-anon-{uuid.uuid4().hex[:6]}"
name = f"{scope}.png"
data = make_asset_bytes(name, 1024)
body = asset_factory(name, ["input", "unit-tests", scope], {}, data)
with requests.Session() as bare:
r = bare.get(api_base + body["preview_url"], timeout=120)
assert r.status_code == 200, (
f"a browser fetching <img src> cannot attach a Comfy-User header, so the "
f"preview URL must resolve without one; got {r.status_code}: {r.text}"
)
assert r.content == data
def test_preview_url_survives_tag_removal(
http: requests.Session, api_base: str, asset_factory, make_asset_bytes
):
scope = f"preview-tags-{uuid.uuid4().hex[:6]}"
name = f"{scope}.png"
data = make_asset_bytes(name, 2048)
body = asset_factory(name, ["input", "unit-tests", scope], {}, data)
aid = body["id"]
preview_url = body["preview_url"]
assert preview_url, "an uploaded image starts out with a preview"
r = http.delete(
f"{api_base}/api/assets/{aid}/tags", json={"tags": ["input"]}, timeout=120
)
assert r.status_code == 200, r.text
after = http.get(f"{api_base}/api/assets/{aid}", timeout=120).json()
assert "input" not in after["tags"]
assert after["preview_url"] == preview_url, (
"dropping the tag that used to select the view type must not take the "
"preview with it"
)
assert http.get(api_base + preview_url, timeout=120).status_code == 200
def test_preview_url_is_the_nominated_preview_when_one_is_set(
http: requests.Session, api_base: str, asset_factory, make_asset_bytes
):
scope = f"preview-id-{uuid.uuid4().hex[:6]}"
thumb_name = f"{scope}_thumb.png"
thumb_data = make_asset_bytes(thumb_name, 1024)
thumb = asset_factory(thumb_name, ["input", "unit-tests", scope], {}, thumb_data)
model_name = f"{scope}.safetensors"
files = {"file": (model_name, make_asset_bytes(model_name, 2048), "application/octet-stream")}
form_data = {
"tags": json.dumps(["models", "model_type:checkpoints", "unit-tests", scope]),
"name": model_name,
"preview_id": thumb["id"],
}
r = http.post(api_base + "/api/assets", files=files, data=form_data, timeout=120)
model = r.json()
assert r.status_code in (200, 201), model
try:
assert model["preview_id"] == thumb["id"]
assert model["preview_url"] == thumb["preview_url"], (
"a nominated preview stands in for content with no visual form"
)
got = http.get(api_base + model["preview_url"], timeout=120)
assert got.status_code == 200, got.text
assert got.content == thumb_data
finally:
with contextlib.suppress(Exception):
http.delete(f"{api_base}/api/assets/{model['id']}", timeout=30)
def test_soft_deleted_preview_is_not_advertised(
http: requests.Session, api_base: str, asset_factory, make_asset_bytes
):
scope = f"preview-gone-{uuid.uuid4().hex[:6]}"
thumb_name = f"{scope}_thumb.png"
thumb = asset_factory(
thumb_name, ["input", "unit-tests", scope], {}, make_asset_bytes(thumb_name, 1024)
)
parent_name = f"{scope}_parent.png"
parent = asset_factory(
parent_name, ["input", "unit-tests", scope], {}, make_asset_bytes(parent_name, 1024)
)
r = http.put(
f"{api_base}/api/assets/{parent['id']}",
json={"preview_id": thumb["id"]},
timeout=120,
)
assert r.status_code == 200, r.text
assert r.json()["preview_url"] == thumb["preview_url"]
assert http.delete(f"{api_base}/api/assets/{thumb['id']}", timeout=30).status_code in (200, 204)
after = http.get(f"{api_base}/api/assets/{parent['id']}", timeout=120).json()
assert after["preview_id"] == thumb["id"]
assert after.get("preview_url") is None, (
"soft delete leaves the parent's preview_id pointing at it, so the "
"response has to drop the URL rather than promise one that 404s"
)
def test_no_preview_url_for_content_a_browser_cannot_render(
http: requests.Session, api_base: str, asset_factory, make_asset_bytes
):
scope = f"preview-model-{uuid.uuid4().hex[:6]}"
name = f"{scope}.safetensors"
body = asset_factory(
name,
["models", "model_type:checkpoints", "unit-tests", scope],
{},
make_asset_bytes(name, 2048),
)
assert body.get("preview_url") is None, (
"model weights have no preview of their own"
)
listed = http.get(
api_base + "/api/assets", params={"include_tags": scope}, timeout=120
).json()["assets"]
assert [a.get("preview_url") for a in listed] == [None], (
"the list route must withhold it too, not just the detail route"
)
def test_text_asset_gets_a_preview_url_that_serves_its_content(
http: requests.Session, api_base: str, asset_factory, make_asset_bytes
):
scope = f"preview-text-{uuid.uuid4().hex[:6]}"
name = f"{scope}.txt"
data = b"line one\nline two\n" + make_asset_bytes(name, 256)
body = asset_factory(name, ["output", "unit-tests", scope], {}, data)
assert body.get("preview_url"), (
"text assets are rendered as a snippet fetched from preview_url"
)
r = http.get(api_base + body["preview_url"], timeout=120)
assert r.status_code == 200, r.text
assert r.content == data
def test_dangerous_text_preview_is_still_forced_to_download(
http: requests.Session, api_base: str, asset_factory
):
scope = f"preview-html-{uuid.uuid4().hex[:6]}"
name = f"{scope}.html"
data = f"<html><script>alert('{scope}')</script></html>".encode()
body = asset_factory(name, ["output", "unit-tests", scope], {}, data)
assert body.get("preview_url"), "text/html matches the previewable prefix"
r = http.get(api_base + body["preview_url"], timeout=120)
r.content
assert r.status_code == 200
ct = r.headers.get("Content-Type", "").lower()
cd = r.headers.get("Content-Disposition", "").lower()
assert ct.startswith("application/octet-stream"), (
f"admitting text/ to the previewable set must not let HTML render "
f"inline in the app origin; got {ct!r}"
)
assert "attachment" in cd, f"expected a forced download, got {cd!r}"