Compare commits

..
Author SHA1 Message Date
Vito Sansevero 48ced31f69 fix(workspace): rescan refreshed images and expose ancestor folders 2026-10-17 11:12:57 -07:00
Vito Sansevero b3189f558d test(workspace): assert empty collections through their status 2026-10-17 11:09:42 -07:00
Vito Sansevero e9021362c6 feat(workspace): add native image browsing and continuous loading 2026-10-17 11:07:03 -07:00
Vito d4bb5c5ba9 feat(ui): add the tested opt-in Prompt Manager workspace shell (#198)
* docs(ui): prototype unified prompt management application

* docs(ui): add masonry and per-image tagging to application concept

* docs(ui): make gallery concept use continuous infinite scrolling

* feat(ui): introduce the tested application workspace shell

* fix(docs): preserve concept controls across cached navigation

* test(ci): emit Python tracebacks for native test crashes

* test(ui): cover workspace restoration across cached navigation
2026-10-17 10:36:30 -07:00
Vito caa9649f32 feat: reuse positive and negative prompt combinations (#195)
* feat(db): add pair schema and stage restore migrations for #193

* feat(database): persist reusable ordered prompt pairs (#193)

* feat(api): expose reusable pairs and atomic metadata enrichment (#193)

* feat(metadata): resolve canonical prompt pairs with bounded provenance (#193)

* feat(scan): enrich image pairs without guessing or duplicate links (#193)

* fix(database): preserve pair tags during global maintenance (#193)

* feat(tracking): enrich live image pairs and cancel scans safely (#193)

* feat(nodes): encode paired prompts and count cached recipe runs (#193)

* fix(database): validate restored pair ownership and provenance (#193)

* feat(admin): report pair scan outcomes and cancellation (#193)

* feat(ui): add paired prompt editor and explicit recipe library for #193

* fix(autocomplete): include reusable pair metadata vocabulary (#193)

* fix(pairs): honor recent sorting and conservative scan outcomes (#193)

* fix(ui): protect paired draft saves and clarify negative variants (#193)

* fix(metadata): preserve bounded paired execution evidence (#193)

* fix(tracking): follow actual pair output roles and prefer execution evidence (#193)

* docs(pairs): document live validation and retain renderer screenshots (#193)

* test(api): inject disposable databases before API startup (#194)

* test(api): canonicalize portable temporary gallery roots (#194)

* docs(ui): prototype branching negative variant history (#193)

* docs(ui): show selected negative beneath positive in timeline concept (#193)

* docs: retain approved timeline for later gallery redesign (#193)

* test(db): close restore fixture connections before cleanup (#194)

* test(db): reuse existing handle guards in all pair fixtures (#194)

* chore: clarify rollback test and intentional exception handling (#193)
2026-10-17 08:00:42 -07:00
Vito 3870590704 feat(ui): add Split Prompt Manager workspace (#192)
* feat(ui): add Split Prompt Manager workspace (#186)

* test(ui): use consistent unittest imports (#186)
2026-10-17 06:47:46 -07:00
Vito a52ff4e052 feat(ui): add Focused Prompt Manager with shared autocomplete (#191)
* feat(ui): add Focused Prompt Manager node (#186)

* feat(ui): autocomplete saved Focused metadata with shared controls (#186)

* feat(ui): autocomplete Focused library filters (#186)
2026-10-17 05:31:24 -07:00
Vito 035e816db6 feat(ui): adopt shared toolkit for Prompt Manager Classic (#190)
* feat(ui): adopt shared toolkit for Prompt Manager Classic (#186)

* test(ui): retain Text node Web UI coverage alongside Classic

* fix(tooling): reject lookalike toolkit download URLs (#186)
2026-10-16 22:18:18 -07:00
157 changed files with 14443 additions and 218 deletions
+3
View File
@@ -9,6 +9,9 @@ on:
push:
branches: [main]
env:
PYTHONFAULTHANDLER: "1"
jobs:
test:
name: py${{ matrix.python-version }} on ${{ matrix.os }}
+18
View File
@@ -150,6 +150,24 @@ node identifier and sockets. The reusable architecture is inspired by
[Pixaroma](https://github.com/pixaroma/ComfyUI-Pixaroma); no implementation or assets
were copied. See the [toolkit maintenance notes](tools/vendor/README.md#shared-comfyassets-browser-controls).
**Prompt Manager (Focused)** adds an editor-first layout with collapsible
composition and metadata fields. Open **Browse library** to search saved prompts,
filter by saved category and comma-separated tags, preview a selection, then choose
**Use prompt**; **Load metadata** controls whether
category and tags come with it. Metadata fields suggest saved categories and tags;
choose with the mouse or arrow keys and Enter. Tag selections append `, ` for the
next tag; category remains a single value. New values can still be typed freely.
Prepend and append STRING sockets remain visible
when the fields are collapsed, so LoRA trigger outputs can connect directly.
Encoding, storage, and CONDITIONING/text outputs match Classic. Both layouts
support ComfyUI Classic and Nodes 2.0.
**Prompt Manager (Split)** keeps the same editor and library visible together.
Wide nodes place the library beside the editor; narrow nodes stack the panels.
It shares Focused's category/tag autocomplete, search filters, preview-before-use,
workflow persistence, and connectable prepend/append inputs. Encoding and storage
match Classic and Focused.
### 🔬 ComfyUI Workflow Analysis
- **PNG Metadata Parsing**: Advanced PNG chunk analysis to extract embedded ComfyUI data
+27
View File
@@ -128,3 +128,30 @@ gh api repos/ComfyAssets/ComfyUI_PromptManager/branches/main/protection/required
Update the manifest whenever required job names change. Keep the frontend check
free of path filters so a required check is never silently skipped.
## Unified workspace
The opt-in `/prompt_manager/workspace` page opens the native Images workspace.
Overview retains real `/prompt_manager/stats` counts. Both use the shipped toolkit. Existing admin, gallery and metadata URLs remain
available while replacement workspaces are developed. Browser smoke tests in
`tests/browser/workspace.spec.mjs` cover navigation/history, keyboard skip links,
mobile layout, theme persistence/fallback, loading/retry and stale responses.
The Images cases in `tests/browser/workspace-images.spec.mjs` additionally cover
server-side filters, cursor append/retry, Grid/List/Masonry, bounded DOM, keyboard
navigation, restored browsing context, Viewer.js cleanup and actual video playback.
`tests/test_api_workspace_images.py` checks signed cursors, source identities,
legacy compatibility and header-only dimensions. Frontend stream/layout units
cover cancellation, malformed responses and bounded visibility lookup. These run
automatically in the existing four-browser-project workflow and frontend coverage job. `tests/comfyui/workspace.spec.mjs` verifies the page against the
managed host with both renderer startup configurations.
Run the focused page checks with:
```sh
npm run --prefix tools/vendor test:workflows -- workspace.spec.mjs workspace-images.spec.mjs
node --test tests/js/workspace-*.test.js
```
Use the disposable fixture and managed-host prerequisites above. A maintainer
live check needs a ComfyUI restart after adding the Python route, then opening
`/prompt_manager/workspace`; the mockup preview server is a separate design tool.
+16 -3
View File
@@ -3,12 +3,14 @@ ComfyUI_PromptManager: A comprehensive ComfyUI custom node that extends text enc
with persistent prompt storage, advanced search capabilities, and an automatic image
gallery system.
This module provides two main node types:
This module provides prompt node types:
- PromptManager: CLIP encoding node that outputs CONDITIONING
- PromptManagerFocused: Editor-first layout using the same encoding contract
- PromptManagerSplit: Side-by-side editor and library using the same contract
- PromptManagerText: Text-only node that outputs STRING with prepend/append
functionality
Both nodes share the same database backend for persistent prompt storage and include
The nodes share the same database backend for persistent prompt storage and include
an automatic image gallery system that monitors ComfyUI output directories and links
generated images to their source prompts.
@@ -38,18 +40,29 @@ def get_version():
return "unknown"
from .prompt_manager import PromptManager # noqa: E402
from .prompt_manager import ( # noqa: E402
PromptManager,
PromptManagerFocused,
PromptManagerSplit,
)
from .prompt_manager_pair import PromptManagerPair # noqa: E402
from .prompt_manager_text import PromptManagerText # noqa: E402
from .prompt_search_list import PromptSearchList # noqa: E402
NODE_CLASS_MAPPINGS = {
"PromptManager": PromptManager,
"PromptManagerFocused": PromptManagerFocused,
"PromptManagerSplit": PromptManagerSplit,
"PromptManagerPair": PromptManagerPair,
"PromptManagerText": PromptManagerText,
"PromptSearchList": PromptSearchList,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"PromptManager": "Prompt Manager (Classic)",
"PromptManagerFocused": "Prompt Manager (Focused)",
"PromptManagerSplit": "Prompt Manager (Split)",
"PromptManagerPair": "Prompt Manager (+/-)",
"PromptManagerText": "Prompt Manager Text",
"PromptSearchList": "Prompt Search List",
}
+310 -22
View File
@@ -4,14 +4,17 @@ Database schema and models for KikoTextEncode prompt storage.
import contextlib
import datetime
import json
import math
import shutil
import sqlite3
import os
import threading
import tempfile
import weakref
from contextlib import closing
from pathlib import Path
from typing import Set, Tuple
from typing import Optional, Set, Tuple
# Import logging system
try:
@@ -29,6 +32,41 @@ except ImportError:
ISO_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%f+00:00"
MAX_PAIR_PROVENANCE_LENGTH = 4_000_000
MAX_PAIR_PROVENANCE_DEPTH = 64
def pair_provenance_error(encoded: object) -> Optional[str]:
"""Validate the same bounded JSON object for live writes and restores."""
if not isinstance(encoded, str):
return "Pair provenance must be JSON text"
if len(encoded) > MAX_PAIR_PROVENANCE_LENGTH:
return "Pair provenance exceeds the size limit"
try:
value = json.loads(encoded)
except (ValueError, TypeError, RecursionError):
return "Pair provenance is not readable JSON"
if not isinstance(value, dict):
return "Pair provenance must be a JSON object"
pending = [(iter((value,)), 0)]
while pending:
children, depth = pending[-1]
try:
item = next(children)
except StopIteration:
pending.pop()
continue
if depth > MAX_PAIR_PROVENANCE_DEPTH:
return "Pair provenance exceeds the nesting limit"
if isinstance(item, float) and not math.isfinite(item):
return "Pair provenance contains nonfinite numbers"
if isinstance(item, dict):
pending.append((iter(item.values()), depth + 1))
elif isinstance(item, list):
pending.append((iter(item), depth + 1))
return None
def utc_now_iso() -> str:
"""Current UTC time as ISO text with microseconds.
@@ -235,6 +273,224 @@ class PromptModel:
# Image uniqueness by full path instead of basename (3.2.4)
self._migrate_image_uniqueness_by_path(conn)
self._migrate_prompt_pairs(conn)
def _migrate_prompt_pairs(self, conn: sqlite3.Connection) -> None:
conn.execute("SAVEPOINT prompt_pair_schema")
try:
conn.execute("""
CREATE TABLE IF NOT EXISTS prompt_pairs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
positive_prompt_id INTEGER NOT NULL,
negative_prompt_id INTEGER,
title TEXT,
category TEXT,
rating INTEGER CHECK(rating BETWEEN 1 AND 5),
notes TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_used_at TEXT,
run_count INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0 CHECK(archived IN (0, 1)),
FOREIGN KEY(positive_prompt_id)
REFERENCES prompts(id) ON DELETE RESTRICT,
FOREIGN KEY(negative_prompt_id)
REFERENCES prompts(id) ON DELETE RESTRICT,
UNIQUE(positive_prompt_id, negative_prompt_id)
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS prompt_pair_tags (
pair_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
PRIMARY KEY(pair_id, tag_id),
FOREIGN KEY(pair_id) REFERENCES prompt_pairs(id) ON DELETE CASCADE,
FOREIGN KEY(tag_id) REFERENCES tags(id) ON DELETE CASCADE
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS image_prompt_pairs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
image_id INTEGER NOT NULL,
pair_id INTEGER NOT NULL,
provenance TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(image_id)
REFERENCES generated_images(id) ON DELETE CASCADE,
FOREIGN KEY(pair_id) REFERENCES prompt_pairs(id) ON DELETE RESTRICT,
UNIQUE(pair_id, image_id, provenance)
)
""")
self._create_pair_indexes(conn)
error = self._pair_schema_error(conn)
if error:
raise sqlite3.DatabaseError(error)
conn.execute("RELEASE prompt_pair_schema")
except BaseException:
conn.execute("ROLLBACK TO prompt_pair_schema")
conn.execute("RELEASE prompt_pair_schema")
raise
@staticmethod
def _create_pair_indexes(conn: sqlite3.Connection) -> None:
conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_prompt_pairs_positive_only "
"ON prompt_pairs(positive_prompt_id) WHERE negative_prompt_id IS NULL"
)
for name, target in (
("idx_prompt_pairs_negative", "prompt_pairs(negative_prompt_id)"),
("idx_prompt_pair_tags_tag", "prompt_pair_tags(tag_id)"),
("idx_image_prompt_pairs_image", "image_prompt_pairs(image_id)"),
):
conn.execute(f"CREATE INDEX IF NOT EXISTS {name} ON {target}")
@staticmethod
def _pair_schema_error(conn: sqlite3.Connection) -> Optional[str]:
required = {
"prompt_pairs": {
"id",
"positive_prompt_id",
"negative_prompt_id",
"title",
"category",
"rating",
"notes",
"created_at",
"updated_at",
"last_used_at",
"run_count",
"archived",
},
"prompt_pair_tags": {"pair_id", "tag_id"},
"image_prompt_pairs": {
"id",
"image_id",
"pair_id",
"provenance",
"created_at",
},
}
present = {
row[0]
for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
}
if not present.intersection(required):
return None
if not set(required).issubset(present):
return "Incomplete prompt pair schema"
foreign_keys = {
"prompt_pairs": {
("positive_prompt_id", "prompts", "id", "RESTRICT"),
("negative_prompt_id", "prompts", "id", "RESTRICT"),
},
"prompt_pair_tags": {
("pair_id", "prompt_pairs", "id", "CASCADE"),
("tag_id", "tags", "id", "CASCADE"),
},
"image_prompt_pairs": {
("pair_id", "prompt_pairs", "id", "RESTRICT"),
("image_id", "generated_images", "id", "CASCADE"),
},
}
unique = {
"prompt_pairs": ("positive_prompt_id", "negative_prompt_id"),
"prompt_pair_tags": ("pair_id", "tag_id"),
"image_prompt_pairs": ("pair_id", "image_id", "provenance"),
}
non_null = {
"prompt_pairs": {
"positive_prompt_id",
"created_at",
"updated_at",
"run_count",
"archived",
},
"prompt_pair_tags": {"pair_id", "tag_id"},
"image_prompt_pairs": {"pair_id", "image_id", "provenance", "created_at"},
}
integer_columns = {
"id",
"positive_prompt_id",
"negative_prompt_id",
"pair_id",
"tag_id",
"image_id",
"rating",
"run_count",
"archived",
}
for table, columns in required.items():
info = {row[1]: row for row in conn.execute(f"PRAGMA table_info({table})")}
if not columns.issubset(info):
return f"{table} is missing required pair columns"
if any(not info[name][3] for name in non_null[table]):
return f"{table} has nullable required pair columns"
if any(
info[name][2].upper()
!= ("INTEGER" if name in integer_columns else "TEXT")
for name in columns
):
return f"{table} has invalid pair column types"
primary = tuple(
name
for name in sorted(info, key=lambda name: info[name][5])
if info[name][5]
)
if primary != (
("pair_id", "tag_id") if table == "prompt_pair_tags" else ("id",)
):
return f"{table} has an invalid pair primary key"
actual_fks = {
(row[3], row[2], row[4], row[6])
for row in conn.execute(f"PRAGMA foreign_key_list({table})")
}
if actual_fks != foreign_keys[table]:
return f"{table} has invalid pair foreign key definitions"
if conn.execute(f"PRAGMA foreign_key_check({table})").fetchone():
return f"{table} contains foreign key violations"
indexes = list(conn.execute(f"PRAGMA index_list({table})"))
index_columns = {}
for row in indexes:
name = row[1].replace('"', '""')
index_columns[row[1]] = tuple(
item[2] for item in conn.execute(f'PRAGMA index_info("{name}")')
)
if not any(
row[2] and not row[4] and index_columns[row[1]] == unique[table]
for row in indexes
):
return f"{table} is missing pair uniqueness constraints"
if table == "prompt_pairs":
null_unique = False
for row in indexes:
if (
row[2]
and row[4]
and index_columns[row[1]] == ("positive_prompt_id",)
):
sql = conn.execute(
"SELECT sql FROM sqlite_master WHERE type='index' AND name"
"=?",
(row[1],),
).fetchone()[0]
if " ".join(sql.lower().split()).endswith(
"where negative_prompt_id is null"
):
null_unique = True
if not null_unique:
return "prompt_pairs is missing empty-negative pair uniqueness"
if conn.execute(
"SELECT 1 FROM image_prompt_pairs ip "
"JOIN prompt_pairs pp ON pp.id=ip.pair_id "
"JOIN generated_images gi ON gi.id=ip.image_id "
"WHERE pp.positive_prompt_id IS NOT gi.prompt_id LIMIT 1"
).fetchone():
return "Image pair association has a different positive prompt owner"
for row in conn.execute("SELECT provenance FROM image_prompt_pairs"):
error = pair_provenance_error(row[0])
if error:
return error
return None
def _create_indexes(self, conn: sqlite3.Connection) -> None:
"""
@@ -772,6 +1028,15 @@ class PromptModel:
False,
"prompts table is missing required columns: " + ", ".join(missing),
)
forbidden = conn.execute(
"SELECT type FROM sqlite_master WHERE type IN ('trigger', 'view') "
"LIMIT 1"
).fetchone()
if forbidden:
return False, f"Database contains an unsupported {forbidden[0]}"
pair_error = PromptModel._pair_schema_error(conn)
if pair_error:
return False, pair_error
return True, "ok"
except sqlite3.Error as e:
return False, f"Invalid SQLite database: {e}"
@@ -814,13 +1079,11 @@ class PromptModel:
"""
Replace the live database with the file at src_path.
The current database is first backed up next to itself as
``<db_path>.backup_<YYYYmmdd_HHMMSS>`` (WAL content included), the
pages of ``src_path`` are copied into the live database through
SQLite's backup API (visible to every open connection) and the schema
migrations run on the restored content.
Callers should run verify_database_file(src_path) first.
A disposable SQLite backup of the source is migrated and verified first.
Only then is the current database backed up next to itself as
``<db_path>.backup_<YYYYmmdd_HHMMSS>`` (WAL content included), and the
staged pages replace the live pages through SQLite's backup API.
Migration failures never replace live content or modify the source.
Args:
src_path: SQLite file to restore
@@ -833,17 +1096,42 @@ class PromptModel:
RuntimeError: If the previous database could not be backed up
OSError: If the restored file could not be copied into place
"""
backup_path = ""
if os.path.exists(self.db_path):
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
backup_path = f"{self.db_path}.backup_{timestamp}"
if not self.backup_database(backup_path):
raise RuntimeError("Could not back up the current database")
self.logger.info(f"Current database backed up to: {backup_path}")
self._replace_contents_from(src_path)
with PromptModel._init_lock:
PromptModel._initialized_paths.discard(self._schema_key())
self._ensure_database_exists()
self.logger.info(f"Database restored from {src_path}")
return backup_path
ok, reason = self.verify_database_file(src_path)
if not ok:
raise ValueError(reason)
with tempfile.TemporaryDirectory(prefix="prompt-manager-restore-") as directory:
staged_path = str(Path(directory) / "staged.db")
source = sqlite3.connect(
Path(src_path).resolve().as_uri() + "?mode=ro", uri=True
)
try:
with closing(sqlite3.connect(staged_path)) as destination:
source.backup(destination)
finally:
source.close()
staged = None
try:
staged = PromptModel(staged_path)
ok, reason = self.verify_database_file(staged_path)
if not ok:
raise ValueError(reason)
staged.close_all()
backup_path = ""
if os.path.exists(self.db_path):
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
backup_path = f"{self.db_path}.backup_{timestamp}"
if not self.backup_database(backup_path):
raise RuntimeError("Could not back up the current database")
self.logger.info(f"Current database backed up to: {backup_path}")
self._replace_contents_from(staged_path)
with PromptModel._init_lock:
PromptModel._initialized_paths.add(self._schema_key())
self.logger.info(f"Database restored from {src_path}")
return backup_path
finally:
if staged is not None:
staged.close_all()
with PromptModel._init_lock:
PromptModel._initialized_paths.discard(
os.path.normcase(os.path.realpath(staged_path))
)
+130 -35
View File
@@ -9,6 +9,7 @@ import os
from typing import Optional, List, Dict, Any
from .models import PromptModel, normalize_image_path, utc_now_iso
from .pairs import PairDependencyError, PairOperations
# Import logging system
try:
@@ -146,7 +147,7 @@ def _coerce_file_info(metadata: Any) -> tuple:
)
class PromptDatabase:
class PromptDatabase(PairOperations):
"""Database operations class for managing prompts."""
def __init__(self, db_path: Optional[str] = None):
@@ -581,7 +582,8 @@ class PromptDatabase:
Returns:
bool: True if deletion was successful, False otherwise
"""
with self.model.get_connection() as conn:
with self._pair_transaction() as conn:
self._ensure_prompts_unpaired(conn, [prompt_id])
# First delete related images to avoid foreign key constraint
conn.execute(
"DELETE FROM generated_images WHERE prompt_id = ?", (prompt_id,)
@@ -597,7 +599,7 @@ class PromptDatabase:
Returns:
Number of prompts deleted.
"""
with self.model.get_connection() as conn:
with self._pair_transaction() as conn:
# Get IDs first for cascade cleanup
ids = [
r[0]
@@ -607,6 +609,7 @@ class PromptDatabase:
]
if not ids:
return 0
self._ensure_prompts_unpaired(conn, ids)
placeholders = ",".join("?" * len(ids))
conn.execute(
f"DELETE FROM generated_images WHERE prompt_id IN ({placeholders})",
@@ -632,7 +635,9 @@ class PromptDatabase:
"""
with self.model.get_connection() as conn:
cursor = conn.execute(
"SELECT DISTINCT TRIM(category) as category FROM prompts WHERE "
"SELECT TRIM(category) AS category FROM prompts WHERE "
"category IS NOT NULL AND TRIM(category) != '' UNION "
"SELECT TRIM(category) AS category FROM prompt_pairs WHERE "
"category IS NOT NULL AND TRIM(category) != '' ORDER BY category"
)
return [row["category"] for row in cursor.fetchall()]
@@ -699,16 +704,16 @@ class PromptDatabase:
def get_all_tags(self) -> List[str]:
"""
Get all unique tags that are in use (linked to at least one prompt).
Get all unique tags linked to a standalone prompt or reusable pair.
Returns:
Sorted list of tag names
"""
with self.model.get_connection() as conn:
cursor = conn.execute(
"SELECT DISTINCT t.name AS tag "
"FROM prompt_tags pt "
"JOIN tags t ON pt.tag_id = t.id "
"SELECT t.name AS tag FROM tags t WHERE "
"EXISTS(SELECT 1 FROM prompt_tags pt WHERE pt.tag_id=t.id) OR "
"EXISTS(SELECT 1 FROM prompt_pair_tags pp WHERE pp.tag_id=t.id) "
"ORDER BY t.name"
)
return [row["tag"] for row in cursor.fetchall()]
@@ -784,7 +789,8 @@ class PromptDatabase:
Returns tags whose name starts with ``prefix`` (case-insensitive),
counted over the prompts that carry every tag in ``with_tags``; the
context tags themselves are left out. Ordered by count, then name.
context tags themselves are left out. Counts include separate standalone
and pair references, with owner types kept distinct. Ordered by count/name.
"""
seen = set()
context = []
@@ -799,11 +805,11 @@ class PromptDatabase:
if context:
placeholders = ",".join("?" * len(context))
where.append(
"pt.prompt_id IN ("
" SELECT pt2.prompt_id FROM prompt_tags pt2"
"(pt.kind,pt.owner_id) IN ("
" SELECT pt2.kind,pt2.owner_id FROM tag_refs pt2"
" JOIN tags t2 ON t2.id = pt2.tag_id"
f" WHERE t2.name COLLATE NOCASE IN ({placeholders})"
" GROUP BY pt2.prompt_id"
" GROUP BY pt2.kind,pt2.owner_id"
" HAVING COUNT(DISTINCT lower(t2.name)) = ?)"
)
params.extend(context)
@@ -813,8 +819,11 @@ class PromptDatabase:
params.append(max(1, int(limit)))
sql = (
"WITH tag_refs(kind,owner_id,tag_id) AS ("
"SELECT 'prompt',prompt_id,tag_id FROM prompt_tags UNION ALL "
"SELECT 'pair',pair_id,tag_id FROM prompt_pair_tags) "
"SELECT t.name AS name, COUNT(*) AS count"
" FROM prompt_tags pt JOIN tags t ON t.id = pt.tag_id"
" FROM tag_refs pt JOIN tags t ON t.id = pt.tag_id"
f" WHERE {' AND '.join(where)}"
" GROUP BY t.id"
" ORDER BY count DESC, t.name COLLATE NOCASE ASC"
@@ -921,6 +930,8 @@ class PromptDatabase:
old_name = old_name.strip()
new_name = new_name.strip()
if old_name == new_name:
return {"success": True, "affected_count": 0, "skipped_count": 0}
with self.model.get_connection() as conn:
cursor = conn.execute("SELECT id FROM tags WHERE name = ?", (old_name,))
@@ -942,12 +953,14 @@ class PromptDatabase:
if existing_new:
# Target tag exists — merge: move links, handle conflicts, delete old
new_tag_id = existing_new["id"]
conn.execute(
"UPDATE OR IGNORE prompt_tags SET tag_id = ? WHERE tag_id = ?",
(new_tag_id, old_tag_id),
)
# Delete remaining links (conflicts = prompt already had the target tag)
conn.execute("DELETE FROM prompt_tags WHERE tag_id = ?", (old_tag_id,))
for junction in ("prompt_tags", "prompt_pair_tags"):
conn.execute(
f"UPDATE OR IGNORE {junction} SET tag_id = ? WHERE tag_id = ?",
(new_tag_id, old_tag_id),
)
conn.execute(
f"DELETE FROM {junction} WHERE tag_id = ?", (old_tag_id,)
)
conn.execute("DELETE FROM tags WHERE id = ?", (old_tag_id,))
else:
# Simple rename
@@ -966,7 +979,7 @@ class PromptDatabase:
"""
Remove a tag from all prompts that use it.
With junction tables, deleting from the tags table cascades to prompt_tags.
Deleting from tags cascades to standalone and pair tag relationships.
Args:
tag_name: Tag name to remove
@@ -1033,20 +1046,27 @@ class PromptDatabase:
if not src_row:
continue
src_id = src_row["id"]
if src_id == target_id:
continue
cursor = conn.execute(
"SELECT COUNT(*) as c FROM prompt_tags WHERE tag_id = ?", (src_id,)
)
src_count = cursor.fetchone()["c"]
if src_count > 0:
# Move links from source to target (ignore conflicts)
conn.execute(
"UPDATE OR IGNORE prompt_tags SET tag_id = ? WHERE tag_id = ?",
(target_id, src_id),
)
# Delete remaining (conflicts = prompt already had target tag)
conn.execute("DELETE FROM prompt_tags WHERE tag_id = ?", (src_id,))
pair_count = conn.execute(
"SELECT COUNT(*) FROM prompt_pair_tags WHERE tag_id = ?", (src_id,)
).fetchone()[0]
if src_count or pair_count:
for junction in ("prompt_tags", "prompt_pair_tags"):
conn.execute(
f"UPDATE OR IGNORE {junction} "
"SET tag_id = ? WHERE tag_id = ?",
(target_id, src_id),
)
conn.execute(
f"DELETE FROM {junction} WHERE tag_id = ?", (src_id,)
)
tags_merged += 1
affected += src_count
@@ -1290,7 +1310,7 @@ class PromptDatabase:
"""
self.logger.info("Starting duplicate cleanup process with image preservation")
try:
with self.model.get_connection() as conn:
with self._pair_transaction() as conn:
# Find duplicates by text content (case-insensitive)
# Note: Removed ORDER BY from GROUP_CONCAT for SQLite compatibility
# We'll sort the IDs manually after fetching
@@ -1319,6 +1339,11 @@ class PromptDatabase:
id_date_pairs.sort(key=lambda x: x[1]) # Sort by date
sorted_ids = [int(pair[0]) for pair in id_date_pairs]
try:
self._ensure_prompts_unpaired(conn, sorted_ids)
except PairDependencyError:
continue
# Keep the oldest one (first), merge and delete the rest
primary_id = sorted_ids[0] # Keep the oldest
duplicate_ids = sorted_ids[1:]
@@ -1864,7 +1889,8 @@ class PromptDatabase:
Raises:
sqlite3.IntegrityError: Another prompt already has the new hash.
"""
with self.model.get_connection() as conn:
with self._pair_transaction() as conn:
self._ensure_prompts_unpaired(conn, [prompt_id])
cursor = conn.execute(
"UPDATE prompts SET text = ?, hash = ?, updated_at = ? WHERE id = ?",
(
@@ -1912,7 +1938,8 @@ class PromptDatabase:
Number of prompts actually deleted.
"""
count = 0
with self.model.get_connection() as conn:
with self._pair_transaction() as conn:
self._ensure_prompts_unpaired(conn, prompt_ids)
for pid in prompt_ids:
conn.execute("DELETE FROM generated_images WHERE prompt_id = ?", (pid,))
cursor = conn.execute("DELETE FROM prompts WHERE id = ?", (pid,))
@@ -1985,7 +2012,7 @@ class PromptDatabase:
cursor = conn.execute(
"""SELECT gi.prompt_id, p.text, p.category, p.rating, p.notes,
gi.workflow_data, gi.prompt_metadata, gi.generation_time,
gi.parameters
gi.parameters, gi.id, gi.image_path, gi.file_path
FROM generated_images gi
JOIN prompts p ON gi.prompt_id = p.id
WHERE gi.image_path = ? OR gi.file_path = ?
@@ -1997,9 +2024,71 @@ class PromptDatabase:
f"%{escape_like(os.path.basename(image_path))}",
),
)
row = cursor.fetchone()
if not row:
rows = cursor.fetchall()
exact = [
row
for row in rows
if row[10] == image_path or row[11] == normalize_image_path(image_path)
]
matches = exact or rows
if len(matches) != 1:
return None
row = matches[0]
associations = self.get_image_prompt_pairs(row[9])
pair_ids = {item["pair_id"] for item in associations}
pair = (
self.get_prompt_pair(next(iter(pair_ids)))
if len(pair_ids) == 1
else None
)
candidates = []
for item in associations:
evidence = item["provenance"]
positive_side = evidence.get("positive")
negative_side = evidence.get("negative")
if not isinstance(positive_side, dict) or not isinstance(
negative_side, dict
):
continue
positive, negative = positive_side.get("text"), negative_side.get(
"text"
)
if not isinstance(positive, str) or not isinstance(negative, str):
continue
sources = []
for side in (positive_side, negative_side):
provenance = side.get("provenance")
sources.append(
provenance.get("text_source")
if isinstance(provenance, dict)
else None
)
score = sources.count("execution")
source = (
"execution"
if score == 2
else (
"mixed"
if score
else (
"embedded-parameters"
if sources == ["embedded-parameters"] * 2
else "graph-inferred"
)
)
)
candidates.append((score, positive, negative, source))
context = None
if candidates and pair:
best_score = max(item[0] for item in candidates)
contexts = {item[1:] for item in candidates if item[0] == best_score}
if len(contexts) == 1:
positive, negative, source = next(iter(contexts))
context = {
"positive_text": positive,
"negative_text": negative,
"text_source": source,
}
tags = self._get_prompt_tags(conn, row[0])
try:
workflow = json.loads(row[5]) if row[5] else None
@@ -2010,6 +2099,8 @@ class PromptDatabase:
except (json.JSONDecodeError, TypeError):
prompt_meta = None
return {
"pair": pair,
"pair_context": context,
"prompt_id": row[0],
"text": row[1],
"category": row[2],
@@ -2050,11 +2141,15 @@ class PromptDatabase:
Returns number of prompts removed.
"""
with self.model.get_connection() as conn:
with self._pair_transaction() as conn:
cursor = conn.execute("""
SELECT p.id FROM prompts p
LEFT JOIN generated_images gi ON p.id = gi.prompt_id
WHERE gi.prompt_id IS NULL
AND NOT EXISTS (
SELECT 1 FROM prompt_pairs pp
WHERE pp.positive_prompt_id = p.id OR pp.negative_prompt_id = p.id
)
AND NOT EXISTS (
SELECT 1 FROM prompt_tags pt
JOIN tags t ON pt.tag_id = t.id
+136
View File
@@ -0,0 +1,136 @@
"""Atomic metadata enrichment for a single known generation context."""
import json
import os
from typing import Any, Dict
from .models import normalize_image_path
from .operations import _coerce_file_info
try:
from ..utils.hashing import generate_prompt_hash
except ImportError:
from utils.hashing import generate_prompt_hash
def ingest_resolved_pair(
db: Any, image_path: str, metadata: Dict[str, Any], resolution: Dict[str, Any]
) -> Dict[str, Any]:
state = resolution.get("state", "unresolved")
if state != "resolved":
return {"status": state, "pairs_added": 0, "links_added": 0}
positive, negative = resolution["positive"], resolution["negative"]
positive_text = positive.get("storage_text", positive["text"])
negative_text = negative.get("storage_text", negative["text"])
normalized = normalize_image_path(image_path)
provenance = {"resolver_version": 1, "positive": positive, "negative": negative}
encoded = json.dumps(
provenance, sort_keys=True, separators=(",", ":"), allow_nan=False
)
with db._pair_transaction() as conn:
images = conn.execute(
"SELECT gi.*,p.hash AS positive_hash FROM generated_images gi "
"JOIN prompts p ON p.id=gi.prompt_id "
"WHERE gi.file_path=? OR gi.image_path=?",
(normalized, image_path),
).fetchall()
if len(images) > 1 or (
images and images[0]["positive_hash"] != generate_prompt_hash(positive_text)
):
return {"status": "conflict", "pairs_added": 0, "links_added": 0}
existing = images[0] if images else None
if existing:
prior = conn.execute(
"SELECT n.hash AS negative_hash FROM image_prompt_pairs ip "
"JOIN prompt_pairs pp ON pp.id=ip.pair_id "
"LEFT JOIN prompts n ON n.id=pp.negative_prompt_id WHERE ip.image_id=?",
(existing["id"],),
).fetchall()
expected = (
generate_prompt_hash(negative_text) if negative_text.strip() else None
)
if any(row["negative_hash"] != expected for row in prior):
return {"status": "conflict", "pairs_added": 0, "links_added": 0}
prior_pair = conn.execute(
"SELECT pp.id FROM prompt_pairs pp "
"JOIN prompts p ON p.id=pp.positive_prompt_id "
"LEFT JOIN prompts n ON n.id=pp.negative_prompt_id "
"WHERE p.hash=? AND n.hash IS ?",
(
generate_prompt_hash(positive_text),
generate_prompt_hash(negative_text) if negative_text.strip() else None,
),
).fetchone()
pair_id = db._save_pair(conn, positive_text, negative_text)
pair = conn.execute(
"SELECT * FROM prompt_pairs WHERE id=?", (pair_id,)
).fetchone()
pairs_added = int(prior_pair is None)
file_size, width, height, image_format = _coerce_file_info(metadata)
snapshots = {
column: json.dumps(metadata[key], sort_keys=True, allow_nan=False)
for column, key in (
("workflow_data", "workflow"),
("prompt_metadata", "prompt"),
("parameters", "parameters"),
)
if key in metadata
}
info = {
"file_size": file_size,
"width": width,
"height": height,
"format": image_format,
}
snapshots.update(
{key: value for key, value in info.items() if value is not None}
)
changed = False
if existing:
image_id = existing["id"]
updates = {
key: value for key, value in snapshots.items() if existing[key] != value
}
if updates:
conn.execute(
"UPDATE generated_images SET "
+ ",".join(f"{key}=?" for key in updates)
+ " WHERE id=?",
[*updates.values(), image_id],
)
changed = True
else:
columns = ["prompt_id", "image_path", "filename", "file_path", *snapshots]
values = [
pair["positive_prompt_id"],
image_path,
os.path.basename(image_path),
normalized,
*snapshots.values(),
]
image_id = conn.execute(
"INSERT INTO generated_images("
+ ",".join(columns)
+ ") VALUES("
+ ",".join("?" for _ in columns)
+ ")",
values,
).lastrowid
linked = conn.execute(
"SELECT 1 FROM image_prompt_pairs WHERE pair_id=? "
"AND image_id=? AND provenance=?",
(pair_id, image_id, encoded),
).fetchone()
db._link_image_to_pair(conn, pair_id, image_id, provenance)
status = (
"added"
if existing is None
else "enriched" if changed or not linked else "unchanged"
)
return {
"status": status,
"pairs_added": pairs_added,
"links_added": int(not linked),
"pair_id": pair_id,
"image_id": image_id,
}
+387
View File
@@ -0,0 +1,387 @@
"""Ordered reusable prompt pairs and their generation provenance."""
import json
import sqlite3
from contextlib import contextmanager
from typing import Any, Dict, Iterator, List, Optional
from .models import pair_provenance_error, utc_now_iso
try:
from ..utils.hashing import generate_prompt_hash
except ImportError:
from utils.hashing import generate_prompt_hash
class PairDependencyError(ValueError):
"""An operation would change or remove content used by a saved pair."""
def _validate_metadata(
title: Optional[str],
category: Optional[str],
tags: Optional[List[str]],
rating: Optional[int],
notes: Optional[str],
) -> Optional[List[str]]:
for name, value in (("title", title), ("category", category), ("notes", notes)):
if value is not None and not isinstance(value, str):
raise ValueError(f"{name} must be a string")
if rating is not None and (type(rating) is not int or not 1 <= rating <= 5):
raise ValueError("Rating must be between 1 and 5")
if tags is None:
return None
if not isinstance(tags, list) or any(not isinstance(tag, str) for tag in tags):
raise ValueError("tags must be a list of strings")
return list(dict.fromkeys(tag.strip() for tag in tags if tag.strip()))
class PairOperations:
"""Connection-level pair operations mixed into PromptDatabase."""
@contextmanager
def _pair_transaction(self) -> Iterator[sqlite3.Connection]:
conn = self.model.get_connection()
if conn.in_transaction:
raise RuntimeError("Pair operations require their own transaction")
with conn:
conn.execute("BEGIN IMMEDIATE")
yield conn
def _ensure_prompts_unpaired(
self, conn: sqlite3.Connection, prompt_ids: List[int]
) -> None:
if not prompt_ids:
return
placeholders = ",".join("?" for _ in prompt_ids)
row = conn.execute(
f"SELECT id FROM prompt_pairs WHERE positive_prompt_id IN ({placeholders}) "
f"OR negative_prompt_id IN ({placeholders}) LIMIT 1",
[*prompt_ids, *prompt_ids],
).fetchone()
if row:
raise PairDependencyError(
f"Prompt belongs to saved pair {row['id']}; delete the pair first "
"or save changed text as a new prompt"
)
def _pair_component(self, conn: sqlite3.Connection, text: str) -> int:
prompt_hash = generate_prompt_hash(text)
conn.execute(
"INSERT INTO prompts(text, hash, created_at, updated_at) VALUES(?,?,?,?) "
"ON CONFLICT(hash) DO NOTHING",
(text.strip(), prompt_hash, utc_now_iso(), utc_now_iso()),
)
return conn.execute(
"SELECT id FROM prompts WHERE hash = ?", (prompt_hash,)
).fetchone()["id"]
def _sync_pair_tags(
self, conn: sqlite3.Connection, pair_id: int, tags: List[str]
) -> None:
conn.execute("DELETE FROM prompt_pair_tags WHERE pair_id = ?", (pair_id,))
for tag_id in self._ensure_tags(conn, tags).values():
conn.execute(
"INSERT INTO prompt_pair_tags(pair_id,tag_id) VALUES(?,?)",
(pair_id, tag_id),
)
def save_prompt_pair(
self,
positive_text: str,
negative_text: str = "",
*,
title: Optional[str] = None,
category: Optional[str] = None,
tags: Optional[List[str]] = None,
rating: Optional[int] = None,
notes: Optional[str] = None,
) -> int:
"""Save an ordered pair atomically; repeat saves preserve its metadata."""
with self._pair_transaction() as conn:
return self._save_pair(
conn,
positive_text,
negative_text,
title=title,
category=category,
tags=tags,
rating=rating,
notes=notes,
)
def _save_pair(
self,
conn: sqlite3.Connection,
positive_text: str,
negative_text: str = "",
*,
title: Optional[str] = None,
category: Optional[str] = None,
tags: Optional[List[str]] = None,
rating: Optional[int] = None,
notes: Optional[str] = None,
) -> int:
"""Save an ordered pair atomically; repeat saves preserve its metadata."""
if not isinstance(positive_text, str) or not positive_text.strip():
raise ValueError("Positive prompt text cannot be empty")
if not isinstance(negative_text, str):
raise ValueError("Negative prompt text must be a string; use '' for empty")
tags = _validate_metadata(title, category, tags, rating, notes)
positive = self._pair_component(conn, positive_text)
negative = (
self._pair_component(conn, negative_text) if negative_text.strip() else None
)
existing = conn.execute(
"SELECT id FROM prompt_pairs WHERE positive_prompt_id = ? "
"AND negative_prompt_id IS ?",
(positive, negative),
).fetchone()
if existing:
return existing["id"]
now = utc_now_iso()
pair_id = conn.execute(
"INSERT INTO prompt_pairs(positive_prompt_id,negative_prompt_id,title,"
"category,rating,notes,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)",
(positive, negative, title, category, rating, notes, now, now),
).lastrowid
if tags:
self._sync_pair_tags(conn, pair_id, tags)
return pair_id
def _read_pair_rows(
self, conn: sqlite3.Connection, where: str, params: List[Any], suffix: str = ""
) -> List[Dict[str, Any]]:
rows = conn.execute(
"SELECT pp.*, p.text AS positive_text, "
"COALESCE(n.text,'') AS negative_text,"
"(SELECT COUNT(DISTINCT image_id) FROM image_prompt_pairs ip "
"WHERE ip.pair_id=pp.id) AS image_count FROM prompt_pairs pp "
"JOIN prompts p ON p.id=pp.positive_prompt_id "
"LEFT JOIN prompts n ON n.id=pp.negative_prompt_id WHERE " + where + suffix,
params,
).fetchall()
pairs = [dict(row) for row in rows]
if not pairs:
return pairs
by_id = {pair["id"]: pair for pair in pairs}
for pair in pairs:
pair["archived"] = bool(pair["archived"])
pair["tags"] = []
placeholders = ",".join("?" for _ in by_id)
for row in conn.execute(
"SELECT pt.pair_id,t.name FROM prompt_pair_tags pt JOIN tags t ON "
f"t.id=pt.tag_id WHERE pt.pair_id IN ({placeholders}) ORDER BY t.name",
list(by_id),
):
by_id[row["pair_id"]]["tags"].append(row["name"])
return pairs
def get_prompt_pair(self, pair_id: int) -> Optional[Dict[str, Any]]:
"""Return components, pair metadata and image count, including archives."""
rows = self._read_pair_rows(self.model.get_connection(), "pp.id = ?", [pair_id])
return rows[0] if rows else None
def list_prompt_pairs(
self,
*,
text: Optional[str] = None,
category: Optional[str] = None,
tags: Optional[List[str]] = None,
include_archived: bool = False,
limit: int = 50,
offset: int = 0,
sort: str = "newest",
) -> Dict[str, Any]:
"""List pairs with all-tag filtering and stable paginated ordering."""
orders = {
"newest": "pp.id DESC",
"last_used_desc": "julianday(pp.last_used_at) DESC, pp.id DESC",
}
if sort not in orders:
raise ValueError("Unknown pair sort order")
if type(limit) is not int or not 1 <= limit <= 500:
raise ValueError("limit must be between 1 and 500")
if type(offset) is not int or offset < 0:
raise ValueError("offset must be a nonnegative integer")
tags = _validate_metadata(None, category, tags, None, None)
if text is not None and not isinstance(text, str):
raise ValueError("text must be a string")
clauses, params = ["1=1"], []
if not include_archived:
clauses.append("pp.archived=0")
if category is not None:
clauses.append("pp.category = ?")
params.append(category)
if text:
clauses.append("(p.text LIKE ? OR n.text LIKE ? OR pp.title LIKE ?)")
params.extend([f"%{text}%"] * 3)
for tag in tags or []:
clauses.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=?)"
)
params.append(tag)
where = " AND ".join(clauses)
conn = self.model.get_connection()
total = conn.execute(
"SELECT COUNT(*) FROM prompt_pairs pp "
"JOIN prompts p ON p.id=pp.positive_prompt_id "
"LEFT JOIN prompts n ON n.id=pp.negative_prompt_id WHERE " + where,
params,
).fetchone()[0]
pairs = self._read_pair_rows(
conn,
where,
[*params, limit, offset],
f" ORDER BY {orders[sort]} LIMIT ? OFFSET ?",
)
return {
"pairs": pairs,
"total": total,
"limit": limit,
"offset": offset,
"has_more": offset + len(pairs) < total,
}
def update_prompt_pair_metadata(
self,
pair_id: int,
*,
title: Optional[str] = None,
category: Optional[str] = None,
tags: Optional[List[str]] = None,
rating: Optional[int] = None,
notes: Optional[str] = None,
) -> bool:
"""Update pair metadata explicitly; None preserves and empty values clear."""
tags = _validate_metadata(title, category, tags, rating, notes)
changes = {
key: value
for key, value in (
("title", title),
("category", category),
("rating", rating),
("notes", notes),
)
if value is not None
}
if not changes and tags is None:
return False
with self._pair_transaction() as conn:
if not conn.execute(
"SELECT 1 FROM prompt_pairs WHERE id=?", (pair_id,)
).fetchone():
return False
changes["updated_at"] = utc_now_iso()
conn.execute(
"UPDATE prompt_pairs SET "
+ ",".join(f"{key}=?" for key in changes)
+ " WHERE id=?",
[*changes.values(), pair_id],
)
if tags is not None:
self._sync_pair_tags(conn, pair_id, tags)
return True
def archive_prompt_pair(self, pair_id: int, archived: bool = True) -> bool:
"""Hide a reusable pair without changing components or generation history."""
if type(archived) is not bool:
raise ValueError("archived must be a boolean")
with self._pair_transaction() as conn:
return (
conn.execute(
"UPDATE prompt_pairs SET archived=?,updated_at=? WHERE id=?",
(int(archived), utc_now_iso(), pair_id),
).rowcount
> 0
)
def delete_prompt_pair(self, pair_id: int) -> bool:
"""Delete unused pair metadata; generated pairs must be archived instead."""
with self._pair_transaction() as conn:
if conn.execute(
"SELECT 1 FROM image_prompt_pairs WHERE pair_id=? LIMIT 1", (pair_id,)
).fetchone():
raise PairDependencyError(
"Pair has generated images; archive it instead"
)
return (
conn.execute("DELETE FROM prompt_pairs WHERE id=?", (pair_id,)).rowcount
> 0
)
def record_prompt_pair_use(self, pair_id: int, times: int = 1) -> bool:
"""Count pair uses independently of standalone prompt usage."""
if type(times) is not int or times < 1:
raise ValueError("times must be a positive integer")
with self._pair_transaction() as conn:
return (
conn.execute(
"UPDATE prompt_pairs SET run_count=run_count+?,last_used_at=? "
"WHERE id=?",
(times, utc_now_iso(), pair_id),
).rowcount
> 0
)
def link_image_to_pair(
self, pair_id: int, image_id: int, provenance: Dict[str, Any]
) -> int:
"""Associate an image with immutable run provenance, idempotently."""
with self._pair_transaction() as conn:
return self._link_image_to_pair(conn, pair_id, image_id, provenance)
def _link_image_to_pair(
self,
conn: sqlite3.Connection,
pair_id: int,
image_id: int,
provenance: Dict[str, Any],
) -> int:
"""Associate an image with immutable run provenance, idempotently."""
if not isinstance(provenance, dict):
raise ValueError("provenance must be an object")
try:
encoded = json.dumps(
provenance, sort_keys=True, separators=(",", ":"), allow_nan=False
)
except (TypeError, ValueError, RecursionError) as error:
raise ValueError("provenance must contain JSON values") from error
error = pair_provenance_error(encoded)
if error:
raise ValueError(error)
pair = conn.execute(
"SELECT positive_prompt_id FROM prompt_pairs WHERE id=?", (pair_id,)
).fetchone()
image = conn.execute(
"SELECT prompt_id FROM generated_images WHERE id=?", (image_id,)
).fetchone()
if not pair or not image:
raise ValueError("Pair and image must exist")
if pair["positive_prompt_id"] != image["prompt_id"]:
raise ValueError("Image must belong to the pair's positive prompt")
conn.execute(
"INSERT INTO image_prompt_pairs(pair_id,image_id,provenance,created_at) "
"VALUES(?,?,?,?) ON CONFLICT(pair_id,image_id,provenance) DO NOTHING",
(pair_id, image_id, encoded, utc_now_iso()),
)
return conn.execute(
"SELECT id FROM image_prompt_pairs "
"WHERE pair_id=? AND image_id=? AND provenance=?",
(pair_id, image_id, encoded),
).fetchone()["id"]
def get_image_prompt_pairs(self, image_id: int) -> List[Dict[str, Any]]:
"""Return every recorded pair association with decoded provenance."""
rows = (
self.model.get_connection()
.execute(
"SELECT * FROM image_prompt_pairs WHERE image_id=? ORDER BY id",
(image_id,),
)
.fetchall()
)
return [
{**dict(row), "provenance": json.loads(row["provenance"])} for row in rows
]
+81
View File
@@ -0,0 +1,81 @@
# Application Redesign Development Plan
The maintainer approved the [interactive concept](concepts/workspace/README.md)
on 2026-10-17. It is a design reference with simulated operations, not production
code. This plan proposes implementation order and acceptance gates.
## Delivery status
- Foundation merged in [#198](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/198).
- [#200](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/200) delivers native Images browsing, physical-file details, infinite scroll, Grid/List/Masonry, Viewer.js and video playback. The workspace opens on Images; original application entry points remain available.
- Prompt metadata, role-aware sending, tagging/review, library/history and System consolidation remain subsequent slices under #196. The physical-file inspector does not display prompt text until the shared prompt inspector/send flow is implemented.
## Scope and compatibility
Rebuild the standalone application around one shared shell. Preserve existing
node schemas, saved workflows, backend capabilities, pinned Viewer.js and the
ComfyAssets toolkit. Keep existing application entry points usable during the
transition. Do not change the running maintainer instance during automated tests.
Use the concept's feature inventory as a parity checklist. Each row needs an
implementation issue, observable acceptance criteria and automated test coverage.
Record any intentional behavior change explicitly; no silent feature removal.
## Proposed delivery sequence
Use a tracking issue and small issue-linked branches and PRs. Dependencies may
require splitting these milestones further.
| Milestone | Deliverable and acceptance focus |
| --- | --- |
| 1. Foundation | Production shell, routing, shared theme and disposable page-test harness. Verify startup, navigation, dark/light changes, cleanup and existing entry points. Establish discovered-test and coverage baselines before changes. |
| 2. Image workspace | Real API-backed filters, Grid/List/Masonry, readable thumbnails, selection and shared Viewer.js inspector. Continuous infinite scroll only, with accessible Load more fallback, stable ordering, retry and restored browsing context. Bound retained DOM and test large datasets. |
| 3. Prompt library and history | Real positive/negative associations, selected full negative, run history and shared image filtering. Design any missing execution-history persistence and migration before wiring the timeline; never invent historical runs from incomplete metadata. |
| 4. Send to ComfyUI | Shared paper-plane actions wherever prompt text appears; role-specific and pair sends, destination preview, acknowledgement and failure recovery. Cover explicit empty versus unknown negatives and connected inputs. Never queue or replace a workflow. |
| 5. Editing and review | Prompt editing, vocabulary management, per-image manual/auto-tag approval and Needs review. Preserve cancellation semantics and distinguish tag approval from prompt-association decisions. |
| 6. System and maintenance | Settings, sources/scans, integrations, jobs, diagnostics, backup/restore and maintenance impact previews. Cover existing capabilities before retiring their old controls. |
| 7. Additional organization | Favorites independent of ratings, collections, saved views, Activity, scheduled backups and portable import previews. Specify persistence, validation and recovery separately for each capability. |
| 8. Cutover | Complete the parity checklist, migration and upgrade validation, accessibility/performance checks, documentation and targeted maintainer live testing. Switch the default only after these pass; preserve compatible URLs. |
## TDD and test layers
For each behavior, first write and run a meaningful failing test. Implement the
smallest working change, verify it passes, then refactor with the suite green.
Include failing-before/passing-after evidence in the PR.
- **Unit:** filtering and selection state, cursor handling, prompt roles,
formatting, validation, navigation and lifecycle cleanup.
- **Integration:** real HTTP handlers and disposable SQLite/filesystem fixtures;
persistence, migration from supported old schemas, idempotent scans, restore,
rollback and reference-safe deletion. Mock inference and external services.
- **Smoke:** shipped production pages start and navigate with real fixture APIs;
test populated, empty and error states, assets and unexpected console failures.
- **Browser workflows:** production HTML/JS across Chromium, Firefox, WebKit and
touch; editing/reload, infinite append, viewer cleanup, tagging approval,
keyboard access, delayed responses, cancellation and retries.
- **Host integration:** pinned disposable CPU ComfyUI, Classic and Nodes 2.0,
switching both ways and workflow reload for host-facing changes. Exercise
independent and combined pack installations for shared bridge/toolkit changes.
Retain existing test discovery and coverage gates. Carry forward database
injection and open-handle checks into new fixtures. Add new frontend modules to
coverage measurement and its baseline; do not lower floors or replace existing
regression coverage with mockup-only tests.
## GitHub and release gates
Extend existing required workflows: the Python 3.10–3.13 matrix on Linux,
Windows and macOS; 85% Python coverage; formatting; frontend units and per-file
coverage floors; vendor checks; browser workflows; real ComfyUI compatibility.
Publish traces, screenshots, logs and coverage artifacts on failures. See
[TESTING.md](../TESTING.md) for commands and fixture isolation requirements.
Every PR must map acceptance criteria to tests, report relevant checks and include
screenshots for visual changes. All required checks must pass before merging.
Use maintainer live review at meaningful visual and host-integration milestones;
capture discovered regressions in automated tests. Notify the maintainer when
Python changes require a ComfyUI restart.
Keep version/publication changes separate from intermediate UI work: the existing
registry workflow publishes on main/master changes to `pyproject.toml` or manual
dispatch. Do not trigger a release merely to preview a redesign milestone.
File diff suppressed because one or more lines are too long
+138
View File
@@ -0,0 +1,138 @@
# Prompt Manager application redesign concept
Status: visual concept approved by the maintainer, 2026-10-17. Production
implementation is progressing in tested slices; see the [development plan](../../application-redesign.md).
The production workspace now has native Images browsing; this concept remains the
approved target for the complete application.
## Review the mockup
Run `python tools/preview-prompt-timeline.py --bind 0.0.0.0 --port 8767` and
open `/workspace/`. The original approved timeline remains available at `/`.
The server exposes an explicit allowlist of concept files and pinned assets.
All examples are synthetic; there are no application API calls, database access,
real model downloads, or ComfyUI handoffs. Local file inspection previews an
image in the browser without uploading it. Exports contain sample data only.
The sidebar opens Images, Prompt library, Tags & categories, Inspect a file,
Activity, System and Design review. System includes Overview, Appearance,
Sources & scans, Integrations, Auto-tagging, Maintenance, Backup & export, and
Diagnostics & logs. The in-app Design review contains the feature map below.
## Proposed direction
One application shell replaces separate Admin, Gallery and Metadata shells.
Use the approved shared ComfyAssets theme: ComfyUI dark/light, blue primary
actions, green positive roles, red negative roles and errors. Preserve existing
capabilities and services while consolidating controls and clarifying their scope.
Browsing is image-led with readable thumbnails and one metadata inspector.
Prompt library groups by positive; negative variants and runs share its gallery.
The full selected negative appears directly beneath the positive in history.
Favorites express preference independently of the existing quality rating.
Collections organize projects without duplicating recipe text; saved views keep
search/filter context. New capabilities are proposals, not implemented features.
Maintenance moves out of the browsing toolbar. Checks, impact preview, backups,
and apply steps have separate responsibilities. Distinguish prompt text reuse
from image-file duplicates, and metadata enrichment from thumbnail generation.
No repeated global thumbnail-generation prompt on opening the gallery.
## Send to ComfyUI
Use the same original paper-plane outline, blue accent and accessible role label
on every positive/negative prompt block, including image details and the viewer.
A pair action sends both. Preview destination and exact fields before applying;
positive-only and negative-only actions leave the other role untouched. An
explicitly empty negative means clear the target field; unknown metadata must
never be interpreted as empty. Sending text neither queues execution nor
replaces a workflow.
Production needs a shared host bridge with versioned payloads, origin validation,
destination discovery, capability checks, disconnected-host handling, linked-input
checks and acknowledgements. Preview actual target values and report success only
after acknowledgement. Default to asking for a destination; never guess between
multiple nodes or positive and negative encoder inputs. Verify Classic and Nodes
2.0 with both packs installed independently and together.
## Existing feature inventory
| Current feature | Proposed home / behavior | Disposition |
| --- | --- | --- |
| Admin prompt cards / per-prompt gallery | Prompt library + one prompt history workspace | Retained, rebuilt |
| Standalone image gallery / Admin image modal | One Images workspace + shared Viewer.js viewer | Consolidated |
| Video playback, autoplay / mute / loop, viewer slideshow and transforms | Shared media viewer; playback controls in Appearance; same image navigation | Retained |
| Metadata page / viewer sidebar / full-text popups | One reusable metadata inspector; inline complete prompt text | Consolidated |
| Prompt add, edit, rating, notes, copy, delete | Recipe editor, shared details, contextual bulk actions; reference-safe archive | Retained, clarified |
| Categories, tag rename / merge, autocomplete | Tags & categories; same vocabulary in every editor | Retained |
| Grid/list, thumbnail size, columns, pages/infinite scroll | Infinite scroll only; one Appearance configuration and contextual layout / density overrides | Simplified |
| Scan, rescan, monitoring directories, worker threads | Sources & scans; explicit metadata enrichment vs thumbnail work | Consolidated |
| Ollama, HuggingFace, WD14 variants, thresholds, review | One Auto-tagging configuration and review flow | Retained |
| Thumbnail quality, size limit, metadata cache, paths | Appearance + cache controls in Maintenance | Retained |
| Prompt dedup, hash checks, consistency, unused prompts | Maintenance plan with dependency checks and impact preview | Retained, safer flow |
| Image dedup, orphan image links, browser cache | Separate reviewed operations in Maintenance | Retained |
| Database backup / restore, JSON export | Backup & export; recovery and portable recipes clearly distinguished | Retained |
| Diagnostics, image-link checks, logs, rotation, log levels | System → Diagnostics & logs | Retained |
| LoRA detection/import, trigger injection, CivitAI key | System → Integrations; positive-only triggers | Retained |
| Statistics / counts | System overview + usage breakdown; context counts in the workspace | Retained |
| Send prompts to ComfyUI from every prompt surface | Role-aware paper-plane action + destination / overwrite preview | New proposal |
| Reusable negatives / approved branch timeline | Prompt history with selected negative directly beneath positive | Approved concept |
| Favorites, saved searches, project collections | Sidebar views; collections do not duplicate recipe text | New proposal |
| Ambiguous metadata review, source provenance, run comparisons | Needs review queue; recorded vs inferred evidence; pair/run selection | New / expanded |
| Unified jobs, cancellation, resumable progress visibility | Activity center; cancellation distinguished from hiding progress | New proposal |
| Scheduled backups, pre-change safety snapshots | Explicit recovery policy after storage configuration | New proposal |
## Implementation boundaries and test requirements
Keep Viewer.js 1.15.2 and the shared toolkit as reproducible pinned artifacts;
this mock uses their actual checked-in files without modifying them. Preserve
legacy routes during the transition, current node layouts/schemas, existing
controllers where reusable, and all existing test/coverage gates.
The timeline needs persisted execution records to represent real runs. Saved
views, collections, metadata review resolution, activity history, scheduled
backups, portable JSON import, and the ComfyUI bridge need separate implementation
and persistence work. Preserve source evidence and distinguish recorded from
inferred metadata. Existing schema migration is automatic; old image enrichment
remains an optional explicit scan. The one-time upgrade notice belongs in the
new shell, with Scan and Later choices.
Implement in reviewed slices: shared application shell and inspector; reusable
prompt blocks/send bridge; image workspace; grouped library/history; settings and
maintenance; optional organization and workflow improvements. Write regressions
first for production behavior. Retain Python, frontend unit, Chromium/Firefox/
WebKit/touch and managed Classic/Nodes 2.0 tests. Add behavior tests for shared
filters, navigation state, viewer cleanup, interrupted jobs, dependency-safe
maintenance, backup/restore, paired metadata and correct send destinations.
Screenshots in `screenshots/` record the reviewed starting point. The approved
standalone timeline in `../prompt-variant-timeline.html` remains unchanged.
## Review polish
History actions use a shared branch icon. Images can switch among Grid, List and
Masonry; Masonry preserves each image's proportions and keeps DOM order while
packing cards with a responsive layout. It does not require experimental native
masonry support. Resize observers are disconnected when leaving the view.
Every image card includes a tagging action beside Info, including cards in Needs
review. Choose WD14 SwinV2, WD14 ViT, Ollama or HuggingFace; adjust thresholds or
instructions, generate sample suggestions, edit tags manually, and approve only
the desired suggestions. Changes are staged until approval, apply to that image
alone and do not resolve uncertain prompt associations. Changing models discards
previous suggestions. Cancel preserves existing tags. All inference is simulated.
## Continuous browsing decision
The redesign uses infinite scroll only. Remove page numbers, previous/next page
buttons, paged/infinite mode selection and results-per-page controls. Keep request
batching internal. The mock appends 12 images at a time in Grid, List and Masonry;
it preserves existing card nodes, focus and selection while appending. It also
retains the loaded range when switching layouts or revisiting a search. A
keyboard-accessible Load more action provides an alternate trigger for the same
continuous stream, not pagination. Exhausted and empty results are explicit.
Production will need bounded rendering, stable cursors, scroll restoration and
loading/error/retry behavior for large collections. Existing backend pagination
can continue providing internal request batches; no production tests or routes
are removed as part of this prototype.
File diff suppressed because it is too large Load Diff
+105
View File
@@ -0,0 +1,105 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="referrer" content="no-referrer" />
<title>Images · Prompt Manager concept</title>
<link rel="stylesheet" href="/toolkit/controls.css" />
<link rel="stylesheet" href="/vendor/viewer.css" />
<link rel="stylesheet" href="/workspace/style.css" />
<script src="/vendor/viewer.js" defer></script>
<script src="/workspace/app.js" type="module"></script>
</head>
<body class="ca-tools">
<a class="skip" href="#main">Skip to workspace</a>
<div class="app-shell">
<aside class="sidebar">
<a class="brand" href="#images"
><span class="brand-mark">P<span>±</span></span
><span>Prompt Manager<small>COMFYASSETS</small></span></a
>
<div class="sidebar-label">WORKSPACE</div>
<nav id="primary-nav" aria-label="Workspace"></nav>
<div class="sidebar-label">
SAVED VIEWS <span class="new-label">PROPOSED</span>
</div>
<nav id="saved-nav" aria-label="Saved views"></nav>
<div class="sidebar-label">
COLLECTIONS <span class="new-label">PROPOSED</span>
</div>
<nav id="collections-nav" aria-label="Collections"></nav>
<div class="sidebar-bottom">
<nav id="system-nav" aria-label="Tools and system"></nav>
<div class="connection">
<i></i>
<div>
ComfyUI workspace<small
>Demo connection · Classic / Nodes 2.0</small
>
</div>
</div>
</div>
</aside>
<div class="workspace">
<header class="topbar">
<div class="breadcrumbs">
ComfyAssets <span>/</span> <span id="breadcrumb">Images</span>
</div>
<div class="top-actions">
<span class="concept-badge">Interactive concept</span
><button id="activity-open" type="button">
Activity <span class="count" id="activity-count">2</span></button
><label class="theme-label" for="theme"
>Theme
<select id="theme" aria-label="Theme">
<option value="dark">Dark</option>
<option value="light">Light</option>
<option value="system">System</option>
</select></label
><a class="canvas-link" href="#send"
>ComfyUI <span aria-hidden="true">↗</span></a
>
</div>
</header>
<main id="main" tabindex="-1"></main>
<footer class="app-footer">
<span
>Design prototype · sample images and data · actions stay in this
tab</span
><button type="button" id="review-link">What changes & why</button>
</footer>
</div>
</div>
<div id="toast" class="toast" role="status" aria-atomic="true"></div>
<dialog id="modal" aria-labelledby="modal-title">
<div class="dialog-header">
<h2 id="modal-title"></h2>
<button type="button" id="modal-close" aria-label="Close dialog">
×
</button>
</div>
<div id="modal-body"></div>
</dialog>
<dialog id="viewer-dialog" aria-labelledby="viewer-title">
<div class="dialog-header">
<div>
<h2 id="viewer-title">Image details</h2>
<p class="muted small">
One viewer · image, prompts, generation data, workflow
</p>
</div>
<button type="button" id="viewer-close" aria-label="Close image viewer">
×
</button>
</div>
<div class="viewer-layout">
<div class="viewer-left">
<div id="viewer-stage"></div>
<div class="viewer-toolbar" id="viewer-toolbar"></div>
</div>
<aside id="viewer-details" aria-label="Image metadata"></aside>
</div>
</dialog>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 441 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 440 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 443 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 478 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

File diff suppressed because it is too large Load Diff
+111
View File
@@ -0,0 +1,111 @@
# Reusable positive and negative prompts
Tracking: [#193](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/193).
This work builds on the merged Split node in PR #192. Keep the feature unmerged
until maintainer live testing is complete.
## Storage contract
Keep existing prompt IDs, text hashes, metadata, image links and node schemas.
A component can serve either role. Ordered combinations refer to shared prompt
rows; an explicitly empty negative uses a null reference. Missing or ambiguous
metadata does not mean an empty negative. Pair metadata belongs to the
combination; saving another combination does not overwrite component metadata.
Existing trim/lowercase hash normalization remains unchanged.
Saved components are immutable while referenced. To change text, create or reuse
a component and save a new combination. Archive combinations that have generation
history. Pruning and legacy editing/deletion must respect these dependencies.
Historical workflow snapshots remain intact, including their original text.
## Migration and recovery
Create pair tables after existing legacy rebuild migrations, in an atomic,
repeatable migration. Never scan media during schema initialization. Test fresh,
legacy and current databases, repeated startup, injected failures and retries.
Preserve old backup compatibility. Migrate and validate a disposable backup copy
before replacing live database pages. Reject broken pair schemas, foreign keys,
triggers and views. Use SQLite backup APIs so committed WAL data is included.
## Metadata and scanning
Resolve positive and negative from the same sampler/output context. Track both
node ID and output slot; bound graph traversal and text construction. Unknown
conditioning operations and conflicting sampler candidates remain unresolved.
Never guess that the first encoder or most recent negative belongs to an image.
Import high-confidence pairs atomically with their image association and source
provenance. Repeated scans must not duplicate components, pairs or image links,
regenerate thumbnails, or increment generation run counts. Preserve existing
conflicting/manual associations for review. Report new, enriched, unchanged,
ambiguous, unresolved and failed results separately.
## Live node
Use the shared toolkit and Split foundation. Positive and negative editors have
equal size. Only positive receives prepend, append and automatic LoRA triggers.
Keep upstream connections authoritative. Library selection previews both fields
before an explicit load. Saving text changes creates another reusable combination.
Verify Classic and Nodes 2.0 startup, switching both directions, reload, caching,
linked inputs, popup behavior and lifecycle cleanup before maintainer testing.
## Delivery gate
Keep all existing coverage floors and test suites. Record regression failures
before implementing each behavior. Tests use disposable state and the managed
CPU host; never mutate the maintainer's running ComfyUI or personal database.
Python changes require a ComfyUI restart. Live approval is required before merge.
## Live testing
1. Download a database backup from Admin before the first restart. The additive
schema migration runs on startup; existing workflows and prompt IDs stay valid.
2. Restart ComfyUI, reload the browser, and add **Prompt Manager (+/-)** from
**ComfyAssets / Prompts**. Connect CLIP and the two conditioning outputs to the
sampler's positive and negative inputs.
3. Enter both texts, save a pair, then keep positive unchanged and change negative.
Recent should show both combinations with distinct negative excerpts. Selecting
one previews both; **Use pair** loads both together.
4. Test category/tag autocomplete and metadata opt-in, resizing, light/dark,
switching Classic / Nodes 2.0 both ways, and workflow reload. Connected fields
stay authoritative; run the node to save connected metadata values.
5. Queue the same recipe twice and change only negative once. Usage should count
dequeued jobs even when ComfyUI caches the node. Prepend, append and LoRA triggers
affect positive only. Gallery text is labelled as recorded during execution or
recovered from workflow metadata.
6. Run an output metadata scan to enrich existing images. Repeat it: pairs and
links must remain deduplicated, with unchanged results rather than new links.
Conflicting or ambiguous files need review and are preserved.
Fresh executions embed bounded text evidence in PNG metadata. A cached node may
not emit fresh evidence, so its graph-derived text is explicitly marked inferred.
Multiple distinct batch records without an image anchor remain unresolved.
Rescanning after interruption is safe through idempotent per-file writes; this
version does not persist a filesystem scan cursor across server restarts.
The API can request cancellation with `POST /prompt_manager/scan/cancel`; the
existing dashboard Hide button continues to hide progress without cancelling.
Pair APIs are under `/prompt_manager/pairs`: POST saves a combination, GET lists
and filters, GET/PATCH/DELETE `/{id}` reads, edits metadata or removes an unused
pair. Changing text creates another combination. Null metadata updates preserve
existing values; empty strings/lists clear supported fields. Archive a pair with
`PATCH {"archived":true}`. GET `/export` provides versioned JSON containing both
texts and pair metadata; this is an export format, not a JSON import endpoint.
## Gallery redesign concept retained
The maintainer approved the interactive
[negative variant timeline concept](concepts/prompt-variant-timeline.html) on
2026-10-17. Preserve it for the later gallery redesign; it is not production UI
and does not expand the current node work.
Keep one positive parent and one shared gallery, with red branches for reusable
negative variants and smaller dots for repeat runs. Show the selected negative
in full directly beneath the unchanged positive; selecting another variant or
run updates that text and filters the shared gallery. Comparison shows both
negatives. Distinguish explicitly empty negatives from an all-variants view and
saved variants from executed runs. Production history needs persisted execution
records; the prototype's timeline uses illustrative sample data.
The Admin upgrade/scan notice remains a separate follow-up: explain automatic
schema migration and offer an optional metadata enrichment scan or Later.
Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

+19
View File
@@ -281,3 +281,22 @@ class PromptManager(PromptManagerBase, ComfyNodeABC):
combined = f"{text}|{prepend_text}|{append_text}|{category}|{tags}"
return hashlib.sha256(combined.encode()).hexdigest()
class PromptManagerFocused(PromptManager):
"""The editor-first UI uses the existing encoding and storage contract."""
@classmethod
def INPUT_TYPES(cls) -> InputTypeDict:
inputs = super().INPUT_TYPES()
for name in ("prepend_text", "append_text"):
kind, options = inputs["optional"][name]
inputs["optional"][name] = (
kind,
{**options, "default": "", "forceInput": True},
)
return inputs
class PromptManagerSplit(PromptManagerFocused):
"""The persistent library layout shares Focused encoding and storage."""
+226
View File
@@ -0,0 +1,226 @@
"""Paired positive and negative conditioning with reusable recipe storage."""
import hashlib
import json
import time
from typing import Any, Dict, Optional, Tuple
try:
from .prompt_manager import IO, InputTypeDict, PromptManagerFocused
from .utils.hashing import generate_prompt_hash
from .utils.pair_execution import record_pair_execution
from .utils.prompt_graph import intrinsic_pair_nodes, run_prompt_components
from .utils.prompt_pair_resolver import resolve_text_input
from .utils.usage_tracking import (
PENDING_FIRST_USE,
PENDING_PAIR_FIRST_USE,
pair_run_key,
)
except ImportError:
from prompt_manager import IO, InputTypeDict, PromptManagerFocused
from utils.hashing import generate_prompt_hash
from utils.pair_execution import record_pair_execution
from utils.prompt_graph import intrinsic_pair_nodes, run_prompt_components
from utils.prompt_pair_resolver import resolve_text_input
from utils.usage_tracking import (
PENDING_FIRST_USE,
PENDING_PAIR_FIRST_USE,
pair_run_key,
)
class PromptManagerPair(PromptManagerFocused):
"""Keep raw editors as one recipe while encoding their effective texts."""
@classmethod
def INPUT_TYPES(cls) -> InputTypeDict:
inputs = super().INPUT_TYPES()
inputs["required"] = {
"text": inputs["required"]["text"],
"negative_text": (
IO.STRING,
{"default": "", "multiline": True, "dynamicPrompts": True},
),
"clip": inputs["required"]["clip"],
}
inputs["hidden"]["extra_pnginfo"] = "EXTRA_PNGINFO"
return inputs
RETURN_TYPES = (IO.CONDITIONING, IO.CONDITIONING, IO.STRING, IO.STRING)
RETURN_NAMES = ("positive", "negative", "positive_text", "negative_text")
OUTPUT_TOOLTIPS = (
"Positive conditioning with composition and optional LoRA trigger words.",
"Negative conditioning from the negative editor, including empty text.",
"The effective positive text that was encoded.",
"The unchanged negative text that was encoded.",
)
DESCRIPTION = "Encode and save a reusable positive/negative prompt pair."
def encode_prompt(
self,
clip: Any,
text: str,
negative_text: str = "",
category: str = "",
tags: str = "",
search_text: str = "",
prepend_text: str = "",
append_text: str = "",
prompt: Optional[Dict[str, Any]] = None,
unique_id: Optional[str] = None,
extra_pnginfo: Optional[Dict[str, Any]] = None,
) -> Tuple[Any, Any, str, str]:
"""Encode both sides once; database and tracking failures never block CLIP."""
if clip is None:
raise RuntimeError("ERROR: clip input is invalid: None")
if not isinstance(text, str) or not isinstance(negative_text, str):
raise ValueError("Positive and negative prompt text must be strings")
positive = self._inject_lora_trigger_words(
" ".join(
part
for part in (prepend_text.strip(), text, append_text.strip())
if part
)
)
positive_conditioning = clip.encode_from_tokens_scheduled(
clip.tokenize(positive)
)
negative_conditioning = clip.encode_from_tokens_scheduled(
clip.tokenize(negative_text)
)
record_pair_execution(
extra_pnginfo,
unique_id,
text=text,
negative_text=negative_text,
positive_text=positive,
negative_effective_text=negative_text,
prepend_text=prepend_text.strip(),
append_text=append_text.strip(),
)
pair_id = prompt_id = None
known = isinstance(prompt, dict) and unique_id is not None
node_id = str(unique_id)
used_fields = (
{
field
for source, field in run_prompt_components(prompt)
if source == node_id
}
if known
else {"text"}
)
is_positive = "text" in used_fields
pair_used = node_id in intrinsic_pair_nodes(prompt) if known else True
try:
pair_id = self.db.save_prompt_pair(
text,
negative_text,
category=category.strip() or None,
tags=self._parse_tags(tags),
)
pair = self.db.get_prompt_pair(pair_id)
prompt_id = pair["positive_prompt_id"]
resolved_positive = (
resolve_text_input(prompt, node_id, "text") if known else None
)
resolved_negative = (
resolve_text_input(prompt, node_id, "negative_text") if known else None
)
positive_seen = resolved_positive is not None and generate_prompt_hash(
resolved_positive
) == generate_prompt_hash(text)
pair_seen = (
positive_seen
and resolved_negative is not None
and generate_prompt_hash(resolved_negative)
== generate_prompt_hash(negative_text)
)
positive_pending = getattr(self, "pending_first_use", PENDING_FIRST_USE)
pair_pending = getattr(
self, "pending_pair_first_use", PENDING_PAIR_FIRST_USE
)
counted = set()
for field, component_text, resolved_text, component_id in (
("text", text, resolved_positive, prompt_id),
(
"negative_text",
negative_text,
resolved_negative,
pair["negative_prompt_id"],
),
):
component_hash = generate_prompt_hash(component_text)
if (
field not in used_fields
or not component_id
or component_hash in counted
):
continue
counted.add(component_hash)
seen_at_queue = (
resolved_text is not None
and generate_prompt_hash(resolved_text) == component_hash
)
runs = positive_pending.consume(component_hash) + int(not seen_at_queue)
if runs:
self.db.record_prompt_use(component_id, times=runs)
if pair_used:
runs = pair_pending.consume(pair_run_key(text, negative_text)) + int(
not pair_seen
)
if runs:
self.db.record_prompt_pair_use(pair_id, times=runs)
if is_positive:
self.prompt_tracker.set_current_prompt(
prompt_text=positive,
additional_data={
"prompt_id": prompt_id,
"pair_id": pair_id if pair_used else None,
"positive_text": positive,
"negative_text": negative_text,
"positive_storage_text": text.strip(),
"negative_storage_text": negative_text.strip(),
"prepend_text": prepend_text.strip(),
"append_text": append_text.strip(),
"final_text": positive,
"category": category.strip() or None,
"tags": self._parse_tags(tags) or [],
},
push_to_queue=not positive_seen or (pair_used and not pair_seen),
)
except Exception as error:
self.logger.warning(f"Could not save or track paired prompt: {error}")
try:
self.comfyui_integration.register_prompt(
f"promptmanager_pair_{time.time_ns()}",
positive,
{
"prompt_id": prompt_id,
"pair_id": pair_id if pair_used else None,
"negative_text": negative_text,
"category": category.strip() or None,
},
)
except Exception as error:
self.logger.warning(f"Could not register paired metadata: {error}")
return positive_conditioning, negative_conditioning, positive, negative_text
@classmethod
def IS_CHANGED(
cls,
clip: Any,
text: str = "",
negative_text: str = "",
category: str = "",
tags: str = "",
search_text: str = "",
prepend_text: str = "",
append_text: str = "",
**kwargs: Any,
) -> str:
"""Either editor, composition or pair metadata invalidates the node cache."""
serialized = json.dumps(
[text, negative_text, prepend_text, append_text, category, tags]
)
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
+11 -3
View File
@@ -22,6 +22,7 @@ from aiohttp import web
from PIL import Image
from .prompts import PromptRoutesMixin
from .pairs import PairRoutesMixin
from .images import ImageRoutesMixin
from .admin import AdminRoutesMixin
from .logging_routes import LoggingRoutesMixin
@@ -84,6 +85,7 @@ def _public_error(exc: BaseException) -> str:
# doubles as the allow-list.
STATIC_MIME_TYPES = {
".js": "application/javascript",
".mjs": "application/javascript",
".css": "text/css",
".map": "application/json",
".woff": "font/woff",
@@ -240,6 +242,7 @@ async def _gzip_middleware(request, handler):
class PromptManagerAPI(
PairRoutesMixin,
PromptRoutesMixin,
ImageRoutesMixin,
AdminRoutesMixin,
@@ -262,12 +265,12 @@ class PromptManagerAPI(
db (PromptDatabase): Database connection and operations handler
"""
def __init__(self):
"""Initialize the PromptManager API with database connection and cleanup."""
def __init__(self, db: PromptDatabase | None = None) -> None:
"""Initialize the API, using an injected database before any cleanup."""
self.logger = get_logger("prompt_manager.api")
self.logger.info("Initializing PromptManager API")
self.db = PromptDatabase()
self.db = db if db is not None else PromptDatabase()
self._cached_output_dir = None # Lazy-cached by _find_comfyui_output_dir()
self._html_cache = {} # Cached HTML file contents keyed by path
self._gallery_cache = {} # dict: path_str -> (files, timestamp)
@@ -361,6 +364,10 @@ class PromptManagerAPI(
# ── Web UI serving routes ─────────────────────────────────────
@routes.get("/prompt_manager/workspace")
async def serve_workspace_ui(request: web.Request) -> web.Response:
return self._serve_html_page("workspace.html", "Workspace")
@routes.get("/prompt_manager/web")
async def serve_web_ui(request):
return self._serve_html_page("index.html", "Web UI", cache=False)
@@ -391,6 +398,7 @@ class PromptManagerAPI(
# ── Register domain-specific routes from mixins ───────────────
self._register_pair_routes(routes)
self._register_prompt_routes(routes)
self._register_image_routes(routes)
self._register_admin_routes(routes)
+87 -12
View File
@@ -26,8 +26,12 @@ except ImportError:
try:
from ...database.operations import PromptDatabase
from ...database.pair_ingestion import ingest_resolved_pair
from ...utils.prompt_pair_resolver import resolve_prompt_pair
except ImportError:
from database.operations import PromptDatabase
from database.pair_ingestion import ingest_resolved_pair
from utils.prompt_pair_resolver import resolve_prompt_pair
try:
@@ -40,6 +44,20 @@ VIDEO_EXTENSIONS = (".mp4", ".avi", ".mov", ".mkv", ".webm", ".m4v", ".wmv")
SCAN_BATCH_SIZE = 50 # Files per executor call during the output scan
MAX_SAFETY_BACKUPS = 10 # '<db>.backup_*' files kept next to the live database
MAX_SCAN_FILES = 50_000 # Upper bound on media files one scan will look at
SCAN_COUNT_KEYS = (
"processed",
"found",
"added",
"linked",
"enriched",
"unchanged",
"ambiguous",
"unresolved",
"conflict",
"failed",
"pairs_added",
"pair_links_added",
)
MAX_SCAN_DEPTH = 12 # Directory levels below an output root that are scanned
# Files the duplicate cleaner may delete: everything the scan reports, plus
# the common single-f TIFF spelling. Anything else inside the output
@@ -359,6 +377,10 @@ class AdminRoutesMixin:
async def scan_images_route(request):
return await self.scan_images(request)
@routes.post("/prompt_manager/scan/cancel")
async def cancel_scan_route(request):
return await self.cancel_scan(request)
@routes.get("/prompt_manager/scan/status")
async def scan_status_route(request):
return await self.scan_status(request)
@@ -1280,6 +1302,14 @@ class AdminRoutesMixin:
job = self._start_scan_job()
return await self._stream_scan_job(request, job)
async def cancel_scan(self, request):
"""Stop between file transactions; an in-flight extraction can finish."""
job = self._scan_job
requested = bool(job and job.running)
if requested:
job.cancelled = True
return web.json_response({"success": True, "cancellation_requested": requested})
async def scan_status(self, request):
"""Whether an output scan is running, and its latest progress event."""
job = self._scan_job
@@ -1351,7 +1381,7 @@ class AdminRoutesMixin:
media_files = await self._run_in_executor(
self._collect_output_media_sync, output_dirs
)
counts = {"processed": 0, "found": 0, "added": 0, "linked": 0}
counts = dict.fromkeys(SCAN_COUNT_KEYS, 0)
if media_files:
yield self._scan_progress(
5, f"Found {len(media_files)} media files to process...", counts
@@ -1364,7 +1394,9 @@ class AdminRoutesMixin:
f"found={counts['found']}, new_prompts_added={counts['added']}, "
f"images_linked_to_existing={counts['linked']}"
)
yield {"type": "complete", **counts, "directories": dir_names}
job = self._scan_job
final_type = "cancelled" if job and job.cancelled else "complete"
yield {"type": final_type, **counts, "directories": dir_names}
except Exception:
self.logger.exception("Scan error")
@@ -1380,6 +1412,8 @@ class AdminRoutesMixin:
"""
total = len(media_files)
for batch_start in range(0, total, SCAN_BATCH_SIZE):
if self._scan_job and self._scan_job.cancelled:
return
batch = media_files[batch_start : batch_start + SCAN_BATCH_SIZE]
batch_results = await self._run_in_executor(
self._extract_batch_metadata_sync, batch
@@ -1405,7 +1439,7 @@ class AdminRoutesMixin:
"progress": progress,
"status": status,
"processed": counts.get("processed", 0),
"found": counts.get("found", 0),
**{key: counts.get(key, 0) for key in SCAN_COUNT_KEYS},
}
def _collect_output_media_sync(self, output_dirs):
@@ -1431,25 +1465,64 @@ class AdminRoutesMixin:
def _ingest_scan_batch_sync(self, batch_results):
"""Store the prompts found in a metadata batch (blocking DB work)."""
counts = {"processed": 0, "found": 0, "added": 0, "linked": 0}
counts = dict.fromkeys(SCAN_COUNT_KEYS, 0)
for media_file, metadata in batch_results:
if self._scan_job and self._scan_job.cancelled:
break
counts["processed"] += 1
try:
outcome = self._ingest_scanned_file(media_file, metadata)
outcome = self._ingest_scanned_file(media_file, metadata, detailed=True)
except Exception as e:
self.logger.error(f"Error processing {media_file.name}: {e}")
counts["failed"] += 1
continue
if outcome is None:
continue
counts["found"] += 1
if outcome in ("added", "linked"):
counts[outcome] += 1
status = outcome["status"] if isinstance(outcome, dict) else outcome
if status in (
"added",
"linked",
"enriched",
"unchanged",
"ambiguous",
"unresolved",
"conflict",
"failed",
):
counts[status] += 1
if isinstance(outcome, dict):
counts["pairs_added"] += outcome.get("pairs_added", 0)
counts["pair_links_added"] += outcome.get("links_added", 0)
return counts
def _ingest_scanned_file(self, media_file, metadata):
"""Save or link one scanned file; returns 'added', 'linked', 'found' or None."""
def _ingest_scanned_file(self, media_file, metadata, detailed=False):
"""Enrich exact pairs; retain legacy positive-only import for absent pairs."""
if not metadata:
return None
canonical = dict(metadata)
for key in ("prompt", "workflow"):
if isinstance(canonical.get(key), str):
try:
canonical[key] = json.loads(canonical[key])
except (ValueError, TypeError):
# Preserve malformed metadata for conservative resolver rejection.
pass
if not canonical.get("prompt") and not canonical.get("parameters"):
return None
resolution = resolve_prompt_pair(canonical)
if resolution["state"] == "resolved":
result = ingest_resolved_pair(
self.db, str(media_file), canonical, resolution
)
return result if detailed else result["status"]
if resolution["state"] == "ambiguous":
return "ambiguous"
if resolution["state"] == "unresolved":
if canonical.get("prompt") or not isinstance(
canonical.get("parameters"), str
):
return resolution["state"]
parsed = self._parse_comfyui_prompt(metadata)
if not (parsed.get("prompt") or parsed.get("parameters")):
return None
@@ -1465,13 +1538,15 @@ class AdminRoutesMixin:
existing = self.db.get_prompt_by_hash(prompt_hash)
if existing:
try:
self.db.link_image_to_prompt(existing["id"], str(media_file))
image_id = self.db.link_image_to_prompt(
existing["id"], str(media_file), metadata=canonical
)
except Exception as e:
self.logger.error(
f"Failed to link {media_file.name} to existing prompt: {e}"
)
return "found"
return "linked"
return "linked" if image_id else "unchanged"
prompt_id = self.db.save_prompt(
prompt_text,
@@ -1484,7 +1559,7 @@ class AdminRoutesMixin:
if not prompt_id:
return "found"
try:
self.db.link_image_to_prompt(prompt_id, str(media_file))
self.db.link_image_to_prompt(prompt_id, str(media_file), metadata=canonical)
except Exception as e:
self.logger.error(f"Failed to link {media_file.name} to new prompt: {e}")
return "added"
+220 -4
View File
@@ -1,7 +1,10 @@
"""Image and gallery API routes for PromptManager."""
import asyncio
import base64
import binascii
import hashlib
import hmac
import json
import os
import re
@@ -228,6 +231,70 @@ MAX_JSON_BODY_BYTES = 1024 * 1024
_READ_CHUNK = 64 * 1024
_WORKSPACE_CURSOR_SECRET = os.urandom(32)
def _workspace_root(query: dict, roots: list[Path]) -> int | None:
value = query.get("root")
if value is None:
return None
try:
index = int(value)
except ValueError:
raise ValueError("Invalid gallery root") from None
if not 0 <= index < len(roots):
raise ValueError("Invalid gallery root")
return index
def _workspace_key(entry: tuple, order: str) -> tuple:
path, mtime, root, index = entry
relative = path.relative_to(root).as_posix()
tie = hashlib.sha256(relative.encode("utf-8")).hexdigest()
name = path.name.casefold()
if order in ("name", "name-desc"):
return (name, tie, index, mtime)
return ((-mtime if order == "newest" else mtime), name, tie, index)
def _workspace_cursor(key: tuple, scope: str) -> str:
payload = json.dumps(
{"v": 1, "scope": scope, "key": key}, separators=(",", ":"), ensure_ascii=False
).encode()
signature = hmac.new(_WORKSPACE_CURSOR_SECRET, payload, hashlib.sha256).digest()
return base64.urlsafe_b64encode(signature + payload).decode().rstrip("=")
def _workspace_cursor_key(token: str, scope: str, order: str) -> tuple:
try:
if len(token) > 4096:
raise ValueError()
raw = base64.b64decode(
token + "=" * (-len(token) % 4), altchars=b"-_", validate=True
)
signature, payload = raw[:32], raw[32:]
expected = hmac.new(_WORKSPACE_CURSOR_SECRET, payload, hashlib.sha256).digest()
if not hmac.compare_digest(signature, expected):
raise ValueError()
data = json.loads(payload)
key = data["key"]
if data["v"] != 1 or data["scope"] != scope or not isinstance(key, list):
raise ValueError()
types = (
(str, str, int, (float, int))
if order in ("name", "name-desc")
else ((float, int), str, str, int)
)
if len(key) != len(types) or not all(
type(value) in (kind if isinstance(kind, tuple) else (kind,))
for value, kind in zip(key, types)
):
raise ValueError()
return tuple(key)
except (ValueError, KeyError, TypeError, UnicodeError, binascii.Error):
raise ValueError("Invalid gallery cursor; refresh the gallery") from None
class _BodyTooLarge(Exception):
"""Raised when a request body exceeds MAX_JSON_BODY_BYTES."""
@@ -594,6 +661,8 @@ class ImageRoutesMixin:
"""Get all images from ComfyUI output folder(s)."""
try:
output_dirs = self._get_all_output_dirs()
if request.query.get("mode") == "workspace":
return await self._get_workspace_images(request, list(output_dirs))
if not output_dirs:
return web.json_response(
{
@@ -629,6 +698,119 @@ class ImageRoutesMixin:
{"success": False, "error": _safe_error(e)}, status=500
)
async def _get_workspace_images(
self, request, output_dirs: list[Path]
) -> web.Response:
try:
limit, _offset = parse_page_params(request.query, default_limit=100)
except ValueError:
return bad_request("limit and offset must be integers")
try:
root_index = _workspace_root(request.query, output_dirs)
query = request.query.get("q", "").strip().casefold()
media = request.query.get("media", "all")
order = request.query.get("sort", "newest")
folder = request.query.get("subfolder", "").strip().replace("\\", "/")
if (
len(query) > 500
or media not in ("all", "image", "video")
or order not in ("newest", "oldest", "name", "name-desc")
):
raise ValueError("Invalid gallery filters")
if folder and (_has_traversal(folder) or len(folder) > 1024):
raise ValueError("Invalid gallery folder")
scope = hashlib.sha256(
json.dumps(
[
query,
media,
order,
folder,
root_index,
[str(root) for root in output_dirs],
]
).encode()
).hexdigest()
token = request.query.get("cursor")
after = _workspace_cursor_key(token, scope, order) if token else None
except ValueError as exc:
return bad_request(str(exc))
listings = [
(index, root, await self._get_gallery_files(root))
for index, root in enumerate(output_dirs)
if root_index is None or index == root_index
]
page, total, has_more, next_cursor = await self._run_in_executor(
self._select_workspace_page,
listings,
folder,
query,
media,
order,
after,
limit,
scope,
)
images = await self._run_in_executor(self._format_workspace_page, page)
return web.json_response(
{
"success": True,
"images": images,
"total": total,
"limit": limit,
"has_more": has_more,
"next_cursor": next_cursor,
}
)
@staticmethod
def _select_workspace_page(
listings: list[tuple],
folder: str,
query: str,
media: str,
order: str,
after: tuple | None,
limit: int,
scope: str,
) -> tuple:
entries = []
for index, root, files in listings:
for path, mtime in files:
relative = path.relative_to(root)
if folder and not _in_subfolder(
str(relative.parent), folder.replace("/", os.sep)
):
continue
if query and query not in relative.as_posix().casefold():
continue
kind = "video" if path.suffix.lower() in VIDEO_EXTENSIONS else "image"
if media != "all" and media != kind:
continue
entries.append((path, mtime, root, index))
total = len(entries)
reverse = order == "name-desc"
entries.sort(key=lambda entry: _workspace_key(entry, order), reverse=reverse)
if after is not None:
entries = [
entry
for entry in entries
if (
_workspace_key(entry, order) < after
if reverse
else _workspace_key(entry, order) > after
)
]
page = entries[:limit]
has_more = len(entries) > limit
next_cursor = (
_workspace_cursor(_workspace_key(page[-1], order), scope)
if has_more
else None
)
return page, total, has_more, next_cursor
async def serve_image(self, request):
"""Serve the actual image file using streamed FileResponse."""
try:
@@ -775,7 +957,20 @@ class ImageRoutesMixin:
async def get_gallery_subfolders(self, request):
"""Get distinct subfolders from gallery output directories."""
try:
output_dirs = self._get_all_output_dirs()
output_dirs = list(self._get_all_output_dirs())
workspace = request.query.get("mode") == "workspace"
roots = None
if workspace:
try:
selected = _workspace_root(request.query, output_dirs)
except ValueError as exc:
return bad_request(str(exc))
roots = [
{"index": index, "label": _public_path(root)}
for index, root in enumerate(output_dirs)
]
if selected is not None:
output_dirs = [output_dirs[selected]]
subfolders = set()
for output_path in output_dirs:
files = await self._get_gallery_files(output_path)
@@ -795,9 +990,10 @@ class ImageRoutesMixin:
ancestors.add("/".join(parts[:i]))
subfolders.update(ancestors)
return web.json_response(
{"success": True, "subfolders": sorted(subfolders)}
)
payload = {"success": True, "subfolders": sorted(subfolders)}
if workspace:
payload["roots"] = roots
return web.json_response(payload)
except Exception as e:
self.logger.error(f"Subfolders error: {e}")
return web.json_response(
@@ -1529,6 +1725,26 @@ class ImageRoutesMixin:
if _in_subfolder(str(entry[0].relative_to(entry[2]).parent), subfolder)
]
def _format_workspace_page(self, page: list[tuple]) -> list[dict]:
images = self._format_output_page(page)
paths = {
(entry[3], entry[0].relative_to(entry[2]).as_posix()): entry[0]
for entry in page
}
for image in images:
image["width"] = None
image["height"] = None
if image["is_video"]:
continue
try:
with paths[(image["root_index"], image["path"])].open("rb") as stream:
with Image.open(stream) as source:
image["width"], image["height"] = source.size
except (OSError, ValueError, Image.DecompressionBombError):
# A corrupt or disappearing preview must not hide the listing.
continue
return images
def _format_output_page(self, page):
entries = []
public_roots = {}
+204
View File
@@ -0,0 +1,204 @@
"""Reusable ordered prompt combinations, separate from legacy prompt CRUD."""
from functools import wraps
from typing import Callable
from aiohttp import web
from .prompts import parse_page_params
try:
from ...database.pairs import PairDependencyError
from ...utils.validators import (
validate_category,
validate_prompt_text,
validate_rating,
validate_tags,
)
except ImportError:
from database.pairs import PairDependencyError
from utils.validators import (
validate_category,
validate_prompt_text,
validate_rating,
validate_tags,
)
def pair_response(handler: Callable) -> Callable:
@wraps(handler)
async def wrapped(self, request: web.Request) -> web.Response:
try:
return await handler(self, request)
except PairDependencyError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=409)
except (ValueError, TypeError) as exc:
return web.json_response({"success": False, "error": str(exc)}, status=400)
except Exception:
self.logger.exception("Prompt pair operation failed")
return web.json_response(
{"success": False, "error": "Prompt pair operation failed"}, status=500
)
return wrapped
def metadata_fields(data: dict) -> dict:
fields = {}
for name in ("title", "category", "tags", "rating", "notes"):
if name not in data:
continue
value = data[name]
if name == "tags":
if not isinstance(value, list):
raise ValueError("Tags must be a list of strings")
validate_tags(value)
elif name == "rating":
if isinstance(value, bool):
raise ValueError("Rating must be an integer")
validate_rating(value)
else:
if value is not None and not isinstance(value, str):
raise ValueError(f"{name} must be a string")
if name == "category":
validate_category(value)
elif value is not None and len(value) > 10000:
raise ValueError(f"{name} is too long")
fields[name] = value
return fields
async def object_body(request: web.Request) -> dict:
data = await request.json()
if not isinstance(data, dict):
raise ValueError("Expected a JSON object")
return data
def missing_pair() -> web.Response:
return web.json_response({"success": False, "error": "Pair not found"}, status=404)
class PairRoutesMixin:
def _register_pair_routes(self, routes: web.RouteTableDef) -> None:
routes.get("/prompt_manager/pairs/export")(self.export_prompt_pairs)
routes.get("/prompt_manager/pairs")(self.list_prompt_pairs)
routes.post("/prompt_manager/pairs")(self.save_prompt_pair)
routes.get("/prompt_manager/pairs/{pair_id}")(self.get_prompt_pair)
routes.patch("/prompt_manager/pairs/{pair_id}")(self.update_prompt_pair)
routes.delete("/prompt_manager/pairs/{pair_id}")(self.delete_prompt_pair)
@pair_response
async def save_prompt_pair(self, request: web.Request) -> web.Response:
data = await object_body(request)
positive = data.get("positive_text")
negative = data.get("negative_text")
validate_prompt_text(positive)
if not isinstance(negative, str):
raise ValueError(
"negative_text must be explicit; use an empty string for none"
)
if negative.strip():
validate_prompt_text(negative)
fields = metadata_fields(data)
pair_id = await self._run_in_executor(
self.db.save_prompt_pair, positive, negative, **fields
)
pair = await self._run_in_executor(self.db.get_prompt_pair, pair_id)
return web.json_response({"success": True, "pair": pair})
@pair_response
async def get_prompt_pair(self, request: web.Request) -> web.Response:
pair = await self._run_in_executor(
self.db.get_prompt_pair, int(request.match_info["pair_id"])
)
return (
web.json_response({"success": True, "pair": pair})
if pair
else missing_pair()
)
@pair_response
async def list_prompt_pairs(self, request: web.Request) -> web.Response:
limit, offset = parse_page_params(request.query)
archived = request.query.get("include_archived", "false")
if archived not in ("true", "false"):
raise ValueError("include_archived must be true or false")
result = await self._run_in_executor(
self.db.list_prompt_pairs,
text=request.query.get("text"),
category=request.query.get("category"),
tags=request.query.getall("tag", []),
include_archived=archived == "true",
sort=request.query.get("sort", "newest"),
limit=limit,
offset=offset,
)
return web.json_response({"success": True, **result})
@pair_response
async def update_prompt_pair(self, request: web.Request) -> web.Response:
pair_id = int(request.match_info["pair_id"])
data = await object_body(request)
allowed = {"title", "category", "tags", "rating", "notes", "archived"}
if not data or set(data) - allowed:
raise ValueError(
"Update metadata or archive; save a new pair to change text"
)
fields = metadata_fields(data)
if "archived" in data:
if not isinstance(data["archived"], bool) or fields:
raise ValueError("Archive must be a separate boolean update")
updated = await self._run_in_executor(
self.db.archive_prompt_pair, pair_id, data["archived"]
)
else:
updated = await self._run_in_executor(
self.db.update_prompt_pair_metadata, pair_id, **fields
)
pair = await self._run_in_executor(self.db.get_prompt_pair, pair_id)
if not updated and not pair:
return missing_pair()
return web.json_response({"success": True, "pair": pair})
@pair_response
async def delete_prompt_pair(self, request: web.Request) -> web.Response:
deleted = await self._run_in_executor(
self.db.delete_prompt_pair, int(request.match_info["pair_id"])
)
return web.json_response({"success": True}) if deleted else missing_pair()
@pair_response
async def export_prompt_pairs(self, request: web.Request) -> web.Response:
pairs = []
offset = 0
while True:
result = await self._run_in_executor(
self.db.list_prompt_pairs,
include_archived=True,
limit=500,
offset=offset,
)
for pair in result["pairs"]:
pairs.append(
{
key: pair[key]
for key in (
"positive_text",
"negative_text",
"title",
"category",
"tags",
"rating",
"notes",
"archived",
)
}
)
offset += len(result["pairs"])
if not result["has_more"] or not result["pairs"]:
break
return web.json_response(
{"schema_version": 1, "kind": "prompt-manager-pairs", "pairs": pairs},
headers={"Content-Disposition": 'attachment; filename="prompt-pairs.json"'},
)
+11
View File
@@ -6,6 +6,11 @@ import os
import sqlite3
from aiohttp import web
try:
from ...database.pairs import PairDependencyError
except ImportError:
from database.pairs import PairDependencyError
from urllib.parse import quote
# Upper bounds on page sizes and offsets for every list endpoint. The offset
@@ -793,6 +798,8 @@ class PromptRoutesMixin:
status=404,
)
except PairDependencyError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=409)
except ValueError:
return web.json_response(
{"success": False, "error": "Invalid prompt ID"}, status=400
@@ -840,6 +847,8 @@ class PromptRoutesMixin:
{"success": False, "error": "Prompt not found"}, status=404
)
except PairDependencyError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=409)
except sqlite3.IntegrityError:
return web.json_response(
{"success": False, "error": "Another prompt already has this text"},
@@ -1081,6 +1090,8 @@ class PromptRoutesMixin:
}
)
except PairDependencyError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=409)
except Exception as e:
self.logger.error(f"Bulk delete error: {e}")
return web.json_response(
+1
View File
@@ -16,6 +16,7 @@ class ScanJob:
def __init__(self):
self.last_event = None
self.finished = False
self.cancelled = False
self.task = None
self._subscribers = set()
+489
View File
@@ -0,0 +1,489 @@
import {readFileSync} from 'node:fs';
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
for (const variant of ['Focused', 'Split']) {
test.describe(variant, () => {
const isSplit = variant === 'Split';
const uiProperty = isSplit ? 'splitUI' : 'focusedUI';
async function openLibrary(page) {
if (isSplit) {
await expect(page.locator('.pm-editor-library')).toBeVisible();
await expect(page.getByRole('button',{name:'Browse library',exact:true})).toHaveCount(0);
} else await page.getByRole('button',{name:'Browse library',exact:true}).click();
}
test.beforeEach(async({page,browserName})=>{
await page.addInitScript(()=>Object.defineProperty(Crypto.prototype,'randomUUID',{value:undefined,configurable:true}));
await startCoverage(page,browserName);
page.focusedErrors=[];page.on('pageerror',error=>page.focusedErrors.push(error.message));
await page.route('**/scripts/app.js',route=>route.fulfill({contentType:'text/javascript',body:'export const app=window.fixtureApp;'}));
await page.route('**/scripts/api.js',route=>route.fulfill({contentType:'text/javascript',body:'export const api={fileURL:path=>path,queuePrompt:async(...args)=>args};'}));
await page.route('**/extensions/promptmanager/prompt_manager.js',route=>route.fulfill({contentType:'text/javascript',body:readFileSync(new URL('../../web/comfy/prompt_manager.js',import.meta.url),'utf8')}));
await page.goto('/prompt_manager/test');
await page.evaluate(async variant=>{
document.body.replaceChildren();document.body.style.setProperty('--bg-color','#222');
window.fixtureApp={registerExtension(extension){this.extension=extension;},ui:{settings:new EventTarget()},graph:{setDirtyCanvas(){}},canvas:{}};
await import('/extensions/promptmanager/prompt_manager.js');
class Node {
constructor(){this.widgets=['text','category','tags','search_text'].map(name=>({name,type:'text',value:'',options:{},inputEl:document.createElement('textarea')}));this.inputs=['clip','prepend_text','append_text'].map(name=>({name,link:null}));this.properties={};this.size=[550,700];}
addWidget(type,name,value,callback,options){const widget={type,name,value,callback,options};this.widgets.push(widget);return widget;}
addDOMWidget(name,type,element){const host=document.createElement('div');host.style.height='650px';host.className='fixture-dom-widget';host.append(element);document.body.append(host);return {element};}
setSize(size){this.size=size;}setDirtyCanvas(){}
serialize(){return {widgets_values:this.widgets.map(widget=>widget.value),properties:{...this.properties}};}
configure(data){this.widgets.forEach((widget,index)=>{widget.value=data.widgets_values[index];});this.properties={...data.properties};}
}
await fixtureApp.extension.beforeRegisterNodeDef(Node,{name:`PromptManager${variant}`},fixtureApp);
window.node=new Node();node.onNodeCreated();
},variant);
await expect(page.locator('.pm-focused')).toBeVisible();
});
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.focusedErrors).toEqual([]);});
test(`${variant} library autocompletes filters and searches without changing editor metadata`, async ({page}) => {
const requests=[];
await page.route('**/prompt_manager/categories',route=>route.fulfill({json:{success:true,categories:['Portrait']}}));
await page.route('**/prompt_manager/tags/suggest?**',route=>route.fulfill({json:{success:true,suggestions:[{name:'studio'}]}}));
await page.route('**/prompt_manager/search?**',route=>{
requests.push(new URL(route.request().url()));
return route.fulfill({json:{success:true,results:[{text:'Filtered portrait',category:'Portrait',tags:['studio']}]}});
});
await page.getByLabel('Main prompt',{exact:true}).fill('My draft');
await openLibrary(page);
const category=page.getByLabel('Filter category',{exact:true});
await category.fill('por');
await expect(page.getByRole('option',{name:'Portrait',exact:true})).toBeVisible();
await category.press('ArrowDown');
await category.press('Enter');
expect(requests).toHaveLength(0);
const tags=page.getByLabel('Filter tags',{exact:true});
await tags.fill('stu');
await page.getByRole('option',{name:'studio',exact:true}).click();
await expect(tags).toHaveValue('studio, ');
await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('portrait tag:studio tag:"film grain"');
await page.getByRole('button',{name:'Search',exact:true}).click();
await expect(page.locator('.pm-editor-result')).toContainText('Filtered portrait');
expect(requests[0].searchParams.get('text')).toBe('portrait');
expect(requests[0].searchParams.get('category')).toBe('Portrait');
expect(requests[0].searchParams.get('tags')).toBe('studio,film grain');
expect(await page.evaluate(()=>node.serialize().widgets_values.slice(0,3))).toEqual(['My draft','','']);
await page.locator('.pm-editor-result').click();
await category.fill('Landscape');
await expect(page.locator('.pm-editor-selection button')).toBeDisabled();
const saved=await page.evaluate(()=>{const saved=node.serialize();node.configure(saved);return saved;});
expect(saved.properties[uiProperty].libraryFilters).toEqual({category:'Landscape',tags:'studio, '});
await expect(category).toHaveValue('Landscape');
await expect(tags).toHaveValue('studio, ');
let recentRequest;
await page.route('**/prompt_manager/recent?**',route=>{recentRequest=new URL(route.request().url());return route.fulfill({json:{success:true,results:[]}});});
await page.getByRole('button',{name:'Recent',exact:true}).click();
await expect.poll(()=>Boolean(recentRequest)).toBe(true);
expect(recentRequest.searchParams.has('category')).toBe(false);
expect(recentRequest.searchParams.has('tags')).toBe(false);
if (isSplit) await page.getByRole('searchbox',{name:'Search saved prompts'}).focus();
else await page.getByRole('button',{name:'Hide library',exact:true}).click();
await expect(page.getByRole('listbox')).toBeHidden();
});
test(`${variant} library can search by filters alone and Enter searches after autocomplete closes`, async ({page}) => {
const requests=[];
await page.route('**/prompt_manager/search?**',route=>{requests.push(new URL(route.request().url()));return route.fulfill({json:{success:true,results:[]}});});
await openLibrary(page);
await page.getByLabel('Filter category',{exact:true}).fill('New category');
await page.getByLabel('Filter category',{exact:true}).press('Enter');
await expect.poll(()=>requests.length).toBe(1);
expect(requests[0].searchParams.get('category')).toBe('New category');
await page.getByLabel('Filter category',{exact:true}).fill('');
await page.getByLabel('Filter tags',{exact:true}).fill('custom, ');
await page.getByLabel('Filter tags',{exact:true}).press('Enter');
await expect.poll(()=>requests.length).toBe(2);
expect(requests[1].searchParams.get('tags')).toBe('custom');
});
test(`${variant} completes saved tags with keyboard and pointer and appends commas`, async ({page}) => {
await page.route('**/prompt_manager/tags/suggest?**', route => {
const q = new URL(route.request().url()).searchParams.get('q');
return route.fulfill({json:{success:true,suggestions:(q === 'bl' ? ['blue eyes','blue sky'] : ['portrait','blue eyes']).map(name => ({name}))}});
});
await page.getByText('Metadata · category / tags',{exact:true}).click();
const tags = page.getByLabel('Tags',{exact:true});
await tags.fill('portrait, bl');
await expect(page.getByRole('option',{name:'blue eyes',exact:true})).toBeVisible();
await tags.press('ArrowDown');
await tags.press('Enter');
await expect(tags).toHaveValue('portrait, blue eyes, ');
expect(await page.evaluate(()=>node.serialize().widgets_values[2])).toBe('portrait, blue eyes, ');
await tags.fill('bl');
await page.getByRole('option',{name:'blue sky',exact:true}).click();
await expect(tags).toHaveValue('blue sky, ');
await tags.fill('portrait, ');
await expect(page.getByRole('option',{name:'portrait',exact:true})).toHaveCount(0);
await expect(page.getByRole('option',{name:'blue eyes',exact:true})).toBeVisible();
await tags.press('Escape');
await expect(page.getByRole('listbox')).toBeHidden();
await tags.fill('bl');
await expect(page.getByRole('option',{name:'blue eyes',exact:true})).toBeVisible();
await page.locator('.pm-focused').dispatchEvent('scroll');
await expect(page.getByRole('listbox')).toBeHidden();
await tags.fill('A brand new tag');
await tags.press('Tab');
await expect(tags).toHaveValue('A brand new tag');
});
test(`${variant} metadata autocomplete ignores stale responses, recovers from errors and cleans up`, async ({page}) => {
let release;
let mode='delayed';
await page.route('**/prompt_manager/tags/suggest?**', async route => {
const q = new URL(route.request().url()).searchParams.get('q');
if(q === 'old') await new Promise(resolve=>{release=resolve;});
if(mode === 'error') return route.fulfill({status:503,body:'Unavailable'});
await route.fulfill({json:{success:true,suggestions:[{name:q+' result'}]}}).catch(()=>{});
});
await page.getByText('Metadata · category / tags',{exact:true}).click();
const tags=page.getByLabel('Tags',{exact:true});
await tags.fill('old');
await expect.poll(()=>Boolean(release)).toBe(true);
await tags.fill('new');
await expect(page.getByRole('option',{name:'new result',exact:true})).toBeVisible();
release();
await expect(page.getByRole('option',{name:'old result',exact:true})).toHaveCount(0);
mode='error';
await tags.fill('unavailable');
await expect(page.getByRole('listbox')).toBeHidden();
await expect(tags).toHaveValue('unavailable');
mode='ok';
await tags.fill('retry');
await expect(page.getByRole('option',{name:'retry result',exact:true})).toBeVisible();
await page.evaluate(()=>node.onRemoved());
await expect(page.getByRole('listbox')).toHaveCount(0);
});
test(`${variant} completes one category, allows new values and dismisses suggestions on blur`, async ({page}) => {
await page.route('**/prompt_manager/categories',route=>route.fulfill({json:{success:true,categories:['Portrait','Landscape','Portrait studio']}}));
await page.getByText('Metadata · category / tags',{exact:true}).click();
const category=page.getByLabel('Category',{exact:true});
await category.fill('por');
await expect(page.getByRole('listbox',{name:'Saved categories'})).toBeVisible();
await category.press('ArrowUp');
await expect(page.getByRole('option',{name:'Portrait studio',exact:true})).toHaveAttribute('aria-selected','true');
await category.press('Enter');
await expect(category).toHaveValue('Portrait studio');
expect(await page.evaluate(()=>node.serialize().widgets_values[1])).toBe('Portrait studio');
await category.fill('por');
await expect(page.getByRole('option',{name:'Portrait',exact:true})).toBeVisible();
await category.dispatchEvent('keydown',{key:'ArrowDown',isComposing:true});
await expect(category).not.toHaveAttribute('aria-activedescendant',/.+/);
await page.getByLabel('Tags',{exact:true}).focus();
await expect(page.getByRole('listbox',{name:'Saved categories'})).toBeHidden();
await category.fill('New category');
await expect(page.getByRole('listbox',{name:'Saved categories'})).toBeHidden();
await expect(category).toHaveValue('New category');
});
test(`${variant} shares suggestion controls safely with another bundled toolkit copy`, async ({page}) => {
await page.evaluate(async()=>{
const {createScope}=await import('/prompt_manager/js/shared/toolkit/core.js');
const first=await import('/prompt_manager/js/shared/toolkit/suggestions.js?pack=first');
const second=await import('/prompt_manager/js/shared/toolkit/suggestions.js?pack=second');
window.completionFixture={scope:createScope(),selected:[],inserted:[]};
const one=first.createSuggestionView(completionFixture.scope,null);
const two=second.createSuggestionView(completionFixture.scope,null,{label:'Other pack'});
completionFixture.views=[one,two];
one.hide();
const items=[{name:'plain'},{name:'tag',type:'tag'},{name:'model',type:'lora'},{name:'nested',type:'embedding',fullPath:'folder/nested'},{name:'flat',type:'embedding',fullPath:'flat'}];
one.render(items,index=>{completionFixture.selected.push(index);one.select(index);},item=>completionFixture.inserted.push(item.name));
one.select(-1);
one.position({left:innerWidth-5,bottom:innerHeight,top:innerHeight-20});
two.render([{name:'Other pack suggestion'}],()=>{},()=>{});
two.position({left:0,bottom:5,top:0});
});
const lists=page.getByRole('listbox');
const ids=await lists.evaluateAll(elements=>elements.map(element=>element.id));
expect(new Set(ids).size).toBe(ids.length);
await page.getByRole('option',{name:'[LORA] model'}).hover();
await expect(page.getByRole('option',{name:'[LORA] model'})).toHaveAttribute('aria-selected','true');
await page.getByRole('option',{name:'[EMB] folder/nested'}).click();
expect(await page.evaluate(()=>completionFixture.inserted)).toEqual(['nested']);
await page.evaluate(()=>completionFixture.scope.dispose());
await expect(lists).toHaveCount(0);
});
test(`${variant} previews before loading, keeps metadata optional and composes text`,async({page})=>{
await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:[{id:1,text:'Library portrait',category:'Portrait',tags:['studio']}]}}));
await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill('My draft');
await openLibrary(page);
await page.getByRole('button',{name:'Recent',exact:true}).click();
await page.getByRole('button',{name:/Library portrait/}).click();
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('My draft');
await page.getByRole('switch',{name:'Load metadata'}).uncheck();
await page.getByRole('button',{name:'Use prompt',exact:true}).click();
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('Library portrait');
await page.getByText('Composition · prepend / append',{exact:true}).click();
await page.getByLabel('Prepend',{exact:true}).fill('trigger');
await page.getByLabel('Append',{exact:true}).fill('detailed');
await page.getByText('Composed text preview',{exact:true}).click();
await expect(page.locator('[data-composed-preview]')).toHaveText('trigger Library portrait detailed');
expect(await page.evaluate(()=>node.serialize().widgets_values.slice(0,6))).toEqual(['Library portrait','','','','trigger','detailed']);
});
test(`${variant} restores workflow values and disclosures and protects connected text`,async({page})=>{
await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill('Saved draft');
await page.getByText('Metadata · category / tags',{exact:true}).click();
await page.getByLabel('Category',{exact:true}).fill('Saved category');
await page.evaluate(()=>{const saved=node.serialize();node.configure(saved);});
await expect(page.getByLabel('Category',{exact:true})).toBeVisible();
await expect(page.getByLabel('Category',{exact:true})).toHaveValue('Saved category');
await page.evaluate(()=>{node.inputs.push({name:'text',link:9});node.onConnectionsChange();});
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toBeHidden();
await expect(page.getByText('Text is supplied by an upstream connection.',{exact:true})).toBeVisible();
await page.evaluate(()=>{node.inputs.find(input=>input.name==='text').link=null;node.onConnectionsChange();});
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('Saved draft');
});
test(`${variant} searches quoted tags with Enter and explicitly loads metadata`, async ({page}) => {
let requested;
await page.route('**/prompt_manager/search?**', async route => {
requested = new URL(route.request().url());
await route.fulfill({json:{success:true,results:[{id:7,text:'Selected fox',category:'Animals',tags:['film grain','fox']}]}});
});
await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill('Preserved draft');
await openLibrary(page);
const query=page.getByRole('searchbox',{name:'Search saved prompts'});
await query.fill('fox tag:"film grain"');
await query.press('Enter');
await page.getByRole('button',{name:/Selected fox/}).click();
expect(requested.searchParams.get('text')).toBe('fox');
expect(requested.searchParams.get('tags')).toBe('film grain');
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('Preserved draft');
await page.getByRole('button',{name:'Use prompt',exact:true}).click();
await page.getByText('Metadata · category / tags',{exact:true}).click();
await expect(page.getByLabel('Category',{exact:true})).toHaveValue('Animals');
await expect(page.getByLabel('Tags',{exact:true})).toHaveValue('film grain, fox');
expect(await page.evaluate(()=>node.serialize().widgets_values.slice(0,4))).toEqual(['Selected fox','Animals','film grain, fox','fox tag:"film grain"']);
});
test(`${variant} reports empty queries, no results, and safely renders backend and HTTP errors`, async ({page}) => {
let requests=0;
let mode='empty';
const malicious='<img src=x onerror="window.focusedInjected=true">';
await page.route('**/prompt_manager/search?**', async route => {
requests++;
if(mode==='http') return route.fulfill({status:503,body:'Unavailable'});
await route.fulfill({json:mode==='error'?{success:false,error:malicious}:{success:true,results:[]}});
});
await openLibrary(page);
const search=page.getByRole('button',{name:'Search',exact:true});
await search.click();
await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('Enter search text, a category or tags.');
expect(requests).toBe(0);
await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('missing');
await search.click();
await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('No matching prompts.');
await expect(page.locator('.pm-editor-result')).toHaveCount(0);
mode='error';
await search.click();
await expect(page.getByRole('alert')).toHaveText(`Could not load prompts: ${malicious}`);
await expect(page.locator('.pm-editor-library img')).toHaveCount(0);
expect(await page.evaluate(()=>window.focusedInjected)).toBeUndefined();
mode='http';
await search.click();
await expect(page.getByRole('alert')).toContainText('HTTP 503');
await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('');
await search.click();
await expect(page.getByRole('alert')).toHaveCount(0);
await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('Enter search text, a category or tags.');
mode='empty';
await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('missing');
await search.click();
await expect(page.getByRole('alert')).toHaveCount(0);
await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('No matching prompts.');
});
test(`${variant} treats saved prompt content and metadata as text`, async ({page}) => {
const malicious='<img src=x onerror="window.focusedInjected=true">';
await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:[{id:8,text:malicious,category:'<script>bad()</script>',tags:['<svg onload=bad()>']}]}}));
await openLibrary(page);
await page.getByRole('button',{name:'Recent',exact:true}).click();
await page.locator('.pm-editor-result').click();
await expect(page.locator('.pm-editor-selection .pm-editor-preview')).toHaveText(malicious);
await expect(page.locator('.pm-editor-library img, .pm-editor-library svg, .pm-editor-library script')).toHaveCount(0);
await page.getByRole('button',{name:'Use prompt',exact:true}).click();
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue(malicious);
expect(await page.evaluate(()=>window.focusedInjected)).toBeUndefined();
});
async function deferLibraryRequests(page) {
await page.evaluate(()=>{
const originalFetch=window.fetch;
window.libraryRequests=[];
window.fetch=(url,options)=>{
if(!/\/prompt_manager\/(search|recent)\?/.test(String(url))) return originalFetch(url,options);
return new Promise(resolve=>window.libraryRequests.push({url:String(url),signal:options.signal,resolve}));
};
window.completeLibraryRequest=(index,text)=>libraryRequests[index].resolve(new Response(JSON.stringify({success:true,results:[{id:index,text}]}),{status:200,headers:{'Content-Type':'application/json'}}));
});
}
test(`${variant} keeps the latest result when searches and recent requests overlap`, async ({page}) => {
await deferLibraryRequests(page);
await openLibrary(page);
await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('older search');
await page.getByRole('button',{name:'Search',exact:true}).click();
await page.getByRole('button',{name:'Recent',exact:true}).click();
expect(await page.evaluate(()=>libraryRequests[0].signal.aborted)).toBe(true);
await page.evaluate(()=>completeLibraryRequest(1,'Latest recent'));
await expect(page.locator('.pm-editor-result')).toHaveText('Latest recent');
await page.evaluate(()=>completeLibraryRequest(0,'Stale search'));
await expect(page.locator('.pm-editor-result')).toHaveText('Latest recent');
await page.getByRole('button',{name:'Recent',exact:true}).click();
await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('latest search');
await page.getByRole('button',{name:'Search',exact:true}).click();
expect(await page.evaluate(()=>libraryRequests[2].signal.aborted)).toBe(true);
await page.evaluate(()=>completeLibraryRequest(3,'Latest search'));
await expect(page.locator('.pm-editor-result')).toHaveText('Latest search');
await page.evaluate(()=>completeLibraryRequest(2,'Stale recent'));
await expect(page.locator('.pm-editor-result')).toHaveText('Latest search');
});
test(`${variant} removal aborts work and stops theme and control listeners`, async ({page}) => {
await deferLibraryRequests(page);
await openLibrary(page);
await page.getByRole('button',{name:'Recent',exact:true}).click();
await expect(page.locator('.pm-focused')).toHaveAttribute('data-ca-theme','dark');
const removed=await page.evaluate(()=>{
node.onRemoved();
document.body.style.setProperty('--bg-color','#ffffff');
fixtureApp.ui.settings.dispatchEvent(new Event('Comfy.ColorPalette.change'));
return {requestAborted:libraryRequests[0].signal.aborted,scopeAborted:node._focusedScope.signal.aborted};
});
expect(removed).toEqual({requestAborted:true,scopeAborted:true});
await page.evaluate(()=>completeLibraryRequest(0,'Late result after removal'));
await expect(page.locator('.pm-editor-result')).toHaveCount(0);
await expect(page.locator('.pm-focused')).toHaveAttribute('data-ca-theme','dark');
if (isSplit) {
const count=await page.evaluate(()=>libraryRequests.length);
await page.getByRole('button',{name:'Recent',exact:true}).click();
expect(await page.evaluate(()=>libraryRequests.length)).toBe(count);
await expect(page.locator('.pm-editor-library')).toBeVisible();
} else {
await page.getByRole('button',{name:'Hide library',exact:true}).click();
await expect(page.getByRole('button',{name:'Hide library',exact:true})).toBeVisible();
}
});
test(`${variant} fields reflow with node width and follow live host themes`, async ({page}) => {
await page.setViewportSize({width:1000,height:1000});
await page.getByText('Composition · prepend / append',{exact:true}).click();
await page.evaluate(()=>{node._focusedView.element.style.width='700px';node._focusedView.element.style.height='850px';});
const prepend=page.getByLabel('Prepend',{exact:true});
const append=page.getByLabel('Append',{exact:true});
let first=await prepend.boundingBox(),second=await append.boundingBox();
expect(Math.abs(first.y-second.y)).toBeLessThan(2);
expect(second.x).toBeGreaterThan(first.x+first.width);
await page.evaluate(()=>{node._focusedView.element.style.width='300px';});
await expect.poll(async()=>{const a=await prepend.boundingBox(),b=await append.boundingBox();return b.y>a.y+a.height;}).toBe(true);
expect(await page.locator('.pm-focused').evaluate(element=>element.scrollWidth<=element.clientWidth+1)).toBe(true);
const dark=await prepend.evaluate(element=>getComputedStyle(element).backgroundColor);
await page.evaluate(()=>document.body.style.setProperty('--bg-color','#ffffff'));
await expect(page.locator('.pm-focused')).toHaveAttribute('data-ca-theme','light');
await expect.poll(()=>prepend.evaluate(element=>getComputedStyle(element).backgroundColor)).not.toBe(dark);
await page.evaluate(()=>document.body.style.setProperty('--bg-color','#222222'));
await expect(page.locator('.pm-focused')).toHaveAttribute('data-ca-theme','dark');
await expect.poll(()=>prepend.evaluate(element=>getComputedStyle(element).backgroundColor)).toBe(dark);
});
test(`${variant} composed preview preserves main prompt whitespace like queued execution`, async ({page}) => {
await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill(' draft ');
await page.getByText('Composition · prepend / append',{exact:true}).click();
await page.getByLabel('Prepend',{exact:true}).fill(' trigger ');
await page.getByLabel('Append',{exact:true}).fill(' detail ');
await page.getByText('Composed text preview',{exact:true}).click();
expect(await page.locator('[data-composed-preview]').textContent()).toBe('trigger draft detail');
});
test(`${variant} exposes the visible text element for autocomplete and host extension listeners`, async ({page}) => {
expect(await page.evaluate(()=>node.widgets.find(widget=>widget.name==='text').inputEl===document.querySelector('.pm-focused textarea'))).toBe(true);
});
test(`${variant} expands its main editor when the node gets taller`,async({page})=>{
await page.locator('.pm-focused').evaluate((element,isSplit)=>{element.style.width=isSplit?'900px':'550px';element.style.height='800px';},isSplit);
const editor=await page.getByRole('textbox',{name:'Main prompt',exact:true}).boundingBox();
expect(editor.height).toBeGreaterThan(400);
});
test(`${variant} respects the existing Web UI display preference`,async({page})=>{
await page.route('**/prompt_manager/settings',route=>route.fulfill({json:{success:true,settings:{webui_display_mode:'newtab'}}}));
await page.evaluate(()=>{
const Node=node.constructor;node.onRemoved();node._focusedView.element.remove();node=new Node();node.onNodeCreated();
window.open=(...args)=>{window.openArgs=args;return {};};
});
await page.evaluate(()=>node._focusedSettingsReady);
await page.getByRole('button',{name:'Web UI ↗',exact:true}).click();
expect(await page.evaluate(()=>window.openArgs)).toEqual(['/prompt_manager/web','_blank']);
});
test(`${variant} reports blocked Web UI popups without leaving the workflow`,async({page})=>{
await page.evaluate(()=>{window.open=()=>null;});
await page.getByRole('button',{name:'Web UI ↗',exact:true}).click();
await expect(page.locator('.pm-focused [role="alert"]')).toContainText('Allow popups');
await expect(page.locator('.pm-focused')).toBeVisible();
});
if (isSplit) test('Split keeps the library beside the editor when wide and stacks it when narrow', async ({page}) => {
await page.setViewportSize({width:1200,height:1000});
const root=page.locator('.pm-split');
await expect(root).toHaveAttribute('aria-label','Split prompt editor');
await expect(page.getByRole('button',{name:'Hide library',exact:true})).toHaveCount(0);
await root.evaluate(element=>{element.style.width='900px';element.style.height='800px';});
const compose=root.locator('.pm-editor-workspace > .pm-editor-compose');
const library=root.locator('.pm-editor-workspace > .pm-editor-library');
await expect(compose.locator('.pm-editor-main')).toBeVisible();
let a=await compose.boundingBox(),b=await library.boundingBox();
expect(Math.abs(a.y-b.y)).toBeLessThan(2);
expect(b.x).toBeGreaterThanOrEqual(a.x+a.width);
await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:[{text:'Library example'}]}}));
await page.getByRole('button',{name:'Recent',exact:true}).click();
await expect(page.locator('.pm-editor-result')).toContainText('Library example');
await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill('Editing beside the library');
await expect(library).toBeVisible();
await expect(page.locator('.pm-editor-result')).toContainText('Library example');
expect(await page.evaluate(()=>node.serialize().widgets_values[0])).toBe('Editing beside the library');
await root.evaluate(element=>{element.style.width='340px';});
await expect.poll(async()=>{const a=await compose.boundingBox(),b=await library.boundingBox();return b.y>=a.y+a.height;}).toBe(true);
expect(await root.evaluate(element=>element.scrollWidth<=element.clientWidth+1)).toBe(true);
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('Editing beside the library');
await expect(library).toBeVisible();
});
if (isSplit) test('Split bounds long recent results and selected previews at the default node size', async ({page}) => {
await page.setViewportSize({width:1200,height:1000});
const root=page.locator('.pm-split');
await root.evaluate(element=>{element.style.width='900px';element.style.height='650px';});
const longPrompt='Long saved prompt '+('detailed portrait in a studio with natural light '.repeat(110));
const prompts=Array.from({length:20},(_,index)=>({id:index+1,text:index===0?longPrompt:`Saved prompt ${index+1}`,category:'Portrait',tags:['studio']}));
await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:prompts}}));
await page.getByRole('button',{name:'Recent',exact:true}).click();
const results=root.locator('.pm-editor-results');
await expect(results.locator('.pm-editor-result')).toHaveCount(20);
const rowHeights=await results.locator('.pm-editor-result > span:first-child').evaluateAll(elements=>elements.map(element=>element.getBoundingClientRect().height));
expect(rowHeights.every(height=>height>0)).toBe(true);
const resultSize=await results.evaluate(element=>({height:element.getBoundingClientRect().height,scroll:element.scrollHeight,client:element.clientHeight}));
expect.soft(resultSize.height).toBeLessThanOrEqual(300);
expect(resultSize.scroll).toBeGreaterThan(resultSize.client);
await results.locator('.pm-editor-result').first().click();
const preview=root.locator('.pm-editor-selection > .pm-editor-preview');
await expect(preview).toHaveText(longPrompt);
const previewSize=await preview.evaluate(element=>({height:element.getBoundingClientRect().height,scroll:element.scrollHeight,client:element.clientHeight}));
expect.soft(previewSize.height).toBeLessThanOrEqual(180);
expect.soft(previewSize.scroll).toBeGreaterThan(previewSize.client);
await page.getByRole('button',{name:'Use prompt',exact:true}).click();
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue(longPrompt);
const bounds=await root.boundingBox();
for (const selector of ['.pm-editor-compose','.pm-editor-library']) {
const pane=await root.locator(selector).boundingBox();
expect(pane.y).toBeGreaterThanOrEqual(bounds.y);
expect(pane.y+pane.height).toBeLessThanOrEqual(bounds.y+bounds.height);
}
expect(await root.evaluate(element=>element.scrollWidth<=element.clientWidth+1)).toBe(true);
});
});
}
+186
View File
@@ -0,0 +1,186 @@
import {readFileSync} from 'node:fs';
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
const pair={id:4,positive_text:'Library positive',negative_text:'Library negative',category:'Portrait',tags:['studio']};
test.beforeEach(async({page,browserName})=>{
await startCoverage(page,browserName);
page.pairErrors=[];page.on('pageerror',error=>page.pairErrors.push(error.message));
await page.route('**/scripts/app.js',route=>route.fulfill({contentType:'text/javascript',body:'export const app=window.fixtureApp;'}));
await page.route('**/scripts/api.js',route=>route.fulfill({contentType:'text/javascript',body:'export const api={fileURL:path=>path,queuePrompt:async(...args)=>args};'}));
await page.route('**/extensions/promptmanager/prompt_manager.js',route=>route.fulfill({contentType:'text/javascript',body:readFileSync(new URL('../../web/comfy/prompt_manager.js',import.meta.url),'utf8')}));
await page.route('**/prompt_manager/pairs?**',route=>route.fulfill({json:{success:true,pairs:[pair],total:1}}));
await page.goto('/prompt_manager/test');
await page.evaluate(async()=>{
document.body.replaceChildren();
const settings=new EventTarget();settings.getSettingValue=()=> 'dark';
window.fixtureApp={registerExtension(extension){this.extension=extension;},ui:{settings},graph:{_nodes:[],setDirtyCanvas(){}},canvas:{},graphToPrompt:async()=>({})};
await import('/extensions/promptmanager/prompt_manager.js');
class Node {
constructor(){this.id=1;this.type='PromptManagerPair';this.widgets=['text','negative_text','category','tags','search_text'].map(name=>({name,type:'text',value:'',options:{},inputEl:document.createElement('textarea')}));this.inputs=['clip','prepend_text','append_text'].map(name=>({name,link:null}));this.properties={};this.size=[1100,700];}
addWidget(type,name,value,callback,options){const widget={type,name,value,callback,options};this.widgets.push(widget);return widget;}
addDOMWidget(name,type,element){const host=document.createElement('div');host.style.cssText='height:700px;width:1100px;max-width:100%';host.className='fixture-dom-widget';host.append(element);document.body.append(host);return {element};}
setSize(size){this.size=size;}setDirtyCanvas(){}
serialize(){return {widgets_values:this.widgets.map(widget=>widget.value),properties:{...this.properties}};}
configure(data){this.widgets.forEach((widget,index)=>widget.value=data.widgets_values[index]);this.properties={...data.properties};}
}
await fixtureApp.extension.beforeRegisterNodeDef(Node,{name:'PromptManagerPair'},fixtureApp);
window.node=new Node();node.onNodeCreated?.();fixtureApp.graph._nodes=[node];
});
});
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.pairErrors).toEqual([]);});
async function selectPair(page){await page.getByRole('button',{name:'Recent',exact:true}).click();await page.locator('.pm-editor-result').click();}
async function draft(page,positive='Draft positive',negative='Draft negative'){await page.getByRole('textbox',{name:'Positive prompt',exact:true}).fill(positive);await page.getByRole('textbox',{name:'Negative prompt',exact:true}).fill(negative);}
test('paired editor previews both texts before explicitly replacing both',async({page})=>{
await expect(page.locator('.pm-pair')).toBeVisible();
await draft(page);
await selectPair(page);
await expect(page.locator('.pm-editor-selection')).toContainText('Library positive');
await expect(page.locator('.pm-editor-selection')).toContainText('Library negative');
await expect(page.getByRole('textbox',{name:'Positive prompt',exact:true})).toHaveValue('Draft positive');
await expect(page.getByRole('textbox',{name:'Negative prompt',exact:true})).toHaveValue('Draft negative');
await page.getByRole('switch',{name:'Load metadata'}).uncheck();
await page.getByRole('button',{name:'Use pair',exact:true}).click();
await expect(page.getByRole('textbox',{name:'Positive prompt',exact:true})).toHaveValue(pair.positive_text);
await expect(page.getByRole('textbox',{name:'Negative prompt',exact:true})).toHaveValue(pair.negative_text);
expect(await page.evaluate(()=>Object.fromEntries(node.widgets.map(w=>[w.name,w.value])))).toMatchObject({text:pair.positive_text,negative_text:pair.negative_text,category:'',tags:''});
await page.evaluate(()=>{const saved=node.serialize();node.configure(saved);});
await expect(page.getByRole('textbox',{name:'Negative prompt',exact:true})).toHaveValue(pair.negative_text);
});
test('paired editor sizes both editors equally and composes positive text only',async({page},testInfo)=>{
await draft(page);
const positive=page.getByRole('textbox',{name:'Positive prompt',exact:true});const negative=page.getByRole('textbox',{name:'Negative prompt',exact:true});
const [p,n]=await Promise.all([positive.boundingBox(),negative.boundingBox()]);expect(Math.abs(p.height-n.height)).toBeLessThan(1);expect(Math.abs(p.width-n.width)).toBeLessThan(1);
await page.getByText('Composition · prepend / append',{exact:true}).click();
await page.getByRole('textbox',{name:'Prepend',exact:true}).fill('trigger');
await page.getByRole('textbox',{name:'Append',exact:true}).fill('detail');
await page.getByText('Composed text preview',{exact:true}).click();
await expect(page.locator('[data-composed-preview]')).toHaveText('trigger Draft positive detail');
await expect(negative).toHaveValue('Draft negative');
await page.screenshot({path:testInfo.outputPath('pair-editor.png')});
});
for(const field of ['text','negative_text']) test(`paired loading is atomic when ${field} is connected`,async({page})=>{
await draft(page);
await selectPair(page);
await page.evaluate(field=>{node.inputs.push({name:field,link:8});node.onConnectionsChange();},field);
await expect(page.getByRole('button',{name:'Use pair',exact:true})).toBeDisabled();
await page.getByRole('button',{name:'Use pair',exact:true}).evaluate(button=>button.dispatchEvent(new Event('click',{bubbles:true})));
expect(await page.evaluate(()=>Object.fromEntries(node.widgets.map(w=>[w.name,w.value])))).toMatchObject({text:'Draft positive',negative_text:'Draft negative'});
await page.evaluate(()=>{node.inputs=node.inputs.filter(input=>input.link!==8);node.onConnectionsChange();});
await expect(page.getByRole('button',{name:'Use pair',exact:true})).toBeEnabled();
});
test('save pair accepts empty negatives, recovers after errors and never clears the draft',async({page})=>{
let attempts=0;const bodies=[];
await page.route('**/prompt_manager/pairs',route=>{bodies.push(route.request().postDataJSON());return ++attempts===1?route.fulfill({status:503,json:{success:false,error:'Temporary save error'}}):route.fulfill({json:{success:true,pair:{...pair,negative_text:''}}});});
await draft(page,'Save this','');
await page.getByRole('button',{name:'Save pair',exact:true}).click();
await expect(page.getByRole('alert')).toContainText('Temporary save error');
await expect(page.getByRole('textbox',{name:'Positive prompt',exact:true})).toHaveValue('Save this');
await page.getByRole('button',{name:'Save pair',exact:true}).click();
await expect(page.getByRole('status').filter({hasText:'Pair saved'})).toBeVisible();
expect(bodies).toEqual([{positive_text:'Save this',negative_text:'',category:'',tags:[]},{positive_text:'Save this',negative_text:'',category:'',tags:[]}]);
});
test('pair filters use repeated tags and removal cleans up',async({page})=>{
const urls=[];await page.route('**/prompt_manager/pairs?**',route=>{urls.push(new URL(route.request().url()));return route.fulfill({json:{success:true,pairs:[pair]}});});
await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('portrait tag:studio');
await page.getByLabel('Filter category',{exact:true}).fill('Portrait');
await page.getByLabel('Filter tags',{exact:true}).fill('film, studio, ');
await page.getByRole('button',{name:'Search',exact:true}).click();await expect(page.locator('.pm-editor-result')).toBeVisible();
expect(urls[0].searchParams.get('category')).toBe('Portrait');expect(urls[0].searchParams.getAll('tag')).toEqual(['studio','film']);
await page.evaluate(()=>node.onRemoved());await expect(page.locator('.ca-completions')).toHaveCount(0);
});
test('validating and saving prevents duplicate submissions while preserving pair metadata and drafts',async({page})=>{
let release;let requests=0;
await page.route('**/prompt_manager/pairs',async route=>{requests++;await new Promise(resolve=>release=resolve);await route.fulfill({json:{success:true,pair:{...pair,category:'Original category',tags:['original-tag']}}});});
await page.getByRole('button',{name:'Save pair',exact:true}).click();
await expect(page.getByRole('alert')).toContainText('positive');expect(requests).toBe(0);
await draft(page);
await page.getByRole('button',{name:'Save pair',exact:true}).click();
await expect(page.getByRole('button',{name:'Save pair',exact:true})).toBeDisabled();
await page.getByRole('button',{name:'Save pair',exact:true}).evaluate(button=>button.dispatchEvent(new Event('click',{bubbles:true})));
expect(requests).toBe(1);release();
await expect(page.getByRole('status').filter({hasText:'Pair saved'})).toBeVisible();
expect(await page.evaluate(()=>Object.fromEntries(node.widgets.map(w=>[w.name,w.value])))).toMatchObject({text:'Draft positive',negative_text:'Draft negative',category:'',tags:''});
});
test('a superseded library response never replaces the latest pair selection',async({page})=>{
let release;let started=false;
await page.route('**/prompt_manager/pairs?**',async route=>{
if(new URL(route.request().url()).searchParams.get('text')==='old'){started=true;await new Promise(resolve=>release=resolve);await route.fulfill({json:{success:true,pairs:[{...pair,positive_text:'Stale positive'}]}}).catch(()=>{});}
else await route.fulfill({json:{success:true,pairs:[pair]}});
});
const search=page.getByRole('searchbox',{name:'Search saved prompts'});
await search.fill('old');await page.getByRole('button',{name:'Search',exact:true}).click();await expect.poll(()=>started).toBe(true);
await search.fill('new');await page.getByRole('button',{name:'Search',exact:true}).click();
await expect(page.locator('.pm-editor-result')).toContainText('Library positive');release();
await expect(page.locator('.pm-editor-result')).not.toContainText('Stale positive');
});
test('queue hook synchronizes both editors, preserves links and forwards branch execution arguments',async({page})=>{
await draft(page);
const args=await page.evaluate(async()=>{
await fixtureApp.extension.setup();
const {api}=await import('/scripts/api.js');
const output={1:{inputs:{text:'stale',negative_text:'stale'}}};
const first=await api.queuePrompt(7,{output,workflow:{name:'workflow'}},['branch'],{preview:'latent'});
node.inputs.push({name:'negative_text',link:17});
const linked={1:{inputs:{text:'old',negative_text:['17',0]}}};
const second=await api.queuePrompt(8,{output:linked,workflow:{}},['another']);
return {first,second};
});
expect(args.first).toEqual([7,{output:{1:{inputs:{text:'Draft positive',negative_text:'Draft negative'}}},workflow:{name:'workflow'}},['branch'],{preview:'latent'}]);
expect(args.second[1].output[1].inputs).toEqual({text:'Draft positive',negative_text:['17',0]});
});
test('paired editors stack at narrow widths while keeping equal editing areas',async({page})=>{
await page.locator('.fixture-dom-widget').evaluate(host=>host.style.width='560px');
const [p,n]=await Promise.all(['Positive prompt','Negative prompt'].map(name=>page.getByRole('textbox',{name,exact:true}).boundingBox()));expect(n.y).toBeGreaterThanOrEqual(p.y+p.height);expect(Math.abs(p.width-n.width)).toBeLessThan(1);expect(Math.abs(p.height-n.height)).toBeLessThan(1);
});
test('removal aborts an in-flight pair save and restores host-owned textarea references',async({page})=>{
await draft(page);
const state=await page.evaluate(async()=>{
const previous=window.fetch;let signal;
window.fetch=(url,options)=>{if(String(url).endsWith('/pairs')){signal=options.signal;return new Promise((resolve,reject)=>signal.addEventListener('abort',()=>reject(new DOMException('Aborted','AbortError')),{once:true}));}return previous(url,options);};
const visible=node.widgets.find(w=>w.name==='negative_text').inputEl;
[...document.querySelectorAll('button')].find(button=>button.textContent==='Save pair').click();
node.onRemoved();
return {aborted:signal.aborted,restored:node.widgets.find(w=>w.name==='negative_text').inputEl!==visible};
});
expect(state).toEqual({aborted:true,restored:true});
});
test('actual pair saves coexist with resetting the disposable browser fixture',async({page,request})=>{
await draft(page,'Actual browser pair','Actual negative');await page.getByRole('button',{name:'Save pair',exact:true}).click();await expect(page.getByRole('status').filter({hasText:'Pair saved'})).toBeVisible();
const response=await request.get('/prompt_manager/pairs?text=Actual%20browser%20pair');expect(response.ok()).toBe(true);expect((await response.json()).pairs).toEqual(expect.arrayContaining([expect.objectContaining({positive_text:'Actual browser pair',negative_text:'Actual negative'})]));
const reset=await request.post('/__test__/reset');expect(reset.ok()).toBe(true);
const after=await request.get('/prompt_manager/pairs?text=Actual%20browser%20pair');expect((await after.json()).pairs).toHaveLength(0);
});
for(const field of ['category','tags'])test(`manual pair save waits for execution when ${field} comes from an upstream link`,async({page})=>{
let requests=0;await page.route('**/prompt_manager/pairs',route=>{requests++;return route.fulfill({json:{success:true,pair}});});await draft(page);
await page.evaluate(field=>{node.inputs.push({name:field,link:19});node.onConnectionsChange();},field);
const save=page.getByRole('button',{name:'Save pair',exact:true});await expect(save).toBeDisabled();await expect(page.getByRole('status').filter({hasText:'Run the node to save connected values.'})).toBeVisible();
await save.evaluate(button=>button.dispatchEvent(new Event('click',{bubbles:true})));expect(requests).toBe(0);
await page.evaluate(()=>{node.inputs=node.inputs.filter(input=>input.link!==19);node.onConnectionsChange();});await expect(save).toBeEnabled();await save.click();await expect.poll(()=>requests).toBe(1);
});
for(const field of ['positive','negative','category','tags'])test(`save completion identifies an earlier draft after editing ${field}`,async({page})=>{
let release;await page.route('**/prompt_manager/pairs',async route=>{await new Promise(resolve=>release=resolve);await route.fulfill({json:{success:true,pair}});});await draft(page);
await page.getByText('Metadata · category / tags',{exact:true}).click();await page.getByRole('button',{name:'Save pair',exact:true}).click();await expect.poll(()=>Boolean(release)).toBe(true);
const input=field==='positive'?page.getByRole('textbox',{name:'Positive prompt',exact:true}):field==='negative'?page.getByRole('textbox',{name:'Negative prompt',exact:true}):page.getByRole('combobox',{name:field==='category'?'Category':'Tags',exact:true});
await input.fill(`Changed ${field}`);release();await expect(page.getByRole('status').filter({hasText:'Earlier draft saved. Current changes are not saved yet.'})).toBeVisible();await expect(input).toHaveValue(`Changed ${field}`);
});
test('pair library rows distinguish negative variants and explicitly show empty negatives as text',async({page})=>{
const longNegative='Variant one '+ 'x'.repeat(200);
await page.route('**/prompt_manager/pairs?**',route=>route.fulfill({json:{success:true,pairs:[{...pair,negative_text:longNegative},{...pair,id:5,negative_text:'Variant two'},{...pair,id:6,negative_text:''},{...pair,id:7,negative_text:'<img src=x onerror=alert(1)>'}]}}));
await page.getByRole('button',{name:'Recent',exact:true}).click();const rows=page.locator('.pm-editor-result');await expect(rows).toHaveCount(4);await expect(rows.nth(0)).toContainText('Negative: Variant one');await expect(rows.nth(1)).toContainText('Negative: Variant two');await expect(rows.nth(2)).toContainText('Negative: (empty)');await expect(rows.nth(3)).toContainText('Negative: <img src=x onerror=alert(1)>');await expect(rows.locator('img')).toHaveCount(0);await expect(rows.nth(0)).not.toContainText('x'.repeat(180));await expect(rows.nth(0).locator('span').nth(1)).toHaveAttribute('title',longNegative);
});
+28 -3
View File
@@ -78,9 +78,17 @@ def create_app(root: Path) -> web.Application:
api = PromptManagerAPI()
api.add_routes(routes)
def seed(extra_images: int = 0) -> None:
def seed(extra_images: int = 0, include_video: bool = False) -> None:
with api.db.model.get_connection() as conn:
for table in ("generated_images", "prompt_tags", "tags", "prompts"):
for table in (
"image_prompt_pairs",
"prompt_pair_tags",
"prompt_pairs",
"generated_images",
"prompt_tags",
"tags",
"prompts",
):
conn.execute(f"DELETE FROM {table}")
for name in ("output", "input"):
shutil.rmtree(root / name)
@@ -106,25 +114,42 @@ def create_app(root: Path) -> web.Application:
)
api.db.link_image_to_prompt(pid, str(image_path))
if index == 0:
video_prompt_id = pid
for number in range(extra_images):
extra = root / directory / "bulk" / f"{number:04}.png"
extra.parent.mkdir(exist_ok=True)
shutil.copyfile(image_path, extra)
api.db.link_image_to_prompt(pid, str(extra))
api._generate_gallery_thumbnails_sync(api._get_all_output_dirs(), (150, 150))
if include_video:
# Seed after thumbnail generation to exercise a real unthumbnailed video.
video = root / "output" / "clip.webm"
shutil.copyfile(
Path(__file__).resolve().parents[1] / "fixtures" / "tiny.webm", video
)
api.db.link_image_to_prompt(video_prompt_id, str(video))
api.invalidate_gallery_cache()
@routes.post("/__test__/reset")
async def reset(request: web.Request) -> web.Response:
data = await request.json() if request.can_read_body else {}
extra = data.get("extra_images", 0)
include_video = data.get("include_video", False)
if (
isinstance(extra, bool)
or not isinstance(extra, int)
or not 0 <= extra <= 200
or not isinstance(include_video, bool)
):
raise web.HTTPBadRequest()
await api._run_in_executor(seed, extra)
await api._run_in_executor(seed, extra, include_video)
return web.json_response({"success": True})
@routes.post("/__test__/add-nested-image")
async def add_nested_image(request: web.Request) -> web.Response:
destination = root / "output" / "project" / "day" / "new-output.png"
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(root / "output" / "shared" / "same.png", destination)
return web.json_response({"success": True})
@routes.get("/api/settings/Comfy.ColorPalette")
+482
View File
@@ -0,0 +1,482 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
const workspace = '/prompt_manager/workspace#/images';
const cards = page => page.locator('article[data-image-key]');
const details = page => page.getByRole('region', { name: 'Image details', exact: true });
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
await expect(await request.post('/__test__/reset')).toBeOK();
page.imageErrors = [];
page.on('pageerror', error => page.imageErrors.push(error.message));
});
test.afterEach(async ({ page, browserName }) => {
await saveCoverage(page, browserName);
expect(page.imageErrors).toEqual([]);
});
test('native Images keeps colliding filenames distinct and inspects the selected source', async ({ page, request }) => {
const snapshot = await (await request.get('/prompt_manager/stats')).json();
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
expect(await cards(page).evaluateAll(elements => elements.map(element => element.dataset.imageKey)))
.toEqual(['0:shared/same.png', '1:shared/same.png']);
const originals = await cards(page).locator('img').evaluateAll(elements => elements.map(element => element.src));
expect(new Set(originals).size).toBe(2);
await cards(page).nth(1).getByRole('button', { name: 'Open same.png', exact: true }).click();
await expect(details(page)).toContainText('same.png');
await expect(details(page)).toContainText('shared/same.png');
await expect(details(page)).toContainText(/input/i);
await cards(page).first().getByRole('button', { name: 'Open same.png', exact: true }).click();
await expect(details(page)).toContainText(/output/i);
expect((await (await request.get('/prompt_manager/stats')).json()).stats).toEqual(snapshot.stats);
});
test('workspace defaults to the native Images collection without a hash route', async ({ page }) => {
await page.goto('/prompt_manager/workspace');
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
await expect(cards(page)).toHaveCount(2);
await expect(page.getByRole('navigation', { name: 'Workspace', exact: true })
.getByRole('link', { name: 'Images', exact: true })).toHaveAttribute('aria-current', 'page');
});
test('image selection is announced on the selected image button', async ({ page }) => {
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
const selected = cards(page).last().getByRole('button', { name: 'Open same.png', exact: true });
await selected.click();
await expect(selected).toHaveAttribute('aria-pressed', 'true');
await expect(cards(page).first().getByRole('button', { name: 'Open same.png', exact: true }))
.toHaveAttribute('aria-pressed', 'false');
});
test('closing details preview restores its trigger focus', async ({ page }) => {
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
const selected = cards(page).last().getByRole('button', { name: 'Open same.png', exact: true });
await selected.click();
const preview = details(page).getByRole('button', { name: 'Open full preview', exact: true });
await preview.click();
await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.locator('dialog[open]')).toHaveCount(0);
await expect(preview).toBeFocused();
});
test('a short visible batch automatically continues until the sentinel is filled or exhausted', async ({ page, request }) => {
const real = await (await request.get('/prompt_manager/images/output?limit=40')).json();
let reads = 0;
await page.route('**/prompt_manager/images/output?*', async route => {
const current = reads++;
await route.fulfill({ json: {
success: true, images: [real.images[current]], total: 2,
has_more: current === 0, next_cursor: current === 0 ? 'next-short-batch' : null,
} });
});
await page.setViewportSize({ width: 1440, height: 1200 });
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
expect(reads).toBe(2);
await expect(page.getByRole('button', { name: 'Load more images', exact: true })).toBeHidden();
});
test('source, folder and media filters operate on real backend records', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { extra_images: 3 } })).toBeOK();
await page.goto(workspace);
await expect(cards(page)).toHaveCount(5);
await page.getByRole('combobox', { name: 'Source', exact: true }).selectOption('1');
await expect(cards(page)).toHaveCount(1);
await expect(cards(page).first()).toHaveAttribute('data-image-key', '1:shared/same.png');
await page.getByRole('combobox', { name: 'Source', exact: true }).selectOption('all');
await page.getByRole('combobox', { name: 'Folder', exact: true }).selectOption('bulk');
await expect(cards(page)).toHaveCount(3);
await expect(cards(page).first()).toHaveAttribute('data-image-key', /^0:bulk\//);
await page.getByRole('combobox', { name: 'Media', exact: true }).selectOption('video');
await expect(cards(page)).toHaveCount(0);
await expect(page.locator('#workspace-main')).toContainText(/no (matching )?(images|media)/i);
await page.getByRole('combobox', { name: 'Media', exact: true }).selectOption('image');
await expect(cards(page)).toHaveCount(3);
await page.getByRole('combobox', { name: 'Folder', exact: true }).selectOption('');
await expect(cards(page)).toHaveCount(5);
});
test('filename search finds records beyond the initial batch and sort orders them', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { extra_images: 100 } })).toBeOK();
await page.goto(workspace);
await expect(cards(page)).toHaveCount(40);
await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill('same.png');
await expect(cards(page)).toHaveCount(2);
await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill('0099');
await expect(cards(page)).toHaveCount(1);
await expect(cards(page)).toHaveAttribute('data-image-key', '0:bulk/0099.png');
await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill('same.png');
await expect(cards(page)).toHaveCount(2);
await page.getByRole('combobox', { name: 'Sort', exact: true }).selectOption('oldest');
await expect(cards(page).first()).toHaveAttribute('data-image-key', '1:shared/same.png');
await page.getByRole('combobox', { name: 'Sort', exact: true }).selectOption('newest');
await expect(cards(page).first()).toHaveAttribute('data-image-key', '0:shared/same.png');
});
test('layout changes retain selection and use one collection in Grid, List and Masonry', async ({ page }) => {
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
await cards(page).nth(1).getByRole('button', { name: 'Open same.png', exact: true }).click();
for (const layout of ['List', 'Masonry', 'Grid']) {
await page.getByRole('button', { name: layout, exact: true }).click();
await expect(page.getByRole('button', { name: layout, exact: true })).toHaveAttribute('aria-pressed', 'true');
await expect(cards(page)).toHaveCount(2);
await expect(details(page)).toContainText(/input/i);
const boxes = await cards(page).evaluateAll(elements => elements.map(element => {
const box = element.getBoundingClientRect();
return { x: box.x, y: box.y, right: box.right, bottom: box.bottom };
}));
expect(boxes[0].right <= boxes[1].x || boxes[1].right <= boxes[0].x ||
boxes[0].bottom <= boxes[1].y || boxes[1].bottom <= boxes[0].y).toBe(true);
}
});
test('returning to Images restores the filtered stream, layout, selection and scroll context', async ({ page, request }) => {
await page.addInitScript(() => { window.IntersectionObserver = undefined; });
await expect(await request.post('/__test__/reset', { data: { extra_images: 100 } })).toBeOK();
await page.goto(workspace);
const search = page.getByRole('searchbox', { name: 'Search filenames', exact: true });
await page.getByRole('combobox', { name: 'Source', exact: true }).selectOption('0');
await search.fill('00');
await expect(page.locator('.image-toolbar')).toContainText('40 of 100 images');
await page.getByRole('button', { name: 'List', exact: true }).click();
await page.getByRole('button', { name: 'Load more images', exact: true }).click();
await expect(page.locator('.image-toolbar')).toContainText('80 of 100 images');
await cards(page).first().getByRole('button', { name: /^Open / }).click();
const selectedTitle = await details(page).getByRole('heading', { level: 2 }).textContent();
await cards(page).nth(30).scrollIntoViewIfNeeded();
const previousScroll = await page.evaluate(() => window.scrollY);
expect(previousScroll).toBeGreaterThan(500);
// Hash navigation preserves the scroll position being tested; clicking the fixed
// navigation can otherwise scroll a non-sticky mobile sidebar into view first.
await page.evaluate(() => { window.location.hash = '/overview'; });
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
await page.evaluate(() => { window.location.hash = '/images'; });
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
await expect(search).toHaveValue('00');
await expect(page.getByRole('combobox', { name: 'Source', exact: true })).toHaveValue('0');
await expect(page.getByRole('button', { name: 'List', exact: true })).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('.image-toolbar')).toContainText('80 of 100 images');
await expect(cards(page)).toHaveCount(80);
await expect(details(page).getByRole('heading', { level: 2 })).toHaveText(selectedTitle);
await expect.poll(async () => Math.abs((await page.evaluate(() => window.scrollY)) - previousScroll)).toBeLessThan(120);
});
test('continuous loading appends without replacing cards or losing selected details', async ({ page, request }) => {
// Exercise the keyboard fallback independently from IntersectionObserver scrolling.
await page.addInitScript(() => { window.IntersectionObserver = undefined; });
await expect(await request.post('/__test__/reset', { data: { extra_images: 100 } })).toBeOK();
await page.goto(workspace);
await expect(cards(page)).toHaveCount(40);
await cards(page).first().evaluate(element => { element.dataset.preserved = 'yes'; });
await cards(page).first().getByRole('button', { name: /^Open / }).click();
const selected = await cards(page).first().getAttribute('data-image-key');
const more = page.getByRole('button', { name: 'Load more images', exact: true });
await more.focus();
await page.keyboard.press('Enter');
await expect(cards(page)).toHaveCount(80);
await expect(cards(page).first()).toHaveAttribute('data-preserved', 'yes');
await expect(cards(page).first()).toHaveAttribute('data-image-key', selected);
await expect(details(page)).toBeVisible();
await expect(more).toBeFocused();
await page.keyboard.press('Enter');
await expect(cards(page)).toHaveCount(102);
expect(new Set(await cards(page).evaluateAll(elements => elements.map(element => element.dataset.imageKey))).size).toBe(102);
await expect(more).toBeHidden();
await expect(page.getByRole('button', { name: /next page|previous page/i })).toHaveCount(0);
});
test('scrolling to the sentinel automatically appends the next backend batch', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { extra_images: 100 } })).toBeOK();
await page.goto(workspace);
await expect(cards(page)).toHaveCount(40);
const first = cards(page).first();
await first.evaluate(element => { element.dataset.preserved = 'scroll'; });
await page.getByRole('button', { name: 'Load more images', exact: true }).scrollIntoViewIfNeeded();
await expect(cards(page)).toHaveCount(80);
await expect(first).toHaveAttribute('data-preserved', 'scroll');
});
test('large continuous collections bound the mounted cards and restore earlier images on backscroll', async ({ page, request }) => {
await page.addInitScript(() => { window.IntersectionObserver = undefined; });
await expect(await request.post('/__test__/reset', { data: { extra_images: 200 } })).toBeOK();
await page.goto(workspace);
await expect(cards(page)).toHaveCount(40);
const first = cards(page).first();
const firstKey = await first.getAttribute('data-image-key');
await first.getByRole('button', { name: /^Open / }).click();
const selectedTitle = await details(page).getByRole('heading', { level: 2 }).textContent();
const more = page.getByRole('button', { name: 'Load more images', exact: true });
for (const loaded of [80, 120, 160, 200, 202]) {
await more.click();
await expect(page.locator('.image-toolbar')).toContainText(`${loaded} of 202 images`);
}
await expect(more).toBeHidden();
await expect.poll(() => cards(page).count()).toBeLessThan(120);
await expect(details(page).getByRole('heading', { level: 2 })).toHaveText(selectedTitle);
expect(new Set(await cards(page).evaluateAll(elements => elements.map(element => element.dataset.imageKey))).size)
.toBe(await cards(page).count());
await page.evaluate(() => window.scrollTo(0, 0));
await expect(page.locator(`article[data-image-key="${firstKey}"]`)).toBeVisible();
await expect.poll(() => cards(page).count()).toBeLessThan(120);
await expect(details(page).getByRole('heading', { level: 2 })).toHaveText(selectedTitle);
await page.locator(`article[data-image-key="${firstKey}"]`)
.getByRole('button', { name: /^Preview / }).click();
await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible();
await page.getByRole('button', { name: 'Close preview', exact: true }).click();
});
test('virtual gallery keyboard navigation reaches loaded endpoints and announces collection positions', async ({ page, request }) => {
await page.addInitScript(() => { window.IntersectionObserver = undefined; });
await expect(await request.post('/__test__/reset', { data: { extra_images: 200 } })).toBeOK();
await page.goto(workspace);
await expect(cards(page)).toHaveCount(40);
const firstKey = await cards(page).first().getAttribute('data-image-key');
const firstName = await cards(page).first().getByRole('button', { name: /^Open / }).getAttribute('aria-label');
await cards(page).first().getByRole('button', { name: /^Open / }).click();
const selectedTitle = await details(page).getByRole('heading', { level: 2 }).textContent();
const more = page.getByRole('button', { name: 'Load more images', exact: true });
for (const loaded of [80, 120, 160, 200, 202]) {
await more.click();
await expect(page.locator('.image-toolbar')).toContainText(`${loaded} of 202 images`);
}
await page.evaluate(() => window.scrollTo(0, 0));
const first = page.locator(`article[data-image-key="${firstKey}"]`);
await expect(first).toBeVisible();
await first.getByRole('button', { name: firstName, exact: true }).focus();
await page.keyboard.press('End');
await expect(page.locator('article[data-image-key="1:shared/same.png"]')
.getByRole('button', { name: 'Open same.png', exact: true })).toBeFocused();
await expect(page.locator('article[data-image-key="1:shared/same.png"]')).toHaveAttribute('aria-posinset', '202');
await expect(page.locator('article[data-image-key="1:shared/same.png"]')).toHaveAttribute('aria-setsize', '202');
await expect(page.getByRole('list', { name: 'Image collection', exact: true })).toBeVisible();
await expect(page.locator('article[data-image-key="1:shared/same.png"]')).toHaveAttribute('role', 'listitem');
await page.keyboard.press('Home');
await expect(first.getByRole('button', { name: firstName, exact: true })).toBeFocused();
await expect(first).toHaveAttribute('aria-posinset', '1');
await page.keyboard.press('ArrowRight');
await expect(page.locator('article[aria-posinset="2"]').getByRole('button', { name: /^Open / })).toBeFocused();
await expect(details(page).getByRole('heading', { level: 2 })).toHaveText(selectedTitle);
await expect.poll(() => cards(page).count()).toBeLessThan(120);
});
test('preview uses actual Viewer.js and cleans up when closing or leaving Images', async ({ page }) => {
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
await cards(page).last().getByRole('button', { name: 'Preview same.png', exact: true }).click();
await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible();
await expect(page.locator('dialog[open] .viewer-canvas img')).toHaveAttribute('src', /root=1/);
await page.getByRole('button', { name: 'Close preview', exact: true }).click();
await expect(page.locator('dialog[open]')).toHaveCount(0);
await cards(page).first().getByRole('button', { name: 'Preview same.png', exact: true }).click();
await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible();
await page.evaluate(() => { window.location.hash = '/overview'; });
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
await expect(page.locator('.viewer-container')).toHaveCount(0);
await expect(page.locator('dialog[open]')).toHaveCount(0);
});
test('real video media has a usable card and native playback cleans up on close and navigation', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { include_video: true } })).toBeOK();
const media = await (await request.get('/prompt_manager/images/output?limit=40')).json();
expect(media.images.find(image => image.filename === 'clip.webm').thumbnail_url).toBeNull();
await page.goto(workspace);
await page.getByRole('combobox', { name: 'Media', exact: true }).selectOption('video');
await expect(cards(page)).toHaveCount(1);
const card = cards(page).first();
await expect(card).toHaveAttribute('data-image-key', '0:clip.webm');
await expect(card.locator('img')).toHaveCount(0);
await expect(card.getByRole('button', { name: 'Open clip.webm', exact: true })).toBeVisible();
await card.getByRole('button', { name: 'Preview clip.webm', exact: true }).click();
const video = page.locator('dialog[open] video');
await expect(video).toBeVisible();
await expect(video).toHaveJSProperty('controls', true);
await expect.poll(() => video.evaluate(element => element.readyState)).toBeGreaterThanOrEqual(1);
const playing = await video.evaluate(async element => {
window.__previewVideo = element;
element.muted = true;
await element.play();
return !element.paused;
});
expect(playing).toBe(true);
await page.getByRole('button', { name: 'Close preview', exact: true }).click();
await expect(page.locator('dialog video')).toHaveCount(0);
expect(await page.evaluate(() => window.__previewVideo.paused && !window.__previewVideo.isConnected)).toBe(true);
await card.getByRole('button', { name: 'Preview clip.webm', exact: true }).click();
await expect(video).toBeVisible();
await video.evaluate(async element => {
window.__previewVideo = element;
element.muted = true;
await element.play();
});
await page.evaluate(() => { window.location.hash = '/overview'; });
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
await expect(page.locator('video')).toHaveCount(0);
expect(await page.evaluate(() => window.__previewVideo.paused && !window.__previewVideo.isConnected)).toBe(true);
});
test('failed image requests offer retry while stale results cannot replace a newer route', async ({ page }) => {
await page.route('**/prompt_manager/images/output?*', route => route.fulfill({
status: 503, json: { success: false, error: 'Images unavailable' },
}), { times: 1 });
await page.goto(workspace);
await expect(page.locator('#workspace-main').getByRole('alert')).toBeVisible();
await page.getByRole('button', { name: 'Retry', exact: true }).click();
await expect(cards(page)).toHaveCount(2);
let release;
const pending = new Promise(resolve => { release = resolve; });
let arrived;
const received = new Promise(resolve => { arrived = resolve; });
await page.route('**/prompt_manager/images/output?*', async route => {
const response = await route.fetch();
arrived();
await pending;
await route.fulfill({ response }).catch(() => {});
});
await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill('nothing');
await received;
await page.getByRole('navigation', { name: 'Workspace', exact: true })
.getByRole('link', { name: 'Overview', exact: true }).click();
release();
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
await expect(cards(page)).toHaveCount(0);
await expect(details(page)).toHaveCount(0);
});
test('source and folder discovery failure stays visible and Refresh restores usable filters', async ({ page }) => {
await page.route('**/prompt_manager/gallery/subfolders?*', route => route.fulfill({
status: 503, json: { success: false, error: 'Sources unavailable' },
}), { times: 1 });
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
await expect(page.getByRole('alert')).toHaveText('Sources and folders could not be loaded. Refresh to retry.');
await page.getByRole('button', { name: 'Refresh', exact: true }).click();
await expect(page.getByRole('alert')).toHaveCount(0);
await page.getByRole('combobox', { name: 'Source', exact: true }).selectOption('1');
await expect(cards(page)).toHaveCount(1);
await expect(cards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
await page.getByRole('combobox', { name: 'Folder', exact: true }).selectOption('shared');
await expect(cards(page)).toHaveCount(1);
});
test('mobile Images keeps filters, layouts and selected details within the viewport', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
for (const layout of ['List', 'Masonry', 'Grid']) {
await page.getByRole('button', { name: layout, exact: true }).click();
await cards(page).last().getByRole('button', { name: 'Open same.png', exact: true }).click();
await expect(details(page)).toContainText(/input/i);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
}
await expect(page.getByRole('searchbox', { name: 'Search filenames', exact: true })).toBeVisible();
});
test('a delayed search cannot replace results for a newer filename query', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { extra_images: 3 } })).toBeOK();
await page.goto(workspace);
await expect(cards(page)).toHaveCount(5);
let release;
let arrived;
let complete;
const pending = new Promise(resolve => { release = resolve; });
const received = new Promise(resolve => { arrived = resolve; });
const completed = new Promise(resolve => { complete = resolve; });
let delayed = false;
await page.route('**/prompt_manager/images/output?*', async route => {
if (delayed) return route.continue();
delayed = true;
const response = await route.fetch();
arrived();
await pending;
await route.fulfill({ response }).catch(() => {});
complete();
});
const search = page.getByRole('searchbox', { name: 'Search filenames', exact: true });
await search.fill('same.png');
await received;
await search.fill('0001');
await expect(cards(page)).toHaveCount(1);
await expect(cards(page)).toHaveAttribute('data-image-key', '0:bulk/0001.png');
release();
await completed;
await expect(cards(page)).toHaveCount(1);
await expect(cards(page)).toHaveAttribute('data-image-key', '0:bulk/0001.png');
});
test('selected layout stays blue with readable text on hover in either theme', async ({ page }) => {
await page.goto(workspace);
const layout = page.getByRole('group', { name: 'Image layout' }).getByRole('button', { name: 'Masonry', exact: true });
for (const theme of ['light', 'dark']) {
await page.getByRole('combobox', { name: 'Appearance', exact: true }).selectOption(theme);
await layout.click();
await layout.hover();
await expect(layout).toHaveCSS('background-color', 'rgb(8, 119, 187)');
await expect(layout).toHaveCSS('color', 'rgb(255, 255, 255)');
}
});
test('refresh releases the previous inspector video source', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { include_video: true } })).toBeOK();
await page.goto(workspace);
const card = cards(page).filter({ hasText: 'clip.webm' });
await card.getByRole('button', { name: 'Open clip.webm', exact: true }).click();
await expect(details(page).locator('video')).toBeVisible();
await details(page).locator('video').evaluate(element => { window.__oldInspectorVideo = element; });
await page.getByRole('button', { name: 'Refresh', exact: true }).click();
await expect.poll(() => page.evaluate(() => !window.__oldInspectorVideo.isConnected)).toBe(true);
expect(await page.evaluate(() => window.__oldInspectorVideo.getAttribute('src'))).toBeNull();
});
test('Refresh rescans cached files and preserves the collection when rescan fails', async ({ page, request }) => {
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
await expect(await request.post('/__test__/add-nested-image')).toBeOK();
await page.route('**/prompt_manager/gallery/rescan', route => route.fulfill({
status: 503, json: { success: false, error: 'Rescan unavailable' },
}), { times: 1 });
await page.getByRole('button', { name: 'Refresh', exact: true }).click();
await expect(page.getByRole('alert')).toContainText('could not be refreshed');
await expect(cards(page)).toHaveCount(2);
await page.getByRole('button', { name: 'Refresh', exact: true }).click();
await expect(cards(page)).toHaveCount(3);
await expect(cards(page).filter({ hasText: 'new-output.png' })).toHaveCount(1);
await expect(page.getByRole('alert')).toBeHidden();
});
test('folder discovery offers ancestors that filter nested images', async ({ page, request }) => {
await expect(await request.post('/__test__/add-nested-image')).toBeOK();
await page.goto(workspace);
await expect(cards(page)).toHaveCount(3);
const folder = page.getByRole('combobox', { name: 'Folder', exact: true });
await expect(folder.locator('option[value="project"]')).toHaveCount(1);
await folder.selectOption('project');
await expect(cards(page)).toHaveCount(1);
await expect(cards(page)).toContainText('new-output.png');
});
test('leaving during Refresh cancels it without replacing the next workspace', async ({ page }) => {
let release;
const pending = new Promise(resolve => { release = resolve; });
await page.route('**/prompt_manager/gallery/rescan', async route => {
await pending;
await route.fulfill({ json: { success: true } }).catch(() => {});
});
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
const requested = page.waitForRequest('**/prompt_manager/gallery/rescan');
await page.getByRole('button', { name: 'Refresh', exact: true }).click();
await requested;
await page.getByRole('navigation', { name: 'Workspace', exact: true })
.getByRole('link', { name: 'Overview', exact: true }).click();
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
release();
await expect(page.getByRole('heading', { name: 'Continue creating', exact: true })).toBeVisible();
await expect(page.getByText('Images could not be refreshed. Try Refresh again.', { exact: true })).toHaveCount(0);
});
+218
View File
@@ -0,0 +1,218 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
const workspace = '/prompt_manager/workspace';
const statistic = (page, label) => page.locator('#workspace-main dt')
.filter({ hasText: new RegExp(`^${label}$`) }).locator('..').locator('dd');
const routes = [
['overview', 'Overview', null],
['images', 'Images', '/prompt_manager/gallery'],
['library', 'Prompt library', '/prompt_manager/admin'],
['tags', 'Tags & categories', '/prompt_manager/admin#/tags'],
['inspect', 'Inspect a file', '/prompt_manager/gallery.html'],
['system', 'System', '/prompt_manager/admin'],
];
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
await expect(await request.post('/__test__/reset')).toBeOK();
page.workspaceErrors = [];
page.on('pageerror', error => page.workspaceErrors.push(error.message));
page.on('response', response => {
if (response.status() >= 400 && /\/prompt_manager\/(js|lib)\//.test(response.url())) {
page.workspaceErrors.push(`${response.status()} ${response.url()}`);
}
});
});
test.afterEach(async ({ page, browserName }) => {
await saveCoverage(page, browserName);
expect(page.workspaceErrors).toEqual([]);
});
test('workspace starts with real persisted statistics without mutating data', async ({ page, request }) => {
const before = await (await request.get('/prompt_manager/stats')).json();
const mutations = [];
page.on('request', request => {
if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method())) mutations.push(request.url());
});
await page.goto(`${workspace}#/overview`);
await expect(page).toHaveTitle(/Prompt Manager/);
await expect(page.getByRole('navigation', { name: 'Workspace', exact: true })).toBeVisible();
const main = page.locator('#workspace-main');
await expect(main.getByRole('heading', { level: 1, name: 'Overview', exact: true })).toBeVisible();
for (const [label, key] of [['Prompts', 'total_prompts'], ['Images', 'total_images'], ['Tags', 'total_tags']]) {
await expect(statistic(page, label)).toHaveText(String(before.stats[key]));
}
expect(before.stats.total_prompts).toBe(2);
expect((await (await request.get('/prompt_manager/stats')).json()).stats).toEqual(before.stats);
expect(mutations).toEqual([]);
await expect(page.locator('iframe')).toHaveCount(0);
});
test('all shell destinations deep-link, reload and expose working existing applications', async ({ page, request }) => {
for (const [route, label, legacy] of routes) {
await page.goto(`${workspace}#/${route}`);
const main = page.locator('#workspace-main');
await expect(main.getByRole('heading', { level: 1, name: label, exact: true })).toBeVisible();
await expect(page.getByRole('navigation', { name: 'Workspace', exact: true })
.getByRole('link', { name: label, exact: true })).toHaveAttribute('aria-current', 'page');
if (legacy) {
await expect(main.locator(`a[href="${legacy}"]`)).toBeVisible();
await expect(await request.get(legacy.split('#')[0])).toBeOK();
}
await page.reload();
await expect(main.getByRole('heading', { level: 1, name: label, exact: true })).toBeVisible();
}
});
test('keyboard navigation, browser history and unknown routes remain usable', async ({ page }) => {
await page.goto(`${workspace}#/overview`);
const navigation = page.getByRole('navigation', { name: 'Workspace', exact: true });
const images = navigation.getByRole('link', { name: 'Images', exact: true });
await images.focus();
await page.keyboard.press('Enter');
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
await navigation.getByRole('link', { name: 'Prompt library', exact: true }).click();
await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
await page.goBack();
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
await page.goForward();
await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
await page.goto(`${workspace}#/not-a-page`);
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
await expect(navigation.getByRole('link', { name: 'Images', exact: true }))
.toHaveAttribute('aria-current', 'page');
});
test('skip to content focuses the current view without changing its route', async ({ page }) => {
await page.goto(`${workspace}#/library`);
await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
await page.keyboard.press('Tab');
await expect(page.getByRole('link', { name: 'Skip to content', exact: true })).toBeFocused();
await page.keyboard.press('Enter');
await expect(page).toHaveURL(`${workspace}#/library`);
await expect(page.locator('#workspace-main')).toBeFocused();
await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
});
test('returning to overview refreshes persisted counts without duplicating navigation', async ({ page, request }) => {
await page.goto(`${workspace}#/overview`);
const prompts = statistic(page, 'Prompts');
await expect(prompts).toHaveText('2');
const navigation = page.getByRole('navigation', { name: 'Workspace', exact: true });
await navigation.getByRole('link', { name: 'Images', exact: true }).click();
await expect(await request.post('/prompt_manager/save', { data: { text: 'A new persisted workspace prompt' } })).toBeOK();
await navigation.getByRole('link', { name: 'Overview', exact: true }).click();
await expect(prompts).toHaveText('3');
await expect(navigation).toHaveCount(1);
await expect(navigation.getByRole('link')).toHaveCount(routes.length);
});
test('appearance follows ComfyUI and persists an explicit light or dark preference', async ({ page }) => {
await page.route('**/api/settings/Comfy.ColorPalette', route => route.fulfill({ json: 'light' }));
await page.goto(`${workspace}#/overview`);
const theme = page.locator('[data-ca-theme]').first();
await expect(theme).toHaveAttribute('data-ca-theme', 'light');
const appearance = page.getByRole('combobox', { name: 'Appearance', exact: true });
await appearance.selectOption({ label: 'Dark' });
await expect(theme).toHaveAttribute('data-ca-theme', 'dark');
await page.reload();
await expect(theme).toHaveAttribute('data-ca-theme', 'dark');
await expect(appearance).toHaveValue('dark');
await appearance.selectOption({ label: 'Light' });
await expect(theme).toHaveAttribute('data-ca-theme', 'light');
await appearance.selectOption({ label: 'Follow ComfyUI' });
await expect(theme).toHaveAttribute('data-ca-theme', 'light');
});
test('unavailable host palette falls back to system appearance and tracks system changes', async ({ page }) => {
await page.route('**/api/settings/Comfy.ColorPalette', route => route.fulfill({ status: 503, json: {} }));
await page.emulateMedia({ colorScheme: 'light' });
await page.goto(`${workspace}#/overview`);
const theme = page.locator('[data-ca-theme]').first();
await expect(theme).toHaveAttribute('data-ca-theme', 'light');
await page.emulateMedia({ colorScheme: 'dark' });
await expect(theme).toHaveAttribute('data-ca-theme', 'dark');
});
test('overview reports loading, offers retry after failure and renders real empty statistics', async ({ page }) => {
let release;
const pending = new Promise(resolve => { release = resolve; });
await page.route('**/prompt_manager/stats', async route => {
await pending;
await route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } });
});
await page.goto(`${workspace}#/overview`);
await expect(page.locator('#workspace-main').getByRole('status')).toBeVisible();
release();
await expect(page.locator('#workspace-main').getByRole('alert')).toBeVisible();
await page.unroute('**/prompt_manager/stats');
await page.route('**/prompt_manager/stats', route => route.fulfill({
json: { success: true, stats: { total_prompts: 0, total_images: 0, total_tags: 0 } },
}));
await page.getByRole('button', { name: 'Retry', exact: true }).click();
await expect(page.locator('#workspace-main').getByRole('alert')).toHaveCount(0);
for (const label of ['Prompts', 'Images', 'Tags']) {
await expect(statistic(page, label)).toHaveText('0');
}
});
test('small screens keep navigation and appearance accessible without horizontal page overflow', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto(`${workspace}#/overview`);
const navigation = page.getByRole('navigation', { name: 'Workspace', exact: true });
for (const [, label] of routes) {
await navigation.getByRole('link', { name: label, exact: true }).click();
await expect(page.locator('#workspace-main h1')).toHaveText(label);
}
await expect(page.getByRole('combobox', { name: 'Appearance', exact: true })).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
});
test('leaving a loading overview prevents its delayed result from replacing the new view', async ({ page }) => {
let release;
let completed;
const pending = new Promise(resolve => { release = resolve; });
const finished = new Promise(resolve => { completed = resolve; });
await page.route('**/prompt_manager/stats', async route => {
await pending;
await route.fulfill({ json: {
success: true, stats: { total_prompts: 999, total_images: 888, total_tags: 777 },
} }).catch(() => {});
completed();
});
await page.goto(`${workspace}#/overview`);
await expect(page.locator('#workspace-main').getByRole('status')).toBeVisible();
await page.getByRole('navigation', { name: 'Workspace', exact: true })
.getByRole('link', { name: 'Images', exact: true }).click();
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
release();
await finished;
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
await expect(page.locator('#workspace-main')).not.toContainText('999');
await expect(page.locator('#workspace-main a[href="/prompt_manager/gallery"]')).toBeVisible();
});
test('cached-page restoration remounts one working shell and refreshes its data', async ({ page }) => {
let reads = 0;
page.on('request', request => {
if (new URL(request.url()).pathname === '/prompt_manager/stats') reads++;
});
await page.goto(`${workspace}#/overview`);
await expect(statistic(page, 'Prompts')).toHaveText('2');
for (let restore = 0; restore < 2; restore++) {
await page.evaluate(() => {
window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: true }));
window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true }));
});
await expect(statistic(page, 'Prompts')).toHaveText('2');
}
expect(reads).toBe(3);
const navigation = page.getByRole('navigation', { name: 'Workspace', exact: true });
await expect(navigation.getByRole('link')).toHaveCount(routes.length);
await navigation.getByRole('link', { name: 'Images', exact: true }).click();
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
await page.getByRole('combobox', { name: 'Appearance' }).selectOption('light');
await expect(page.locator('body')).toHaveAttribute('data-ca-theme', 'light');
});
+441
View File
@@ -0,0 +1,441 @@
import { writeFile } from 'node:fs/promises';
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import { installHostHooks } from './host-fixture.mjs';
installHostHooks(test, ['PromptManager', 'PromptManagerText', 'PromptSearchList', 'PromptManagerFocused']);
async function addEditor(page, type) {
await page.waitForFunction(type => Boolean(globalThis.LiteGraph?.registered_node_types?.[type]), type);
return page.evaluate(async type => {
const { app } = await import('/scripts/app.js');
const node = LiteGraph.createNode(type);
if (!node) throw new Error(`${type} must be registered by the real host`);
app.graph.add(node);
node.setSize([type === 'PromptManagerSplit' ? 1100 : 680, 1050]);
app.canvas.ds.scale = .8;
app.canvas.centerOnNode(node);
return node.id;
}, type);
}
async function model(page, id) {
return page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
return Object.fromEntries(app.graph.getNodeById(id).widgets.map(widget => [widget.name, widget.value]));
}, id);
}
async function seed(request, text, category = 'Focused host tests', tags = ['focused-host']) {
const response = await request.post('/prompt_manager/save', { data: { text, category, tags } });
expect(response.ok()).toBe(true);
expect((await response.json()).success).toBe(true);
}
async function showLibrary(page) {
const button = page.locator('.pm-focused').getByRole('button', { name: 'Browse library', exact: true });
if (await button.count()) await button.click();
}
async function browse(page, text) {
const root = page.locator('.pm-focused');
await showLibrary(page);
await root.getByRole('button', { name: 'Recent', exact: true }).click();
await root.locator('.pm-editor-results').getByRole('button').filter({ has: page.getByText(text, { exact: true }) }).click();
return root;
}
for (const [label, type] of [['Focused', 'PromptManagerFocused'], ['Split', 'PromptManagerSplit']]) {
test.describe(label, () => {
test(`${label} definition preserves Classic encoding and exposes composition STRING sockets`, async ({ request }) => {
const definitions = await (await request.get('/object_info')).json();
const focused = definitions[type];
expect(focused).toBeDefined();
expect(focused.display_name).toBe(`Prompt Manager (${label})`);
expect(focused.output).toEqual(definitions.PromptManager.output);
expect(focused.input.required).toEqual(definitions.PromptManager.input.required);
for (const name of ['prepend_text', 'append_text']) {
expect(focused.input.optional[name][0]).toBe('STRING');
expect(focused.input.optional[name][1]).toMatchObject({ forceInput: true, default: '' });
}
});
test(`${label} preview, explicit load, composition, renderer changes and workflow reload preserve state`, async ({ page, request }, testInfo) => {
const text = `Focused selected ${testInfo.project.name}`;
await seed(request, text);
const id = await addEditor(page, type);
const root = page.locator('.pm-focused');
const editor = root.getByRole('textbox', { name: 'Main prompt', exact: true });
await expect(root).toBeVisible();
expect(await editor.evaluate(async (element, id) => {
const { app } = await import('/scripts/app.js');
return app.graph.getNodeById(id).widgets.find(widget => widget.name === 'text').inputEl === element;
}, id)).toBe(true);
await editor.fill('Original editing text');
await browse(page, text);
await expect(editor).toHaveValue('Original editing text');
expect((await model(page, id)).text).toBe('Original editing text');
await root.getByRole('button', { name: 'Use prompt', exact: true }).click();
await expect(editor).toHaveValue(text);
await expect.poll(() => model(page, id)).toMatchObject({ text, category: 'Focused host tests', tags: 'focused-host' });
await root.getByText('Composition · prepend / append', { exact: true }).click();
await root.getByRole('textbox', { name: 'Prepend', exact: true }).fill('LoRA trigger');
await root.getByRole('textbox', { name: 'Append', exact: true }).fill('high detail');
await root.getByText('Composed text preview', { exact: true }).click();
await expect(root).toContainText(`LoRA trigger ${text} high detail`);
for (const renderer of [!testInfo.project.metadata.initialRenderer, testInfo.project.metadata.initialRenderer]) {
await page.evaluate(async renderer => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
app.canvas.centerOnNode(app.graph._nodes[0]);
}, renderer);
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
await expect(root).toHaveCount(1);
await expect(editor).toHaveValue(text);
}
const queued = await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
const prompt = await app.graphToPrompt();
await app.loadGraphData(app.graph.serialize());
return prompt.output[String(id)].inputs;
}, id);
expect(queued).toMatchObject({ text, category: 'Focused host tests', tags: 'focused-host', prepend_text: 'LoRA trigger', append_text: 'high detail' });
await expect(editor).toHaveValue(text);
await expect.poll(() => model(page, id)).toMatchObject({ text, prepend_text: 'LoRA trigger', append_text: 'high detail' });
await page.screenshot({ path: testInfo.outputPath(`${label.toLowerCase()}-reloaded.png`) });
await page.evaluate(async id => { const { app } = await import('/scripts/app.js'); app.graph.remove(app.graph.getNodeById(id)); }, id);
await expect(root).toHaveCount(0);
});
test(`${label} search preview respects metadata opt-out`, async ({ page, request }, testInfo) => {
const text = `Focused search ${testInfo.project.name}`;
await seed(request, text, 'Incoming category', ['incoming-tag']);
const id = await addEditor(page, type);
const root = page.locator('.pm-focused');
await root.getByText('Metadata · category / tags', { exact: true }).click();
await root.getByRole('combobox', { name: 'Category', exact: true }).fill('Keep category');
await root.getByRole('combobox', { name: 'Tags', exact: true }).fill('keep-tag');
await showLibrary(page);
await root.getByRole('searchbox', { name: 'Search saved prompts', exact: true }).fill(text);
await root.getByRole('button', { name: 'Search', exact: true }).click();
await root.locator('.pm-editor-results').getByRole('button').filter({ has: page.getByText(text, { exact: true }) }).click();
await root.getByRole('switch', { name: 'Load metadata', exact: true }).uncheck();
await root.getByRole('button', { name: 'Use prompt', exact: true }).click();
await expect.poll(() => model(page, id)).toMatchObject({ text, category: 'Keep category', tags: 'keep-tag' });
});
test(`${label} connected STRING inputs remain linked through UI actions and reload`, async ({ page, request }, testInfo) => {
const text = `Connected library ${testInfo.project.name}`;
await seed(request, text);
const id = await addEditor(page, type);
const root = page.locator('.pm-focused');
await root.getByRole('textbox', { name: 'Main prompt', exact: true }).fill('Local main');
await root.getByText('Composition · prepend / append', { exact: true }).click();
await root.getByRole('textbox', { name: 'Prepend', exact: true }).fill('Local prepend');
await root.getByRole('textbox', { name: 'Append', exact: true }).fill('Local append');
await root.getByText('Composition · prepend / append', { exact: true }).click();
const links = await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
const { convertToInput } = await import('/extensions/core/widgetInputs.js');
const target = app.graph.getNodeById(id);
convertToInput(target, target.widgets.find(widget => widget.name === 'text'));
const result = {};
for (const name of ['text', 'prepend_text', 'append_text']) {
const source = LiteGraph.createNode('PrimitiveString');
app.graph.add(source);
source.pos = [target.pos[0] - 360, target.pos[1] + Object.keys(result).length * 150];
source.widgets.find(widget => widget.name === 'value').value = `Connected ${name}`;
const slot = target.inputs.findIndex(input => input.name === name);
if (slot < 0) throw new Error(`Missing ${name} input socket`);
source.connect(0, target, slot);
result[name] = [String(source.id), 0];
}
app.canvas.centerOnNode(target);
return result;
}, id);
await browse(page, text);
await expect(root.getByRole('textbox', { name: 'Main prompt', exact: true })).toBeHidden();
await expect(root.getByRole('button', { name: 'Use prompt', exact: true })).toBeDisabled();
await root.getByText('Composition · prepend / append', { exact: true }).click();
for (const name of ['Prepend', 'Append']) await expect(root.getByRole('textbox', { name, exact: true })).toBeHidden();
const guardedValues = await model(page, id);
await root.locator('textarea, input').evaluateAll(inputs => {
for (const input of inputs) {
if (!input.hidden && !input.closest('[hidden]')) continue;
input.value = 'Attempt to replace upstream text';
input.dispatchEvent(new Event('input', { bubbles: true }));
}
});
expect(await model(page, id)).toMatchObject(guardedValues);
for (let reload = 0; reload < 2; reload++) {
const inputs = await page.evaluate(async ({ id, reload }) => {
const { app } = await import('/scripts/app.js');
if (reload) await app.loadGraphData(app.graph.serialize());
return (await app.graphToPrompt()).output[String(id)].inputs;
}, { id, reload });
expect(inputs).toMatchObject(links);
}
await expect(root.getByRole('button', { name: 'Use prompt', exact: true, includeHidden: true })).toBeDisabled();
const disconnected = await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
const node = app.graph.getNodeById(id);
for (const name of ['text', 'prepend_text', 'append_text']) node.disconnectInput(node.inputs.findIndex(input => input.name === name));
return (await app.graphToPrompt()).output[String(id)].inputs;
}, id);
expect(disconnected).toMatchObject({ text: 'Local main', prepend_text: 'Local prepend', append_text: 'Local append' });
for (const [name, value] of [['Main prompt', 'Local main'], ['Prepend', 'Local prepend'], ['Append', 'Local append']]) {
const input = page.getByRole('textbox', { name, exact: true });
await expect(input).toHaveCount(1);
await expect(input).toBeEditable();
await expect(input).toHaveValue(value);
}
await page.screenshot({ path: testInfo.outputPath(`${label.toLowerCase()}-disconnected.png`) });
});
test(`${label} follows actual ComfyUI light and dark palettes`, async ({ page }, testInfo) => {
await addEditor(page, type);
for (const palette of ['light', 'dark']) {
await page.evaluate(async palette => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.ColorPalette', palette);
}, palette);
const root = page.locator('.pm-focused');
await expect(root).toHaveAttribute('data-ca-theme', palette);
if (label === 'Split') {
await expect(root.getByRole('button', { name: /^(Browse|Hide) library$/ })).toHaveCount(0);
await expect(root.getByRole('button', { name: 'Search', exact: true })).toHaveCSS('background-color', 'rgb(8, 119, 187)');
} else {
await expect(root.getByRole('button', { name: 'Browse library', exact: true })).toHaveCSS('background-color', 'rgb(8, 119, 187)');
}
await page.screenshot({ path: testInfo.outputPath(`${label.toLowerCase()}-${palette}.png`) });
}
});
test(`${label} Web UI button opens actual standalone pages`, async ({ page }) => {
await addEditor(page, type);
const popupPromise = page.waitForEvent('popup');
await page.locator('.pm-focused').getByRole('button', { name: 'Web UI ↗', exact: true }).click();
const popup = await popupPromise;
await popup.waitForLoadState('domcontentloaded');
await expect(popup).toHaveURL(/\/prompt_manager\/(web|admin)/);
await expect(popup.locator('body')).toContainText('Prompt');
await expect(page.locator('.pm-focused').getByRole('status').filter({ hasText: 'Web UI opened.' })).toHaveText('Web UI opened.');
await popup.goto(new URL('/prompt_manager/gallery', page.url()).href);
await expect(popup.locator('#addPromptBtn')).toBeVisible();
await popup.close();
});
test(`${label} metadata completion uses saved categories and tags across renderers and reload`, async ({ page, request }, testInfo) => {
const category = `Host completion category ${testInfo.project.name}`;
const tag = `host-completion-${testInfo.project.name}`;
await seed(request, `Saved autocomplete ${testInfo.project.name}`, category, [tag]);
const id = await addEditor(page, type);
const root = page.locator('.pm-focused');
await root.getByRole('textbox', { name: 'Main prompt', exact: true }).fill('Autocomplete workflow');
await root.getByText('Metadata · category / tags', { exact: true }).click();
const categoryInput = root.getByRole('combobox', { name: 'Category', exact: true });
const tagInput = root.getByRole('combobox', { name: 'Tags', exact: true });
await categoryInput.fill(category.slice(0, -3));
const categories = page.getByRole('listbox', { name: 'Saved categories', exact: true });
await expect(categories.getByRole('option', { name: category, exact: true })).toBeVisible();
await categoryInput.press('ArrowDown');
await expect(categoryInput).toHaveAttribute('aria-activedescendant', /.+/);
await categoryInput.press('Enter');
await expect(categoryInput).toHaveValue(category);
await expect(categoryInput).toHaveAttribute('aria-expanded', 'false');
for (const palette of ['light', 'dark']) {
await page.evaluate(async palette => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.ColorPalette', palette);
}, palette);
await tagInput.fill(`keep, ${tag.slice(0, -3)}`);
const tags = page.getByRole('listbox', { name: 'Saved tags', exact: true });
const option = tags.getByRole('option', { name: tag, exact: true });
await expect(option).toBeVisible();
await expect(tags).toHaveAttribute('data-ca-theme', palette);
const bounds = await tags.boundingBox();
const viewport = page.viewportSize();
expect(bounds.x).toBeGreaterThanOrEqual(0);
expect(bounds.y).toBeGreaterThanOrEqual(0);
expect(bounds.x + bounds.width).toBeLessThanOrEqual(viewport.width);
expect(bounds.y + bounds.height).toBeLessThanOrEqual(viewport.height);
await page.screenshot({ path: testInfo.outputPath(`${label.toLowerCase()}-metadata-${palette}.png`) });
await option.click();
await expect(tagInput).toHaveValue(`keep, ${tag}, `);
}
const expected = { text: 'Autocomplete workflow', category, tags: `keep, ${tag}, ` };
await expect.poll(() => model(page, id)).toMatchObject(expected);
for (const renderer of [!testInfo.project.metadata.initialRenderer, testInfo.project.metadata.initialRenderer]) {
await page.evaluate(async renderer => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
app.canvas.centerOnNode(app.graph._nodes[0]);
}, renderer);
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
await expect(categoryInput).toHaveValue(category);
await expect(tagInput).toHaveValue(expected.tags);
}
const queued = await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
const prompt = await app.graphToPrompt();
await app.loadGraphData(app.graph.serialize());
return prompt.output[String(id)].inputs;
}, id);
expect(queued).toMatchObject(expected);
await expect(categoryInput).toHaveValue(category);
await expect(tagInput).toHaveValue(expected.tags);
await categoryInput.fill(category.slice(0, -3));
await expect(categories).toBeVisible();
await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
app.graph.remove(app.graph.getNodeById(id));
}, id);
await expect(page.getByRole('listbox', { name: 'Saved categories', includeHidden: true })).toHaveCount(0);
await expect(page.getByRole('listbox', { name: 'Saved tags', includeHidden: true })).toHaveCount(0);
});
test(`${label} library autocomplete filters saved prompts without editing draft metadata`, async ({ page, request }, testInfo) => {
const category = `Library category ${testInfo.project.name}`;
const tag = `library-filter-${testInfo.project.name}`;
const matchingText = `Matching library prompt ${testInfo.project.name}`;
await seed(request, matchingText, category, [tag]);
await seed(request, `Wrong category ${testInfo.project.name}`, `Other category ${testInfo.project.name}`, [tag]);
await seed(request, `Wrong tag ${testInfo.project.name}`, category, [`other-filter-${testInfo.project.name}`]);
const id = await addEditor(page, type);
const root = page.locator('.pm-focused');
await root.getByRole('textbox', { name: 'Main prompt', exact: true }).fill('Keep my draft');
await root.getByText('Metadata · category / tags', { exact: true }).click();
await root.getByRole('combobox', { name: 'Category', exact: true }).fill('Draft category');
await root.getByRole('combobox', { name: 'Tags', exact: true }).fill('draft-tag');
await showLibrary(page);
const filterCategory = root.getByRole('combobox', { name: 'Filter category', exact: true });
const filterTags = root.getByRole('combobox', { name: 'Filter tags', exact: true });
await filterCategory.fill(category.slice(0, -3));
await expect(page.getByRole('listbox', { name: 'Saved categories', exact: true }).getByRole('option', { name: category, exact: true })).toBeVisible();
await filterCategory.press('ArrowDown');
await filterCategory.press('Enter');
await expect(filterCategory).toHaveValue(category);
for (const palette of ['light', 'dark']) {
await page.evaluate(async palette => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.ColorPalette', palette);
}, palette);
await filterTags.fill(tag.slice(0, -3));
const option = page.getByRole('listbox', { name: 'Saved tags', exact: true }).getByRole('option', { name: tag, exact: true });
await expect(option).toBeVisible();
await page.screenshot({ path: testInfo.outputPath(`${label.toLowerCase()}-library-filters-${palette}.png`) });
await option.click();
await expect(filterTags).toHaveValue(`${tag}, `);
}
const draft = { text: 'Keep my draft', category: 'Draft category', tags: 'draft-tag' };
await root.getByRole('button', { name: 'Search', exact: true }).click();
const results = root.locator('.pm-editor-results').getByRole('button');
await expect(results).toHaveCount(1);
await expect(results.first()).toContainText(matchingText);
await results.first().click();
await expect.poll(() => model(page, id)).toMatchObject(draft);
for (const renderer of [!testInfo.project.metadata.initialRenderer, testInfo.project.metadata.initialRenderer]) {
await page.evaluate(async renderer => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
app.canvas.centerOnNode(app.graph._nodes[0]);
}, renderer);
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
await expect(filterCategory).toHaveValue(category);
await expect(filterTags).toHaveValue(`${tag}, `);
await expect.poll(() => model(page, id)).toMatchObject(draft);
}
const queued = await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
const prompt = await app.graphToPrompt();
await app.loadGraphData(app.graph.serialize());
return prompt.output[String(id)].inputs;
}, id);
expect(queued).toMatchObject(draft);
await expect(filterCategory).toHaveValue(category);
await expect(filterTags).toHaveValue(`${tag}, `);
await root.getByRole('button', { name: 'Search', exact: true }).click();
await expect(results).toHaveCount(1);
await expect(results.first()).toContainText(matchingText);
await expect.poll(() => model(page, id)).toMatchObject(draft);
});
if (label === 'Split') {
test('Split keeps its library visible beside the editor and stacks narrow nodes', async ({ page, request }, testInfo) => {
for (let index = 0; index < 21; index++) {
await seed(request, `Split layout recent ${testInfo.project.name} ${index}`);
}
const id = await addEditor(page, type);
const root = page.locator('.pm-split');
await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
const node = app.graph.getNodeById(id);
node.setSize([900, 650]);
app.canvas.centerOnNode(node);
}, id);
await expect(root).toHaveAttribute('aria-label', 'Split prompt editor');
const savedProperties = await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
return app.graph.getNodeById(id).serialize().properties;
}, id);
expect(savedProperties).toHaveProperty('splitUI');
expect(savedProperties).not.toHaveProperty('focusedUI');
await expect(root.getByRole('button', { name: /^(Browse|Hide) library$/ })).toHaveCount(0);
const editor = root.getByRole('textbox', { name: 'Main prompt', exact: true });
const library = root.getByRole('region', { name: 'Prompt library', exact: true });
await expect(library).toBeVisible();
const captureGeometry = async phase => {
const geometry = await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
const node = app.graph.getNodeById(id);
const root = node._focusedView.element;
const bounds = selector => root.querySelector(selector)?.getBoundingClientRect().toJSON();
const ancestors = [];
for (let element = root; element && element !== document.body; element = element.parentElement) {
const style = getComputedStyle(element);
ancestors.push({ tag: element.tagName, className: element.className, bounds: element.getBoundingClientRect().toJSON(), inlineStyle: element.style.cssText, computed: Object.fromEntries(['height', 'min-height', 'max-height', 'display', 'overflow', 'flex', 'contain', 'position'].map(name => [name, style.getPropertyValue(name)])) });
}
return { nodeSize: [...node.size], ancestors, root: root.getBoundingClientRect().toJSON(), library: bounds('.pm-editor-library'), results: bounds('.pm-editor-results'), use: bounds('.pm-editor-selection button'), footer: bounds(':scope > .pm-editor-status') };
}, id);
const path = testInfo.outputPath(`split-${phase}-geometry.json`);
await writeFile(path, JSON.stringify(geometry, null, 2));
await testInfo.attach(`split-${phase}-geometry.json`, { path, contentType: 'application/json' });
return geometry;
};
const beforeRecent = await captureGeometry('before-recent');
await root.getByRole('button', { name: 'Recent', exact: true }).click();
const results = library.locator('.pm-editor-results').getByRole('button');
await expect(results).toHaveCount(20);
const promptText = results.first().locator('span').first();
const selectedText = await promptText.innerText();
await results.first().click();
const use = root.getByRole('button', { name: 'Use prompt', exact: true });
const afterSelection = await captureGeometry('after-selection');
await page.screenshot({ path: testInfo.outputPath('split-default-selected.png') });
expect.soft((await promptText.boundingBox())?.height || 0).toBeGreaterThan(10);
expect.soft(afterSelection.root.height).toBeLessThanOrEqual(beforeRecent.root.height + 1);
await use.click();
await expect(editor).toHaveValue(selectedText);
await expect.poll(async () => {
const main = await editor.boundingBox();
const browser = await library.boundingBox();
return browser.x >= main.x + main.width;
}).toBe(true);
await page.screenshot({ path: testInfo.outputPath('split-wide.png') });
await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
const node = app.graph.getNodeById(id);
node.setSize([480, 1050]);
app.canvas.centerOnNode(node);
}, id);
await expect.poll(async () => {
const main = await editor.boundingBox();
const browser = await library.boundingBox();
return browser.y >= main.y + main.height;
}).toBe(true);
await expect(library).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('split-narrow.png') });
});
}
});
}
+43
View File
@@ -0,0 +1,43 @@
import { expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import runtime from '../../tools/vendor/comfyui-runtime.json' with { type: 'json' };
export function installHostHooks(test, expectedTypes = ['PromptManager', 'PromptManagerText', 'PromptSearchList']) {
test.beforeEach(async ({ page, context, request }, testInfo) => {
const response = await request.get('/system_stats');
expect(response.ok()).toBe(true);
const info = (await response.json()).system;
expect(info.comfyui_version).toBe(runtime.serverVersion);
expect(info.pytorch_version).toBe(`${runtime.torchVersion}+cpu`);
expect(info.required_frontend_version).toBe(runtime.frontendVersion);
expect(info.comfy_package_versions.find(item => item.name === 'comfyui-frontend-package')?.installed).toBe(runtime.frontendVersion);
await testInfo.attach('host-versions.json', { body: JSON.stringify(info, null, 2), contentType: 'application/json' });
page.hostErrors = [];
page.on('pageerror', error => page.hostErrors.push(error.message));
await context.route('**/*', async route => {
const request = route.request();
const path = new URL(request.url()).pathname.replace(/^\/api\//, '/');
if (path === '/settings' && request.method() === 'GET') {
const response = await route.fetch();
return route.fulfill({ json: { ...await response.json(),
'Comfy.VueNodes.Enabled': testInfo.project.metadata.initialRenderer,
'Comfy.TutorialCompleted': true, 'Comfy.Workflow.Persist': false,
'Comfy.Notification.ShowVersionUpdates': false } });
}
if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method()) && !path.startsWith('/prompt_manager/')) return route.fulfill({ json: {} });
return route.continue();
});
await page.goto('/', { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('status', { name: 'Loading ComfyUI' })).toBeHidden({ timeout: 30000 });
await page.waitForFunction(async expectedTypes => {
const { app } = await import('/scripts/app.js');
return !!app.graph && expectedTypes.every(name => globalThis.LiteGraph?.registered_node_types?.[name]);
}, expectedTypes);
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(testInfo.project.metadata.initialRenderer);
await page.evaluate(async () => { const { app } = await import('/scripts/app.js'); app.graph.clear(); });
});
test.afterEach(async ({ page }) => {
expect(page.hostErrors).toEqual([]);
});
}
+83
View File
@@ -0,0 +1,83 @@
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import {installHostHooks} from './host-fixture.mjs';
installHostHooks(test,['PromptManagerPair']);
async function addPair(page){return page.evaluate(async()=>{const {app}=await import('/scripts/app.js');const node=LiteGraph.createNode('PromptManagerPair');app.graph.add(node);app.canvas.ds.scale=.8;app.canvas.centerOnNode(node);return node.id;});}
async function seed(request,positive,negative){const response=await request.post('/prompt_manager/pairs',{data:{positive_text:positive,negative_text:negative,category:'Paired host tests',tags:['pair-host']}});expect(response.ok()).toBe(true);return (await response.json()).pair;}
async function values(page,id){return page.evaluate(async id=>{const {app}=await import('/scripts/app.js');return Object.fromEntries(app.graph.getNodeById(id).widgets.map(w=>[w.name,w.value]));},id);}
async function select(page,text){const root=page.locator('.pm-pair');await root.getByRole('button',{name:'Recent',exact:true}).click();await root.locator('.pm-editor-result').filter({has:page.getByText(text,{exact:true})}).click();return root;}
test('paired definition exposes both conditioning/text outputs and positive-only composition',async({request})=>{
const definitions=await(await request.get('/object_info')).json();const pair=definitions.PromptManagerPair;
expect(pair).toBeDefined();expect(pair.output).toEqual(['CONDITIONING','CONDITIONING','STRING','STRING']);
expect(pair.input.required.text[0]).toBe('STRING');expect(pair.input.required.negative_text[0]).toBe('STRING');expect(pair.input.required.clip[0]).toBe('CLIP');
for(const name of ['prepend_text','append_text'])expect(pair.input.optional[name]).toMatchObject(['STRING',{default:'',forceInput:true}]);
expect(pair.input.optional.negative_prepend_text).toBeUndefined();
});
test('paired library previews both, atomically loads, queues and survives renderer switches and reload',async({page,request},testInfo)=>{
const positive=`Pair positive ${testInfo.project.name}`;const negative=`Pair negative ${testInfo.project.name}`;await seed(request,positive,negative);const id=await addPair(page);const root=page.locator('.pm-pair');
const pos=root.getByRole('textbox',{name:'Positive prompt',exact:true});const neg=root.getByRole('textbox',{name:'Negative prompt',exact:true});
await pos.fill('Local positive');await neg.fill('Local negative');
for(const [name,field]of [['text',pos],['negative_text',neg]])expect(await field.evaluate(async(element,{id,name})=>{const {app}=await import('/scripts/app.js');return app.graph.getNodeById(id).widgets.find(w=>w.name===name).inputEl===element;},{id,name})).toBe(true);
await select(page,positive);await expect(root.locator('.pm-editor-selection')).toContainText(negative);await expect(pos).toHaveValue('Local positive');await expect(neg).toHaveValue('Local negative');
await root.getByRole('button',{name:'Use pair',exact:true}).click();await expect(pos).toHaveValue(positive);await expect(neg).toHaveValue(negative);
await root.getByText('Composition · prepend / append',{exact:true}).click();await root.getByRole('textbox',{name:'Prepend',exact:true}).fill('trigger');await root.getByRole('textbox',{name:'Append',exact:true}).fill('detail');
for(const renderer of [!testInfo.project.metadata.initialRenderer,testInfo.project.metadata.initialRenderer]){
await page.evaluate(async renderer=>{const {app}=await import('/scripts/app.js');await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled',renderer);app.canvas.centerOnNode(app.graph._nodes[0]);},renderer);
await expect.poll(()=>page.evaluate(()=>LiteGraph.vueNodesMode)).toBe(renderer);await expect(pos).toHaveValue(positive);await expect(neg).toHaveValue(negative);
}
const queued=await page.evaluate(async id=>{const {app}=await import('/scripts/app.js');const inputs=(await app.graphToPrompt()).output[String(id)].inputs;await app.loadGraphData(app.graph.serialize());return inputs;},id);
expect(queued).toMatchObject({text:positive,negative_text:negative,prepend_text:'trigger',append_text:'detail'});await expect(pos).toHaveValue(positive);await expect(neg).toHaveValue(negative);
await page.screenshot({path:testInfo.outputPath('pair-reloaded.png')});
await page.evaluate(async id=>{const {app}=await import('/scripts/app.js');app.graph.remove(app.graph.getNodeById(id));},id);await expect(root).toHaveCount(0);await expect(page.locator('.ca-completions')).toHaveCount(0);
});
test('paired real STRING connections stay linked and restore both local editors after disconnect',async({page,request},testInfo)=>{
const positive=`Connected pair ${testInfo.project.name}`;await seed(request,positive,'Connected library negative');const id=await addPair(page);const root=page.locator('.pm-pair');
await root.getByRole('textbox',{name:'Positive prompt',exact:true}).fill('Local positive');await root.getByRole('textbox',{name:'Negative prompt',exact:true}).fill('Local negative');
await root.getByText('Composition · prepend / append',{exact:true}).click();await root.getByRole('textbox',{name:'Prepend',exact:true}).fill('Local prepend');await root.getByRole('textbox',{name:'Append',exact:true}).fill('Local append');
const links=await page.evaluate(async id=>{const {app}=await import('/scripts/app.js');const {convertToInput}=await import('/extensions/core/widgetInputs.js');const node=app.graph.getNodeById(id);for(const name of ['text','negative_text'])convertToInput(node,node.widgets.find(w=>w.name===name));const result={};for(const name of ['text','negative_text','prepend_text','append_text']){const source=LiteGraph.createNode('PrimitiveString');app.graph.add(source);source.widgets.find(w=>w.name==='value').value=`Connected ${name}`;source.connect(0,node,node.inputs.findIndex(i=>i.name===name));result[name]=[String(source.id),0];}app.canvas.centerOnNode(node);return result;},id);
await select(page,positive);await expect(root.getByRole('button',{name:'Use pair',exact:true})).toBeDisabled();await expect(root.getByRole('button',{name:'Save pair',exact:true})).toBeDisabled();
for(const name of ['Positive prompt','Negative prompt','Prepend','Append'])await expect(root.getByRole('textbox',{name,exact:true})).toBeHidden();
const before=await values(page,id);await root.locator('textarea,input').evaluateAll(inputs=>{for(const input of inputs){if(!input.hidden&&!input.closest('[hidden]'))continue;input.value='Overwrite attempt';input.dispatchEvent(new Event('input',{bubbles:true}));}});expect(await values(page,id)).toMatchObject(before);
for(const reload of [false,true]){const queued=await page.evaluate(async({id,reload})=>{const {app}=await import('/scripts/app.js');if(reload)await app.loadGraphData(app.graph.serialize());return(await app.graphToPrompt()).output[String(id)].inputs;},{id,reload});expect(queued).toMatchObject(links);}
const local=await page.evaluate(async id=>{const {app}=await import('/scripts/app.js');const node=app.graph.getNodeById(id);for(const name of ['text','negative_text','prepend_text','append_text'])node.disconnectInput(node.inputs.findIndex(i=>i.name===name));return(await app.graphToPrompt()).output[String(id)].inputs;},id);
expect(local).toMatchObject({text:'Local positive',negative_text:'Local negative',prepend_text:'Local prepend',append_text:'Local append'});
for(const [name,value]of [['Positive prompt','Local positive'],['Negative prompt','Local negative'],['Prepend','Local prepend'],['Append','Local append']]){const field=root.getByRole('textbox',{name,exact:true});await expect(field).toHaveCount(1);await expect(field).toBeEditable();await expect(field).toHaveValue(value);}
});
test('paired default layout uses equal editors and follows real host themes',async({page},testInfo)=>{
await addPair(page);const root=page.locator('.pm-pair');await root.getByRole('textbox',{name:'Positive prompt',exact:true}).fill('Positive draft');await root.getByRole('textbox',{name:'Negative prompt',exact:true}).fill('Negative draft');
for(const palette of ['light','dark']){await page.evaluate(async palette=>{const {app}=await import('/scripts/app.js');await app.ui.settings.setSettingValue('Comfy.ColorPalette',palette);},palette);await expect(root).toHaveAttribute('data-ca-theme',palette);
const [p,n]=await Promise.all(['Positive prompt','Negative prompt'].map(name=>root.getByRole('textbox',{name,exact:true}).boundingBox()));expect(Math.abs(p.width-n.width)).toBeLessThan(1);expect(Math.abs(p.height-n.height)).toBeLessThan(1);expect(p.height).toBeGreaterThan(100);
await page.screenshot({path:testInfo.outputPath(`pair-${palette}.png`)});
}
});
test('paired explicit Save persists an empty negative through actual API and preserves local draft',async({page,request},testInfo)=>{
const id=await addPair(page);const root=page.locator('.pm-pair');const text=`Saved pair empty negative ${testInfo.project.name}`;await root.getByRole('textbox',{name:'Positive prompt',exact:true}).fill(text);await root.getByRole('button',{name:'Save pair',exact:true}).click();
await expect(root.getByRole('status').filter({hasText:'Pair saved.'})).toBeVisible();const response=await request.get(`/prompt_manager/pairs?text=${encodeURIComponent(text)}`);expect(response.ok()).toBe(true);expect((await response.json()).pairs).toEqual(expect.arrayContaining([expect.objectContaining({positive_text:text,negative_text:''})]));expect(await values(page,id)).toMatchObject({text,negative_text:''});
});
test('paired library filters search both texts, persist on reload and keep editor metadata separate',async({page,request},testInfo)=>{
const token=`paired-filter-${testInfo.project.name}`;await seed(request,`Matching ${token}`,`Negative ${token}`);const other=await request.post('/prompt_manager/pairs',{data:{positive_text:`Other ${token}`,negative_text:'different',category:'Other paired category',tags:['other-pair-tag']}});expect(other.ok()).toBe(true);
const id=await addPair(page);const root=page.locator('.pm-pair');await root.getByRole('textbox',{name:'Positive prompt',exact:true}).fill('Draft positive');await root.getByRole('textbox',{name:'Negative prompt',exact:true}).fill('Draft negative');
await root.getByText('Metadata · category / tags',{exact:true}).click();await root.getByRole('combobox',{name:'Category',exact:true}).fill('Keep category');await root.getByRole('combobox',{name:'Tags',exact:true}).fill('keep-tag');await root.getByText('Metadata · category / tags',{exact:true}).click();
await root.getByRole('searchbox',{name:'Search saved prompts'}).fill(token);await root.getByLabel('Filter category',{exact:true}).fill('Paired host tests');await root.getByLabel('Filter tags',{exact:true}).fill('pair-host, ');await root.getByRole('button',{name:'Search',exact:true}).click();
await expect(root.locator('.pm-editor-result')).toHaveCount(1);await root.locator('.pm-editor-result').click();await expect(root.locator('.pm-editor-selection')).toContainText(`Negative ${token}`);
expect(await values(page,id)).toMatchObject({text:'Draft positive',negative_text:'Draft negative',category:'Keep category',tags:'keep-tag'});await root.getByRole('switch',{name:'Load metadata'}).uncheck();await root.getByRole('button',{name:'Use pair',exact:true}).click();expect(await values(page,id)).toMatchObject({text:`Matching ${token}`,negative_text:`Negative ${token}`,category:'Keep category',tags:'keep-tag'});
await page.screenshot({path:testInfo.outputPath('pair-library-preview.png')});
await page.evaluate(async()=>{const {app}=await import('/scripts/app.js');await app.loadGraphData(app.graph.serialize());});await expect(root.getByLabel('Filter category',{exact:true})).toHaveValue('Paired host tests');await expect(root.getByLabel('Filter tags',{exact:true})).toHaveValue('pair-host, ');
await page.evaluate(async id=>{const {app}=await import('/scripts/app.js');const node=app.graph.getNodeById(id);node.setSize([560,1000]);app.canvas.centerOnNode(node);},id);
const [p,n]=await Promise.all(['Positive prompt','Negative prompt'].map(name=>root.getByRole('textbox',{name,exact:true}).boundingBox()));expect(n.y).toBeGreaterThan(p.y+p.height-1);expect(Math.abs(p.width-n.width)).toBeLessThan(1);await page.screenshot({path:testInfo.outputPath('pair-narrow.png')});
});
for(const field of ['category','tags'])test(`manual Save pair guards real connected ${field} and resumes after disconnect`,async({page},testInfo)=>{
const id=await addPair(page);const root=page.locator('.pm-pair');await root.getByRole('textbox',{name:'Positive prompt',exact:true}).fill(`Metadata link ${field} ${testInfo.project.name}`);
let saves=0;await page.route('**/prompt_manager/pairs',async route=>{if(route.request().method()==='POST')saves++;await route.continue();});
const link=await page.evaluate(async({id,field})=>{const {app}=await import('/scripts/app.js');const {convertToInput}=await import('/extensions/core/widgetInputs.js');const target=app.graph.getNodeById(id);convertToInput(target,target.widgets.find(widget=>widget.name===field));const source=LiteGraph.createNode('PrimitiveString');app.graph.add(source);source.widgets.find(widget=>widget.name==='value').value='Connected metadata';source.connect(0,target,target.inputs.findIndex(input=>input.name===field));app.canvas.centerOnNode(target);return [String(source.id),0];},{id,field});
const save=root.getByRole('button',{name:'Save pair',exact:true});await expect(save).toBeDisabled();await expect(root.getByRole('status').filter({hasText:'Run the node to save connected values.'})).toBeVisible();await save.evaluate(button=>button.dispatchEvent(new Event('click',{bubbles:true})));expect(saves).toBe(0);
const input=await page.evaluate(async({id,field})=>{const {app}=await import('/scripts/app.js');return(await app.graphToPrompt()).output[String(id)].inputs[field];},{id,field});expect(input).toEqual(link);
await page.evaluate(async({id,field})=>{const {app}=await import('/scripts/app.js');const target=app.graph.getNodeById(id);target.disconnectInput(target.inputs.findIndex(input=>input.name===field));},{id,field});await expect(save).toBeEnabled();await save.click();await expect(root.getByRole('status').filter({hasText:'Pair saved.'})).toBeVisible();expect(saves).toBe(1);
});
+2 -38
View File
@@ -1,43 +1,7 @@
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import runtime from '../../tools/vendor/comfyui-runtime.json' with { type: 'json' };
import { installHostHooks } from './host-fixture.mjs';
test.beforeEach(async ({ page, context, request }, testInfo) => {
const response = await request.get('/system_stats');
expect(response.ok()).toBe(true);
const info = (await response.json()).system;
expect(info.comfyui_version).toBe(runtime.serverVersion);
expect(info.pytorch_version).toBe(`${runtime.torchVersion}+cpu`);
expect(info.required_frontend_version).toBe(runtime.frontendVersion);
expect(info.comfy_package_versions.find(item => item.name === 'comfyui-frontend-package')?.installed).toBe(runtime.frontendVersion);
await testInfo.attach('host-versions.json', { body: JSON.stringify(info, null, 2), contentType: 'application/json' });
page.hostErrors = [];
page.on('pageerror', error => page.hostErrors.push(error.message));
await context.route('**/*', async route => {
const request = route.request();
const path = new URL(request.url()).pathname.replace(/^\/api\//, '/');
if (path === '/settings' && request.method() === 'GET') {
const response = await route.fetch();
return route.fulfill({ json: { ...await response.json(),
'Comfy.VueNodes.Enabled': testInfo.project.metadata.initialRenderer,
'Comfy.TutorialCompleted': true, 'Comfy.Workflow.Persist': false,
'Comfy.Notification.ShowVersionUpdates': false } });
}
if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method()) && !path.startsWith('/prompt_manager/')) return route.fulfill({ json: {} });
return route.continue();
});
await page.goto('/', { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('status', { name: 'Loading ComfyUI' })).toBeHidden({ timeout: 30000 });
await page.waitForFunction(async () => {
const { app } = await import('/scripts/app.js');
return !!app.graph && ['PromptManager', 'PromptManagerText', 'PromptSearchList'].every(name => globalThis.LiteGraph?.registered_node_types?.[name]);
});
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(testInfo.project.metadata.initialRenderer);
await page.evaluate(async () => { const { app } = await import('/scripts/app.js'); app.graph.clear(); });
});
test.afterEach(async ({ page }) => {
expect(page.hostErrors).toEqual([]);
});
installHostHooks(test);
for (const type of ['PromptManager', 'PromptManagerText']) {
test(`${type} selects real database prompts and preserves text across renderers and reload`, async ({ page, request }, testInfo) => {
+49
View File
@@ -0,0 +1,49 @@
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import { installHostHooks } from './host-fixture.mjs';
installHostHooks(test);
test('workspace serves real library data beside either ComfyUI renderer', async ({ page, context, request }, testInfo) => {
const saved = await request.post('/prompt_manager/save', { data: {
text: `Workspace host smoke ${testInfo.project.name}`, tags: ['workspace-host'],
} });
expect(saved.ok()).toBe(true);
const stats = (await (await request.get('/prompt_manager/stats')).json()).stats;
const workspace = await context.newPage();
const errors = [];
workspace.on('pageerror', error => errors.push(error.message));
try {
await workspace.goto('/prompt_manager/workspace#/overview');
await expect(workspace.locator('#workspace-main h1')).toHaveText('Overview');
await expect(workspace.locator('dt').filter({ hasText: /^Prompts$/ }).locator('+ dd'))
.toHaveText(stats.total_prompts.toLocaleString());
await workspace.getByRole('combobox', { name: 'Appearance' }).selectOption('light');
await expect(workspace.locator('body')).toHaveAttribute('data-ca-theme', 'light');
const imageResponse = workspace.waitForResponse(response => {
const url = new URL(response.url());
return url.pathname === '/prompt_manager/images/output' && url.searchParams.get('mode') === 'workspace';
});
await workspace.getByRole('navigation', { name: 'Workspace', exact: true })
.getByRole('link', { name: 'Images', exact: true }).click();
await expect(workspace.locator('#workspace-main h1')).toHaveText('Images');
const images = await imageResponse;
expect(images.ok()).toBe(true);
const collection = await images.json();
expect(collection.success).toBe(true);
await expect(workspace.getByRole('list', { name: 'Image collection', exact: true })).toBeAttached();
if (collection.images.length) {
await expect(workspace.locator('article[data-image-key]').first()).toBeVisible();
} else {
await expect(workspace.getByText('No matching images or media. Try changing the filters.', { exact: true })).toBeVisible();
}
await expect(workspace.locator('#workspace-main a[href="/prompt_manager/gallery"]')).toHaveAttribute('href', '/prompt_manager/gallery');
await workspace.reload();
await expect(workspace.locator('#workspace-main h1')).toHaveText('Images');
await expect(workspace.locator('body')).toHaveAttribute('data-ca-theme', 'light');
await workspace.screenshot({ path: testInfo.outputPath('workspace-host.png') });
expect(errors).toEqual([]);
expect(await page.evaluate(() => LiteGraph.vueNodesMode)).toBe(testInfo.project.metadata.initialRenderer);
} finally {
await workspace.close();
}
});
+14
View File
@@ -0,0 +1,14 @@
# Browser media fixtures
`tiny.webm` is an original, silent, one-second VP8 color test pattern generated
for this repository. It contains no user media and is dedicated to the public
domain under CC0-1.0.
Reproduce with FFmpeg:
```sh
ffmpeg -f lavfi -i testsrc=size=32x32:rate=4:duration=1 \
-c:v libvpx -pix_fmt yuv420p -an tiny.webm
```
Browser tests use the committed file; FFmpeg is not required to run them.
BIN
View File
Binary file not shown.
+67
View File
@@ -327,3 +327,70 @@ test("missing or empty metadata yields placeholders", () => {
assert.equal(r.checkpoint, "Unknown");
assert.equal(extractGenerationParams(null).seed, "Unknown");
});
test("Focused API metadata composes linked positive text and preserves negative role", () => {
const prompt = {
1: { class_type: "KSampler", inputs: { positive: ["2", 0], negative: ["3", 0], model: ["4", 0] } },
2: { class_type: "PromptManagerFocused", inputs: { text: "a fox", prepend_text: ["5", 0], append_text: "8k" } },
3: { class_type: "PromptManagerFocused", inputs: { text: "blurry", prepend_text: "avoid" } },
4: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "model.safetensors" } },
5: { class_type: "PrimitiveString", inputs: { value: "lora trigger" } },
};
const result = extractGenerationParams({ prompt });
assert.equal(result.positivePrompt, "lora trigger a fox 8k");
assert.equal(result.negativePrompt, "avoid blurry");
});
test("Focused workflow-only metadata recovers serialized fields and connected composition", () => {
const workflow = {
nodes: [
{ id: 1, type: "KSampler", inputs: [{ name: "positive", link: 1 }, { name: "negative", link: 2 }, { name: "model", link: 3 }] },
{ id: 2, type: "PromptManagerFocused", widgets_values: ["a fox", "animals", "fox", "ignored search", "stale widget", "8k"], inputs: [{ name: "prepend_text", link: 4 }] },
{ id: 3, type: "PromptManagerFocused", widgets_values: ["blurry", "negative", "bad", "ignored search", "avoid", "watermark"] },
{ id: 4, type: "UNETLoader", widgets_values: ["model.safetensors", "default"] },
{ id: 5, type: "CLIPTextEncode", widgets_values: ["lora trigger"] },
],
links: [
[1, 2, 0, 1, 0, "CONDITIONING"], [2, 3, 0, 1, 1, "CONDITIONING"],
[3, 4, 0, 1, 2, "MODEL"], [4, 5, 0, 2, 0, "STRING"],
],
};
const result = extractGenerationParams({ workflow });
assert.equal(result.positivePrompt, "lora trigger a fox 8k");
assert.equal(result.negativePrompt, "avoid blurry watermark");
assert.equal(result.checkpoint, "model.safetensors");
});
test("Split API metadata composes linked positive text and preserves negative role", () => {
const prompt = {
1: { class_type: "KSampler", inputs: { positive: ["2", 0], negative: ["3", 0], model: ["4", 0] } },
2: { class_type: "PromptManagerSplit", inputs: { text: "a fox", prepend_text: ["5", 0], append_text: "8k" } },
3: { class_type: "PromptManagerSplit", inputs: { text: "blurry", prepend_text: "avoid" } },
4: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "model.safetensors" } },
5: { class_type: "PrimitiveString", inputs: { value: "lora trigger" } },
};
const result = extractGenerationParams({ prompt });
assert.equal(result.positivePrompt, "lora trigger a fox 8k");
assert.equal(result.negativePrompt, "avoid blurry");
});
test("Split workflow-only metadata recovers serialized fields and connected composition", () => {
const workflow = {
nodes: [
{ id: 1, type: "KSampler", inputs: [{ name: "positive", link: 1 }, { name: "negative", link: 2 }, { name: "model", link: 3 }] },
{ id: 2, type: "PromptManagerSplit", widgets_values: ["a fox", "animals", "fox", "ignored search", "stale widget", "8k"], inputs: [{ name: "prepend_text", link: 4 }] },
{ id: 3, type: "PromptManagerSplit", widgets_values: ["blurry", "negative", "bad", "ignored search", "avoid", "watermark"] },
{ id: 4, type: "UNETLoader", widgets_values: ["model.safetensors", "default"] },
{ id: 5, type: "CLIPTextEncode", widgets_values: ["lora trigger"] },
],
links: [
[1, 2, 0, 1, 0, "CONDITIONING"], [2, 3, 0, 1, 1, "CONDITIONING"],
[3, 4, 0, 1, 2, "MODEL"], [4, 5, 0, 2, 0, "STRING"],
],
};
const result = extractGenerationParams({ workflow });
assert.equal(result.positivePrompt, "lora trigger a fox 8k");
assert.equal(result.negativePrompt, "avoid blurry watermark");
assert.equal(result.checkpoint, "model.safetensors");
});
+61
View File
@@ -0,0 +1,61 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const path = require('node:path');
const ImageHelpers = require('../../web/js/image-helpers.js');
const {extractGenerationParams} = require('../../web/js/comfy-metadata.js');
function graph(positive = 0, negative = 1) {
return {
1:{class_type:'PromptManagerPair',inputs:{text:'cat',negative_text:'blur',prepend_text:'trigger',append_text:'detail'}},
2:{class_type:'KSampler',inputs:{positive:['1',positive],negative:['1',negative],model:['3',0]}},
3:{class_type:'CheckpointLoaderSimple',inputs:{ckpt_name:'m.safetensors'}},
};
}
test('paired conditioning uses actual output roles and composes only positive', () => {
const metadata = extractGenerationParams({prompt:graph()});
assert.equal(metadata.positivePrompt,'trigger cat detail');
assert.equal(metadata.negativePrompt,'blur');
const swapped = extractGenerationParams({prompt:graph(1,0)});
assert.equal(swapped.positivePrompt,'blur');
assert.equal(swapped.negativePrompt,'trigger cat detail');
});
test('paired strings passed through encoders retain the connected output slot', () => {
const prompt=graph();
prompt[4]={class_type:'CLIPTextEncode',inputs:{text:['1',2]}};
prompt[5]={class_type:'CLIPTextEncode',inputs:{text:['1',3]}};
prompt[2].inputs.positive=['4',0];prompt[2].inputs.negative=['5',0];
const metadata=extractGenerationParams({prompt});
assert.equal(metadata.positivePrompt,'trigger cat detail');
assert.equal(metadata.negativePrompt,'blur');
});
test('recorded image pair overrides unrelated PNG fallback, including an empty negative', async () => {
for (const negative of ['blur','']) {
const context=vm.createContext({document:{addEventListener(){}},window:{},console,ImageHelpers,
ApiClient:{fetchUrl:async()=>({ok:true,json:async()=>({success:true,prompt:{text:'raw cat',pair:{negative_text:negative},pair_context:{positive_text:'trigger cat',negative_text:negative}}})})},
});
const Manager=vm.runInContext(fs.readFileSync(path.join(__dirname,'../../web/js/gallery.js'),'utf8')+'\nGalleryManager;',context);
const manager=Object.create(Manager.prototype);
manager.extractImageMetadata=async()=>({positivePrompt:'wrong',negativePrompt:'wrong'});
manager.displayMetadata=data=>{manager.last=data;};
await manager.loadImageMetadata('/prompt_manager/images/serve/example.png',{});
assert.equal(manager.last.positivePrompt,'trigger cat');
assert.equal(manager.last.negativePrompt,negative);
}
});
test('gallery labels graph-inferred text separately from recorded execution text', () => {
const context=vm.createContext({document:{addEventListener(){}},window:{},console,escapeHtml:value=>String(value)});
const Manager=vm.runInContext(fs.readFileSync(path.join(__dirname,'../../web/js/gallery.js'),'utf8')+'\nGalleryManager;',context);
const manager=Object.create(Manager.prototype);
manager.formatPromptText=value=>String(value||'');manager.setupMetadataActions=()=>{};
for (const [source,label] of [['graph-inferred','Recovered from workflow metadata'],['execution','Recorded during execution'],['embedded-parameters','Read from embedded parameters'],['mixed','Execution and workflow metadata']]) {
const panel={};
manager.displayMetadata({positivePrompt:'cat',negativePrompt:'blur',textSource:source},panel);
assert.ok(panel.innerHTML.includes(label),source);
}
});
+27
View File
@@ -76,3 +76,30 @@ test("progressLabel summarises a running scan for the minimized pill", () => {
assert.equal(progressLabel(running), "Scanning 42% · 84 files · 12 prompts");
assert.equal(progressLabel(initialState()), "");
});
test('pair totals survive completion and cancellation closes the running state', () => {
const running=reduceScanEvent(initialState(),{type:'progress',progress:30,processed:3});
const done=reduceScanEvent(running,{type:'complete',pairs_added:2,pair_links_added:3,enriched:1,unchanged:4,ambiguous:1,failed:2});
assert.deepEqual([done.pairs_added,done.pair_links_added,done.enriched,done.unchanged,done.ambiguous,done.failed],[2,3,1,4,1,2]);
const cancelled=reduceScanEvent(running,{type:'cancelled',processed:3,pairs_added:1});
assert.equal(cancelled.status,'done');
assert.equal(cancelled.cancelled,true);
assert.equal(cancelled.statusText,'Scan cancelled.');
assert.equal(cancelled.progress,30);
});
test('completion label reports pair enrichment, unresolved work and failures', () => {
const {completionLabel}=require('../../web/js/scan-progress.js');
const state=reduceScanEvent(initialState(),{type:'complete',processed:10,found:8,added:1,linked:2,pairs_added:3,pair_links_added:4,enriched:2,unchanged:1,ambiguous:1,unresolved:2,conflict:1,failed:1});
assert.match(completionLabel(state),/3 new pairs, 4 pair links/);
assert.match(completionLabel(state),/4 need review, 1 failed/);
assert.match(completionLabel({...state,cancelled:true}),/^Scan cancelled!/);
});
test('older servers without pair counts retain a complete readable summary', () => {
const {completionLabel}=require('../../web/js/scan-progress.js');
const state=reduceScanEvent(initialState(),{type:'complete',processed:2,found:1,added:1,linked:0});
assert.match(completionLabel(initialState()),/0 new pairs, 0 pair links; 0 need review, 0 failed/);
assert.match(completionLabel(state),/0 enriched, 0 unchanged/);
assert.equal(fromStatus({running:true,last_event:{type:'unknown'}}).status,'running');
});
+161
View File
@@ -0,0 +1,161 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const source = import('../../web/js/workspace/images-state.mjs');
const image = (path, root_index = 0) => ({path, root_index, id:'same-hash'});
const page = (images, total = images.length, next_cursor = null) => ({images, total, next_cursor, has_more:next_cursor !== null});
const deferred = () => { let resolve, reject; const promise = new Promise((a,b) => {resolve=a;reject=b;}); return {promise,resolve,reject}; };
test('stream serializes allowed filters, appends distinct rooted paths, and emits immutable snapshots', async () => {
const { createImageStream, imageKey } = await source;
const calls = [], states = [];
const pages = [page([image('a'),image('a',1)],3,'next'), page([image('a',1),image('b')],3)];
const stream = createImageStream({api:{get:async (url, options) => { calls.push([url, options]);return pages.shift(); }},onChange:s => states.push(s),batchSize:2});
assert.equal(await stream.reset({q:'a & b',root:0,media:'image',sort:'oldest',subfolder:'nested/one',ignored:'unsafe'}), 2);
const query = new URL(calls[0][0],'http://local').searchParams;
assert.equal(query.get('q'),'a & b'); assert.equal(query.get('root'),'0');
assert.equal(query.get('subfolder'),'nested/one'); assert.equal(query.get('mode'),'workspace');
assert.equal(query.get('limit'),'2'); assert.equal(query.has('cursor'),false); assert.equal(query.has('ignored'),false);
assert.ok(calls[0][1].signal instanceof AbortSignal);
assert.equal(imageKey(image('a',0)),'0:a'); assert.notEqual(imageKey(image('a',0)), imageKey(image('a',1)));
const first = stream.state;
assert.ok(Object.isFrozen(first)); assert.ok(Object.isFrozen(first.items)); assert.ok(Object.isFrozen(first.items[0]));
const more = stream.loadMore();
assert.strictEqual(stream.state.items,first.items);
assert.equal(await more,1);
assert.equal(new URL(calls[1][0],'http://local').searchParams.get('cursor'),'next');
assert.deepEqual(stream.state.items.map(imageKey),['0:a','1:a','0:b']);
assert.equal(stream.state.loaded,3); assert.equal(stream.state.total,3); assert.equal(stream.state.hasMore,false);
assert.equal(await stream.loadMore(),0); assert.equal(calls.length,2);
assert.equal(first.items.length,2); assert.equal(states.at(-1),stream.state);
});
test('reset aborts pending requests and ignores stale success and failure', async () => {
const { createImageStream } = await source;
const requests=[]; const states=[];
const stream=createImageStream({api:{get:(url,{signal})=>{const d=deferred();requests.push({...d,url,signal});return d.promise;}},onChange:s=>states.push(s)});
const old=stream.reset({q:'old'}); const fresh=stream.reset({q:'fresh'});
assert.equal(requests[0].signal.aborted,true);
requests[1].resolve(page([image('fresh')]));await fresh;
requests[0].resolve(page([image('old')])); assert.equal(await old,0);
assert.equal(stream.state.items[0].path,'fresh');
const failing=stream.reset({q:'failure'}); const newest=stream.reset();
requests[2].reject(new Error('stale'));requests[3].resolve(page([]));
await Promise.all([failing,newest]);assert.equal(stream.state.error,null);
assert.equal(stream.state.loading,false);
});
test('concurrent loads do not duplicate requests, and failures retain data and cursor for retry', async () => {
const {createImageStream}=await source; const pending=deferred(); const calls=[];
const responses=[page([image('a')],2,'next'),pending.promise,page([image('b')],2)];
const stream=createImageStream({api:{get:async url=>{calls.push(url);return responses.shift();}}});
await stream.reset();const more=stream.loadMore();assert.equal(await stream.loadMore(),0);assert.equal(calls.length,2);
pending.reject(new Error('offline'));assert.equal(await more,0);
assert.equal(stream.state.items.length,1);assert.equal(stream.state.cursor,'next');assert.equal(stream.state.error.message,'offline');
assert.equal(stream.state.hasMore,true);assert.equal(await stream.loadMore(),1);assert.equal(stream.state.error,null);
assert.equal(calls[1],calls[2]);
});
test('malformed pages fail atomically and allow retry without corrupting the stream', async () => {
const {createImageStream}=await source;
for(const invalid of [null,{},page('bad'),page([{}]),page([image('')]),page([image('x',-1)]),page([], -1),{...page([]),has_more:'yes'},page([],2,''),{...page([],2,'next'),has_more:false}]) {
const replies=[page([image('existing')],2,'next'),invalid,page([image('fixed')],2)];
const stream=createImageStream({api:{get:async()=>replies.shift()}});await stream.reset();
await stream.loadMore();assert.match(stream.state.error.message,/image|page|cursor/i);
assert.equal(stream.state.items[0].path,'existing'); assert.equal(stream.state.cursor,'next');
await stream.loadMore();assert.equal(stream.state.items.length,2);assert.equal(stream.state.error,null);
}
});
test('duplicate pages can advance cursors, but repeated cursors fail instead of looping', async () => {
const {createImageStream}=await source;
const replies=[page([image('a')],2,'one'),page([image('a')],2,'two'),page([image('b')],2,'two')];
const stream=createImageStream({api:{get:async()=>replies.shift()}});await stream.reset();
assert.equal(await stream.loadMore(),0);assert.equal(stream.state.cursor,'two');assert.equal(stream.state.hasMore,true);
await stream.loadMore();assert.match(stream.state.error.message,/cursor/i);assert.equal(stream.state.items.length,1);
});
test('dispose aborts the active request and suppresses notifications and further requests', async () => {
const {createImageStream}=await source;const d=deferred();let signal,count=0,notifications=0;
const stream=createImageStream({api:{get:(url,opts)=>{signal=opts.signal;count++;return d.promise;}},onChange:()=>notifications++});
const request=stream.reset();stream.dispose();stream.dispose();assert.equal(signal.aborted,true);
const before=notifications;d.reject(Object.assign(new Error('aborted'),{name:'AbortError'}));await request;
assert.equal(notifications,before);assert.equal(await stream.reset(),0);assert.equal(await stream.loadMore(),0);assert.equal(count,1);
});
test('stream validates constructor and filter input before requesting', async () => {
const {createImageStream}=await source;
assert.throws(()=>createImageStream({api:{}}),/api/i);
for(const batchSize of [0,-1,1.5,Infinity,201]) assert.throws(()=>createImageStream({api:{get(){}},batchSize}),/batch/i);
const stream=createImageStream({api:{get:async()=>page([])}});
for(const filters of [{root:-1},{root:'oops'},{media:'executable'},{sort:'random'},{q:{}},{subfolder:[]}]) await assert.rejects(stream.reset(filters),/filter/i);
await stream.reset({root:'all',media:'all',sort:'newest'});assert.equal(stream.state.hasMore,false);
});
test('the metadata stream retains earlier images beyond one rendered window and deduplicates within a batch', async () => {
const {createImageStream}=await source;
let request=0;
const stream=createImageStream({api:{get:async()=>{
const start=request++*40;
return page(Array.from({length:40},(_,index)=>image(`image-${start+index}`)),320,start+40<320?String(start+40):null);
}}});
await stream.reset();
while(stream.state.hasMore) await stream.loadMore();
assert.equal(stream.state.loaded,320);assert.equal(stream.state.items[0].path,'image-0');
assert.equal(stream.state.items.at(-1).path,'image-319');
const duplicate=createImageStream({api:{get:async()=>page([image('a'),image('a')],1)}});
assert.equal(await duplicate.reset(),1);assert.equal(duplicate.state.items.length,1);
});
test('all-source defaults are omitted and descending name sort remains supported', async () => {
const {createImageStream}=await source;let query;
const stream=createImageStream({api:{get:async url=>{query=new URL(url,'http://local').searchParams;return page([]);}}});
await stream.reset({root:'all',media:'all',sort:'name-desc'});
assert.equal(query.has('root'),false);assert.equal(query.has('media'),false);
assert.equal(query.get('sort'),'name-desc');assert.equal(query.get('limit'),'40');
});
test('restored browsing state resumes its cursor and filters without an initial request', async () => {
const {createImageStream}=await source;
const calls=[];
const cachedItems=[image('existing')];
const cache={filters:{q:'saved search',root:1,sort:'oldest',media:'image',subfolder:'nested'},state:{items:cachedItems,total:3,cursor:'saved-cursor',hasMore:true,loaded:1,loading:true,error:new Error('old error')}};
const stream=createImageStream({api:{get:async url=>{calls.push(url);return page([image('existing'),image('new')],3,'next');}},restore:cache});
assert.equal(calls.length,0);assert.equal(stream.state.items.length,1);
assert.equal(stream.state.loading,false);assert.equal(stream.state.error,null);
assert.ok(Object.isFrozen(stream.state));assert.ok(Object.isFrozen(stream.state.items));assert.ok(Object.isFrozen(stream.state.items[0]));
cachedItems[0].path='external mutation';cachedItems.push(image('external'));
assert.equal(stream.state.items[0].path,'existing');assert.equal(stream.state.items.length,1);
assert.equal(await stream.loadMore(),1);
const query=new URL(calls[0],'http://local').searchParams;
for(const [key,value] of Object.entries({q:'saved search',root:'1',sort:'oldest',media:'image',subfolder:'nested',cursor:'saved-cursor'})) assert.equal(query.get(key),value);
assert.deepEqual(stream.state.items.map(item=>item.path),['existing','new']);
await stream.reset({q:'new search'});
assert.equal(new URL(calls[1],'http://local').searchParams.has('cursor'),false);
assert.equal(new URL(calls[1],'http://local').searchParams.get('q'),'new search');
stream.dispose();assert.equal(await stream.loadMore(),0);
});
test('restoring frozen state reuses immutable metadata and respects a completed collection', async () => {
const {createImageStream}=await source;
const first=createImageStream({api:{get:async()=>page([image('saved')])}});
await first.reset();let requests=0;
const restored=createImageStream({api:{get:async()=>{requests++;return page([]);}},restore:{filters:{},state:first.state}});
assert.strictEqual(restored.state.items,first.state.items);assert.equal(restored.state.loaded,1);
assert.equal(await restored.loadMore(),0);assert.equal(requests,0);
});
test('restored interrupted initial loading can restart from the first request',async()=>{
const {createImageStream}=await source;let called=0;
const stream=createImageStream({api:{get:async url=>{called++;assert.equal(new URL(url,'http://local').searchParams.has('cursor'),false);return page([]);}},
restore:{filters:{},state:{items:[],total:0,cursor:null,hasMore:true,loading:true}}});
assert.equal(called,0);assert.equal(await stream.loadMore(),0);assert.equal(called,1);assert.equal(stream.state.hasMore,false);
});
test('malformed restored state is rejected before making requests',async()=>{
const {createImageStream}=await source;
const base={items:[image('a')],total:2,cursor:'saved',hasMore:true};
const invalid=[null,{}, {filters:{},state:null},
...[{items:'bad'},{items:[{}]},{items:[image('a'),image('a')]},{total:-1},{cursor:''},{cursor:null},{hasMore:'yes'},{hasMore:false}].map(change=>({filters:{},state:{...base,...change}})),
{filters:{root:-1},state:base}, {filters:null,state:base}];
for(const restore of invalid) assert.throws(()=>createImageStream({api:{get(){assert.fail('unexpected request');}},restore}),TypeError);
});
+80
View File
@@ -0,0 +1,80 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const source = import('../../web/js/workspace/image-layout.mjs');
test('layouts preserve data order, remain within width and never overlap', async () => {
const {layoutImages} = await source;
const images=Array.from({length:200},(_,i)=>({width:i%2?1024:640,height:i%2?640:1024}));
for(const width of [280,760,1400]) for(const mode of ['grid','list','masonry']) {
const {positions,height}=layoutImages(images,width,mode);
assert.equal(positions.length,200);
for(const [i,a] of positions.entries()) {
assert.ok(a.x>=0 && a.y>=0 && a.width>0 && a.height>0);
assert.ok(a.x+a.width<=width+.01);
assert.ok(a.y+a.height<=height+.01);
for(const b of positions.slice(i+1)) assert.ok(a.x+a.width<=b.x+.01 || b.x+b.width<=a.x+.01 || a.y+a.height<=b.y+.01 || b.y+b.height<=a.y+.01);
}
}
});
test('empty, unknown dimensions and narrow containers keep valid geometry',async()=>{
const {layoutImages}=await source;
assert.deepEqual(layoutImages([],0,'grid'),{positions:[],height:0,maxHeight:0});
for(const mode of ['grid','list','masonry']) {
const result=layoutImages([{}, {width:0,height:null}],1,mode);
assert.ok(Number.isFinite(result.height));
}
});
test('virtual window retains visible rows and focused card while bounding rendered media',async()=>{
const {layoutImages,visibleImages}=await source;
const {positions}=layoutImages(Array(5000).fill({}),900,'masonry');
const indexes=visibleImages(positions,10000,900,0);
assert.ok(indexes.length<100);
assert.ok(indexes.includes(0));
assert.ok(indexes.some(i=>positions[i].y>=10000 && positions[i].y<10900));
assert.deepEqual(indexes,[...indexes].sort((a,b)=>a-b));
assert.equal(new Set(indexes).size,indexes.length);
assert.ok(visibleImages(positions,0,800).includes(0));
});
test('large virtual collections inspect a bounded range rather than every loaded position', async () => {
const {layoutImages,visibleImages}=await source;
for(const mode of ['grid','list','masonry']) {
const geometry=layoutImages(Array.from({length:50000},(_,index)=>({width:640,height:index%2?320:1024})),900,mode);
for(let index=1;index<geometry.positions.length;index++) assert.ok(geometry.positions[index].y>=geometry.positions[index-1].y);
let reads=0;
const positions=new Proxy(geometry.positions,{get(target,key,receiver){
if(typeof key==='string' && /^\d+$/.test(key)) reads++;
return Reflect.get(target,key,receiver);
}});
const top=geometry.positions[25000].y;
const indexes=visibleImages(positions,top,900,49999,geometry.maxHeight);
assert.ok(reads<500,`${mode} read ${reads} positions`);
const expected=geometry.positions.flatMap((position,index)=>(index===49999 ||
(position.y+position.height>=top-900 && position.y<=top+1800))?[index]:[]);
assert.deepEqual(indexes,expected);
}
});
test('binary-searched windows handle boundaries, empty collections and focus without duplicates',async()=>{
const {layoutImages,visibleImages}=await source;
assert.deepEqual(visibleImages([],0,800,0),[]);
const {positions}=layoutImages(Array(1000).fill({}),900,'grid');
for(const focused of [-1,0,999,1000]) {
for(const top of [0,10000,1000000]) {
const actual=visibleImages(positions,top,600,focused);
const expected=positions.flatMap((position,index)=>(index===focused ||
(position.y+position.height>=top-600 && position.y<=top+1200))?[index]:[]);
assert.deepEqual(actual,expected);
}
}
assert.deepEqual(visibleImages(positions,1000000,0),[]);
});
test('reported maximum height keeps unusually tall custom layouts inside the virtual window',async()=>{
const {layoutImages,visibleImages}=await source;
const geometry=layoutImages([{}, {}, {}],10000,'grid',20000);
assert.equal(geometry.maxHeight,7572);
assert.deepEqual(visibleImages(geometry.positions,6000,200,-1,geometry.maxHeight),[0]);
});
+49
View File
@@ -0,0 +1,49 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const source = import('../../web/js/workspace/state.mjs');
test('workspace routes resolve only supported deep links', async () => {
const { resolveRoute } = await source;
for (const route of ['overview', 'images', 'library', 'tags', 'inspect', 'system']) {
assert.equal(resolveRoute(`#/${route}`).id, route);
}
for (const hash of ['', '#', '#/unknown', '#/images/other', '#/<script>']) {
assert.equal(resolveRoute(hash).id, 'images');
}
});
test('statistics preserve zero and reject incomplete or invalid counts', async () => {
const { summarizeStats } = await source;
assert.deepEqual(summarizeStats({total_prompts:0, total_images:12, total_tags:3}), [
['Prompts', 0], ['Images', 12], ['Tags', 3],
]);
for (const stats of [undefined, null, {}, {total_prompts:0, total_images:-1, total_tags:0},
{total_prompts:'2', total_images:0, total_tags:0},
{total_prompts:Infinity, total_images:0, total_tags:0},
{total_prompts:0, total_images:1.5, total_tags:0}]) {
assert.throws(() => summarizeStats(stats), /statistics/i);
}
});
test('router normalizes unknown routes, avoids duplicate renders, and disposes its listener', async () => {
const { mountRouter } = await source;
const win = new EventTarget();
win.location = {hash:'#/missing'};
const replacements = [];
win.history = {state: {keep:1}, replaceState(state, unused, hash) {
replacements.push([state, hash]); win.location.hash = hash;
}};
const seen = [];
const dispose = mountRouter(win, route => seen.push(route.id));
assert.deepEqual(seen, ['images']);
assert.deepEqual(replacements, [[{keep:1}, '#/images']]);
win.location.hash = '#/library';
win.dispatchEvent(new Event('hashchange'));
win.dispatchEvent(new Event('hashchange'));
assert.deepEqual(seen, ['images', 'library']);
dispose();
dispose();
win.location.hash = '#/system';
win.dispatchEvent(new Event('hashchange'));
assert.deepEqual(seen, ['images', 'library']);
});
+142
View File
@@ -0,0 +1,142 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createScope } from '../../web/js/shared/toolkit/core.js';
import { mountWorkspaceTheme } from '../../web/js/workspace/theme.mjs';
function fixture({ dark = false, preference, fetchImpl, storageDenied = false, pathname = '/prompt_manager/workspace' } = {}) {
const media = new EventTarget(); media.matches = dark;
const win = new EventTarget(); win.location = { pathname }; win.matchMedia = () => media;
const documentElement = { style: { colorScheme: 'light dark' } };
const root = { dataset: {}, ownerDocument: { documentElement } };
const saved = new Map(preference === undefined ? [] : [['pm_workspace_theme', preference]]);
const storage = {
getItem(key) { if (storageDenied) throw new Error('denied'); return saved.get(key); },
setItem(key, value) { if (storageDenied) throw new Error('denied'); saved.set(key, value); },
};
const calls = [];
const scope = createScope();
const theme = mountWorkspaceTheme({ root, scope, win, storage, fetchImpl: fetchImpl || (async (url, init) => {
calls.push({ url, init }); return { ok: true, json: async () => 'dark' };
}) });
return { root, documentElement, saved, media, win, scope, theme, calls };
}
const response = value => ({ ok: true, json: async () => value });
const tick = () => new Promise(resolve => setTimeout(resolve, 0));
test('follow uses host palette over OS and preserves reverse proxy prefix', async () => {
const f = fixture({ pathname: '/proxy/comfy/prompt_manager/workspace' });
await f.theme.ready;
assert.equal(f.root.dataset.caTheme, 'dark');
assert.equal(f.documentElement.style.colorScheme, 'dark');
assert.equal(f.calls[0].url, '/proxy/comfy/api/settings/Comfy.ColorPalette');
f.scope.dispose();
});
test('supported light palettes accept string and wrapped values', async () => {
for (const palette of ['light', 'solarized', 'github', 'arc']) {
const f = fixture({ dark: true, fetchImpl: async () => response({ value: palette }) });
await f.theme.ready;
assert.equal(f.root.dataset.caTheme, 'light', palette);
f.scope.dispose();
}
});
test('explicit overrides persist and follow restores host choice', async () => {
const f = fixture({ preference: 'light' }); await f.theme.ready;
assert.equal(f.root.dataset.caTheme, 'light');
f.theme.setPreference('dark'); assert.equal(f.root.dataset.caTheme, 'dark');
assert.equal(f.saved.get('pm_workspace_theme'), 'dark');
f.theme.setPreference('follow'); assert.equal(f.root.dataset.caTheme, 'dark');
assert.equal(f.saved.get('pm_workspace_theme'), 'follow');
assert.throws(() => f.theme.setPreference('orange'), /preference/i);
f.scope.dispose();
});
test('unknown host palettes and invalid stored preferences follow changing OS', async () => {
const f = fixture({ preference: 'orange', fetchImpl: async () => response('custom') });
await f.theme.ready; assert.equal(f.root.dataset.caTheme, 'light');
f.media.matches = true; f.media.dispatchEvent(new Event('change'));
assert.equal(f.root.dataset.caTheme, 'dark'); f.scope.dispose();
});
test('network, HTTP, JSON and storage failures recover using system theme', async () => {
for (const fetchImpl of [async () => { throw new Error('offline'); }, async () => ({ ok: false }), async () => ({ ok: true, json: async () => { throw new Error('invalid'); } })]) {
const f = fixture({ dark: true, storageDenied: true, fetchImpl });
await f.theme.ready; assert.equal(f.root.dataset.caTheme, 'dark');
assert.doesNotThrow(() => f.theme.setPreference('light'));
assert.equal(f.root.dataset.caTheme, 'light'); f.scope.dispose();
}
});
test('focus refreshes host palette and stale responses cannot replace newest palette', async () => {
const pending = [];
const f = fixture({ fetchImpl: (url, init) => new Promise(resolve => pending.push({ resolve, signal: init.signal })) });
f.win.dispatchEvent(new Event('focus'));
assert.equal(pending.length, 2); assert.equal(pending[0].signal.aborted, true);
pending[1].resolve(response('light')); await tick();
pending[0].resolve(response('dark')); await f.theme.ready;
assert.equal(f.root.dataset.caTheme, 'light'); f.scope.dispose();
});
test('cleanup aborts requests, restores attributes and native scheme and removes listeners', async () => {
let resolve, fSignal;
const f = fixture({ fetchImpl: (url, init) => new Promise(done => { resolve = done; fSignal = init.signal; }) });
f.scope.dispose(); assert.equal(fSignal.aborted, true);
assert.equal(f.root.dataset.caTheme, undefined);
assert.equal(f.documentElement.style.colorScheme, 'light dark');
f.win.dispatchEvent(new Event('focus')); f.media.dispatchEvent(new Event('change'));
resolve(response('dark')); await f.theme.ready;
assert.equal(f.root.dataset.caTheme, undefined);
f.theme.setPreference('dark'); assert.equal(f.root.dataset.caTheme, undefined);
});
test('cleanup restores an existing toolkit theme', async () => {
const f = fixture(); await f.theme.ready; f.scope.dispose();
f.root.dataset.caTheme = 'light';
const scope = createScope();
const theme = mountWorkspaceTheme({ root: f.root, scope, win: f.win, storage: f.saved, fetchImpl: async () => response('dark') });
await theme.ready; scope.dispose(); assert.equal(f.root.dataset.caTheme, 'light');
});
test('malformed palette payloads fall back without losing native theme alignment', async () => {
for (const payload of [null, {}, {value: 42}, 42]) {
const f = fixture({ dark: true, fetchImpl: async () => response(payload) });
await f.theme.ready;
assert.equal(f.root.dataset.caTheme, 'dark');
assert.equal(f.documentElement.style.colorScheme, 'dark');
f.scope.dispose();
}
});
test('focus failure clears obsolete host palette and resumes system following', async () => {
let calls = 0;
const f = fixture({ fetchImpl: async () => {
if (++calls === 1) return response('dark');
throw new Error('host offline');
} });
await f.theme.ready;
assert.equal(f.root.dataset.caTheme, 'dark');
f.win.dispatchEvent(new Event('focus')); await tick();
assert.equal(f.root.dataset.caTheme, 'light');
f.scope.dispose();
f.win.dispatchEvent(new Event('focus')); await tick();
assert.equal(calls, 2);
});
test('blocked localStorage getter and missing proxy marker stay usable', async () => {
const f = fixture(); await f.theme.ready; f.scope.dispose();
Object.defineProperty(f.win, 'localStorage', { get() { throw new Error('denied'); } });
f.win.location.pathname = '/standalone';
const scope = createScope();
let url;
const theme = mountWorkspaceTheme({ root: f.root, win: f.win, scope,
fetchImpl: async value => { url = value; return response(' Obsidian '); } });
assert.equal(theme.preference, 'follow');
await theme.ready;
assert.equal(url, '/api/settings/Comfy.ColorPalette');
assert.equal(f.root.dataset.caTheme, 'dark');
theme.setPreference('light');
assert.equal(theme.preference, 'light');
scope.dispose();
});
+1 -2
View File
@@ -31,8 +31,7 @@ class APITestCase(AioHTTPTestCase):
app = web.Application()
routes = web.RouteTableDef()
self.api = PromptManagerAPI()
self.api.db = PromptDatabase(self._temp_db.name)
self.api = PromptManagerAPI(db=PromptDatabase(self._temp_db.name))
self.api.add_routes(routes)
app.router.add_routes(routes)
return app
+29 -8
View File
@@ -70,8 +70,7 @@ class AdminAPITestCase(AioHTTPTestCase):
db_path = os.path.join(self.tmpdir, "prompts.db")
app = web.Application()
routes = web.RouteTableDef()
self.api = PromptManagerAPI()
self.api.db = PromptDatabase(db_path)
self.api = PromptManagerAPI(db=PromptDatabase(db_path))
self.api.add_routes(routes)
app.router.add_routes(routes)
return app
@@ -476,10 +475,13 @@ class TestScansRunOffTheEventLoop(AdminAPITestCase):
).text()
self.assertIn('"added": 1', first)
self.assertIn('"linked": 1', second)
self.assertIn('"unchanged": 1', second)
self.assertIn('"added": 0', second)
prompts = self.api.db.get_recent_prompts(limit=10)["prompts"]
self.assertEqual([p["text"] for p in prompts], ["a red square"])
self.assertEqual({p["text"] for p in prompts}, {"a red square", "bad"})
pairs = self.api.db.list_prompt_pairs()["pairs"]
self.assertEqual(len(pairs), 1)
self.assertEqual(pairs[0]["negative_text"], "bad")
async def test_output_scan_without_directories_reports_error(self):
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.outside_dir)]
@@ -1556,7 +1558,7 @@ class TestOutputScanInternals(AdminAPITestCase):
"found",
)
self.assertEqual(self.api._ingest_scanned_file(f, meta), "added")
self.assertEqual(self.api._ingest_scanned_file(f, meta), "linked")
self.assertEqual(self.api._ingest_scanned_file(f, meta), "unchanged")
def test_ingest_link_failures_are_tolerated(self):
f = Path("gen.png")
@@ -1589,7 +1591,23 @@ class TestOutputScanInternals(AdminAPITestCase):
counts = self.api._ingest_scan_batch_sync(
[(Path("a.png"), meta), (Path("b.png"), {})]
)
self.assertEqual(counts, {"processed": 2, "found": 0, "added": 0, "linked": 0})
self.assertEqual(
counts,
{
"processed": 2,
"found": 0,
"added": 0,
"linked": 0,
"enriched": 0,
"unchanged": 0,
"ambiguous": 0,
"unresolved": 0,
"conflict": 0,
"failed": 1,
"pairs_added": 0,
"pair_links_added": 0,
},
)
async def test_scan_internal_error_streams_error_event(self):
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.output_dir)]
@@ -1637,7 +1655,10 @@ class TestOutputScanIsABackgroundJob(AdminAPITestCase):
await asyncio.wait_for(self.api._scan_job.task, 10)
prompts = self.api.db.get_recent_prompts(limit=10)["prompts"]
self.assertEqual([p["text"] for p in prompts], ["a red square"])
self.assertEqual({p["text"] for p in prompts}, {"a red square", "bad"})
pairs = self.api.db.list_prompt_pairs()["pairs"]
self.assertEqual(len(pairs), 1)
self.assertEqual(pairs[0]["negative_text"], "bad")
self.assertEqual(self.api._scan_job.last_event["type"], "complete")
self.assertFalse(self.api._scan_job.running)
@@ -1704,7 +1725,7 @@ class TestOutputScanIsABackgroundJob(AdminAPITestCase):
body = await (await self.client.request("POST", "/prompt_manager/scan")).text()
self.assertEqual(len(calls), 2)
self.assertIn('"linked": 1', body)
self.assertIn('"unchanged": 1', body)
async def test_failed_scan_is_not_left_running(self):
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.output_dir)]
+1 -2
View File
@@ -119,8 +119,7 @@ class AutotagAPITestCase(AioHTTPTestCase):
app = web.Application()
routes = web.RouteTableDef()
self.api = PromptManagerAPI()
self.api.db = PromptDatabase(self._temp_db.name)
self.api = PromptManagerAPI(db=PromptDatabase(self._temp_db.name))
self.api._get_all_output_dirs = lambda: [self.output_dir]
self.api.add_routes(routes)
app.router.add_routes(routes)
+1 -2
View File
@@ -45,8 +45,7 @@ class BackupRestoreTestCase(AioHTTPTestCase):
app = web.Application()
routes = web.RouteTableDef()
self.api = PromptManagerAPI()
self.api.db = PromptDatabase(self.db_path)
self.api = PromptManagerAPI(db=PromptDatabase(self.db_path))
self.api.add_routes(routes)
app.router.add_routes(routes)
return app
+28 -7
View File
@@ -322,7 +322,9 @@ class TestLongJobLock(unittest.TestCase):
"""_acquire_long_job: one job per family at a time, released on exit."""
def setUp(self):
self.api = PromptManagerAPI()
database = MagicMock()
database.cleanup_duplicates.return_value = 0
self.api = PromptManagerAPI(db=database)
def _run(self, coro):
import asyncio
@@ -373,8 +375,9 @@ class CoreRoutesTestCase(AioHTTPTestCase):
self.tmpdir = tempfile.mkdtemp()
app = web.Application()
routes = web.RouteTableDef()
self.api = PromptManagerAPI()
self.api.db = PromptDatabase(os.path.join(self.tmpdir, "prompts.db"))
self.api = PromptManagerAPI(
db=PromptDatabase(os.path.join(self.tmpdir, "prompts.db"))
)
self.api.add_routes(routes)
app.router.add_routes(routes)
return app
@@ -382,6 +385,7 @@ class CoreRoutesTestCase(AioHTTPTestCase):
async def tearDownAsync(self):
await super().tearDownAsync() # closes the aiohttp test client
self.api.db.close_all()
assert_closed(self, self.tmpdir)
shutil.rmtree(self.tmpdir, ignore_errors=True)
def _fake_root(self):
@@ -396,6 +400,7 @@ class CoreRoutesTestCase(AioHTTPTestCase):
UI_PATHS = (
"/prompt_manager/workspace",
"/prompt_manager/web",
"/prompt_manager/gallery.html",
"/prompt_manager/admin",
@@ -426,7 +431,13 @@ class TestUiRoutes(CoreRoutesTestCase):
async def test_unreadable_pages_are_500(self):
web_dir = self._fake_root()
for name in ("index.html", "metadata.html", "admin.html", "gallery.html"):
for name in (
"workspace.html",
"index.html",
"metadata.html",
"admin.html",
"gallery.html",
):
(web_dir / name).mkdir() # a directory cannot be read as a file
for path in UI_PATHS:
resp = await self.client.request("GET", path)
@@ -445,6 +456,13 @@ class TestStaticRoutes(CoreRoutesTestCase):
self.assertEqual(resp.status, 200)
self.assertIn("application/javascript", resp.headers["Content-Type"])
async def test_workspace_module_served_with_javascript_mime(self):
resp = await self.client.request(
"GET", "/prompt_manager/js/workspace/index.mjs"
)
self.assertEqual(resp.status, 200)
self.assertIn("application/javascript", resp.headers["Content-Type"])
async def test_lib_served(self):
web_dir = self._fake_root()
(web_dir / "lib" / "x.css").write_text("body{}")
@@ -565,8 +583,9 @@ class TestInstanceHelpers(FolderPathsFixture):
def setUp(self):
super().setUp()
self.api = PromptManagerAPI()
self.api.db = PromptDatabase(os.path.join(self.tmpdir, "prompts.db"))
self.api = PromptManagerAPI(
db=PromptDatabase(os.path.join(self.tmpdir, "prompts.db"))
)
self.addCleanup(self.api.db.close_all) # runs before the rmtree cleanup
orig_dirs = list(GalleryConfig.MONITORING_DIRECTORIES)
self.addCleanup(setattr, GalleryConfig, "MONITORING_DIRECTORIES", orig_dirs)
@@ -668,7 +687,9 @@ class TestInstanceHelpers(FolderPathsFixture):
class TestMetadataParsing(unittest.TestCase):
def setUp(self):
self.api = PromptManagerAPI()
database = MagicMock()
database.cleanup_duplicates.return_value = 0
self.api = PromptManagerAPI(db=database)
self.tmpdir = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.tmpdir, True)
+71
View File
@@ -0,0 +1,71 @@
"""API fixtures never open the configured database before installing test state."""
import unittest.mock
from py.api import PromptManagerAPI
class TestAPIDatabaseIsolation(unittest.TestCase):
def test_injected_database_never_constructs_the_configured_database(self):
database = unittest.mock.MagicMock()
database.cleanup_duplicates.return_value = 0
with (
unittest.mock.patch(
"py.api.PromptDatabase", side_effect=AssertionError("default DB opened")
) as factory,
unittest.mock.patch("py.api.get_logger"),
):
api = PromptManagerAPI(db=database)
self.assertIs(api.db, database)
factory.assert_not_called()
database.cleanup_duplicates.assert_called_once_with()
def test_falsey_injected_database_is_still_used(self):
database = unittest.mock.MagicMock()
database.__bool__.return_value = False
database.cleanup_duplicates.return_value = 0
with (
unittest.mock.patch(
"py.api.PromptDatabase", side_effect=AssertionError("default DB opened")
) as factory,
unittest.mock.patch("py.api.get_logger"),
):
api = PromptManagerAPI(db=database)
self.assertIs(api.db, database)
factory.assert_not_called()
def test_default_runtime_construction_still_uses_the_configured_factory(self):
database = unittest.mock.MagicMock()
database.cleanup_duplicates.return_value = 2
with (
unittest.mock.patch(
"py.api.PromptDatabase", return_value=database
) as factory,
unittest.mock.patch("py.api.get_logger") as logger,
):
api = PromptManagerAPI()
self.assertIs(api.db, database)
factory.assert_called_once_with()
logger.return_value.info.assert_any_call(
"Startup cleanup: removed 2 duplicate prompts"
)
def test_injected_cleanup_failure_is_logged_without_falling_back_to_default(self):
database = unittest.mock.MagicMock()
database.cleanup_duplicates.side_effect = RuntimeError("test cleanup")
with (
unittest.mock.patch(
"py.api.PromptDatabase", side_effect=AssertionError("default DB opened")
) as factory,
unittest.mock.patch("py.api.get_logger") as logger,
):
api = PromptManagerAPI(db=database)
self.assertIs(api.db, database)
factory.assert_not_called()
logger.return_value.error.assert_called_once_with(
"Startup cleanup failed: test cleanup"
)
if __name__ == "__main__":
unittest.main()
+1 -2
View File
@@ -64,8 +64,7 @@ class ImageAPITestCase(AioHTTPTestCase):
app = web.Application()
routes = web.RouteTableDef()
self.api = PromptManagerAPI()
self.api.db = PromptDatabase(self._temp_db.name)
self.api = PromptManagerAPI(db=PromptDatabase(self._temp_db.name))
self.api._get_all_output_dirs = lambda: [self.output_dir]
self.api._find_comfyui_output_dir = lambda: str(self.output_dir)
self.api.add_routes(routes)
+3 -2
View File
@@ -104,8 +104,9 @@ class LoggingAPITestCase(AioHTTPTestCase):
app = web.Application()
routes = web.RouteTableDef()
self.api = PromptManagerAPI()
self.api.db = PromptDatabase(os.path.join(self.tmpdir, "prompts.db"))
self.api = PromptManagerAPI(
db=PromptDatabase(os.path.join(self.tmpdir, "prompts.db"))
)
self.api._get_logger_manager = lambda: self.manager
self.api.add_routes(routes)
app.router.add_routes(routes)
+1 -2
View File
@@ -76,8 +76,7 @@ class LoraAPITestCase(AioHTTPTestCase):
app = web.Application()
routes = web.RouteTableDef()
self.api = PromptManagerAPI()
self.api.db = PromptDatabase(self._temp_db.name)
self.api = PromptManagerAPI(db=PromptDatabase(self._temp_db.name))
self.api.add_routes(routes)
app.router.add_routes(routes)
return app
+154
View File
@@ -0,0 +1,154 @@
"""Pair routes preserve shared text and expose portable combinations."""
from tests.test_api_prompts import PromptAPITestCase
class TestPairAPI(PromptAPITestCase):
async def _create(self, **overrides):
payload = {"positive_text": "cat", "negative_text": "blur"}
payload.update(overrides)
return await self.client.post("/prompt_manager/pairs", json=payload)
async def test_create_reuse_read_and_search(self):
response = await self._create(tags=["studio"], title="Portrait")
self.assertEqual(response.status, 200)
pair = (await response.json())["pair"]
response = await self._create(negative_text="noise")
second = (await response.json())["pair"]
self.assertEqual(pair["positive_prompt_id"], second["positive_prompt_id"])
self.assertNotEqual(pair["id"], second["id"])
response = await self.client.get("/prompt_manager/pairs?text=blur")
data = await response.json()
self.assertEqual(data["total"], 1)
self.assertEqual(data["pairs"][0]["tags"], ["studio"])
response = await self.client.get(f'/prompt_manager/pairs/{pair["id"]}')
self.assertEqual((await response.json())["pair"]["title"], "Portrait")
async def test_negative_must_be_explicit_but_can_be_empty(self):
response = await self.client.post(
"/prompt_manager/pairs", json={"positive_text": "cat"}
)
self.assertEqual(response.status, 400)
response = await self._create(negative_text="")
self.assertEqual(response.status, 200)
self.assertIsNone((await response.json())["pair"]["negative_prompt_id"])
async def test_invalid_payloads_are_400_without_writes(self):
for body in (
[],
None,
{},
{"positive_text": 12, "negative_text": ""},
{"positive_text": "cat", "negative_text": None},
{"positive_text": "cat", "negative_text": "blur", "rating": True},
{"positive_text": "cat", "negative_text": "blur", "tags": [12]},
):
response = await self.client.post("/prompt_manager/pairs", json=body)
self.assertEqual(response.status, 400, body)
response = await self.client.get("/prompt_manager/pairs")
self.assertEqual((await response.json())["total"], 0)
async def test_repeated_save_does_not_overwrite_metadata(self):
response = await self._create(title="Original", tags=["kept"])
pair = (await response.json())["pair"]
response = await self._create(title="Other", tags=["ignored"])
repeated = (await response.json())["pair"]
self.assertEqual(repeated["id"], pair["id"])
self.assertEqual(repeated["title"], "Original")
self.assertEqual(repeated["tags"], ["kept"])
async def test_metadata_update_and_archive(self):
pair = (await (await self._create()).json())["pair"]
url = f'/prompt_manager/pairs/{pair["id"]}'
response = await self.client.patch(url, json={"title": "New", "tags": ["a"]})
self.assertEqual(response.status, 200)
response = await self.client.patch(url, json={"positive_text": "dog"})
self.assertEqual(response.status, 400)
response = await self.client.patch(url, json={"archived": True})
self.assertEqual(response.status, 200)
self.assertEqual(
(await (await self.client.get("/prompt_manager/pairs")).json())["total"], 0
)
response = await self.client.get("/prompt_manager/pairs?include_archived=true")
self.assertEqual((await response.json())["total"], 1)
response = await self.client.delete(url)
self.assertEqual(response.status, 200)
self.assertIsNotNone(self.api.db.get_prompt_by_id(pair["positive_prompt_id"]))
async def test_export_is_versioned_and_contains_text_not_only_local_ids(self):
await self._create(tags=["a"])
response = await self.client.get("/prompt_manager/pairs/export")
self.assertEqual(response.status, 200)
data = await response.json()
self.assertEqual(data["schema_version"], 1)
self.assertEqual(data["pairs"][0]["positive_text"], "cat")
self.assertEqual(data["pairs"][0]["negative_text"], "blur")
self.assertNotIn("positive_prompt_id", data["pairs"][0])
async def test_missing_invalid_ids_and_pagination(self):
for verb in ("get", "delete"):
for suffix, status in (("abc", 400), ("9999", 404)):
response = await getattr(self.client, verb)(
f"/prompt_manager/pairs/{suffix}"
)
self.assertEqual(response.status, status)
for query in ("limit=abc", "offset=abc", "include_archived=maybe"):
response = await self.client.get("/prompt_manager/pairs?" + query)
self.assertEqual(response.status, 400)
response = await self.client.get("/prompt_manager/pairs?limit=99999&offset=-1")
data = await response.json()
self.assertEqual(data["limit"], 500)
self.assertEqual(data["offset"], 0)
async def test_old_edit_and_delete_routes_report_pair_dependency(self):
pair = (await (await self._create()).json())["pair"]
prompt_id = pair["positive_prompt_id"]
response = await self.client.put(
f"/prompt_manager/prompts/{prompt_id}", json={"text": "dog"}
)
self.assertEqual(response.status, 409)
response = await self.client.delete(f"/prompt_manager/delete/{prompt_id}")
self.assertEqual(response.status, 409)
response = await self.client.post(
"/prompt_manager/bulk/delete", json={"prompt_ids": [prompt_id]}
)
self.assertEqual(response.status, 409)
async def test_recent_pairs_sort_by_use_and_reject_unknown_sort(self):
first = (await (await self._create()).json())["pair"]
await self._create(negative_text="noise")
self.api.db.record_prompt_pair_use(first["id"])
response = await self.client.get("/prompt_manager/pairs?sort=last_used_desc")
self.assertEqual((await response.json())["pairs"][0]["id"], first["id"])
response = await self.client.get("/prompt_manager/pairs?sort=invalid")
self.assertEqual(response.status, 400)
async def test_null_metadata_patch_is_noop_for_existing_pair(self):
pair = (await (await self._create(rating=4)).json())["pair"]
response = await self.client.patch(
f'/prompt_manager/pairs/{pair["id"]}', json={"rating": None}
)
self.assertEqual(response.status, 200)
self.assertEqual((await response.json())["pair"]["rating"], 4)
response = await self.client.patch(
"/prompt_manager/pairs/99999", json={"rating": None}
)
self.assertEqual(response.status, 404)
async def test_database_failures_are_generic_and_bad_json_is_rejected(self):
from unittest.mock import patch
with patch.object(
self.api.db,
"save_prompt_pair",
side_effect=RuntimeError("/private/db/path"),
):
response = await self._create()
self.assertEqual(response.status, 500)
self.assertNotIn("/private", await response.text())
response = await self.client.post(
"/prompt_manager/pairs",
data="{",
headers={"Content-Type": "application/json"},
)
self.assertEqual(response.status, 400)

Some files were not shown because too many files have changed in this diff Show More