Compare commits

..
Author SHA1 Message Date
Vito Sansevero fabd87e1bc Merge remote-tracking branch 'origin/main' into fix/460-case-history
# Conflicts:
#	database/execution_contexts.py
#	database/history_reads.py
#	tests/comfyui/execution-history.spec.mjs
#	tests/test_execution_context_database.py
#	tests/test_execution_history_hooks.py
#	tests/test_hashing.py
#	tests/test_history_reads.py
2026-10-29 05:47:06 -07:00
Vito Sansevero 9d183a26f6 Merge remote-tracking branch 'origin/main' into fix/460-case-history 2026-10-28 23:27:19 -07:00
Vito Sansevero ca5dc5192e Merge commit '1050deffeda3ce52f4e7c23017d16fa2eb641f6e' into fix/460-case-history 2026-10-28 23:27:19 -07:00
Vito Sansevero 1050deffed Merge remote-tracking branch 'origin/main' into fix/438-whitespace-history 2026-10-28 21:24:53 -07:00
Vito Sansevero efe05a6a6f test(history): a real-host case-variant re-queue joins the prompt's history (#460) 2026-10-28 20:56:14 -07:00
Vito Sansevero d605a6a758 fix(history): join case-variant raw text to the history of the prompt it counts toward (#460)
The prompt hash folds case, so a run of "A Lighthouse" counts toward the
saved "a lighthouse" but never joined its history. History now compares the
hash's own key (prompt_key = strip + str.lower()); SQL calls Python for it
through pm_prompt_key/pm_prompt_hash registered on every managed connection,
since SQLite's lower() folds ASCII only. Hints match by text first (#438) and
fall back to the dedup hash only when no text matches, so no existing hint
becomes ambiguous. A history position from before this change still resumes.
2026-10-28 20:55:09 -07:00
Vito Sansevero 1d4446082f fix(history): join outer-whitespace raw text to the history of the prompt it saves (#438)
The Library stores text.strip() while a run keeps its exact raw text. The
history window, run detail scope id, newest pair run and prompt hints now
compare the raw text's stored form (one rule, utils/hashing.py, mirrored in
SQLite with the same code points). Hints also keep matching the exact raw
text, so older rows that kept outer whitespace stay hinted and protected.
2026-10-28 20:27:53 -07:00
32 changed files with 160 additions and 1395 deletions
+4 -9
View File
@@ -36,11 +36,6 @@ concurrency:
# 12m53s, node units and the viewer suite before 11.8m of workflows), so coverage is the tight one
# in its 18 minutes. This holds only when the npm cache hits: a cold cache adds up to 5m to npm ci,
# and then the job limit can still end the job before the step limit does.
# #473: firefox/webkit shard 1/2 reached its 18-minute step limit (queue run 40866252975: webkit 622 tests
# passed in 17m56s, 48.5 worker-minutes vs 28.0 in shard 2/2; recent shard 1/2 jobs 13.7m-18.9m, shard 2/2
# 7.3m-12.2m), because --shard splits by test count and the slow concept specs sort into shard 1. The page
# workflows now run in three shards per browser with the limits unchanged; the per-shard times are
# re-measured after three green main runs.
jobs:
frontend-coverage:
name: frontend coverage (shard ${{ matrix.shard }}/3)
@@ -180,7 +175,7 @@ jobs:
retention-days: 14
browser-workflows:
name: page workflows (${{ matrix.browser }}, shard ${{ matrix.shard }}/3)
name: page workflows (${{ matrix.browser }}, shard ${{ matrix.shard }}/2)
# Merge queue, main and manual runs only (#420); pull requests run the Chromium/touch coverage shards.
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
@@ -199,7 +194,7 @@ jobs:
fail-fast: false
matrix:
browser: [firefox, webkit]
shard: [1, 2, 3]
shard: [1, 2]
steps:
- uses: actions/checkout@v7
# The image's own Python 3.12 and Node 24: setup-python/setup-node install the runner's toolcache
@@ -226,9 +221,9 @@ jobs:
path: ~/.npm
key: npm-${{ runner.os }}-playwright-v1.64.1-${{ hashFiles('tools/vendor/package-lock.json') }}
- run: npm ci --prefix tools/vendor --ignore-scripts
- name: Page workflows in ${{ matrix.browser }}, shard ${{ matrix.shard }}/3
- name: Page workflows in ${{ matrix.browser }}, shard ${{ matrix.shard }}/2
timeout-minutes: 18
run: npm run --prefix tools/vendor test:workflows -- --project ${{ matrix.browser }} --shard ${{ matrix.shard }}/3
run: npm run --prefix tools/vendor test:workflows -- --project ${{ matrix.browser }} --shard ${{ matrix.shard }}/2
# Traces and the blob report (test-results/blob/) of a failed, timed-out or cancelled step.
- name: Upload browser traces
if: always()
+12 -41
View File
@@ -1830,9 +1830,8 @@ Inspect plan keeps D60–D66 (plan D60 → D83, D61 → D84, D65 → D88, D68
blocks' context ("Current file image tags" in History). An empty source draws no
section. Repeats are removed within a source only, so a linked tag that is also an
image tag shows in both sections and each count is true. D21's filter rules are
unchanged. History's current-file read carries the same `image_tags` facet as the Images
read (#457), so History's inline current file and its viewer show the image tags section
too (`test_current_inspection_carries_the_files_image_tags`). `tests/browser/inspector-concept.spec.mjs` wraps the
unchanged. History's current-file read carries no `image_tags` facet (D88), so there
only the linked section shows. `tests/browser/inspector-concept.spec.mjs` wraps the
concept's row in the same sections at runtime (`withTagSections`, the D154 rule
injected) and compares the measured sections (frame, heading, label, row); a
mutation of the label color, the row margin or the top rule fails it. Browser tests:
@@ -2443,38 +2442,10 @@ Recorded deviations on this page:
view" filters are active, and plain "Filters" otherwise; the text is its accessible
name, so the count is spoken. The concept has no indicator, and without one a Tags
filter set by Tags & categories Browse was invisible on the shelf. The Library counts
Tags, Folder and Tag match (D150); Images counts Source, Folder, Media and Tags (each once). The toolbar category is
Tags; Images counts Source, Folder, Media and Tags (each once). The toolbar category is
not a Refine filter and is not counted. The caption's count is a `role="status"`
region, written only when it changes and kept shown while the shelf reloads, so
Load more and scrolling never re-announce it.
- **D150** (#444, cutover #334): "Refine this view" on the Library holds the concept
Filters dialog's **Folder** ("All output folders", then each folder below an output
root as a tree built by path segment (each folder directly under its own parent,
case variants as distinct siblings, siblings case-insensitive then exact), indented
by depth, read from `GET /prompt_manager/library/options?include=folders`
when the dialog opens; the chosen folder stays listed even when the list lacks it) and
**Tags**, plus **Tag match** ("All of these tags" / "Any of these tags" / "Prompts without tags", D149) drawn as the
concept's own select; the concept's "Prompt evidence" and prototype note are absent.
`GET /prompt_manager/library` takes `folder` (`/`-joined; `\` is a separator only on
Windows, on POSIX it is part of a folder's name as the listing shows it; `.`, `..`,
empty segments and, on Windows, drives are 400) and `tag_mode` (`all` default, `any`, `untagged` from D149), and echoes
both; a client whose non-default value is not echoed (an un-restarted older server)
shows the read error, never the unfiltered shelf. A folder keeps a parent with one
linked image (`generated_images.prompt_id`) in it or below it under any spelling of
any output root (the root as configured or detected and after `realpath`, so a
symlinked output folder works): `file_path` (`normcase(normpath(abspath))`) is
compared with prefixes built by the same normalization, so case folding is the
platform's own, including non-ASCII on Windows. The folder is an outer AND around the
parent and pair arms; Any puts one `t.name IN (...)` in each arm, so a card matches
when the parent or one active pair carries any tag (text and category still AND
inside each arm); with one tag Any equals All. Filters counts Tags, Folder and a Tag
match other than All (D44). Saved Library views store `tag_mode` (`all|any|untagged`) and
`folder` (text 1024); older rows read the defaults. Tests:
`tests/test_library_folders.py` (pure prefixes and names with `posixpath`/`ntpath`,
drive, UNC, non-ASCII, symlinked root, the any-tag union), `test_api_library.py`,
`test_saved_views.py`, the library-state and saved-views JS units, and
`tests/browser/workspace-library-filters.spec.mjs` (behavior, the Refine dialog against
the concept Filters dialog, artifact captures). Windows: run `portability.yml` on the PR.
`tests/browser/library-concept.spec.mjs` compares the shelf (#249 E3) on the
concept-shaped fixture, which creates the concept's prompts last to first so Recently
@@ -6361,7 +6332,7 @@ The versioned public reads are:
| `GET /prompt_manager/library/{positive_id}/history/pairs/{pair_id}/newest` | The pair's newest recorded run for the history run dot (#249 D26): `{run: {run_id, anchor_id, context_id}}` or `{run: null}` |
| `GET /prompt_manager/history/runs/{run_id}` | Frozen fields, per-field provenance, capture and outcome for a known UUID |
| `GET /prompt_manager/history/runs/{run_id}/outputs` | Ordered host-reported occurrences, including repeated locations |
| `GET /prompt_manager/history/runs/{run_id}/output/current/{media,thumbnail,inspection}` | Authorized current file at that reported location, using the returned epoch/scope URL; `inspection` carries the shared inspector envelope including the file's `image_tags` facet (#457) |
| `GET /prompt_manager/history/runs/{run_id}/output/current/{media,thumbnail,inspection}` | Authorized current file at that reported location, using the returned epoch/scope URL |
The newest-pair read takes no query. It answers 404 when the positive is not an
eligible saved prompt or the pair is not one of its pairs. It walks the positive's exact
@@ -6665,12 +6636,12 @@ Deploying this backend needs a ComfyUI restart (Python and route changes).
`#/tags` ("Tags & categories", page title "Tags") is a native workspace route; it no
longer hands off to the legacy tag manager. That page stays reachable from Overview's
"Open existing tag manager" card for its own page; tag search and sort, the untagged
entry (D149) and multi-tag OR (the Library's Tag match, D150) moved to the workspace
(#444); bulk prompt delete stays on the legacy admin page (bulk tags: the Prompt
library's selection bar, D151). A prompt's category is changed in the Prompt library
editor, on many prompts at once with the Prompt library's selection bar (D148, #437), or
with the legacy admin page's "Set Category" (both `POST /prompt_manager/bulk/category`);
"Open existing tag manager" card for multi-tag AND/OR browsing (tag search and sort
and the untagged entry moved here with D149, #444); bulk prompt delete stays on the
legacy admin page (bulk tags: the Prompt library's selection bar, D151). A
prompt's category is changed in the Prompt library editor, on many prompts at once with
the Prompt library's selection bar (D148, #437), or with the legacy admin page's "Set
Category" (both `POST /prompt_manager/bulk/category`);
the Images selection bar's Set category sets images' own categories only (D140, #332),
and Manage category renames a value on prompts, recipes and images together (D144). The screen consumes only the `/prompt_manager/vocabulary/*` routes
above and adds no route, so PR B needed no ComfyUI restart; D149 (#444) adds read
@@ -6743,10 +6714,10 @@ fixture-only tags after the concept's sample tags.
counted every prompt without `prompt_tags` and hid the entry at 0; this row always
shows) and echoes `search` and `sort`. Browse opens the Library with `tag_mode=untagged`
(`GET /prompt_manager/library`, echoed as `tag_mode`; with a `tag` it is 400): "Filters ·
1", and Refine this view adds **Tag match** after Tags ("All of these tags" / "Any of these tags" (D150) / "Prompts
1", and Refine this view adds **Tag match** after Tags ("All of these tags" / "Prompts
without tags", the latter disables Tags and drops them on Apply). A client whose request
the server did not echo (an un-restarted older server) shows the read error with Retry,
never an unfiltered list. Saved Library views store `tag_mode` (`all|any|untagged`, default
never an unfiltered list. Saved Library views store `tag_mode` (`all|untagged`, default
`all`); untagged with tags is refused on write and reads back as `all`; an older build
opens an untagged view as the whole Library. The concept comparison holds the row to the
concept's first category row, its top to the concept table's offset, the table to that
+19 -56
View File
@@ -5,7 +5,6 @@ import sqlite3
from collections.abc import Iterator
from contextlib import contextmanager
from .library_folders import folder_condition, list_library_folders
from .library_tokens import library_edit_token
_ELIGIBLE = """(EXISTS(SELECT 1 FROM prompt_pairs ep WHERE ep.positive_prompt_id=p.id)
@@ -122,9 +121,6 @@ def _archive(value: str) -> str:
return ARCHIVE_SCOPES[value]
# How the Tags filter matches (#444): every tag (AND), any of them (OR), or
# parents without any tag.
TAG_MODES = ("all", "any", "untagged")
# A parent that no tag's Browse returns (#444 "Untagged prompts"): no tag of its
# own and no active pair carrying one. The vocabulary's untagged count uses it too.
UNTAGGED = (
@@ -132,6 +128,8 @@ UNTAGGED = (
"AND NOT EXISTS(SELECT 1 FROM prompt_pairs up JOIN prompt_pair_tags upt "
"ON upt.pair_id=up.id WHERE up.positive_prompt_id=p.id AND up.archived=0)"
)
# How the Tags filter matches: every tag (AND), or parents without any tag.
TAG_MODES = ("all", "untagged")
def _tag_mode(tag_mode: object, tags: list[str]) -> str:
@@ -142,33 +140,8 @@ def _tag_mode(tag_mode: object, tags: list[str]) -> str:
return tag_mode
def _tag_conditions(tags: list[str], any_tag: bool) -> tuple[list, list, list]:
"""Parent-arm and pair-arm tag conditions and the tags' parameters."""
parent_exists = (
"EXISTS(SELECT 1 FROM prompt_tags pt JOIN tags t ON t.id=pt.tag_id "
"WHERE pt.prompt_id=p.id AND t.name{test})"
)
pair_exists = (
"EXISTS(SELECT 1 FROM prompt_pair_tags pt JOIN tags t ON t.id=pt.tag_id "
"WHERE pt.pair_id=pp.id AND t.name{test})"
)
if any_tag and tags:
# One condition per arm: the card, or one active pair, carries any tag.
test = " IN (" + ",".join("?" for _ in tags) + ")"
return [parent_exists.format(test=test)], [pair_exists.format(test=test)], tags
return (
[parent_exists.format(test="=?") for _ in tags],
[pair_exists.format(test="=?") for _ in tags],
tags,
)
def _filter(
text: str,
category: str | None,
tags: list[str],
tag_mode: str = "all",
folder_prefixes: list[str] | None = None,
text: str, category: str | None, tags: list[str], tag_mode: str = "all"
) -> tuple[str, list]:
if not isinstance(text, str) or len(text) > 500:
raise ValueError("Search text must be a string of at most 500 characters")
@@ -180,8 +153,7 @@ def _filter(
or any(not isinstance(tag, str) or not tag or len(tag) > 200 for tag in tags)
):
raise ValueError("Invalid library tags")
mode = _tag_mode(tag_mode, tags)
untagged = mode == "untagged"
untagged = _tag_mode(tag_mode, tags) == "untagged"
parent, pair, parent_params, pair_params = [], ["pp.archived=0"], [], []
if text:
parent.append("(p.text LIKE ? ESCAPE '\\' OR ls.title LIKE ? ESCAPE '\\')")
@@ -196,11 +168,17 @@ def _filter(
pair.append("pp.category=?")
parent_params.append(category)
pair_params.append(category)
parent_tags, pair_tags, tag_params = _tag_conditions(tags, mode == "any")
parent.extend(parent_tags)
pair.extend(pair_tags)
parent_params.extend(tag_params)
pair_params.extend(tag_params)
for tag in tags:
parent.append(
"EXISTS(SELECT 1 FROM prompt_tags pt JOIN tags t ON t.id=pt.tag_id "
"WHERE pt.prompt_id=p.id AND t.name=?)"
)
pair.append(
"EXISTS(SELECT 1 FROM prompt_pair_tags pt JOIN tags t ON t.id=pt.tag_id "
"WHERE pt.pair_id=pp.id AND t.name=?)"
)
parent_params.append(tag)
pair_params.append(tag)
condition = (
"("
+ (" AND ".join(parent) or "1=1")
@@ -216,13 +194,7 @@ def _filter(
# A whole-card condition: inside the parent-or-pair OR an untagged pair
# would let a tagged parent through.
condition = f"({condition}) AND {UNTAGGED}"
params = [*parent_params, *pair_params]
if folder_prefixes:
# A whole-card condition around both arms: images link to the parent.
folder, folder_params = folder_condition(folder_prefixes)
condition = f"({condition}) AND {folder}"
params.extend(folder_params)
return f"({_ELIGIBLE}) AND ({condition})", params
return f"({_ELIGIBLE}) AND ({condition})", [*parent_params, *pair_params]
class LibraryOperations:
@@ -306,7 +278,6 @@ class LibraryOperations:
category: str | None = None,
tags: list[str] | None = None,
tag_mode: str = "all",
folder_prefixes: list[str] | None = None,
sort: str = "newest",
limit: int = 40,
after: dict | None = None,
@@ -314,9 +285,7 @@ class LibraryOperations:
_limit(limit)
if sort not in _ORDERS:
raise ValueError("Unknown library sort order")
where, params = _filter(
text, category, [] if tags is None else tags, tag_mode, folder_prefixes
)
where, params = _filter(text, category, [] if tags is None else tags, tag_mode)
if after is not None:
if (
not isinstance(after, dict)
@@ -382,7 +351,6 @@ class LibraryOperations:
category: str | None = None,
tags: list[str] | None = None,
tag_mode: str = "all",
folder_prefixes: list[str] | None = None,
sort: str = "newest",
limit: int = 40,
after: dict | None = None,
@@ -396,7 +364,6 @@ class LibraryOperations:
category=category,
tags=tags,
tag_mode=tag_mode,
folder_prefixes=folder_prefixes,
sort=sort,
limit=limit,
after=after,
@@ -527,13 +494,9 @@ class LibraryOperations:
"categories": [row["category"] for row in rows],
}
def get_library_options(self, folder_roots=None) -> dict:
"""Categories; with *folder_roots* (root spellings) also ``folders``."""
def get_library_options(self) -> dict:
with self.library_snapshot() as (conn, epoch, _ceiling):
result = self._get_library_options(conn, epoch)
if folder_roots is not None:
result["folders"] = list_library_folders(conn, folder_roots, _ELIGIBLE)
return result
return self._get_library_options(conn, epoch)
def get_library_previews(self, positive_ids: list[int]) -> dict[int, list[dict]]:
"""Read at most two links per parent in one bounded page query."""
-114
View File
@@ -1,114 +0,0 @@
"""Library output-folder filter (#444): folder names, root prefixes and listing.
A folder is a ``/``-joined path relative to an output root, as Images' folder
filter and the legacy ``/prompt_manager/subfolders`` name them. A parent
matches a folder when one of its linked images (``generated_images.prompt_id``,
the link its previews and image count read) lies in that folder or below it,
under any spelling of any output root.
Matching compares ``generated_images.file_path``, which is always
``normcase(normpath(abspath(path)))`` (``models.normalize_image_path``), with
prefixes built by the same normalization here, so case folding is the
platform's own on both sides (Windows folds non-ASCII too, unlike SQLite's
``lower()``) and the comparison is an exact ``substr`` with no wildcards.
"""
import os
import sqlite3
_MAX_FOLDER = 1024
_MAX_PREFIXES = 64
def normalize_folder(value: object, pathmod=os.path) -> str:
"""The folder as ``a/b``, outer slashes dropped; empty means no filter.
``/`` always separates. ``\\`` separates only on Windows (``ntpath``): on
POSIX it is an ordinary character of a folder's name, and the listing
(``relative_folder``) names such a folder with it, so both agree.
``.``/``..``/empty segments, control characters and (on Windows) a drive
are refused: the value only ever names a folder below a root.
"""
if not isinstance(value, str) or len(value) > _MAX_FOLDER:
raise ValueError(f"folder must be text of at most {_MAX_FOLDER} characters")
windows = pathmod.sep == "\\"
folder = (value.replace("\\", "/") if windows else value).strip("/")
if not folder:
return ""
parts = folder.split("/")
if (
any(part in ("", ".", "..") for part in parts)
or (windows and ":" in parts[0])
or any(ord(char) < 32 or ord(char) == 127 for char in folder)
):
raise ValueError("folder must be a folder below an output root")
return folder
def _base(root: object, pathmod) -> str:
return pathmod.normcase(pathmod.normpath(str(root)))
def folder_prefixes(roots, folder: str, pathmod=os.path) -> list[str]:
"""One ``file_path`` prefix per root spelling for a normalized folder."""
prefixes = []
for root in roots:
joined = pathmod.join(_base(root, pathmod), *folder.split("/"))
prefix = pathmod.normcase(pathmod.normpath(joined)) + pathmod.sep
if prefix not in prefixes:
prefixes.append(prefix)
if not prefixes or len(prefixes) > _MAX_PREFIXES:
raise ValueError("No output folder to filter by")
return prefixes
def folder_condition(prefixes: list[str]) -> tuple[str, list]:
"""A condition on alias ``p``: one linked image under any prefix."""
tests = " OR ".join("substr(fg.file_path,1,?)=?" for _ in prefixes)
params = [value for prefix in prefixes for value in (len(prefix), prefix)]
return (
"EXISTS(SELECT 1 FROM generated_images fg WHERE fg.prompt_id=p.id "
f"AND ({tests}))",
params,
)
def relative_folder(image_path: str, roots, pathmod=os.path) -> str | None:
"""The folder of *image_path* below the first root spelling holding it.
The spelling of the stored path is kept (its case on Windows); ``None``
for an image directly in a root or outside every root.
"""
directory = pathmod.normpath(pathmod.dirname(image_path))
folded = pathmod.normcase(directory)
for root in roots:
base = _base(root, pathmod)
start = base if base.endswith(pathmod.sep) else base + pathmod.sep
if not folded.startswith(start):
continue
# normcase keeps the length for ordinary names; else show the folded form.
rest = (directory if len(directory) == len(folded) else folded)[len(start) :]
parts = rest.split(pathmod.sep)
if any(part in ("", ".", "..") for part in parts):
return None
return "/".join(parts)
return None
def list_library_folders(
conn: sqlite3.Connection, roots, eligible: str, pathmod=os.path
) -> list[str]:
"""Every folder (and ancestor) holding an image of an eligible parent."""
folders = set()
for (image_path,) in conn.execute(
"SELECT DISTINCT g.image_path FROM generated_images g "
"JOIN prompts p ON p.id=g.prompt_id WHERE " + eligible
):
if not isinstance(image_path, str) or not image_path:
continue
folder = relative_folder(image_path, roots, pathmod)
if folder is None:
continue
parts = folder.split("/")
folders.update("/".join(parts[:index]) for index in range(1, len(parts) + 1))
return sorted(folders, key=lambda value: (value.casefold(), value))
+3 -5
View File
@@ -33,9 +33,8 @@ MAX_TAG_LENGTH = 50
_IMAGE_SORTS = ("newest", "oldest", "name", "name-desc", "rating")
_LIBRARY_SORTS = ("newest", "oldest", "name", "rating")
_MEDIA = ("all", "image", "video")
# How a Library view's Tags match (#444): every tag, any of them, or parents
# without tags ("untagged" never goes with tags; the listing refuses both).
_TAG_MODES = ("all", "any", "untagged")
# How a Library view's Tags match (#444): every tag, or parents without tags.
_TAG_MODES = ("all", "untagged")
_CONTROL = re.compile(r"[\x00-\x1f\x7f]")
_SOURCE = re.compile(r"all|0|[1-9][0-9]{0,8}")
# A collection id as the listing's ``collection`` query takes it (#331): none
@@ -70,9 +69,8 @@ _STATE_FIELDS = {
"category": ("text", 100, ""),
"sort": ("choice", _LIBRARY_SORTS, "newest"),
"tags": ("tags", None, ()),
# #444: the Tags match and the output folder (relative, "/"-joined).
# #444: "untagged" never goes with tags (the listing refuses both).
"tag_mode": ("choice", _TAG_MODES, "all"),
"folder": ("text", 1024, ""),
},
}
+13 -26
View File
@@ -40,7 +40,6 @@ from aiohttp import web
from PIL import Image
from .prompts import PromptRoutesMixin
from .revalidation import revalidated_response
from .pairs import PairRoutesMixin
from .library import LibraryRoutesMixin
from .vocabulary import VocabularyRoutesMixin
@@ -654,39 +653,35 @@ class PromptManagerAPI(
@routes.get("/prompt_manager/workspace")
async def serve_workspace_ui(request: web.Request) -> web.Response:
return self._serve_html_page(request, "workspace.html", "Workspace")
return self._serve_html_page("workspace.html", "Workspace")
@routes.get("/prompt_manager/web")
async def serve_web_ui(request):
return self._serve_html_page(request, "index.html", "Web UI", cache=False)
return self._serve_html_page("index.html", "Web UI", cache=False)
@routes.get("/prompt_manager/gallery.html")
async def serve_gallery_ui(request):
return self._serve_html_page(request, "metadata.html", "Gallery")
return self._serve_html_page("metadata.html", "Gallery")
@routes.get("/prompt_manager/admin")
async def serve_admin_ui(request):
return self._serve_html_page(request, "admin.html", "Admin UI")
return self._serve_html_page("admin.html", "Admin UI")
@routes.get("/prompt_manager/gallery")
async def serve_gallery_admin_ui(request):
return self._serve_html_page(request, "gallery.html", "Gallery")
return self._serve_html_page("gallery.html", "Gallery")
# ── Static file serving ───────────────────────────────────────
@routes.get("/prompt_manager/lib/{filepath:.*}")
async def serve_lib_static(request):
"""Serve static library files (JS, CSS) from web/lib directory."""
return self._serve_static_file(
request, "lib", request.match_info.get("filepath")
)
return self._serve_static_file("lib", request.match_info.get("filepath"))
@routes.get("/prompt_manager/js/{filepath:.*}")
async def serve_js_static(request):
"""Serve static JavaScript files from web/js directory."""
return self._serve_static_file(
request, "js", request.match_info.get("filepath")
)
return self._serve_static_file("js", request.match_info.get("filepath"))
# ── Register domain-specific routes from mixins ───────────────
@@ -731,11 +726,8 @@ class PromptManagerAPI(
self.logger.info("All routes registered with decorator pattern")
def _serve_static_file(self, request, subdir, filepath):
"""Serve ``web/<subdir>/<filepath>``; 403 outside it, 404 when missing.
A served file is revalidated on every reuse (#478, ``revalidation``).
"""
def _serve_static_file(self, subdir, filepath):
"""Serve ``web/<subdir>/<filepath>``; 403 outside it, 404 when missing."""
root = os.path.join(_get_project_root(), "web", subdir)
exact = STATIC_EXACT_FILES.get((subdir, filepath))
# An exact file passes the same containment check, its extension only.
@@ -750,13 +742,10 @@ class PromptManagerAPI(
return web.Response(text="Not Found", status=404)
with open(file_path, "rb") as f:
content = f.read()
return revalidated_response(request, content, content_type)
return web.Response(body=content, content_type=content_type)
def _serve_html_page(self, request, filename, title, cache=True):
"""Serve ``web/<filename>``; 404 when missing, 500 when unreadable.
A served page is revalidated on every reuse (#478, ``revalidation``).
"""
def _serve_html_page(self, filename, title, cache=True):
"""Serve ``web/<filename>``; 404 when missing, 500 when unreadable."""
html_path = os.path.join(_get_project_root(), "web", filename)
try:
if cache and html_path in self._html_cache:
@@ -773,9 +762,7 @@ class PromptManagerAPI(
content_type="text/html",
status=404,
)
return revalidated_response(
request, content.encode("utf-8"), "text/html", charset="utf-8"
)
return web.Response(text=content, content_type="text/html", charset="utf-8")
except Exception:
self.logger.exception(f"Failed to load {title}")
return web.Response(
+1 -13
View File
@@ -27,11 +27,7 @@ from .history_current import (
resolve_current_candidate,
snapshot_current_output_scope,
)
from .inspection_service import (
CurrentFileChanged,
image_tags_reader,
inspect_current_file,
)
from .inspection_service import CurrentFileChanged, inspect_current_file
from .images import image_category_key
try:
@@ -782,14 +778,6 @@ class HistoryRoutesMixin:
),
own,
),
# The file's own image tags, keyed as the Tag dialog
# keys them: the normcased realpath (#457).
image_tags=image_tags_reader(
db,
str(resolved.canonical_target),
self.logger,
close=True,
),
recorded=recorded_output_lookup(
db, close=True, reference=receipt["reference"]
),
+21 -3
View File
@@ -55,7 +55,6 @@ from .inspection_service import (
IMAGE_EXTENSIONS as _IMAGE_EXTENSIONS,
SERVABLE_EXTENSIONS,
VIDEO_EXTENSIONS,
image_tags_reader,
inspect_current_file,
)
@@ -3963,9 +3962,28 @@ class ImageRoutesMixin:
)
def _inspection_image_tags(self, root: Path, parts: tuple) -> Callable:
"""The inspector's ``image_tags`` reader for one rooted file (S1)."""
"""The inspector's ``image_tags`` reader for one rooted file (S1).
The returned function takes the inspection's own stable stat and
answers ``current`` only for a byte record verified at exactly that
``(size, mtime_ns)``; a failed read is ``unavailable``, never an error.
"""
locator = os.path.normcase(os.path.join(os.path.realpath(root), *parts))
return image_tags_reader(self.db, locator, self.logger)
def read(size: int, mtime_ns: int) -> dict:
try:
found = self.db.current_image_tags({locator: (size, mtime_ns)})
except Exception:
self.logger.warning(
"Inspection image tags could not be read", exc_info=True
)
return {"state": "unavailable", "tags": []}
tags = found.get(locator)
if tags is None:
return {"state": "none", "tags": []}
return {"state": "current", "tags": list(tags)}
return read
def _read_own_category(self, sources, path):
"""One file's own category, None, or OWN_CATEGORY_UNREAD (blocking).
-29
View File
@@ -93,35 +93,6 @@ def _in_host_temp(path: Path) -> bool:
return False
def image_tags_reader(
db: Any, locator: str, logger: Any, *, close: bool = False
) -> Callable[[int, int], dict]:
"""The inspector's ``image_tags`` reader for one file *locator* (S1).
The returned function takes the inspection's own stable stat and answers
``current`` only for a byte record verified at exactly that
``(size, mtime_ns)``; a failed read is ``unavailable``, never an error.
``close`` closes the calling thread's connection afterwards, for routes
that close theirs after reading (History).
"""
def read(size: int, mtime_ns: int) -> dict:
try:
found = db.current_image_tags({locator: (size, mtime_ns)})
except Exception:
logger.warning("Inspection image tags could not be read", exc_info=True)
return {"state": "unavailable", "tags": []}
finally:
if close:
db.model.close()
tags = found.get(locator)
if tags is None:
return {"state": "none", "tags": []}
return {"state": "current", "tags": list(tags)}
return read
def inspect_current_file(
path: Path,
*,
+2 -51
View File
@@ -21,11 +21,9 @@ from .pairs import pair_response
try:
from ...database.library import ARCHIVE_SCOPES
from ...database.library_commands import LibraryCommandError
from ...database.library_folders import folder_prefixes, normalize_folder
except ImportError:
from database.library import ARCHIVE_SCOPES
from database.library_commands import LibraryCommandError
from database.library_folders import folder_prefixes, normalize_folder
_SECRET = os.urandom(32)
@@ -285,47 +283,13 @@ class LibraryRoutesMixin:
outputs = list(self._get_all_output_dirs())
return self._media_roots(outputs), len(outputs)
def _library_root_spellings(self) -> list[str]:
"""Every spelling of each output root (#444 folder filter): the
validated, resolved root and each configured or detected path that
resolves to it, so images saved through a symlinked root match."""
from ..config import GalleryConfig
roots = [str(root) for root in self._get_all_output_dirs()]
candidates = [
*GalleryConfig.MONITORING_DIRECTORIES,
self._find_comfyui_output_dir(),
*self._comfyui_output_dir_list(),
]
spellings = []
for root in roots:
for value in (root, *candidates):
if not value or not isinstance(value, (str, os.PathLike)):
continue
value = str(value)
same = value == root or os.path.normcase(
os.path.realpath(value)
) == os.path.normcase(root)
if same and value not in spellings:
spellings.append(value)
return spellings
@pair_response
async def get_library_options(self, request: web.Request) -> web.Response:
include = request.query.get("include", "")
if include not in ("", "folders"):
raise ValueError("include must be empty or 'folders'")
if include:
# Folders are read only when Refine opens (a walk of linked paths).
roots = await self._run_in_executor(self._library_root_spellings)
result = await self._run_in_executor(self.db.get_library_options, roots)
else:
result = await self._run_in_executor(self.db.get_library_options)
result = await self._run_in_executor(self.db.get_library_options)
return web.json_response({"success": True, **result})
@pair_response
async def list_library(self, request: web.Request) -> web.Response:
folder = normalize_folder(request.query.get("folder", ""))
options = {
"text": request.query.get("text", ""),
"category": request.query.get("category"),
@@ -335,34 +299,21 @@ class LibraryRoutesMixin:
}
limit = _page_limit(request)
cursor = request.query.get("cursor")
roots = (
await self._run_in_executor(self._library_root_spellings) if folder else []
)
prefixes = folder_prefixes(roots, folder) if folder else None
def read() -> tuple[dict, dict]:
with self.db.library_snapshot(include_ceiling=True) as snapshot:
conn, epoch, ceiling = snapshot
# The folder and the roots it was resolved under join the scope.
scope = _scope(
{
"kind": "parents",
"projection": 2,
"epoch": epoch,
**options,
**({"folder": folder, "roots": roots} if folder else {}),
}
{"kind": "parents", "projection": 2, "epoch": epoch, **options}
)
result = self.db._list_library(
conn,
epoch,
ceiling,
**options,
folder_prefixes=prefixes,
limit=limit,
after=_decode(cursor, scope),
)
result["folder"] = folder
records = self.db.get_library_previews(
[parent["id"] for parent in result["items"]]
)
-47
View File
@@ -1,47 +0,0 @@
"""Revalidated responses for the pages and static files PromptManager serves.
``Cache-Control: no-cache`` lets the browser keep a copy but makes it ask the
server before every reuse, so a PromptManager update reaches the browser on a
normal reload (#478). The ETag lets an unchanged file answer 304 without a
body, which keeps that reload cheap.
The tag hashes the bytes rather than the file's mtime and size: an update can
keep both (an archive extract, a same-length edit within one clock tick), and
the handlers read the whole file anyway. It is weak because the gzip
middleware may send the same content in a different encoding.
"""
import hashlib
from aiohttp import web
REVALIDATE = "no-cache"
def entity_tag(body: bytes) -> str:
"""Weak ETag for ``body``."""
return f'W/"{hashlib.sha256(body).hexdigest()[:32]}"'
def etag_matches(if_none_match, etag: str) -> bool:
"""True when an If-None-Match header names ``etag`` (weak comparison)."""
if not if_none_match:
return False
if if_none_match.strip() == "*":
return True
opaque = etag.removeprefix("W/")
return any(
candidate.strip().removeprefix("W/") == opaque
for candidate in if_none_match.split(",")
)
def revalidated_response(request, body: bytes, content_type: str, charset=None):
"""200 with ``body``, or 304 when the request already holds this version."""
etag = entity_tag(body)
headers = {"Cache-Control": REVALIDATE, "ETag": etag}
if etag_matches(request.headers.get("If-None-Match"), etag):
return web.Response(status=304, headers=headers)
return web.Response(
body=body, content_type=content_type, charset=charset, headers=headers
)
+10 -26
View File
@@ -1,7 +1,6 @@
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import {test,expect} from './fixtures.mjs';
import {armUnseenRestores,unseenRestore} from './unseen-restore.mjs';
import {saveImageTags} from './concept-inventory.mjs';
import {readFile,writeFile} from 'node:fs/promises';
import {createHash} from 'node:crypto';
@@ -496,38 +495,23 @@ test('the inline current file names its prompts apart from the frozen run fields
expect(await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'))).toBe(before);
});
// #449 (D154), #457: History's inline current file draws the file's own image tags in the shared labelled section, named
// with its context so they stay apart from the frozen run's fields. The tags are saved on the real file through the Tag
// routes and come from History's own current-file read (its `image_tags` facet), as Images reads them.
test('the inline current file labels its image tags after its negative prompt',async({page,request})=>{
const reads=[];
// #449 (D154): History's inline current file draws tags in the shared labelled sections, named with its context so they
// stay apart from the frozen run's fields. The server sends History no image_tags facet today (D88), so the read is
// given one here to prove the shared render in History.
test('the inline current file labels its image tags after its negative prompt',async({page})=>{
await page.route(url=>url.pathname.endsWith('/inspection'),async route=>{const response=await route.fetch();const body=await response.json();
reads.push(body.image_tags);
body.prompts={positive:{status:'resolved',text:'current file positive',source:'graph-inferred'},negative:{status:'resolved',text:'current file negative',source:'graph-inferred'}};
body.image_tags={state:'current',tags:['history_tag']};
await route.fulfill({response,json:body});});
await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.shared).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();
await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();
await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape');await expect(dialog(page)).toBeVisible();
const current=dialog(page).getByRole('region',{name:'Current reported file',exact:true});
const tags=current.getByRole('region',{name:'Current file image tags',exact:true});
const showCurrent=async()=>{
await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.shared).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();
await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();
await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape');await expect(dialog(page)).toBeVisible();
await expect(current.getByRole('region',{name:'Current file negative prompt',exact:true})).toBeVisible();
};
// An untagged file draws no image tags section.
await showCurrent();await expect(tags).toHaveCount(0);expect(reads.at(-1)).toEqual({state:'none',tags:[]});
await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
expect(await saveImageTags(request,'history/current.png',['history_tag','dusk'])).toEqual(['history_tag','dusk']);
await showCurrent();expect(reads.at(-1)).toEqual({state:'current',tags:['history_tag','dusk']});
await expect(tags.locator('.role-label')).toHaveText('Image tags · 2');await expect(tags.locator('.tags > .chip')).toHaveText(['history_tag','dusk']);
await expect(tags.locator('.role-label')).toHaveText('Image tags · 1');await expect(tags.locator('.tags > .chip')).toHaveText(['history_tag']);
const negative=current.getByRole('region',{name:'Current file negative prompt',exact:true});
expect(await negative.evaluate((node,section)=>({after:Boolean(node.compareDocumentPosition(section)&Node.DOCUMENT_POSITION_FOLLOWING),inside:node.contains(section)}),await tags.elementHandle())).toEqual({after:true,inside:false});
// The viewer opened from History shares the inspection: the same section, plain chips (no Tags filter there, D88).
await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();
const viewerTags=page.locator('dialog#viewer-dialog #viewer-details section.tag-section.image-tags');
await expect(viewerTags.locator('.role-label')).toHaveText('Image tags · 2');
await expect(viewerTags.locator('span.chip')).toHaveText(['history_tag','dusk']);await expect(viewerTags.locator('button')).toHaveCount(0);
await page.keyboard.press('Escape');await expect(dialog(page)).toBeVisible();
});
// #332 CUJ 5 (MC8, §5.3): History's current-file inspector reads the file's own category with the Images key rule, so
@@ -1,143 +0,0 @@
// #444 (D150): the Library's output-folder filter and multi-tag OR in "Refine this view", against the real server, and
// the Refine dialog against the concept's Filters dialog (app.js:1345-1352).
import {writeFile} from 'node:fs/promises';
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import {test,expect} from './fixtures.mjs';
import {captureCanonicalState} from './history-canonical-comparison.mjs';
import {inventoryOf,diffInventory} from './library-editor-canonical-comparison.mjs';
const route='/prompt_manager/workspace#/library';
const concept='/__test__/canonical/workspace';
const cards=page=>page.locator('article[data-prompt-id]');
const card=(page,text)=>cards(page).filter({has:page.getByText(text,{exact:true})});
const toolbar=page=>page.locator('#workspace-main search.toolbar');
const filtersButton=page=>toolbar(page).getByRole('button',{name:/^Filters(?: · \d)?$/});
const refine=page=>page.getByRole('dialog',{name:'Refine this view',exact:true});
const folder=page=>refine(page).getByRole('combobox',{name:'Folder',exact:true});
const tags=page=>refine(page).getByRole('textbox',{name:'Tags',exact:true});
const match=page=>refine(page).getByRole('combobox',{name:'Tag match',exact:true});
const apply=page=>refine(page).getByRole('button',{name:'Apply filters',exact:true});
const caption=page=>page.locator('#workspace-main .section-caption > span').first();
test.beforeEach(async({page,request,browserName})=>{
await startCoverage(page,browserName);await expect(await request.post('/__test__/reset',{data:{native_library:true}})).toBeOK();
page.libraryErrors=[];page.on('pageerror',error=>page.libraryErrors.push(error.message));
});
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.libraryErrors).toEqual([]);});
async function open(page) {
await page.goto(route);await expect(caption(page)).toHaveText(/^3 positive prompts · /);
}
/** Open Refine and wait for the folder list it reads on opening. */
async function openRefine(page) {
const folders=page.waitForResponse(item=>new URL(item.url()).pathname==='/prompt_manager/library/options'&&new URL(item.url()).searchParams.get('include')==='folders');
await filtersButton(page).click();await expect(refine(page)).toBeVisible();await folders;
}
test('Refine offers Folder, Tags and Tag match; a folder narrows the shelf to prompts with images there',async({page})=>{
await open(page);await openRefine(page);
await expect(refine(page).locator('.field label')).toHaveText(['Folder','Tags','Tag match']);
// Both roots hold a "shared" folder; the root's own images name no folder.
await expect(folder(page).locator('option')).toHaveText(['All output folders','shared']);
await expect(match(page).locator('option')).toHaveText(['All of these tags','Any of these tags','Prompts without tags']);
await folder(page).selectOption('shared');await apply(page).click();
await expect(caption(page)).toHaveText(/^1 positive prompt · /);
await expect(cards(page)).toHaveCount(1);await expect(card(page,'Cat portrait')).toHaveCount(1);
await expect(filtersButton(page)).toHaveText('Filters · 1');
await openRefine(page);await expect(folder(page)).toHaveValue('shared');
await refine(page).getByRole('button',{name:'Clear filters',exact:true}).click();
await expect(caption(page)).toHaveText(/^3 positive prompts · /);await expect(filtersButton(page)).toHaveText('Filters');
});
test('Tag match Any shows every prompt carrying one of the tags; changing only the match applies it',async({page})=>{
await open(page);await openRefine(page);
await tags(page).fill('cats, dogs');await apply(page).click();
await expect(caption(page)).toHaveText(/^0 positive prompts · /);await expect(filtersButton(page)).toHaveText('Filters · 1');
await openRefine(page);await match(page).selectOption('any');await apply(page).click();
await expect(caption(page)).toHaveText(/^2 positive prompts · /);
await expect(cards(page)).toHaveCount(2);await expect(card(page,'Cat portrait')).toHaveCount(1);await expect(card(page,'Dog landscape')).toHaveCount(1);
await expect(filtersButton(page)).toHaveText('Filters · 2');
});
test('a saved view keeps its folder and tag match',async({page})=>{
await open(page);await openRefine(page);
await folder(page).selectOption('shared');await tags(page).fill('cats, dogs');await match(page).selectOption('any');await apply(page).click();
await expect(caption(page)).toHaveText(/^1 positive prompt · /);
await toolbar(page).getByRole('button',{name:'Save view',exact:true}).click();
const save=page.getByRole('dialog',{name:'Save this view',exact:true});
await save.getByRole('textbox',{name:'View name',exact:true}).fill('Shared cats or dogs');
const created=page.waitForResponse(item=>new URL(item.url()).pathname==='/prompt_manager/saved-views'&&item.request().method()==='POST');
await save.getByRole('button',{name:'Save',exact:true}).click();
const body=(await created).request().postDataJSON();
expect(body.state).toMatchObject({folder:'shared',tag_mode:'any',tags:['cats','dogs']});
await page.goto('about:blank');await open(page);
await page.getByRole('navigation',{name:'Saved views',exact:true}).getByRole('link',{name:'Shared cats or dogs',exact:true}).click();
await expect(caption(page)).toHaveText(/^1 positive prompt · /);await expect(filtersButton(page)).toHaveText('Filters · 3');
});
test('a chosen folder missing from the folder list stays listed and chosen in Refine',async({page})=>{
await open(page);await openRefine(page);await folder(page).selectOption('shared');await apply(page).click();
await expect(caption(page)).toHaveText(/^1 positive prompt · /);
// The folder has no image any more as far as the list knows (or the read failed): Apply must not drop it.
await page.route('**/prompt_manager/library/options?include=folders',route=>route.fulfill({json:{success:true,library_epoch:'x',categories:[],folders:[]}}));
await openRefine(page);
await expect(folder(page).locator('option')).toHaveText(['All output folders','shared']);await expect(folder(page)).toHaveValue('shared');
await apply(page).click();await expect(caption(page)).toHaveText(/^1 positive prompt · /);await expect(filtersButton(page)).toHaveText('Filters · 1');
});
// Live B1: the server's flat list sorts full paths as text ("-" and "_" before "/"); Refine shows each folder directly
// under its own parent and keeps case variants apart.
test('the Folder list nests each folder under its exact parent',async({page})=>{
const folders=['CCC','CCC/mabes9sans-dataset','CCC/mabes9sans-dataset-v1','CCC/mabes9sans-dataset/mabes9sans-dataset_IMG_GEN',
'FLUX','FLUX/2025-12-11','flux','flux/images','flux-klein','kiko','kiko-v6','kiko/char'].sort();
await page.route('**/prompt_manager/library/options?include=folders',route=>route.fulfill({json:{success:true,library_epoch:'x',categories:[],folders}}));
await open(page);await openRefine(page);
const I='\u00a0\u00a0';
await expect(folder(page).locator('option')).toHaveText(['All output folders','CCC',`${I}mabes9sans-dataset`,`${I}${I}mabes9sans-dataset_IMG_GEN`,
`${I}mabes9sans-dataset-v1`,'FLUX',`${I}2025-12-11`,'flux',`${I}images`,'flux-klein','kiko',`${I}char`,'kiko-v6']);
expect(await folder(page).locator('option').evaluateAll(nodes=>nodes.map(node=>node.value))).toEqual(['','CCC','CCC/mabes9sans-dataset',
'CCC/mabes9sans-dataset/mabes9sans-dataset_IMG_GEN','CCC/mabes9sans-dataset-v1','FLUX','FLUX/2025-12-11','flux','flux/images','flux-klein',
'kiko','kiko/char','kiko-v6']);
});
test('an older server that ignores the folder shows an error with Retry, never the whole shelf',async({page})=>{
await open(page);
await page.route('**/prompt_manager/library?*',async route=>{
const response=await route.fetch();const body=await response.json();delete body.folder;delete body.tag_mode;
await route.fulfill({response,json:body});
});
await openRefine(page);await folder(page).selectOption('shared');await apply(page).click();
await expect(page.locator('#workspace-main').getByText('Prompt library could not be loaded. Retry.')).toBeVisible();
await expect(cards(page)).toHaveCount(0);
});
// D150: the Refine dialog is the concept's Filters dialog: its Folder select and Tags input, plus Tag match drawn as the
// concept's own select; the concept's "Prompt evidence" (no data yet) and its prototype note are not shown.
function mapFiltersDialog(reference,{folders,matchOptions}) {
const [conceptFolder,,conceptTags]=reference.fields;
return {...reference,
fields:[{...conceptFolder,options:folders},{...conceptTags},{...conceptFolder,label:'Tag match',options:matchOptions}],
small:[],
// Production's Apply reads "Apply filters" (Images and Library share it, #249).
actions:{...reference.actions,buttons:reference.actions.buttons.map(item=>item.text==='Apply tag filter'?{...item,text:'Apply filters'}:item)}};
}
for(const [width,height] of [[1600,1100],[390,844]])for(const theme of ['dark','light'])test(`Refine dialog matches the concept Filters dialog ${width} ${theme} (D150)`,async({page},testInfo)=>{
await page.setViewportSize({width,height});
await page.goto('about:blank');await page.goto(`${concept}#library`);await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
await page.locator('#main').getByRole('button',{name:'Filters',exact:true}).click();await expect(page.locator('#modal')).toBeVisible();
const reference=await page.locator('#modal').evaluate(inventoryOf);
await captureCanonicalState(page,testInfo,`concept-filters-${width}-${theme}`,'#modal');
await open(page);await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);await openRefine(page);
await expect(folder(page).locator('option')).toHaveCount(2);
const actual=await refine(page).evaluate(inventoryOf);
await captureCanonicalState(page,testInfo,`production-refine-${width}-${theme}`,'dialog[open]');
const expected=mapFiltersDialog(reference,{folders:[['','All output folders'],['shared','shared']],matchOptions:[['all','All of these tags'],['any','Any of these tags'],['untagged','Prompts without tags']]});
const path=testInfo.outputPath('refine-inventory.json');await writeFile(path,JSON.stringify({reference,actual},null,2));
await testInfo.attach('refine-inventory',{path,contentType:'application/json'});
expect(reference.fields.map(item=>item.label)).toEqual(['Folder','Prompt evidence','Tags']);
expect(diffInventory(expected,actual)).toEqual([]);
await refine(page).getByRole('button',{name:'Close dialog',exact:true}).click();
await openRefine(page);await folder(page).selectOption('shared');await tags(page).fill('cats, dogs');await match(page).selectOption('any');await apply(page).click();
await expect(caption(page)).toHaveText(/^1 positive prompt · /);await page.mouse.move(0,0);
await captureCanonicalState(page,testInfo,`production-library-folder-any-${width}-${theme}`,'#workspace-main');
});
+2 -3
View File
@@ -155,8 +155,7 @@ test('Retry re-reads a failed category list with the shelf',async({page})=>{
await page.locator('#workspace-main search.toolbar').getByRole('button',{name:'Filters',exact:true}).click();
await refine(page).getByRole('button',{name:'Apply filters',exact:true}).click();await expect(refine(page)).toHaveCount(0);
const search=page.getByRole('searchbox',{name:'Search workspace',exact:true});await search.fill(' ');
// Only the folder list Refine reads on opening (D150), never the shelf.
await page.waitForTimeout(400);expect(reads).toEqual(['/prompt_manager/library/options']);await expect(cards(page)).toHaveCount(3);
await page.waitForTimeout(400);expect(reads).toEqual([]);await expect(cards(page)).toHaveCount(3);
});
test('failed library reads retry while late search responses cannot overwrite newer results',async({page})=>{
@@ -201,7 +200,7 @@ test('library toolbar matches the concept and tags live in Refine this view',asy
// D44: the active Tags filter shows on the button; the category is the toolbar's own and is not counted.
await expect(toolbar.getByRole('button')).toHaveText(['Save view','Filters · 1']);
await toolbar.getByRole('button',{name:'Filters · 1',exact:true}).click();
await expect(refine(page).locator('.field label')).toHaveText(['Folder','Tags','Tag match']);await expect(refine(page).getByRole('textbox',{name:'Tags',exact:true})).toHaveValue('studio');
await expect(refine(page).locator('.field label')).toHaveText(['Tags','Tag match']);await expect(refine(page).getByRole('textbox',{name:'Tags',exact:true})).toHaveValue('studio');
await refine(page).getByRole('button',{name:'Clear filters',exact:true}).click();await expect(refine(page)).toHaveCount(0);
await expect(toolbar.getByRole('button',{name:'Filters',exact:true})).toBeFocused();
await expect(page.getByRole('combobox',{name:'Filter by category',exact:true})).toHaveValue('');await expect(cards(page)).toHaveCount(3);
+6 -6
View File
@@ -208,7 +208,7 @@ test('a view stored under older rules still lists and opens with its usable valu
await raw({ page: 'library', name: '', state: JSON.stringify({ q: 'dog', sort: 'name-desc' }) });
expect(await listed(request)).toEqual([
{ id: imagesId, page: 'images', name: 'Old cats', state: { ...IMAGE_DEFAULTS, q: 'cat' } },
expect.objectContaining({ page: 'library', name: 'Saved view', state: { q: 'dog', category: '', sort: 'newest', tags: [], tag_mode: 'all', folder: '' } }),
expect.objectContaining({ page: 'library', name: 'Saved view', state: { q: 'dog', category: '', sort: 'newest', tags: [], tag_mode: 'all' } }),
]);
// A state that is no JSON object is never listed, and its link behaves like a missing view.
const corruptId = await raw({ page: 'images', name: 'Corrupt', state: '{not json' });
@@ -233,7 +233,7 @@ test('a view stored under older rules still lists and opens with its usable valu
// D153 (#447): the workspace no longer offers "Highest rated"; the API still stores such a view as it was saved.
test('a view saved under Highest rated opens on Recently added on Images and the Library', async ({ page, request }) => {
const images = await create(request, { page: 'images', name: 'Rated cats', state: { ...IMAGE_DEFAULTS, q: 'cat', sort: 'rating' } });
const library = await create(request, { page: 'library', name: 'Rated dogs', state: { q: 'dog', category: '', sort: 'rating', tags: [], tag_mode: 'all', folder: '' } });
const library = await create(request, { page: 'library', name: 'Rated dogs', state: { q: 'dog', category: '', sort: 'rating', tags: [], tag_mode: 'all' } });
expect((await listed(request)).map(view => view.state.sort)).toEqual(['rating', 'rating']);
const listings = [];
page.on('request', item => {
@@ -257,7 +257,7 @@ test('a view saved under Highest rated opens on Recently added on Images and the
test('leaving a saved view returns Images and the Library to their own last state (#249 M2)', async ({ page, request }) => {
const dogs = await create(request, { page: 'images', name: 'Dogs', state: { ...IMAGE_DEFAULTS, q: 'dogs' } });
const cats = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [], tag_mode: 'all', folder: '' } });
const cats = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [], tag_mode: 'all' } });
await page.goto(`${workspace}#/images`);
await search(page).fill('cat');
await expect(imageCards(page)).toHaveCount(1);
@@ -300,7 +300,7 @@ test('a toolbar change while sources load keeps the saved Source and Folder (#24
});
test('a saved view left while its list or rename is pending leaves the next page alone', async ({ page, request }) => {
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [], tag_mode: 'all', folder: '' } });
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [], tag_mode: 'all' } });
let releaseList = () => {};
const holdList = async () => {
const held = new Promise(resolve => { releaseList = resolve; });
@@ -348,7 +348,7 @@ test('an unknown saved view opens Images, and a failed listing offers Retry', as
await expect(page).toHaveURL(/#\/images$/);
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Images');
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [], tag_mode: 'all', folder: '' } });
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [], tag_mode: 'all' } });
let failing = true;
await page.route(`**${API}`, route => failing && route.request().method() === 'GET'
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"down"}' })
@@ -539,7 +539,7 @@ test('Edit renames an open view, and deletes it only after confirming inside the
});
test('a deleted Library view returns to the Library, and failed edits say so', async ({ page, request }) => {
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [], tag_mode: 'all', folder: '' } });
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [], tag_mode: 'all' } });
await page.goto(`${workspace}#/saved/${view.id}`);
await expect(heading(page)).toHaveText('Cats');
const edit = main(page).locator('.heading-actions > button').first();
+1 -40
View File
@@ -104,20 +104,6 @@ test('archived variant reads use their own scope and cursor without changing the
const active=[];const plain=createLibraryDetail({api:{get:async url=>{active.push(url);return {positive:item(1),variants:[]};}},onChange:()=>{}});await plain.load(1);assert.equal(new URL(active[0],'https://x').searchParams.has('archive'),false);
assert.throws(()=>createLibraryDetail({api:{},onChange:()=>{},archive:'parents'}));
});
test('tag mode any and a folder are sent, echoed and validated (#444)',async()=>{
const {createLibraryStream,LIBRARY_TAG_MODES}=await source;assert.deepEqual(LIBRARY_TAG_MODES,['all','any','untagged']);
const calls=[];const pending=[{...page([item(1)]),tag_mode:'any',folder:'2026/Aug'},{...page([item(2)]),tag_mode:'all',folder:''},page([item(3)]),{...page([item(4)]),tag_mode:'all'}];
const s=createLibraryStream({api:{get:async(url)=>{calls.push(new URL(url,'https://x').searchParams);return pending.shift();}}});
assert.equal(await s.reset({tags:['a','b'],tagMode:'any',folder:'2026/Aug'}),1);
assert.equal(calls[0].get('tag_mode'),'any');assert.equal(calls[0].get('folder'),'2026/Aug');assert.deepEqual(calls[0].getAll('tag'),['a','b']);
// An older server that ignored them answers without the echo: an error, never the unfiltered shelf.
assert.equal(await s.reset({tags:['a','b'],tagMode:'any',folder:'2026/Aug'}),0);assert.ok(s.state.error instanceof TypeError);
// The defaults send neither, so they need no echo.
assert.equal(await s.reset({tagMode:'all',folder:''}),1);assert.equal(calls[2].has('tag_mode'),false);assert.equal(calls[2].has('folder'),false);
assert.equal(await s.reset({folder:'x'}),0);assert.ok(s.state.error instanceof TypeError,'a folder without its echo is refused');
await assert.rejects(s.reset({tagMode:'or'}),TypeError);await assert.rejects(s.reset({folder:3}),TypeError);
assert.equal(calls.length,4);s.dispose();
});
test('tag mode untagged is sent, echoed and never combined with tags (#444)',async()=>{
const {createLibraryStream}=await source;const calls=[];const pending=[{...page([item(1)]),tag_mode:'untagged'},page([item(2)]),page([item(3)])];
const s=createLibraryStream({api:{get:async(url)=>{calls.push(url);return pending.shift();}}});
@@ -126,7 +112,7 @@ test('tag mode untagged is sent, echoed and never combined with tags (#444)',asy
assert.equal(await s.reset({tagMode:'untagged'}),0);assert.ok(s.state.error instanceof TypeError);assert.deepEqual(s.state.items,[]);
// The default sends no tag_mode, so it needs no echo.
assert.equal(await s.reset({tagMode:'all'}),1);assert.equal(new URL(calls[2],'https://x').searchParams.has('tag_mode'),false);
await assert.rejects(s.reset({tagMode:'untagged',tags:['a']}),TypeError);await assert.rejects(s.reset({tagMode:'or'}),TypeError);
await assert.rejects(s.reset({tagMode:'untagged',tags:['a']}),TypeError);await assert.rejects(s.reset({tagMode:'none'}),TypeError);
assert.equal(calls.length,3);s.dispose();
});
test('the stream keeps the first page\'s storage epoch and refuses a later page from another one (#437)',async()=>{
@@ -145,28 +131,3 @@ test('the shelf no longer sorts by rating (D153)',async()=>{
assert.throws(()=>createLibraryStream({api:{get:async()=>page([])},restore:{filters:{sort:'rating'},state:{}}}),/Invalid library sort/);
for(const sort of ['newest','oldest','name']) assert.equal(await s.reset({sort}),0);s.dispose();
});
test('Refine › Folder nests each folder under its exact parent, by path segment (#444 live B1)',async()=>{
const {libraryFolderOptions}=await source;assert.equal(typeof libraryFolderOptions,'function');
const I='  ';
// The server's flat list sorts full paths as text, so "-" and "_" land before "/" (live report shapes).
const folders=['CCC','CCC/mabes9sans-dataset','CCC/mabes9sans-dataset-v1','CCC/mabes9sans-dataset/mabes9sans-dataset_IMG_GEN',
'FLUX','FLUX/2025-12-11','flux','flux/images','flux-klein','illustrious','illustrious-base','illustrious/2026-10-26','illustrious/training',
'illustrious/training/a','kiko','kiko-v6','kiko/char','kiko/XXX','QWEN','QWEN/x','Qwen','Qwen/y','Qwen-Training','Video','Video/v','video'].sort();
const options=libraryFolderOptions(folders);
assert.deepEqual(options[0],['','All output folders']);
assert.deepEqual(options.slice(1),[
['CCC','CCC'],['CCC/mabes9sans-dataset',`${I}mabes9sans-dataset`],['CCC/mabes9sans-dataset/mabes9sans-dataset_IMG_GEN',`${I}${I}mabes9sans-dataset_IMG_GEN`],
['CCC/mabes9sans-dataset-v1',`${I}mabes9sans-dataset-v1`],
['FLUX','FLUX'],['FLUX/2025-12-11',`${I}2025-12-11`],['flux','flux'],['flux/images',`${I}images`],['flux-klein','flux-klein'],
['illustrious','illustrious'],['illustrious/2026-10-26',`${I}2026-10-26`],['illustrious/training',`${I}training`],['illustrious/training/a',`${I}${I}a`],
['illustrious-base','illustrious-base'],
['kiko','kiko'],['kiko/char',`${I}char`],['kiko/XXX',`${I}XXX`],['kiko-v6','kiko-v6'],
['QWEN','QWEN'],['QWEN/x',`${I}x`],['Qwen','Qwen'],['Qwen/y',`${I}y`],['Qwen-Training','Qwen-Training'],
['Video','Video'],['Video/v',`${I}v`],['video','video'],
]);
// Every parent precedes its children directly; a chosen folder missing from the list is kept, with its parents.
assert.deepEqual(libraryFolderOptions(['a'],'a/b/c').slice(1),[['a','a'],['a/b',`${I}b`],['a/b/c',`${I}${I}c`]]);
assert.deepEqual(libraryFolderOptions([],''),[['','All output folders']]);
// A POSIX folder whose name holds a backslash is one segment.
assert.deepEqual(libraryFolderOptions(['Metadata\\Klein_4b Wildcard Run']).slice(1),[['Metadata\\Klein_4b Wildcard Run','Metadata\\Klein_4b Wildcard Run']]);
});
+9 -20
View File
@@ -5,7 +5,7 @@ const assert = require('node:assert/strict');
const source = import('../../web/js/workspace/saved-views.mjs');
const IMAGE_DEFAULTS = { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false, collection: '' };
const LIBRARY_DEFAULTS = { q: '', category: '', sort: 'newest', tags: [], tag_mode: 'all', folder: '' };
const LIBRARY_DEFAULTS = { q: '', category: '', sort: 'newest', tags: [], tag_mode: 'all' };
test('a saved state always holds every page key and keeps only values its page accepts', async () => {
const { normalizeViewState, SAVED_VIEW_PAGES } = await source;
@@ -31,8 +31,8 @@ test('a saved state always holds every page key and keeps only values its page a
assert.deepEqual(normalizeViewState('images', { ...full, q: 'x'.repeat(501), folder: 7, tags: Array(21).fill('t') }),
{ ...full, q: '', folder: '', tags: [] });
assert.deepEqual(normalizeViewState('images', { ...full, category: 'bad\u0007', tags: [' '] }), { ...full, category: '', tags: [] });
// Library has no source or media, and no Name Z–A; its folder (#444) is the Library's own.
assert.deepEqual(normalizeViewState('library', { ...full, sort: 'name-desc' }), { q: 'castle', category: 'Portraits', sort: 'newest', tags: ['moody', 'fog'], tag_mode: 'all', folder: 'a/b' });
// Library has no source, folder or media, and no Name Z–A.
assert.deepEqual(normalizeViewState('library', { ...full, sort: 'name-desc' }), { q: 'castle', category: 'Portraits', sort: 'newest', tags: ['moody', 'fog'], tag_mode: 'all' });
assert.deepEqual(normalizeViewState('library', { sort: 'oldest' }), { ...LIBRARY_DEFAULTS, sort: 'oldest' });
// The returned tags are a copy.
const tags = ['one'];
@@ -62,7 +62,7 @@ test('the listing keeps usable views in order and normalizes their state', async
{ id: 1, page: 'images', name: 'Videos', state: { media: 'video', source: '9' } },
] });
assert.deepEqual(views, [
{ id: 3, page: 'library', name: 'Neon', state: { q: 'neon', category: '', sort: 'name', tags: [], tag_mode: 'all', folder: '' } },
{ id: 3, page: 'library', name: 'Neon', state: { q: 'neon', category: '', sort: 'name', tags: [], tag_mode: 'all' } },
{ id: 1, page: 'images', name: 'Videos', state: { ...IMAGE_DEFAULTS, media: 'video', source: '9' } },
]);
assert.equal(parseSavedView(null), null);
@@ -84,8 +84,8 @@ test('page filters and saved states convert both ways', async () => {
assert.equal(imagesViewState({ ...filters, collection: undefined }).collection, '');
assert.equal(imagesViewState({ ...filters, collection: 7 }).collection, '7');
assert.equal(imagesViewState({ ...filters, favorites: undefined }).favorites, false);
const shelf = { text: 'neon', category: 'Cities', sort: 'oldest', tags: ['night'], tagMode: 'any', folder: '2026' };
assert.deepEqual(libraryViewState(shelf), { q: 'neon', category: 'Cities', sort: 'oldest', tags: ['night'], tag_mode: 'any', folder: '2026' });
const shelf = { text: 'neon', category: 'Cities', sort: 'oldest', tags: ['night'], tagMode: 'all' };
assert.deepEqual(libraryViewState(shelf), { q: 'neon', category: 'Cities', sort: 'oldest', tags: ['night'], tag_mode: 'all' });
assert.deepEqual(libraryFilters(libraryViewState(shelf)), shelf);
});
@@ -216,27 +216,16 @@ test('a state over the API 4 KB cap is refused before anything is sent (#249 M5)
assert.deepEqual(api.calls, []);
});
test('a Library view keeps its tag match and folder; an old view reads the defaults (#444)', async () => {
const { normalizeViewState, libraryFilters } = await source;
assert.deepEqual(normalizeViewState('library', { tag_mode: 'any', folder: '2026/Aug' }), { ...LIBRARY_DEFAULTS, tag_mode: 'any', folder: '2026/Aug' });
assert.deepEqual(normalizeViewState('library', { tag_mode: 'or', folder: 'x'.repeat(1025) }), LIBRARY_DEFAULTS);
assert.deepEqual(libraryFilters({ q: 'a' }), { text: 'a', category: '', sort: 'newest', tags: [], tagMode: 'all', folder: '' });
});
test('a Library view keeps untagged, and untagged never travels with tags (#444)', async () => {
test('a Library view keeps its tag match, and untagged never travels with tags (#444)', async () => {
const { normalizeViewState, libraryFilters, libraryViewState } = await source;
assert.equal(normalizeViewState('library', { tag_mode: 'untagged' }).tag_mode, 'untagged');
assert.equal(normalizeViewState('library', { tag_mode: 'any' }).tag_mode, 'all');
assert.deepEqual(normalizeViewState('library', { tags: ['a'], tag_mode: 'untagged' }), { ...LIBRARY_DEFAULTS, tags: ['a'] });
const shelf = { text: '', category: '', sort: 'newest', tags: [], tagMode: 'untagged', folder: '' };
const shelf = { text: '', category: '', sort: 'newest', tags: [], tagMode: 'untagged' };
assert.equal(libraryViewState(shelf).tag_mode, 'untagged');
assert.deepEqual(libraryFilters(libraryViewState(shelf)), shelf);
});
test('a shelf without the #444 filters saves their defaults', async () => {
const { libraryViewState } = await source;
assert.deepEqual(libraryViewState({ text: '', category: '', sort: 'newest', tags: [] }), LIBRARY_DEFAULTS);
});
test('a view saved under the retired Highest rated sort opens on Recently added (D153)', async () => {
const { normalizeViewState, imagesFilters, libraryFilters } = await source;
assert.equal(normalizeViewState('images', { sort: 'rating' }).sort, 'newest');
-126
View File
@@ -684,132 +684,6 @@ class TestStaticRoutes(CoreRoutesTestCase):
self.assertEqual(resp.status, 403)
class TestRevalidatedCaching(CoreRoutesTestCase):
"""Every served page and static file is revalidated on reload (#478).
``Cache-Control: no-cache`` makes the browser ask before it reuses a copy,
and the ETag lets an unchanged file answer 304 without a body, so an update
reaches the browser on a normal reload and an unchanged reload stays cheap.
"""
STATIC_PATHS = (
"/prompt_manager/js/workspace/index.mjs",
"/prompt_manager/js/admin.js",
"/prompt_manager/js/inspection-semantics.json",
"/prompt_manager/lib/viewerjs/viewer.min.css",
)
async def _assert_revalidated(self, path):
resp = await self.client.request("GET", path)
self.assertEqual(resp.status, 200, path)
self.assertEqual(resp.headers.get("Cache-Control"), "no-cache", path)
etag = resp.headers.get("ETag")
self.assertTrue(etag, path)
body = await resp.read()
self.assertTrue(body, path)
again = await self.client.request("GET", path, headers={"If-None-Match": etag})
self.assertEqual(again.status, 304, path)
self.assertEqual(again.headers.get("ETag"), etag, path)
self.assertEqual(again.headers.get("Cache-Control"), "no-cache", path)
self.assertEqual(await again.read(), b"", path)
stale = await self.client.request(
"GET", path, headers={"If-None-Match": 'W/"stale"'}
)
self.assertEqual(stale.status, 200, path)
self.assertEqual(await stale.read(), body, path)
return etag
async def test_static_files_are_revalidated(self):
for path in self.STATIC_PATHS:
await self._assert_revalidated(path)
async def test_pages_are_revalidated(self):
for path in UI_PATHS:
await self._assert_revalidated(path)
async def test_etag_matches_in_a_list_and_as_wildcard(self):
path = "/prompt_manager/js/workspace/index.mjs"
etag = (await self.client.request("GET", path)).headers["ETag"]
for header in (f'W/"other", {etag}', etag.removeprefix("W/"), "*"):
resp = await self.client.request(
"GET", path, headers={"If-None-Match": header}
)
self.assertEqual(resp.status, 304, header)
async def test_changed_file_gets_a_new_etag_and_its_new_body(self):
web_dir = self._fake_root()
module = web_dir / "js" / "m.mjs"
module.write_text("export const v = 1;")
path = "/prompt_manager/js/m.mjs"
first = await self.client.request("GET", path)
etag = first.headers["ETag"]
# Same size and, on coarse clocks, the same mtime: only the bytes differ.
module.write_text("export const v = 2;")
resp = await self.client.request("GET", path, headers={"If-None-Match": etag})
self.assertEqual(resp.status, 200)
self.assertNotEqual(resp.headers["ETag"], etag)
self.assertEqual(await resp.text(), "export const v = 2;")
async def test_gzipped_and_plain_bodies_share_a_weak_etag(self):
"""The gzip middleware changes the bytes, so the tag must be weak."""
resp = await self.client.request("GET", "/prompt_manager/js/admin.js")
self.assertTrue(resp.headers["ETag"].startswith('W/"'))
async def test_errors_carry_no_validator(self):
resp = await self.client.request("GET", "/prompt_manager/js/nope.js")
self.assertEqual(resp.status, 404)
self.assertNotIn("ETag", resp.headers)
@web.middleware
async def _comfyui_cache_control(request, handler):
"""ComfyUI's own middleware (middleware/cache_middleware.py), JS/CSS branch.
It only fills a missing Cache-Control, and ``.mjs`` is not ``.js``: that is
why the workspace modules went out with no header at all before #478.
"""
response = await handler(request)
if request.path.endswith(".js") or request.path.endswith(".css"):
response.headers.setdefault("Cache-Control", "no-store")
return response
class TestRevalidatedCachingBehindMiddlewares(CoreRoutesTestCase):
"""The host's middlewares keep the header and the 304 intact."""
async def get_application(self):
app = await super().get_application()
app.middlewares.append(_comfyui_cache_control)
app.middlewares.append(api_module._gzip_middleware)
return app
async def test_gzipped_files_keep_no_cache_and_answer_304(self):
for path in (
"/prompt_manager/js/workspace/index.mjs",
"/prompt_manager/js/admin.js",
"/prompt_manager/lib/viewerjs/viewer.min.css",
"/prompt_manager/workspace",
):
resp = await self.client.request(
"GET", path, headers={"Accept-Encoding": "gzip"}
)
self.assertEqual(resp.status, 200, path)
self.assertEqual(resp.headers.get("Content-Encoding"), "gzip", path)
self.assertEqual(resp.headers["Cache-Control"], "no-cache", path)
again = await self.client.request(
"GET",
path,
headers={
"Accept-Encoding": "gzip",
"If-None-Match": resp.headers["ETag"],
},
)
self.assertEqual(again.status, 304, path)
self.assertEqual(again.headers["Cache-Control"], "no-cache", path)
# ── instance helpers ──────────────────────────────────────────────────
-116
View File
@@ -1015,122 +1015,6 @@ class HistoryOutputAPITests(ImageAPITestCase):
response = await self.current(descriptor["inspection_url"])
self.assert_error(response, await response.json(), 422, "response-limit")
async def tag_image(self, relative, tags):
"""Save *tags* on Images root 0's *relative* file through the Tag routes.
The real observe and save routes store the locator the Tag dialog
stores (``media_capture._resolve``), so History must find it (#457).
"""
self.addCleanup(self.api.close_annotation_captures)
roots = self.api._get_all_output_dirs()
target = {
"root_index": 0,
"root_scope": self.api._gallery_root_scope(roots[0], 0),
"path": relative,
}
response = await self.client.post(
"/prompt_manager/image-annotations/observe", json={"target": target}
)
observed = await response.json()
self.assertEqual(response.status, 200, observed)
token = observed["capture"]["token"]
try:
response = await self.client.put(
"/prompt_manager/image-annotations/tags",
json={
"target": target,
"capture_token": token,
"sha256": observed["capture"]["sha256"],
"expected_epoch": observed["library_epoch"],
"expected_tag_revision": observed["tag_revision"],
"tags": list(tags),
},
)
saved = await response.json()
self.assertEqual(response.status, 200, saved)
finally:
await self.client.delete(
f"/prompt_manager/image-annotations/capture/{token}"
)
return saved["tags"]
async def test_current_inspection_carries_the_files_image_tags(self):
"""#457: History reads the file's own image tags, as Images does (S1)."""
sub = self.output_dir / "batch"
sub.mkdir()
tagged = png(sub / "tagged.png", prompt=graph("Tagged file"))
png(self.output_dir / "plain.png", prompt=graph("Plain file"))
self.assertEqual(
await self.tag_image("batch/tagged.png", ["dunes", "warm_light"]),
["dunes", "warm_light"],
)
start = self.capture(
[
self.ref("1", subfolder="batch", filename="tagged.png"),
self.ref("2", filename="plain.png"),
]
)
_, page = await self.outputs(start)
urls = [
item["current_candidate"]["media"]["inspection_url"]
for item in page["items"]
]
bodies = []
for url in urls:
response = await self.current(url)
body = await response.json()
self.assertEqual(response.status, 200, body)
bodies.append(body)
self.assertEqual(
bodies[0].get("image_tags"),
{"state": "current", "tags": ["dunes", "warm_light"]},
)
# Image tags are the file's own search metadata, never prompt tags.
self.assertEqual(bodies[0]["prompts"]["positive"]["text"], "Tagged file")
self.assertEqual(bodies[1].get("image_tags"), {"state": "none", "tags": []})
# New bytes at the same path: the tags belong to the old byte record.
stat = tagged.stat()
os.utime(tagged, ns=(stat.st_atime_ns, stat.st_mtime_ns + 10**9))
response = await self.current(urls[0])
body = await response.json()
self.assertEqual(response.status, 200, body)
self.assertEqual(body["image_tags"], {"state": "none", "tags": []})
async def test_current_inspection_finds_image_tags_under_a_symlinked_output(self):
"""An output folder reached through a link (another drive) still matches."""
link = self.comfy_dir / "linked_output"
self._make_symlink(self.output_dir, link, target_is_directory=True)
self.api._get_all_output_dirs = lambda: [link]
self.host_output = str(link)
png(self.output_dir / "image.png", prompt=graph("Linked folder"))
self.assertEqual(await self.tag_image("image.png", ["dunes"]), ["dunes"])
start = self.capture([self.ref()])
descriptor = await self.media(start)
response = await self.current(descriptor["inspection_url"])
body = await response.json()
self.assertEqual(response.status, 200, body)
self.assertEqual(body["image_tags"], {"state": "current", "tags": ["dunes"]})
async def test_current_inspection_image_tag_failure_is_unavailable(self):
png(self.output_dir / "image.png", prompt=graph("File evidence"))
await self.tag_image("image.png", ["dunes"])
start = self.capture([self.ref()])
descriptor = await self.media(start)
with (
self.assertLogs(self.api.logger, "WARNING") as logs,
patch.object(
type(self.api.db),
"current_image_tags",
side_effect=sqlite3.OperationalError("locked"),
),
):
response = await self.current(descriptor["inspection_url"])
body = await response.json()
self.assertEqual(response.status, 200, body)
self.assertEqual(body["image_tags"], {"state": "unavailable", "tags": []})
self.assertEqual(body["prompts"]["positive"]["text"], "File evidence")
self.assertNotIn(str(self.output_dir), "\n".join(logs.output))
async def test_optional_candidate_failure_preserves_every_occurrence_without_urls(
self,
):
+8 -36
View File
@@ -35,7 +35,6 @@ from aiohttp import web # noqa: E402
from aiohttp.test_utils import AioHTTPTestCase # noqa: E402
from PIL import Image # noqa: E402
from database.models import PromptModel # noqa: E402
from database.operations import PromptDatabase # noqa: E402
import py.api.images as images_module
from py.api import gallery_listing # noqa: E402
@@ -93,41 +92,14 @@ class ImageAPITestCase(AioHTTPTestCase):
self._anchor_patch.stop()
# Windows refuses to unlink a database that still has open handles.
self.api.db.close_all()
try:
leaked = self._close_leaked_connections()
finally:
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()
if leaked:
raise AssertionError(
f"{leaked} connection(s) to the test database were still open at "
"teardown; Windows cannot unlink it (WinError 32). Close every "
"extra PromptDatabase before tearDownAsync, not in a cleanup."
)
def _close_leaked_connections(self):
"""Close and count connections other models still hold on the temp DB.
Linux unlinks an open SQLite file, Windows does not; counting here makes
a handle left open past teardown fail on every OS (#475).
"""
key = os.path.normcase(os.path.realpath(self._temp_db.name))
with PromptModel._instances_lock:
models = list(PromptModel._instances)
leaked = 0
for model in models:
if model._schema_key() != key:
continue
with model._conn_lock:
leaked += len(model._connections)
model.close_all()
return leaked
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 ---------------------------------------------------------
-66
View File
@@ -123,72 +123,6 @@ class TestLibraryAPI(ImageAPITestCase):
},
)
async def test_folder_filter_lists_and_matches_prompts_by_output_folder(self):
deep = self._save_prompt("deep")
top = self._save_prompt("top")
other = self._save_prompt("other")
self._save_prompt("no images")
for prompt, parts in ((deep, ("a", "b")), (top, ("a",)), (other, ("ab",))):
path = make_png(self.output_dir.joinpath(*parts, f"{prompt}.png"))
self.api.db.link_image_to_prompt(prompt, str(path))
response, options = await self.get("/options", include="folders")
self.assertEqual(response.status, 200)
self.assertEqual(options["folders"], ["a", "a/b", "ab"])
_, plain = await self.get("/options")
self.assertNotIn("folders", plain)
_, first = await self.get(folder="a", limit=1)
self.assertEqual((first["total"], first["folder"]), (2, "a"))
# Outer slashes are dropped (a backslash separates only on Windows).
outer = "\\a\\" if os.name == "nt" else "/a/"
_, second = await self.get(folder=outer, limit=1, cursor=first["next_cursor"])
self.assertEqual(
sorted([first["items"][0]["text"], second["items"][0]["text"]]),
["deep", "top"],
)
_, all_rows = await self.get()
self.assertEqual((all_rows["total"], all_rows["folder"]), (4, ""))
for params in (
{"folder": "a/../ab"},
*([{"folder": "C:/x"}] if os.name == "nt" else []),
{"folder": "b", "cursor": first["next_cursor"]},
{"cursor": first["next_cursor"]},
):
response, result = await self.get(limit=1, **params)
self.assertEqual(response.status, 400, params)
self.assertEqual(result["success"], False)
response, _ = await self.get("/options", include="tags")
self.assertEqual(response.status, 400)
@unittest.skipIf(os.name == "nt", "a backslash is a separator on Windows")
async def test_a_posix_folder_name_with_a_backslash_filters_its_prompts(self):
prompt = self._save_prompt("klein")
name = "Metadata\\Klein_4b Wildcard Run"
path = make_png(self.output_dir / name / "x.png")
self.api.db.link_image_to_prompt(prompt, str(path))
_, options = await self.get("/options", include="folders")
self.assertIn(name, options["folders"])
_, result = await self.get(folder=name)
self.assertEqual((result["total"], result["folder"]), (1, name))
async def test_any_tag_mode_is_sent_through_and_echoed(self):
self._save_prompt("a only", tags=["a"])
self._save_prompt("b only", tags=["b"])
self._save_prompt("neither")
_, result = await self.get(tag=["a", "b"], tag_mode="any")
self.assertEqual(result["tag_mode"], "any")
self.assertEqual(
sorted(i["text"] for i in result["items"]), ["a only", "b only"]
)
_, both = await self.get(tag=["a", "b"])
self.assertEqual((both["total"], both["tag_mode"]), (0, "all"))
_, first = await self.get(tag=["a", "b"], tag_mode="any", limit=1)
response, _ = await self.get(
tag=["a", "b"], limit=1, cursor=first["next_cursor"]
)
self.assertEqual(response.status, 400)
response, _ = await self.get(tag_mode="or")
self.assertEqual(response.status, 400)
async def test_variant_cursor_is_positive_scoped_and_required_limits_are_bounded(
self,
):
+3 -22
View File
@@ -38,25 +38,11 @@ def _payload(token):
class TestRestartKeepsTheOpenPage(ImageAPITestCase):
async def get_application(self):
self._restarts = []
app = await super().get_application()
for index in range(3):
make_png(self.output_dir / f"image_{index}.png")
return app
async def tearDownAsync(self):
# Before the base teardown unlinks the database: cleanups run after
# tearDownAsync, and Windows cannot unlink a file still open (#475).
errors = []
while self._restarts:
try:
await self._restarts.pop()()
except Exception as error: # every closer runs; the first error wins
errors.append(error)
await super().tearDownAsync()
if errors:
raise errors[0]
async def first_page(self, client=None, **params):
response = await (client or self.client).get(
LISTING, params={"mode": "workspace", "limit": "1", **params}
@@ -81,7 +67,7 @@ class TestRestartKeepsTheOpenPage(ImageAPITestCase):
api.close_auto_thumbnails()
api.db.close_all()
self._restarts.append(close)
self.addAsyncCleanup(close)
return api, client
async def test_a_restart_keeps_every_held_scope_and_cursor_valid(self):
@@ -166,13 +152,8 @@ class TestRestartKeepsTheOpenPage(ImageAPITestCase):
other_db = self.comfy_dir / "elsewhere" / "prompts.db"
other_db.parent.mkdir()
api = PromptManagerAPI(db=PromptDatabase(str(other_db)))
async def close():
api.close_auto_thumbnails()
api.db.close_all()
# Closed before teardown removes the folder that holds its database.
self._restarts.append(close)
self.addCleanup(api.db.close_all)
self.addCleanup(api.close_auto_thumbnails)
self.assertNotEqual(
api._gallery_root_scope(self.output_dir, 0), page["images"][0]["root_scope"]
)
-30
View File
@@ -887,36 +887,6 @@ class TestImageTagsFacet(unittest.TestCase):
self.assertNotIn("image_tags", json.loads(encode(inspection({}))))
self.assertNotIn("image_tags", self.encoded(None))
def history_encoded(self, facet):
from utils.inspection_response import encode_history_inspection_response
body = encode_history_inspection_response(
current_output={"history_epoch": "epoch"},
file={"observation": "current-file", "size": 100},
prompts=PROMPTS,
resolution={"state": "resolved", "sampler_id": "3", "reason": ""},
inspection=inspection({"prompt": graph_text()}),
library={"status": "none"},
image_tags=facet,
)
return json.loads(body)
def test_history_envelope_validates_image_tags_like_the_rooted_one(self):
"""#457: History's current-file read carries the same facet."""
for facet in (
{"state": "current", "tags": ["dunes", "warm_light"]},
{"state": "none", "tags": []},
{"state": "unavailable", "tags": []},
{"state": "current", "tags": ["a,b"]},
{"state": "current", "tags": [], "locator": "/abs/path.png"},
):
with self.subTest(facet=facet):
self.assertEqual(
self.history_encoded(facet)["image_tags"],
self.encoded(facet)["image_tags"],
)
self.assertNotIn("image_tags", self.history_encoded(None))
class TestVideoProbeBoundsMatchPngBudgets(unittest.TestCase):
"""ffprobe escapes tag text; the stdout bounds must never gate a record
-243
View File
@@ -1,243 +0,0 @@
"""#444: the Library's output-folder filter and multi-tag OR."""
import ntpath
import os
import posixpath
import tempfile
import unittest
from pathlib import Path
from database.library_folders import (
folder_prefixes,
normalize_folder,
relative_folder,
)
from database.operations import PromptDatabase
from utils.hashing import generate_prompt_hash
class TestFolderNames(unittest.TestCase):
def test_normalize_strips_outer_slashes_on_both_flavors(self):
for flavor in (posixpath, ntpath):
with self.subTest(flavor=flavor.__name__):
self.assertEqual(normalize_folder("", flavor), "")
self.assertEqual(normalize_folder("/", flavor), "")
self.assertEqual(normalize_folder("/a/b/", flavor), "a/b")
self.assertEqual(normalize_folder("..foo/b", flavor), "..foo/b")
def test_a_backslash_separates_only_on_windows(self):
# Live B1: on POSIX a backslash is part of a folder name, as listed.
name = "Metadata\\Klein_4b Wildcard Run"
self.assertEqual(normalize_folder(name, posixpath), name)
self.assertEqual(
normalize_folder("2026\\08-Aug\\", posixpath), "2026\\08-Aug\\"
)
self.assertEqual(
normalize_folder(name, ntpath), "Metadata/Klein_4b Wildcard Run"
)
self.assertEqual(normalize_folder("2026\\08-Aug\\", ntpath), "2026/08-Aug")
# A colon names a drive only on Windows.
self.assertEqual(normalize_folder("a:b/c", posixpath), "a:b/c")
def test_normalize_refuses_anything_but_a_folder_below_a_root(self):
for flavor, value in (
*(
(flavor, value)
for flavor in (posixpath, ntpath)
for value in ("a/../b", "./a", "a//b", "a\x00b", 3, "x" * 1025)
),
(ntpath, "C:/x"),
(ntpath, "c:"),
(ntpath, "a\\..\\b"),
):
with self.subTest(flavor=flavor.__name__, value=value):
with self.assertRaises(ValueError):
normalize_folder(value, flavor)
def test_the_default_flavor_is_the_platform_path_module(self):
self.assertEqual(normalize_folder("a\\b"), normalize_folder("a\\b", os.path))
def test_posix_prefixes_are_exact_and_end_in_a_separator(self):
self.assertEqual(
folder_prefixes(["/out", "/data/out/"], "a/b", posixpath),
["/out/a/b/", "/data/out/a/b/"],
)
self.assertEqual(folder_prefixes(["/"], "a", posixpath), ["/a/"])
self.assertEqual(
folder_prefixes(["/out", "/out/"], "a", posixpath), ["/out/a/"]
)
with self.assertRaises(ValueError):
folder_prefixes([], "a", posixpath)
def test_windows_prefixes_fold_case_including_non_ascii(self):
self.assertEqual(
folder_prefixes(["C:\\ComfyUI\\Output"], "Ä/Sub", ntpath),
["c:\\comfyui\\output\\ä\\sub\\"],
)
self.assertEqual(folder_prefixes(["D:\\"], "x", ntpath), ["d:\\x\\"])
self.assertEqual(
folder_prefixes(["\\\\Server\\Share\\Out"], "a", ntpath),
["\\\\server\\share\\out\\a\\"],
)
# The stored file_path is normcase(normpath(abspath)), the same fold.
stored = ntpath.normcase(ntpath.normpath("C:/ComfyUI/output/Ä/sub/x.png"))
self.assertTrue(
stored.startswith(
folder_prefixes(["C:/ComfyUI/Output"], "ä/SUB", ntpath)[0]
)
)
def test_relative_folder_keeps_the_stored_spelling(self):
self.assertEqual(
relative_folder("/out/2026/Aug/x.png", ["/out"], posixpath), "2026/Aug"
)
self.assertIsNone(relative_folder("/out/x.png", ["/out"], posixpath))
self.assertIsNone(relative_folder("/outside/x.png", ["/out"], posixpath))
self.assertIsNone(relative_folder("/output2/a/x.png", ["/out"], posixpath))
self.assertEqual(
relative_folder("/out/..foo/x.png", ["/out"], posixpath), "..foo"
)
self.assertEqual(
relative_folder("C:\\Out\\Ä\\Sub\\x.png", ["c:\\out"], ntpath), "Ä/Sub"
)
self.assertEqual(relative_folder("D:\\a\\x.png", ["D:\\"], ntpath), "a")
class TestLibraryFolderAndAnyTag(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
base = Path(os.path.realpath(self.temp.name))
self.root = base / "output"
self.root.mkdir()
self.db = PromptDatabase(str(base / "folders.db"))
def tearDown(self):
try:
self.db.close_all()
finally:
self.temp.cleanup()
def save(self, text, **metadata):
return self.db.save_prompt(
text, prompt_hash=generate_prompt_hash(text), **metadata
)
def link(self, prompt_id, *parts):
path = self.root.joinpath(*parts)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"x")
self.db.link_image_to_prompt(prompt_id, str(path))
def texts(self, **options):
return sorted(item["text"] for item in self.db.list_library(**options)["items"])
def in_folder(self, folder, roots=None):
return self.texts(
folder_prefixes=folder_prefixes(roots or [str(self.root)], folder)
)
def test_folder_matches_its_descendants_only(self):
self.link(self.save("deep"), "a", "b", "x.png")
self.link(self.save("top"), "a", "y.png")
self.link(self.save("sibling"), "ab", "z.png")
self.link(self.save("literal"), "100%_off", "w.png")
self.save("no images")
self.assertEqual(self.in_folder("a"), ["deep", "top"])
self.assertEqual(self.in_folder("a/b"), ["deep"])
self.assertEqual(self.in_folder("ab"), ["sibling"])
self.assertEqual(self.in_folder("100%_off"), ["literal"])
self.assertEqual(self.in_folder("1"), [])
cut = self.db.list_library(
folder_prefixes=folder_prefixes([str(self.root)], "a"), limit=1
)
self.assertEqual(cut["total"], 2)
def test_folder_combines_with_text_category_and_tags(self):
self.link(self.save("red cat", category="Art", tags=["cat"]), "a", "1.png")
self.link(self.save("red dog", category="Art", tags=["dog"]), "a", "2.png")
self.link(self.save("red bird", category="Art", tags=["cat"]), "b", "3.png")
prefixes = folder_prefixes([str(self.root)], "a")
self.assertEqual(
self.texts(folder_prefixes=prefixes, tags=["cat"]), ["red cat"]
)
self.assertEqual(
self.texts(folder_prefixes=prefixes, text="dog", category="Art"),
["red dog"],
)
@unittest.skipIf(os.name == "nt", "symlinks need privileges on Windows")
def test_folder_listing_and_match_through_a_symlinked_root(self):
link = self.root.parent / "linked"
os.symlink(self.root, link)
prompt = self.save("via link")
(self.root / "a").mkdir()
(self.root / "a" / "x.png").write_bytes(b"x")
self.db.link_image_to_prompt(prompt, str(link / "a" / "x.png"))
spellings = [str(self.root), str(link)]
self.assertEqual(self.in_folder("a", spellings), ["via link"])
self.assertEqual(self.in_folder("a", [str(self.root)]), [])
self.assertEqual(self.db.get_library_options(spellings)["folders"], ["a"])
@unittest.skipIf(os.name == "nt", "a backslash is a separator on Windows")
def test_a_posix_folder_with_a_backslash_lists_and_filters_alike(self):
name = "Metadata\\Klein_4b Wildcard Run"
self.link(self.save("klein"), name, "x.png")
self.link(self.save("other"), "Metadata", "y.png")
listed = self.db.get_library_options([str(self.root)])["folders"]
self.assertEqual(listed, ["Metadata", name])
self.assertEqual(self.in_folder(normalize_folder(name)), ["klein"])
self.assertEqual(self.in_folder(normalize_folder("Metadata")), ["other"])
def test_folder_listing_offers_ancestors_and_skips_outside_images(self):
self.link(self.save("deep"), "2026", "08-Aug", "x.png")
self.link(self.save("root image"), "y.png")
outside = Path(self.temp.name) / "elsewhere" / "z.png"
outside.parent.mkdir()
outside.write_bytes(b"x")
self.db.link_image_to_prompt(self.save("outside"), str(outside))
options = self.db.get_library_options([str(self.root)])
self.assertEqual(options["folders"], ["2026", "2026/08-Aug"])
self.assertNotIn("folders", self.db.get_library_options())
def test_folder_combines_with_untagged(self):
self.link(self.save("plain in a"), "a", "1.png")
self.link(self.save("tagged in a", tags=["t"]), "a", "2.png")
self.link(self.save("plain in b"), "b", "3.png")
prefixes = folder_prefixes([str(self.root)], "a")
self.assertEqual(
self.texts(folder_prefixes=prefixes, tag_mode="untagged"), ["plain in a"]
)
def test_any_tag_mode_is_the_union_across_parent_and_pair(self):
self.save("parent a", tags=["a"])
self.save("parent b", tags=["b"])
self.save("both", tags=["a", "b"])
self.save("neither", tags=["c"])
self.db.save_prompt_pair("pair b", "noise", tags=["b"])
split = self.save("split", tags=["a"])
self.db.save_prompt_pair("split", "split noise", tags=["b"])
archived = self.db.save_prompt_pair("archived b", "old", tags=["b"])
self.db.archive_prompt_pair(archived)
self.assertIsInstance(split, int)
self.assertEqual(
self.texts(tags=["a", "b"], tag_mode="any"),
["both", "pair b", "parent a", "parent b", "split"],
)
self.assertEqual(self.texts(tags=["a", "b"], tag_mode="all"), ["both"])
self.assertEqual(self.texts(tags=["a", "b"]), ["both"])
for tags in (["a"], ["b"], ["c"]):
self.assertEqual(
self.texts(tags=tags, tag_mode="any"), self.texts(tags=tags)
)
self.assertEqual(
self.texts(tags=["a", "b"], tag_mode="any", text="parent"),
["parent a", "parent b"],
)
self.assertEqual(self.texts(tag_mode="any"), self.texts())
for mode in ("none", None, "ANY"):
with self.subTest(mode=mode), self.assertRaises(ValueError):
self.db.list_library(tag_mode=mode)
if __name__ == "__main__":
unittest.main()
+3 -20
View File
@@ -772,13 +772,6 @@ class TestMonitorHistoryRecheck(RecordedRuns, unittest.TestCase):
run_attribution.notify_outputs_sealed("run", []) # no dead callback raises
def folder_is_case_sensitive(folder):
"""True when ``folder``'s volume tells names apart by case: the existing
folder spelled with swapped case is not found (#476)."""
head, name = os.path.split(folder)
return not os.path.exists(os.path.join(head, name.swapcase()))
class TestMonitorForeignRuns(RecordedRuns, unittest.TestCase):
"""#247: outputs of workflows without a PromptManager node, in the monitor."""
@@ -949,21 +942,11 @@ class TestMonitorForeignRuns(RecordedRuns, unittest.TestCase):
self.assertEqual(self.handler.awaiting_foreign(), [])
def test_the_sealed_reference_spells_the_file_as_history_recorded_it(self):
"""Windows: the watcher's spelling differs in case from the run's report.
On a case-sensitive folder the two spellings are two files and the
folded key joins them; on a case-insensitive one (Windows, default
macOS) they already name one file, so only history's spelling and
the watcher's differ (#476)."""
"""Windows: the watcher's spelling differs in case from the run's report."""
Path(self.output, "Dataset").mkdir()
recorded = self.image("IMG_GEN_00007_.png", subfolder="Dataset")
seen = os.path.join(self.output, "dataset", "img_gen_00007_.png")
Path(seen).write_bytes(b"png")
recorded = os.path.join(self.output, "Dataset", "IMG_GEN_00007_.png")
self.assertNotEqual(os.path.basename(recorded), os.path.basename(seen))
if folder_is_case_sensitive(os.path.join(self.output, "dataset")):
Path(self.output, "Dataset").mkdir()
self.image("IMG_GEN_00007_.png", subfolder="Dataset")
else:
self.assertTrue(os.path.exists(recorded))
folded = staticmethod(lambda path: os.path.realpath(path).lower())
with mock.patch.object(ImageGenerationHandler, "_awaiting_key", folded):
self.handler.process_new_image(seen)
+11 -23
View File
@@ -48,7 +48,6 @@ LIBRARY_STATE = {
"sort": "rating",
"tags": [],
"tag_mode": "untagged",
"folder": "2026/Aug",
}
IMAGES_DEFAULTS = {
"q": "",
@@ -67,7 +66,6 @@ LIBRARY_DEFAULTS = {
"sort": "newest",
"tags": [],
"tag_mode": "all",
"folder": "",
}
# A row saved by an older build: no favorites key, a retired key, a sort
# that is no longer offered and an over-long folder.
@@ -244,15 +242,14 @@ class TestSavedViewsApi(ImageAPITestCase):
for sort in ("newest", "oldest", "name", "rating"):
await self.create(
page="library",
state={"sort": sort, "tags": ["a b"], "tag_mode": "all", "folder": ""},
state={"sort": sort, "tags": ["a b"], "tag_mode": "all"},
)
for mode in ("untagged", "any"):
view = await self.create(
page="library", state={"tags": [], "tag_mode": mode}
)
self.assertEqual(view["state"]["tag_mode"], mode)
untagged = await self.create(
page="library", state={"tags": [], "tag_mode": "untagged"}
)
self.assertEqual(untagged["state"]["tag_mode"], "untagged")
_, listed = await self.send("GET")
self.assertEqual(len(listed["views"]), 22)
self.assertEqual(len(listed["views"]), 21)
async def test_write_requires_the_collection_key(self):
# #331 D140: a view saved on a collection page keeps the collection id.
@@ -697,24 +694,13 @@ class TestSavedViewsSchema(unittest.TestCase):
)
self.assertEqual(normalize_state("history", {}), {})
def test_library_tag_mode_and_folder_are_kept_with_defaults_for_old_rows(self):
# A row from before #444 has neither key.
def test_library_tag_mode_is_kept_and_never_paired_with_tags(self):
# A row from before #444 has no tag_mode: it opens matching every tag.
old = {"q": "", "category": "", "sort": "rating", "tags": ["a"]}
self.assertEqual(normalize_state("library", old), {**LIBRARY_DEFAULTS, **old})
self.assertEqual(
normalize_state("library", {"tag_mode": "or", "folder": 3}),
LIBRARY_DEFAULTS,
normalize_state("library", {"tag_mode": "any"}), LIBRARY_DEFAULTS
)
for mode in ("all", "any", "untagged"):
self.assertEqual(
normalize_state("library", {"tag_mode": mode})["tag_mode"], mode
)
with self.assertRaises(SavedViewInvalid):
encode_state("library", {**LIBRARY_STATE, "tag_mode": "or"})
with self.assertRaises(SavedViewInvalid):
encode_state("library", {**LIBRARY_STATE, "folder": "x" * 1025})
def test_library_untagged_is_never_paired_with_tags(self):
# Never written by this build: the tags win, as the listing refuses both.
self.assertEqual(
normalize_state("library", {"tags": ["a"], "tag_mode": "untagged"}),
@@ -722,6 +708,8 @@ class TestSavedViewsSchema(unittest.TestCase):
)
with self.assertRaises(SavedViewInvalid):
encode_state("library", {**LIBRARY_STATE, "tags": ["a"]})
with self.assertRaises(SavedViewInvalid):
encode_state("library", {**LIBRARY_STATE, "tag_mode": "none"})
def test_validation_errors_are_saved_view_invalid(self):
for call in (
+3 -7
View File
@@ -212,15 +212,11 @@ test('every browser job sets PM_TEST_WORKERS and runs in the pinned image', () =
assert.match(mergeJob(), PINNED_IMAGE, 'the merge sees the same workspace paths as the shards');
});
// #473: two shards split by test count put the slow concept specs in shard 1, which reached the 18-minute step limit.
test('page workflows run each browser in three shards', () => {
test('page workflows run each browser in two shards', () => {
const workflows = allJobs.get('browser-workflows');
assert.deepEqual(matrixList(workflows, 'browser'), ['firefox', 'webkit']);
assert.deepEqual(matrixList(workflows, 'shard'), ['1', '2', '3']);
assert.match(workflows, /test:workflows -- --project \$\{\{ matrix\.browser \}\} --shard \$\{\{ matrix\.shard \}\}\/3/);
assert.match(workflows, /^ {4}name: page workflows \(\$\{\{ matrix\.browser \}\}, shard \$\{\{ matrix\.shard \}\}\/3\)$/m);
assert.match(workflows, /name: Page workflows in \$\{\{ matrix\.browser \}\}, shard \$\{\{ matrix\.shard \}\}\/3$/m);
assert.doesNotMatch(workflows, /\/2\b/, 'no step still names two shards');
assert.deepEqual(matrixList(workflows, 'shard'), ['1', '2']);
assert.match(workflows, /test:workflows -- --project \$\{\{ matrix\.browser \}\} --shard \$\{\{ matrix\.shard \}\}\/2/);
assert.match(workflows, /^ {6}fail-fast: false$/m);
assert.match(workflows, /name: browser-traces-\$\{\{ matrix\.browser \}\}-shard-\$\{\{ matrix\.shard \}\}/);
});
+1 -6
View File
@@ -548,12 +548,8 @@ def encode_history_inspection_response(
resolution: dict,
inspection: "InspectionRead | None",
library: dict,
image_tags: dict | None = None,
) -> bytes:
"""Use occurrence identity with the same current-file evidence projection.
*image_tags*, when given, adds the validated ``image_tags`` facet (#457).
"""
"""Use occurrence identity with the same current-file evidence projection."""
return _encode_inspection(
identity={
"history_version": 1,
@@ -565,5 +561,4 @@ def encode_history_inspection_response(
resolution=resolution,
inspection=inspection,
library=library,
image_tags=image_tags,
)
+4 -33
View File
@@ -12,22 +12,18 @@ function parameters(filters,limit) {
for(const tag of filters.tags || []) if(tag.trim()) query.append('tag',tag.trim());
const sort=filters.sort || 'newest';
if(!['newest','oldest','name'].includes(sort)) throw new TypeError('Invalid library sort');
// #444: how Tags match (every tag, any of them, or parents no tag's Browse returns, which never goes with tags) and
// the output folder below a root.
const tagMode=filters.tagMode ?? 'all',folder=filters.folder ?? '';
// #444: how Tags match; `untagged` lists parents no tag's Browse returns and never goes with tags.
const tagMode=filters.tagMode ?? 'all';
if(!LIBRARY_TAG_MODES.includes(tagMode) || (tagMode==='untagged' && query.has('tag'))) throw new TypeError('Invalid library tag mode');
if(typeof folder!=='string' || folder.length>1024) throw new TypeError('Invalid library folder');
if(tagMode!=='all') query.set('tag_mode',tagMode);
if(folder) query.set('folder',folder);
query.set('sort',sort);return query;
}
/** The Library's tag match values (#444); the first is the default, sent as no parameter. */
export const LIBRARY_TAG_MODES=Object.freeze(['all','any','untagged']);
export const LIBRARY_TAG_MODES=Object.freeze(['all','untagged']);
// `variant_total` is the filtered parents' active negative variants (the concept caption's "M negative variants").
function validatePage(page,cursor,query) {
// A tag match or folder the server did not echo was not applied (an older server): never show the whole shelf as filtered.
// A tag match the server did not echo was not applied (an older server): never show the whole shelf as filtered.
if(query?.has('tag_mode') && page?.tag_mode!==query.get('tag_mode')) throw new TypeError('Unconfirmed library tag mode');
if(query?.has('folder') && page?.folder!==query.get('folder')) throw new TypeError('Unconfirmed library folder');
if(!page || !Array.isArray(page.items) || page.items.some(item=>!parent(item)) || !Number.isSafeInteger(page.total) || page.total<0 || !Number.isSafeInteger(page.variant_total) || page.variant_total<0 || typeof page.has_more!=='boolean' || !(page.next_cursor===null || (typeof page.next_cursor==='string' && page.next_cursor.length)) || page.has_more!==(page.next_cursor!==null) || (page.has_more && cursor===page.next_cursor)) throw new TypeError('Invalid library page');
}
export function createLibraryStream({api,onChange=()=>{},restore}) {
@@ -125,28 +121,3 @@ export function libraryWindow(count,columns,top,height,focused=-1) {
if(focused>=0 && focused<count) keep.add(Math.floor(focused/columns));
return [...keep].sort((a,b)=>a-b).flatMap(row=>Array.from({length:columns},(_,column)=>row*columns+column).filter(index=>index<count));
}
const byFolderName=(a,b)=>{const x=a.toLowerCase(),y=b.toLowerCase();return x<y?-1:x>y?1:a<b?-1:a>b?1:0;};
/**
* Refine › Folder options (#444 D150): the concept's "All output folders" first, then the folders as a tree built by
* path segment, so each folder sits directly under its own parent (never under a longer sibling such as `kiko-v6`) and
* folders differing only in case stay distinct siblings. Siblings sort case-insensitively, then by exact spelling. A
* label is the last segment indented by depth (the legacy admin select). The chosen folder is kept even when the list
* lacks it, with its parents.
*/
export function libraryFolderOptions(folders,current='') {
const root=new Map();
for(const folder of current?[...folders,current]:folders) {
let level=root;
for(const segment of folder.split('/')) {if(!level.has(segment)) level.set(segment,new Map());level=level.get(segment);}
}
const options=[['','All output folders']];
(function walk(level,prefix,depth) {
for(const segment of [...level.keys()].sort(byFolderName)) {
const path=prefix?`${prefix}/${segment}`:segment;
options.push([path,`${'\u00a0\u00a0'.repeat(depth)}${segment}`]);
walk(level.get(segment),path,depth+1);
}
})(root,'',0);
return options;
}
+19 -28
View File
@@ -2,7 +2,7 @@ import {createScope,createButton} from '../shared/toolkit/core.js';
import {createPromptSender} from './prompt-sender.mjs';
import {mountPromptBlocks} from './prompt-inspector.mjs';
import {mountImages} from './images.mjs';
import {createLibraryStream,createLibraryDetail,libraryPromptRoles,LIBRARY_TAG_MODES,libraryFolderOptions} from './library-state.mjs';
import {createLibraryStream,createLibraryDetail,libraryPromptRoles,LIBRARY_TAG_MODES} from './library-state.mjs';
import {promptRoles} from './prompt-blocks.mjs';
import {createLibraryEditor,showWorkspaceToast} from './library-editor.mjs';
import {createLibraryEditorController} from './library-editor-state.mjs';
@@ -31,8 +31,8 @@ const metadataDrafts=new Map();
const archivedVisible=new Map();
// The concept's sorts first, then the Library's appended Oldest first (D14); D153 (#447) drops "Highest rated".
const LIBRARY_SORTS=Object.freeze([['newest','Recently added'],['name','Name A–Z'],['oldest','Oldest first']]);
// Refine › Tag match (#444 D149, D150), in LIBRARY_TAG_MODES order.
const TAG_MATCH_OPTIONS=Object.freeze([['all','All of these tags'],['any','Any of these tags'],['untagged','Prompts without tags']]);
// Refine › Tag match (#444 D149), in LIBRARY_TAG_MODES order.
const TAG_MATCH_OPTIONS=Object.freeze([['all','All of these tags'],['untagged','Prompts without tags']]);
// A card's focusable controls in order: the D148 selection checkbox, then its buttons (#437).
const CARD_CONTROLS='button, input[type="checkbox"]';
const CATEGORIES_FAILED='Categories could not be loaded. Open the Prompt library again to retry.';
@@ -85,9 +85,9 @@ export function onLibraryWrite(listener) {
* with the existing Library filters, and the heading takes focus as after a save.
*/
export function browseLibrary(filters) {
const {text='',category='',tags=[],sort='newest',tagMode='all',folder=''}=filters || {};
if(typeof text!=='string' || typeof category!=='string' || !Array.isArray(tags) || tags.some(tag=>typeof tag!=='string') || typeof sort!=='string' || !LIBRARY_TAG_MODES.includes(tagMode) || typeof folder!=='string') throw new TypeError('Invalid Library filters');
shelfVisit={filters:{text,category,tags:[...tags],sort,tagMode,folder},anchor:null};
const {text='',category='',tags=[],sort='newest',tagMode='all'}=filters || {};
if(typeof text!=='string' || typeof category!=='string' || !Array.isArray(tags) || tags.some(tag=>typeof tag!=='string') || typeof sort!=='string' || !LIBRARY_TAG_MODES.includes(tagMode)) throw new TypeError('Invalid Library filters');
shelfVisit={filters:{text,category,tags:[...tags],sort,tagMode},anchor:null};
focusHeadingOnMount=true;window.location.hash='#/library';
}
/** Remember the selection a reconciled save names, so the destination history opens on it once. */
@@ -160,16 +160,16 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
const visit=saved?null:shelfVisit, mountedGeneration=writeGeneration;
// An invalidated visit keeps only filters and anchor: the stream restarts and heights are measured again.
const restorable=visit?.state?visit:null;
const filters={text:'',category:'',tags:[],sort:'newest',tagMode:'all',folder:'',...visit?.filters,...saved};
const filters={text:'',category:'',tags:[],sort:'newest',tagMode:'all',...visit?.filters,...saved};
// D153: a sort the shelf no longer offers (an older "Highest rated" visit) reads as Recently added.
if(!LIBRARY_SORTS.some(([value])=>value===filters.sort)) filters.sort='newest';
const section=el('section',undefined,'library-shelf');section.setAttribute('aria-label','Prompt library');
// The shared concept toolbar (app.js toolbar(), a347–360): Tags live in "Refine this view" (D14), no Refresh (D15).
// Its own scope, disposed after the visit is saved: removing it first would shorten the page and clamp scrollY.
const toolbarScope=createScope();
// D44: Filters reads "Filters · N" while Refine filters (from Refine or a Browse) narrow the shelf: Tags, a Folder
// and a Tag match other than All (#444 D149, D150) each count once.
const refinements=()=>(filters.tags.length?1:0)+(filters.folder?1:0)+(filters.tagMode!=='all'?1:0);
// D44: Filters reads "Filters · N" while Refine filters (from Refine or a Browse) narrow the shelf: Tags, and a
// Tag match other than All (#444 D149).
const refinements=()=>(filters.tags.length?1:0)+(filters.tagMode!=='all'?1:0);
const toolbar=mountToolbar(section,{scope:toolbarScope,categories:filters.category?[filters.category]:[],sorts:LIBRARY_SORTS,
value:{q:filters.text,category:filters.category,sort:filters.sort},activeFilters:refinements(),onChange:applyToolbar,onFilters:openFilters,
onSaveView:trigger=>{const value=toolbar.value();onSaveView(trigger,libraryViewState({...filters,text:value.q.trim(),category:value.category,sort:value.sort}));}});
@@ -216,7 +216,7 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
// Nothing follows the grid in the concept; the legacy Library stays one click away on Overview (spec §5).
continuation.append(message,retry,more);section.append(optionsStatus,exportStatus,selection.element,caption,grid,continuation);main.append(section);
const cards=new Map(), rowNodes=new Map(), spacers=new Map(), heights=new Map(), pendingMeasurements=new Map();
let state,frame=0,loadFrame=0,observer,optionsRequest,folderRequest,focusOverride=-1;
let state,frame=0,loadFrame=0,observer,optionsRequest,focusOverride=-1;
const columns=()=>window.innerWidth>=1700?3:window.innerWidth<=700?1:2;
const readInlineSize=()=>Math.round(grid.getBoundingClientRect().width*64)/64;
let inlineSize=readInlineSize(), columnCount=columns(), layout={rows:[],height:0}, dirty=true, items, indexesById=new Map();
@@ -423,26 +423,17 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
if(Object.entries(next).every(([key,item])=>filters[key]===item)) return;
Object.assign(filters,next);reset();
}
/**
* "Refine this view" with the Library's filters: the concept's Folder (#444 D150, its options read when the dialog
* opens), Tags (D14) and Tag match (D149, D150); Clear also clears the toolbar category.
*/
/** "Refine this view" with the Library's extra filters, Tags (D14) and Tag match (D149); Clear also clears the toolbar category. */
function openFilters(trigger) {
const refine=openRefineDialog({scope,trigger,fields:[{key:'folder',options:libraryFolderOptions([],filters.folder)},{key:'tags'},
{key:'tagMode',options:TAG_MATCH_OPTIONS}],values:{folder:filters.folder,tags:filters.tags.join(', '),tagMode:filters.tagMode},
onApply:applyRefinement,onClear:cleared=>{toolbar.setValue({category:cleared.category});applyRefinement(cleared);}});
folderRequest?.abort();const request=folderRequest=new AbortController();
// A failed read leaves "All output folders" and the current folder; nothing else depends on it.
api.get('/prompt_manager/library/options?include=folders',{signal:request.signal}).then(data=>{
if(scope.signal.aborted || folderRequest!==request || !Array.isArray(data?.folders) || data.folders.some(folder=>typeof folder!=='string')) return;
refine.setOptions('folder',libraryFolderOptions(data.folders,filters.folder));
},()=>{});
openRefineDialog({scope,trigger,fields:[{key:'tags'},{key:'tagMode',options:TAG_MATCH_OPTIONS}],
values:{tags:filters.tags.join(', '),tagMode:filters.tagMode},onApply:applyRefinement,
onClear:cleared=>{toolbar.setValue({category:cleared.category});applyRefinement(cleared);}});
}
function applyRefinement({tags,tagMode='all',folder='',category=filters.category}) {
function applyRefinement({tags,tagMode='all',category=filters.category}) {
// "Prompts without tags" takes no Tags (the dialog disables them).
const next=tagMode==='untagged'?[]:[...new Set(tags.split(',').map(tag=>tag.trim()).filter(Boolean))];
if(category===filters.category && tagMode===filters.tagMode && folder===filters.folder && next.length===filters.tags.length && next.every((tag,index)=>tag===filters.tags[index])) return;
filters.category=category;filters.tags=next;filters.tagMode=tagMode;filters.folder=folder;reset();
if(category===filters.category && tagMode===filters.tagMode && next.length===filters.tags.length && next.every((tag,index)=>tag===filters.tags[index])) return;
filters.category=category;filters.tags=next;filters.tagMode=tagMode;reset();
}
scope.listen(more,'click',()=>stream.loadMore());
// Retry also re-reads a category list that failed (D15: Refresh is gone).
@@ -455,7 +446,7 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
// A write while mounted makes this stream stale: keep only what still means something.
// A saved view is not the plain shelf, so it never replaces the Library return state (#249 M2, as Favorites).
if(!saved) shelfVisit=mountedGeneration!==writeGeneration?{filters:savedFilters,anchor:savedAnchor}:state?{filters:savedFilters,state,scrollY:window.scrollY,layout:{inlineSize,columns:columnCount,heights:[...heights],anchor:savedAnchor}}:null;
toolbarScope.dispose();stream.dispose();optionsRequest?.abort();folderRequest?.abort();observer?.disconnect();measurementObserver?.disconnect();cancelAnimationFrame(frame);cancelAnimationFrame(loadFrame);for(const card of cards.values())removeCard(card);cards.clear();});
toolbarScope.dispose();stream.dispose();optionsRequest?.abort();observer?.disconnect();measurementObserver?.disconnect();cancelAnimationFrame(frame);cancelAnimationFrame(loadFrame);for(const card of cards.values())removeCard(card);cards.clear();});
loadCategories();if(restorable) {render(stream.state);if(restorable.state.loading || restorable.state.error)stream.loadMore();} else reset();
}
+4 -6
View File
@@ -47,9 +47,8 @@ const FIELDS = Object.freeze({
}),
library: Object.freeze({
q: text(500), category: text(100), sort: choice(['newest', 'oldest', 'name'], 'newest'), tags,
// #444: how the Tags match ('untagged' never goes with tags; the listing refuses both) and the output folder
// ('/'-joined, below a root).
tag_mode: choice(['all', 'any', 'untagged'], 'all'), folder: text(1024),
// #444: how the Tags match; 'untagged' never goes with tags (the listing refuses both).
tag_mode: choice(['all', 'untagged'], 'all'),
}),
});
@@ -118,12 +117,11 @@ export function imagesViewState(filters) {
/** Library shelf filters (library.mjs `filters`) from a saved state, and the state of the current filters. */
export function libraryFilters(state) {
const view = normalizeViewState('library', state);
return { text: view.q, category: view.category, sort: view.sort, tags: view.tags, tagMode: view.tag_mode, folder: view.folder };
return { text: view.q, category: view.category, sort: view.sort, tags: view.tags, tagMode: view.tag_mode };
}
export function libraryViewState(filters) {
return normalizeViewState('library', { q: filters.text, category: filters.category, sort: filters.sort, tags: filters.tags,
// A filter the shelf lacks (undefined) takes its key's default in normalizeViewState.
tag_mode: filters.tagMode, folder: filters.folder });
tag_mode: filters.tagMode ?? 'all' });
}
/**
+1 -1
View File
@@ -24,7 +24,7 @@
import { createButton, createSwitch } from '../shared/toolkit/core.js';
import { noun } from './plural.mjs';
// `tagMode` (#444, D149, D150): the Library's Tag match select after Tags; its `untagged` value disables Tags.
// `tagMode` (#444, D149): the Library's Tag match select after Tags; its `untagged` value disables Tags.
const FIELD_LABELS = Object.freeze({ source: 'Source', folder: 'Folder', media: 'Media', tags: 'Tags', tagMode: 'Tag match' });
const FIELD_ORDER = Object.keys(FIELD_LABELS);